From fa0aaf16e0cfcf3ae3580410416f96051629a32e Mon Sep 17 00:00:00 2001 From: cadons Date: Sun, 30 Aug 2026 15:34:24 +0200 Subject: [PATCH 1/5] refactor(loom): replace tree-builder tag dispatch with a handler registry DocraftLoomTreeBuilder::build() was a ~17-branch if/else over tag_name dispatching to one build_() method each, all living in a single 1480-line .cc -- adding or maintaining a Craft-language tag meant editing that one shared file. Replace it with DocraftLoomTagHandlerRegistry, a tag -> IDocraftLoomTagHandler map mirroring the registry docraft::craft::DocraftCraftLanguageParser already uses one stage earlier, and the DocraftChartBuilderRegistry precedent. Adding a tag is now one handler file plus one registration line in docraft_loom_builtin_tag_handlers.cc. Handlers reach the builder's state (color/template resolution, child recursion, Foreach item) through two new interfaces -- DocraftLoomTreeBuilderContext (most tags) and the wider DocraftLoomTableHandlerContext (Table only) -- rather than being friended into all of DocraftLoomTreeBuilder's internals. Table (the most cross-cutting tag, see .local/TABELLE_LOOM.md) was migrated last into its own substantial handler, moved close to verbatim: this isolates its complexity rather than solving it. DocraftLoomTreeBuilder shrinks from 1480 to 244 lines (.cc) and 275 to 169 lines (.h). Also, smaller pipeline cleanup: - DocraftLoomVStack gains resolve_vertical_child_gaps(), closing the asymmetry with DocraftLoomHStack's existing resolve_horizontal_child_gaps(); MeasureProcessor and LayoutProcessor's VStack visits now share it instead of hand-rolling the same gap loop. - DocraftLoomMeasureProcessor gains a private incoming_width() helper, mirroring DocraftLoomLayoutProcessor's existing one, deduplicating 5 identical inline ternaries. - parse_page_size/parse_page_orientation move into a new docraft_craft_enum_parsers.h/.cc, and parse_chart_axis_position into docraft_chart_types.h/.cc, out of anonymous namespaces that made them unreachable from anywhere but their original file. Co-Authored-By: Claude Sonnet 5 --- docraft/CMakeLists.txt | 49 + .../craft/parser/docraft_craft_enum_parsers.h | 38 + .../docraft/loom/charts/docraft_chart_types.h | 7 + .../craft/docraft_loom_tag_handler_registry.h | 60 + .../loom/craft/docraft_loom_tree_builder.h | 134 +- .../craft/docraft_loom_tree_builder_context.h | 135 ++ .../craft/docraft_loom_tree_builder_utils.h | 142 ++ .../docraft_loom_blank_line_handler.h | 29 + .../handlers/docraft_loom_canvas_handler.h | 29 + .../handlers/docraft_loom_chart_handler.h | 53 + .../handlers/docraft_loom_circle_handler.h | 29 + .../docraft_loom_curve_line_handler.h | 29 + .../handlers/docraft_loom_image_handler.h | 29 + .../handlers/docraft_loom_layout_handler.h | 34 + .../handlers/docraft_loom_line_handler.h | 29 + .../handlers/docraft_loom_list_handler.h | 33 + .../handlers/docraft_loom_new_page_handler.h | 29 + .../docraft_loom_page_number_handler.h | 29 + .../handlers/docraft_loom_paragraph_handler.h | 29 + .../handlers/docraft_loom_polygon_handler.h | 29 + .../handlers/docraft_loom_rectangle_handler.h | 29 + .../handlers/docraft_loom_section_handler.h | 33 + .../handlers/docraft_loom_table_handler.h | 108 ++ .../handlers/docraft_loom_text_handler.h | 51 + .../handlers/docraft_loom_triangle_handler.h | 29 + .../handlers/i_docraft_loom_tag_handler.h | 59 + .../docraft/loom/nodes/docraft_loom_vstack.h | 8 + .../pipeline/docraft_loom_measure_processor.h | 9 + .../docraft_loom_craft_language_parser.cc | 43 +- .../parser/docraft_craft_enum_parsers.cc | 77 + .../loom/charts/docraft_chart_types.cc | 16 + .../docraft_loom_builtin_tag_handlers.cc | 93 ++ .../docraft_loom_tag_handler_registry.cc | 37 + .../loom/craft/docraft_loom_tree_builder.cc | 1256 +---------------- .../craft/docraft_loom_tree_builder_utils.cc | 133 ++ .../docraft_loom_blank_line_handler.cc | 30 + .../handlers/docraft_loom_canvas_handler.cc | 45 + .../handlers/docraft_loom_chart_handler.cc | 171 +++ .../handlers/docraft_loom_circle_handler.cc | 142 ++ .../docraft_loom_curve_line_handler.cc | 52 + .../handlers/docraft_loom_image_handler.cc | 35 + .../handlers/docraft_loom_layout_handler.cc | 79 ++ .../handlers/docraft_loom_line_handler.cc | 62 + .../handlers/docraft_loom_list_handler.cc | 65 + .../handlers/docraft_loom_new_page_handler.cc | 30 + .../docraft_loom_page_number_handler.cc | 35 + .../docraft_loom_paragraph_handler.cc | 63 + .../handlers/docraft_loom_polygon_handler.cc | 39 + .../docraft_loom_rectangle_handler.cc | 36 + .../handlers/docraft_loom_section_handler.cc | 44 + .../handlers/docraft_loom_table_handler.cc | 410 ++++++ .../handlers/docraft_loom_text_handler.cc | 67 + .../handlers/docraft_loom_triangle_handler.cc | 39 + .../docraft/loom/nodes/docraft_loom_vstack.cc | 18 + .../pipeline/docraft_loom_layout_processor.cc | 11 +- .../docraft_loom_measure_processor.cc | 19 +- 56 files changed, 3024 insertions(+), 1424 deletions(-) create mode 100644 docraft/include/docraft/craft/parser/docraft_craft_enum_parsers.h create mode 100644 docraft/include/docraft/loom/craft/docraft_loom_tag_handler_registry.h create mode 100644 docraft/include/docraft/loom/craft/docraft_loom_tree_builder_context.h create mode 100644 docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_canvas_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_chart_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_circle_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_image_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_layout_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_line_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_list_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_new_page_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_page_number_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_polygon_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_section_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_table_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_text_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/docraft_loom_triangle_handler.h create mode 100644 docraft/include/docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h create mode 100644 docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc create mode 100644 docraft/src/docraft/loom/craft/docraft_loom_builtin_tag_handlers.cc create mode 100644 docraft/src/docraft/loom/craft/docraft_loom_tag_handler_registry.cc create mode 100644 docraft/src/docraft/loom/craft/docraft_loom_tree_builder_utils.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_image_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_new_page_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_page_number_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_polygon_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_section_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_text_handler.cc create mode 100644 docraft/src/docraft/loom/craft/handlers/docraft_loom_triangle_handler.cc diff --git a/docraft/CMakeLists.txt b/docraft/CMakeLists.txt index a342628..c672aaf 100644 --- a/docraft/CMakeLists.txt +++ b/docraft/CMakeLists.txt @@ -136,6 +136,8 @@ set(DOCRAFT_SOURCES include/docraft/craft/docraft_craft_language_parser.h src/docraft/craft/parser/docraft_parser_helpers.cc include/docraft/craft/parser/docraft_parser_helpers.h + src/docraft/craft/parser/docraft_craft_enum_parsers.cc + include/docraft/craft/parser/docraft_craft_enum_parsers.h src/docraft/craft/parser/docraft_shape_parser_utils.cc include/docraft/craft/parser/docraft_shape_parser_utils.h include/docraft/craft/parser/docraft_parser.h @@ -171,6 +173,53 @@ set(DOCRAFT_SOURCES # docraft/loom (translates DocraftParsedElement trees into loom nodes). include/docraft/loom/craft/docraft_loom_tree_builder.h src/docraft/loom/craft/docraft_loom_tree_builder.cc + include/docraft/loom/craft/docraft_loom_tree_builder_context.h + include/docraft/loom/craft/docraft_loom_tree_builder_utils.h + src/docraft/loom/craft/docraft_loom_tree_builder_utils.cc + + # Loom tree-builder tag-handler registry: one IDocraftLoomTagHandler per Craft- + # language tag, mirroring docraft::craft's own IDocraftParser registry one stage + # earlier. Adding a new tag means adding one handler file + one registration line. + include/docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h + include/docraft/loom/craft/docraft_loom_tag_handler_registry.h + src/docraft/loom/craft/docraft_loom_tag_handler_registry.cc + src/docraft/loom/craft/docraft_loom_builtin_tag_handlers.cc + include/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.h + src/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_circle_handler.h + src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_triangle_handler.h + src/docraft/loom/craft/handlers/docraft_loom_triangle_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_polygon_handler.h + src/docraft/loom/craft/handlers/docraft_loom_polygon_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_line_handler.h + src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.h + src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_canvas_handler.h + src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_chart_handler.h + src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_text_handler.h + src/docraft/loom/craft/handlers/docraft_loom_text_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_page_number_handler.h + src/docraft/loom/craft/handlers/docraft_loom_page_number_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_image_handler.h + src/docraft/loom/craft/handlers/docraft_loom_image_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.h + src/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_new_page_handler.h + src/docraft/loom/craft/handlers/docraft_loom_new_page_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_list_handler.h + src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.h + src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_layout_handler.h + src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_section_handler.h + src/docraft/loom/craft/handlers/docraft_loom_section_handler.cc + include/docraft/loom/craft/handlers/docraft_loom_table_handler.h + src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc # docraft/loom/charts (Chart/Series craft tags expand into a populated Canvas via # a style registry -- Docraft's "chart plugin" mechanism; see DocraftChartBuilderRegistry). diff --git a/docraft/include/docraft/craft/parser/docraft_craft_enum_parsers.h b/docraft/include/docraft/craft/parser/docraft_craft_enum_parsers.h new file mode 100644 index 0000000..98e6d63 --- /dev/null +++ b/docraft/include/docraft/craft/parser/docraft_craft_enum_parsers.h @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "docraft/backend/docraft_page_format.h" +#include "docraft/docraft_lib.h" + +namespace docraft::craft::parser::detail { + /** + * @brief Parses a `` attribute value (case-insensitive + * A3/A4/A5/Letter/Legal). + * @throws docraft::exception::InvalidInputException if `size_str` isn't recognized. + */ + DOCRAFT_LIB docraft::backend::DocraftPageSize parse_page_size(const std::string& size_str); + + /** + * @brief Parses a `` attribute value (landscape/portrait). + * @throws docraft::exception::InvalidInputException if `orientation_str` isn't + * recognized. + */ + DOCRAFT_LIB docraft::backend::DocraftPageOrientation parse_page_orientation(const std::string& orientation_str); +} // namespace docraft::craft::parser::detail diff --git a/docraft/include/docraft/loom/charts/docraft_chart_types.h b/docraft/include/docraft/loom/charts/docraft_chart_types.h index 38c2ad4..0d77f6f 100644 --- a/docraft/include/docraft/loom/charts/docraft_chart_types.h +++ b/docraft/include/docraft/loom/charts/docraft_chart_types.h @@ -33,6 +33,13 @@ namespace docraft::loom::charts { kCenter }; + /** + * @brief Parses a `` attribute value. + * @throws docraft::exception::InvalidInputException if `raw` isn't one of + * left/right/center/top-left/top-right/bottom-left/bottom-right. + */ + DOCRAFT_LIB DocraftChartAxisPosition parse_chart_axis_position(const std::string& raw); + /** * @brief A single data series: a name (for the legend), a color, and its points in * data space. diff --git a/docraft/include/docraft/loom/craft/docraft_loom_tag_handler_registry.h b/docraft/include/docraft/loom/craft/docraft_loom_tag_handler_registry.h new file mode 100644 index 0000000..186db06 --- /dev/null +++ b/docraft/include/docraft/loom/craft/docraft_loom_tag_handler_registry.h @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + /** + * @brief Maps a Craft-language tag name (e.g. "Rectangle", "Layout") to the + * `IDocraftLoomTagHandler` that builds it. + * @details Mirrors `docraft::craft::DocraftCraftLanguageParser`'s own tag -> + * `IDocraftParser` map one stage earlier, and `charts::DocraftChartBuilderRegistry`'s + * registry shape. Built-in tags register once via `register_builtin_tag_handlers()`, + * called from `DocraftLoomTreeBuilder`'s constructor. + */ + class DOCRAFT_LIB DocraftLoomTagHandlerRegistry + { + public: + static DocraftLoomTagHandlerRegistry& instance(); + + void register_handler(const std::string& tag, std::unique_ptr handler); + + /** + * @brief Returns the handler registered for `tag`, or nullptr if none is. + */ + IDocraftLoomTagHandler* find(const std::string& tag) const; + + private: + DocraftLoomTagHandlerRegistry() = default; + + std::unordered_map> handlers_; + }; + + /** + * @brief Registers every Craft-language tag Docraft's loom pipeline ships out of the + * box into `DocraftLoomTagHandlerRegistry::instance()`. + * @details Safe to call more than once (re-registering a tag just overwrites its + * handler). + */ + DOCRAFT_LIB void register_builtin_tag_handlers(); +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/docraft_loom_tree_builder.h b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder.h index bfbeae0..fec761e 100644 --- a/docraft/include/docraft/loom/craft/docraft_loom_tree_builder.h +++ b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder.h @@ -28,6 +28,7 @@ #include "docraft/craft/docraft_craft_parsed_element.h" #include "docraft/templating/docraft_template_engine.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_context.h" #include "docraft/loom/nodes/docraft_loom_blank_line.h" #include "docraft/loom/nodes/docraft_loom_circle.h" #include "docraft/loom/nodes/docraft_loom_image.h" @@ -53,9 +54,6 @@ namespace docraft::craft::parser { struct ParsedTextData; struct ParsedPageNumberData; struct ParsedImageData; - struct ParsedTableTitleData; - struct ParsedTableCellData; - struct ParsedTableData; } // namespace docraft::craft::parser namespace docraft::loom::craft { @@ -68,7 +66,7 @@ namespace docraft::loom::craft { * `docraft::loom` -- keeping the craft parser itself fully engine-agnostic (see the * plan's "Craft parser: generic + a separate loom builder layer"). */ - class DOCRAFT_LIB DocraftLoomTreeBuilder + class DOCRAFT_LIB DocraftLoomTreeBuilder : public DocraftLoomTableHandlerContext { public: /** @@ -92,7 +90,7 @@ namespace docraft::loom::craft { * tag this builder recognizes. */ std::shared_ptr build( - const std::shared_ptr& element); + const std::shared_ptr& element) override; /** * @brief Sets the font family applied to Text/Title/Subtitle/PageNumber/table-cell @@ -105,27 +103,6 @@ namespace docraft::loom::craft { private: using ParsedElement = docraft::craft::DocraftParsedElement; - std::shared_ptr build_rectangle(const ParsedElement& element); - std::shared_ptr build_canvas(const ParsedElement& element); - std::shared_ptr build_chart(const ParsedElement& element); - std::shared_ptr build_circle(const ParsedElement& element); - std::shared_ptr build_triangle(const ParsedElement& element); - std::shared_ptr build_polygon(const ParsedElement& element); - std::shared_ptr build_curve_line(const ParsedElement& element); - std::shared_ptr build_line(const ParsedElement& element); - std::shared_ptr build_text(const ParsedElement& element); - std::shared_ptr build_title(const ParsedElement& element); - std::shared_ptr build_subtitle(const ParsedElement& element); - std::shared_ptr build_page_number(const ParsedElement& element); - std::shared_ptr build_image(const ParsedElement& element); - std::shared_ptr build_blank_line(const ParsedElement& element); - std::shared_ptr build_list(const ParsedElement& element); - std::shared_ptr build_table(const ParsedElement& element); - std::shared_ptr build_layout(const ParsedElement& element); - std::shared_ptr build_paragraph(const ParsedElement& element); - std::shared_ptr build_section(const ParsedElement& element); - std::shared_ptr build_new_page(const ParsedElement& element); - /** * @brief Resolves a raw color attribute (hex, named color, or a `${...}` template * expression) against this builder's template engine and current Foreach item, then @@ -136,110 +113,27 @@ namespace docraft::loom::craft { * @throws docraft::exception::InvalidInputException if the resolved string is not a * valid hex code or a recognized named color. */ - DocraftColor resolve_color(const std::string& raw) const; + DocraftColor resolve_color(const std::string& raw) const override; /** * @brief Renders `text` against this builder's template engine, using * current_foreach_item_ if one is in scope (per-Foreach-item `${data("field")}` * resolution) or plain `${variable}` substitution otherwise. */ - std::string render_template_text(const std::string& text) const; + std::string render_template_text(const std::string& text) const override; - void fill_text_node(nodes::DocraftLoomText& node, const docraft::craft::parser::ParsedTextData& data) const; + void fill_text_node(nodes::DocraftLoomText& node, + const docraft::craft::parser::ParsedTextData& data) const override; void fill_page_number_node(nodes::DocraftLoomPageNumber& node, - const docraft::craft::parser::ParsedPageNumberData& data) const; + const docraft::craft::parser::ParsedPageNumberData& data) const override; void fill_image_node(nodes::DocraftLoomImage& node, - const docraft::craft::parser::ParsedImageData& data) const; - - /** - * @brief Builds a ``/`` table title cell. Deliberately resolves only - * plain `${variable}` expressions, not `${data("field")}` -- unlike model/header/cell - * content, a title's text has no per-row or per-object data of its own to bind to, so - * it never consults current_foreach_item_. - */ - std::shared_ptr build_title_cell( - const docraft::craft::parser::ParsedTableTitleData& title) const; - std::shared_ptr build_content_cell( - const docraft::craft::parser::ParsedTableCellData& cell_data) const; - - /** - * @brief Resolves a `` attribute (already known not to be the - * "horizontal"/"vertical" keyword) into its parsed JSON form: `${...}` substitution - * (never against a Foreach item -- Table forces current_foreach_item_ null for its - * own model/header resolution), then single-quote-JSON normalization and parsing. - * @throws docraft::exception::DataFormatException if `raw` doesn't resolve to valid, - * non-empty JSON array. - */ - nlohmann::json resolve_table_model_json(const std::string& raw) const; - - /** - * @brief Converts an already-resolved `
` JSON array into a - * rectangular, string-only matrix (the "array of arrays" model shape). - * @throws docraft::exception::DataFormatException if `parsed` isn't a non-empty, - * rectangular JSON array of arrays of strings. - */ - std::vector> to_string_matrix(const nlohmann::json& parsed) const; + const docraft::craft::parser::ParsedImageData& data) const override; - /** - * @brief Resolves a `
` attribute into a non-empty, - * string-only array. - * @throws docraft::exception::DataFormatException if `raw` doesn't resolve to a - * non-empty JSON array of strings. - */ - std::vector resolve_table_header(const std::string& raw) const; + std::optional default_font_family() const override { return default_font_family_; } - /** - * @brief Result of resolve_series_points(): `points` and `labels` are parallel - * arrays (same size, same index). - * @details See `charts::DocraftChartSeries::point_labels` for how the label side - * is consumed downstream. - */ - struct ResolvedSeriesPoints - { - std::vector points; - std::vector> labels; - }; - - /** - * @brief Resolves a `` attribute into numeric data points. - * @details Applies `${...}` substitution, single-quote-JSON normalization (same - * helpers Table's own model resolution uses), then JSON-parses the result. Each - * array entry must be one of: a 2-element numeric array (`[x,y]`); an object with - * numeric `x`/`y` keys; or a single-key object (`{"label": value}`, the shape - * pie/histogram model data always uses) -- which becomes `x` = the entry's - * ordinal index (0-based), `y` = the value, and `label` = the key. This is a - * separate resolver from `to_string_matrix()` (Table's own model matrix, which - * requires string cells) rather than a variant of it. - * @throws docraft::exception::DataFormatException if `raw` doesn't resolve to a - * JSON array, or any entry doesn't match one of the three accepted shapes. - */ - ResolvedSeriesPoints resolve_series_points(const std::string& raw) const; - - /** - * @brief Builds and appends the header row for a JSON/template `model` table (either - * shape), from `header` attribute or an explicit `` -- whichever `data` - * carries -- validated against `column_count`. Does nothing if neither is present. - * @throws docraft::exception::DataFormatException if the header size doesn't match - * `column_count`. - */ - void add_table_model_header_row(nodes::DocraftLoomTable& table, - const docraft::craft::parser::ParsedTableData& data, - std::size_t column_count) const; - - /** - * @brief Builds rows for a `
` that resolves to a JSON array of - * objects: the explicit `` (already parsed into `data.rows`) is used as a - * per-object row template, repeated once per array element with - * current_foreach_item_ set to that element so `build_content_cell()`'s - * `${data("field")}` substitution resolves against it -- the same mechanism - * expand_foreach() uses for ``. - * @throws docraft::exception::InvalidInputException if `data.rows` is empty (no - * `` template was given). - * @throws docraft::exception::DataFormatException if `model_json` contains a - * non-object element. - */ - void build_templated_model_rows(nodes::DocraftLoomTable& table, const nlohmann::json& model_json, - const docraft::craft::parser::ParsedTableData& data); + docraft::templating::DocraftTemplateEngine& template_engine() const override { return *template_engine_; } + const nlohmann::json* current_foreach_item() const override { return current_foreach_item_; } + void set_current_foreach_item(const nlohmann::json* item) override { current_foreach_item_ = item; } /** * @brief Recursively builds each of `children` and appends the non-null results @@ -248,7 +142,7 @@ namespace docraft::loom::craft { * end up as direct siblings of the surrounding content in `parent`. */ void add_children(const std::shared_ptr& parent, - const std::vector>& children); + const std::vector>& children) override; /** * @brief Expands a `` element's children (its repeat template) into diff --git a/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_context.h b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_context.h new file mode 100644 index 0000000..0ad1e9a --- /dev/null +++ b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_context.h @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include + +#include "docraft/docraft_color.h" +#include "docraft/docraft_lib.h" + +namespace docraft::templating { + class DocraftTemplateEngine; +} // namespace docraft::templating + +namespace docraft::craft { + struct DocraftParsedElement; + namespace parser { + struct ParsedTextData; + struct ParsedPageNumberData; + struct ParsedImageData; + } // namespace parser +} // namespace docraft::craft + +namespace docraft::loom::nodes { + class DocraftLoomNode; + class DocraftLoomText; + class DocraftLoomPageNumber; + class DocraftLoomImage; +} // namespace docraft::loom::nodes + +namespace docraft::loom::craft { + /** + * @brief The narrow set of `DocraftLoomTreeBuilder` primitives a per-tag + * `IDocraftLoomTagHandler` needs: color/template resolution, recursing back into the + * builder for children, and the shared Text/PageNumber/Image field-filling logic. + * @details Implemented by `DocraftLoomTreeBuilder` itself -- handlers receive a + * reference to the builder through this interface instead of being `friend`ed into + * all of its internals (namely `current_foreach_item_` and the Table-only helpers, + * see `DocraftLoomTableHandlerContext`). + */ + class DOCRAFT_LIB DocraftLoomTreeBuilderContext + { + public: + virtual ~DocraftLoomTreeBuilderContext() = default; + + /** + * @brief Resolves a raw color attribute (hex, named color, or a `${...}` template + * expression) against the current template engine and Foreach item. + */ + virtual DocraftColor resolve_color(const std::string& raw) const = 0; + + /** + * @brief Renders `text` against the current template engine and Foreach item (if + * any is in scope). + */ + virtual std::string render_template_text(const std::string& text) const = 0; + + /** + * @brief Recursively builds a loom node (and its subtree) from a parsed element, + * dispatching back through the same tag-handler registry every top-level `build()` + * call uses. + */ + virtual std::shared_ptr build( + const std::shared_ptr& element) = 0; + + /** + * @brief Recursively builds each of `children` and appends the non-null results to + * `parent`, expanding any `` child in place. + */ + virtual void add_children( + const std::shared_ptr& parent, + const std::vector>& children) = 0; + + /** + * @brief The document's `` family, if set. + */ + virtual std::optional default_font_family() const = 0; + + virtual void fill_text_node(nodes::DocraftLoomText& node, + const docraft::craft::parser::ParsedTextData& data) const = 0; + virtual void fill_page_number_node(nodes::DocraftLoomPageNumber& node, + const docraft::craft::parser::ParsedPageNumberData& data) const = 0; + virtual void fill_image_node(nodes::DocraftLoomImage& node, + const docraft::craft::parser::ParsedImageData& data) const = 0; + }; + + /** + * @brief Widens `DocraftLoomTreeBuilderContext` with the few extra primitives only + * `
`'s handler needs: direct template-engine access (a title cell resolves only + * plain `${variable}` text, never `${data("field")}`, unlike everywhere else) and the + * ability to scope the current Foreach item itself (a JSON-object `model` row template + * binds each element to `current_foreach_item_` while building that element's rows). + * @details `IDocraftLoomTagHandler::build()` takes this wider interface uniformly for + * every tag rather than introducing a second handler-interface hierarchy -- + * `DocraftLoomTreeBuilder` is the only implementer of either interface, so no handler + * pays a real cost for the 3 extra methods it doesn't call. + */ + class DOCRAFT_LIB DocraftLoomTableHandlerContext : public DocraftLoomTreeBuilderContext + { + public: + virtual docraft::templating::DocraftTemplateEngine& template_engine() const = 0; + + /** + * @brief The JSON item of the innermost model-based iteration currently being + * expanded (a `` or a `
` row template's + * own per-object binding); nullptr outside of one. + */ + virtual const nlohmann::json* current_foreach_item() const = 0; + + /** + * @brief Overrides the value `current_foreach_item()` returns; the caller is + * responsible for restoring the previous value (see `resolve_table_model_json`'s + * per-row-template use, an RAII scope guard local to the Table handler). + */ + virtual void set_current_foreach_item(const nlohmann::json* item) = 0; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h new file mode 100644 index 0000000..d52756c --- /dev/null +++ b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "docraft/craft/docraft_craft_parsed_element.h" +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/docraft_lib.h" +#include "docraft/loom/nodes/docraft_loom_node.h" +#include "docraft/loom/nodes/docraft_loom_text.h" + +namespace docraft::loom::craft { + /** + * @brief Applies the style enum a Text/Title/Subtitle/PageNumber/table-title-cell's + * parsed data may carry onto `node`'s bold/italic flags. + */ + DOCRAFT_LIB void apply_style(nodes::DocraftLoomText& node, docraft::craft::parser::ParsedTextStyle style); + + DOCRAFT_LIB nodes::TextAlignment to_loom_alignment(docraft::craft::parser::ParsedTextAlignment alignment); + + /** + * @brief Normalizes a JSON string that may use single quotes for strings instead of + * double quotes (the `.craft` convention for embedding JSON in an XML attribute, e.g. + * `model='[{"a":1}]'` vs `model="[{'a':1}]"`), while preserving apostrophes that are + * part of a word (e.g. "don't") rather than string delimiters. Apply after + * `${variable}`/`${data(...)}` resolution, since a resolved variable's value may itself + * use this convention. + */ + DOCRAFT_LIB std::string normalize_single_quoted_json(std::string_view input); + + /** + * @brief Applies the common attributes (name/z_index/padding/margin/width/height/ + * position/x/y) every loom node shares, straight from a parsed element's + * `DocraftCommonAttributes`. + * @details `width`/`height` are only set if `NodeT` exposes `set_width`/`set_height` + * (some node types, e.g. `DocraftLoomCircle`, size themselves through other + * attributes instead). + */ + template + void apply_common_attributes(NodeT& node, const docraft::craft::DocraftCommonAttributes& common) + { + if (common.name) + { + node.set_name(*common.name); + } + if (common.z_index) + { + node.set_z_index(*common.z_index); + } + if (common.padding) + { + node.set_padding(*common.padding); + } + if (common.margin) + { + node.set_margin(*common.margin); + } + if (common.margin_top || common.margin_right || common.margin_bottom || common.margin_left) + { + const auto& current = node.margin(); + node.set_margin(common.margin_top.value_or(current.top), common.margin_right.value_or(current.right), + common.margin_bottom.value_or(current.bottom), + common.margin_left.value_or(current.left)); + } + + if constexpr (requires(NodeT& n, float v) { n.set_width(v); }) + { + if (common.width) + { + node.set_width(*common.width); + } + } + if constexpr (requires(NodeT& n, float v) { n.set_height(v); }) + { + if (common.height) + { + node.set_height(*common.height); + } + } + + if (common.position_mode) + { + node.set_position_mode(*common.position_mode == docraft::craft::PositionMode::kAbsolute + ? nodes::DocraftPositionType::kAbsolute + : nodes::DocraftPositionType::kBlock); + } + if (common.x || common.y) + { + nodes::Position pos = node.explicit_position(); + if (common.x) + { + pos.x = *common.x; + } + if (common.y) + { + pos.y = *common.y; + } + node.set_explicit_position(pos); + } + } + + /** + * @brief Applies the background_color/border_color/border_width fields shared by every + * shape's parsed data (Rectangle/Circle/Triangle/Polygon/Canvas/Chart/Section) onto + * node's own composed `DocraftLoomShapeStyle`. + * @details Templated on the resolver rather than taking a `DocraftLoomTreeBuilderContext&` + * directly so callers can pass e.g. `[&context](const std::string& c) { return + * context.resolve_color(c); }`. + */ + template + void apply_shape_style(NodeT& node, const ShapeDataT& data, const ColorResolver& resolve_color) + { + if (data.background_color) + { + node.edit_style().background_color = resolve_color(*data.background_color); + } + if (data.border_color) + { + node.edit_style().border_color = resolve_color(*data.border_color); + } + if (data.border_width) + { + node.edit_style().border_width = *data.border_width; + } + } +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.h new file mode 100644 index 0000000..c8587f2 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomBlankLineHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_canvas_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_canvas_handler.h new file mode 100644 index 0000000..0449510 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_canvas_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomCanvasHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_chart_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_chart_handler.h new file mode 100644 index 0000000..1e93b41 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_chart_handler.h @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomChartHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + + private: + /** + * @brief Result of resolve_series_points(): `points` and `labels` are parallel + * arrays (same size, same index). + * @details See `charts::DocraftChartSeries::point_labels` for how the label side + * is consumed downstream. + */ + struct ResolvedSeriesPoints; + + /** + * @brief Resolves a `` attribute into numeric data points. + * @details Applies `${...}` substitution, single-quote-JSON normalization, then + * JSON-parses the result. Each array entry must be one of: a 2-element numeric + * array (`[x,y]`); an object with numeric `x`/`y` keys; or a single-key object + * (`{"label": value}`, the shape pie/histogram model data always uses) -- which + * becomes `x` = the entry's ordinal index (0-based), `y` = the value, and `label` + * = the key. + * @throws docraft::exception::DataFormatException if `raw` doesn't resolve to a + * JSON array, or any entry doesn't match one of the three accepted shapes. + */ + static ResolvedSeriesPoints resolve_series_points(const std::string& raw, DocraftLoomTableHandlerContext& context); + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_circle_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_circle_handler.h new file mode 100644 index 0000000..32a81b6 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_circle_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomCircleHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.h new file mode 100644 index 0000000..cf1b1e3 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomCurveLineHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_image_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_image_handler.h new file mode 100644 index 0000000..47f279a --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_image_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomImageHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_layout_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_layout_handler.h new file mode 100644 index 0000000..2e0ff11 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_layout_handler.h @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + /** + * @brief ``: builds a `DocraftLoomHStack` or + * `DocraftLoomVStack` depending on `orientation`, collecting each child's `weight` + * attribute (validated at parse time to appear only here). + */ + class DOCRAFT_LIB DocraftLoomLayoutHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_line_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_line_handler.h new file mode 100644 index 0000000..3c73bfb --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_line_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomLineHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_list_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_list_handler.h new file mode 100644 index 0000000..f2fb1f8 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_list_handler.h @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + /** + * @brief Handles both `` and `` (both map to the same + * `DocraftLoomList`, distinguished by `kind`). + */ + class DOCRAFT_LIB DocraftLoomListHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_new_page_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_new_page_handler.h new file mode 100644 index 0000000..f6cd410 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_new_page_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomNewPageHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_page_number_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_page_number_handler.h new file mode 100644 index 0000000..ecf9473 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_page_number_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomPageNumberHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.h new file mode 100644 index 0000000..33bf17a --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomParagraphHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_polygon_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_polygon_handler.h new file mode 100644 index 0000000..2fa2b0d --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_polygon_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomPolygonHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.h new file mode 100644 index 0000000..ce95f00 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomRectangleHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_section_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_section_handler.h new file mode 100644 index 0000000..cb42ed4 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_section_handler.h @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + /** + * @brief Handles `
`/``/`
`, all three sharing the same + * `ParsedSectionData` shape and all built as a `DocraftLoomVStack`. + */ + class DOCRAFT_LIB DocraftLoomSectionHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_table_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_table_handler.h new file mode 100644 index 0000000..d19e575 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_table_handler.h @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::craft::parser { + struct ParsedTableTitleData; + struct ParsedTableCellData; + struct ParsedTableData; +} // namespace docraft::craft::parser + +namespace docraft::loom::nodes { + class DocraftLoomTable; + class DocraftLoomTableCell; +} // namespace docraft::loom::nodes + +namespace docraft::loom::craft { + /** + * @brief `
`: by far the widest-surface tag (model-as-matrix, model-as-templated- + * object-rows, explicit ``/``, horizontal/vertical orientation) -- + * intentionally kept as one substantial handler rather than decomposed further, per + * `.local/TABELLE_LOOM.md`'s catalogue of Table's cross-cutting complexity. + */ + class DOCRAFT_LIB DocraftLoomTableHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + + private: + /** + * @brief Builds a ``/`` table title cell. Deliberately resolves only + * plain `${variable}` expressions, not `${data("field")}` -- unlike model/header/cell + * content, a title's text has no per-row or per-object data of its own to bind to. + */ + static std::shared_ptr build_title_cell( + const docraft::craft::parser::ParsedTableTitleData& title, DocraftLoomTableHandlerContext& context); + + static std::shared_ptr build_content_cell( + const docraft::craft::parser::ParsedTableCellData& cell_data, DocraftLoomTableHandlerContext& context); + + /** + * @brief Resolves a `
` attribute (already known not to be the + * "horizontal"/"vertical" keyword) into its parsed JSON form: `${...}` substitution + * (against the ambient current Foreach item, if this Table is itself nested inside + * an outer ``), then single-quote-JSON normalization and parsing. + * @throws docraft::exception::DataFormatException if `raw` doesn't resolve to a + * valid, non-empty JSON array. + */ + static nlohmann::json resolve_table_model_json(const std::string& raw, DocraftLoomTableHandlerContext& context); + + /** + * @brief Converts an already-resolved `
` JSON array into a + * rectangular, string-only matrix (the "array of arrays" model shape). + */ + static std::vector> to_string_matrix(const nlohmann::json& parsed); + + /** + * @brief Resolves a `
` attribute into a non-empty, string-only + * array. + */ + static std::vector resolve_table_header(const std::string& raw, + DocraftLoomTableHandlerContext& context); + + /** + * @brief Builds and appends the header row for a JSON/template `model` table + * (either shape), from the `header` attribute or an explicit `` -- whichever + * `data` carries -- validated against `column_count`. Does nothing if neither is + * present. + */ + static void add_table_model_header_row(nodes::DocraftLoomTable& table, + const docraft::craft::parser::ParsedTableData& data, + std::size_t column_count, DocraftLoomTableHandlerContext& context); + + /** + * @brief Builds rows for a `
` that resolves to a JSON array of + * objects: the explicit `` (already parsed into `data.rows`) is used as a + * per-object row template, repeated once per array element with the context's + * current Foreach item scoped to that element so `build_content_cell()`'s + * `${data("field")}` substitution resolves against it. + */ + static void build_templated_model_rows(nodes::DocraftLoomTable& table, const nlohmann::json& model_json, + const docraft::craft::parser::ParsedTableData& data, + DocraftLoomTableHandlerContext& context); + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_text_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_text_handler.h new file mode 100644 index 0000000..8919b89 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_text_handler.h @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomTextHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; + + /** + * @brief ``: a `DocraftLoomText` whose margin defaults to its own font size + * (see `DocraftLoomTitle`'s constructor), otherwise identical to `<Text>`. + */ + class DOCRAFT_LIB DocraftLoomTitleHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr<nodes::DocraftLoomNode> build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; + + /** + * @brief `<Subtitle>`: identical to `<Title>` but for `DocraftLoomSubtitle`'s own + * constructor defaults. + */ + class DOCRAFT_LIB DocraftLoomSubtitleHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr<nodes::DocraftLoomNode> build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/docraft_loom_triangle_handler.h b/docraft/include/docraft/loom/craft/handlers/docraft_loom_triangle_handler.h new file mode 100644 index 0000000..ea03883 --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/docraft_loom_triangle_handler.h @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h" + +namespace docraft::loom::craft { + class DOCRAFT_LIB DocraftLoomTriangleHandler : public IDocraftLoomTagHandler + { + public: + std::shared_ptr<nodes::DocraftLoomNode> build(const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) override; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h b/docraft/include/docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h new file mode 100644 index 0000000..1e8303c --- /dev/null +++ b/docraft/include/docraft/loom/craft/handlers/i_docraft_loom_tag_handler.h @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include <memory> + +#include "docraft/docraft_lib.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_context.h" + +namespace docraft::craft { + struct DocraftParsedElement; +} // namespace docraft::craft + +namespace docraft::loom::nodes { + class DocraftLoomNode; +} // namespace docraft::loom::nodes + +namespace docraft::loom::craft { + /** + * @brief One handler per Craft-language tag: translates a parsed `<Tag>` element into + * its loom node (and, recursively, its subtree). + * @details Registered by tag name in `DocraftLoomTagHandlerRegistry`; adding a new tag + * means adding one handler class plus one registration line, instead of a new branch + * and method on `DocraftLoomTreeBuilder` itself. Mirrors the `docraft::craft::IDocraftParser` + * registry that already exists one stage earlier (XML -> generic `DocraftParsedElement`). + */ + class DOCRAFT_LIB IDocraftLoomTagHandler + { + public: + virtual ~IDocraftLoomTagHandler() = default; + + /** + * @brief Builds the loom node for `element`. + * @param element The parsed element for this handler's tag (never null; the + * "visible" and unrecognized-tag checks happen before dispatch, in + * `DocraftLoomTreeBuilder::build()`). + * @param context The builder-side primitives this handler needs -- see + * `DocraftLoomTableHandlerContext`'s class doc for why every handler receives the + * wider interface uniformly. + */ + virtual std::shared_ptr<nodes::DocraftLoomNode> build( + const docraft::craft::DocraftParsedElement& element, + DocraftLoomTableHandlerContext& context) = 0; + }; +} // namespace docraft::loom::craft diff --git a/docraft/include/docraft/loom/nodes/docraft_loom_vstack.h b/docraft/include/docraft/loom/nodes/docraft_loom_vstack.h index 6f74575..6dae616 100644 --- a/docraft/include/docraft/loom/nodes/docraft_loom_vstack.h +++ b/docraft/include/docraft/loom/nodes/docraft_loom_vstack.h @@ -46,6 +46,14 @@ namespace docraft::loom::nodes { void set_weights(std::vector<float> weights); const std::vector<float>& weights() const; + /** + * @brief DocraftLoomLayoutContainer::resolve_child_gap() applied to every + * adjacent pair of node's children at once, using each child's bottom/top + * margin (the vertical-stacking counterpart of + * DocraftLoomHStack::resolve_horizontal_child_gaps()). + */ + std::vector<float> resolve_vertical_child_gaps(const DocraftLoomNode& node, float container_spacing); + private: float height_ = 0.0F; std::vector<float> weights_; diff --git a/docraft/include/docraft/loom/pipeline/docraft_loom_measure_processor.h b/docraft/include/docraft/loom/pipeline/docraft_loom_measure_processor.h index a61eee8..e1faf30 100644 --- a/docraft/include/docraft/loom/pipeline/docraft_loom_measure_processor.h +++ b/docraft/include/docraft/loom/pipeline/docraft_loom_measure_processor.h @@ -87,6 +87,15 @@ namespace docraft::loom::pipeline { void visit(docraft::loom::nodes::DocraftLoomNewPage*) override; private: + /** + * @brief Whatever width an ancestor (Rectangle/VStack/weighted HStack) pushed + * down via inherited_wrap_width_, or content_width_ if none did -- the width + * budget a container about to measure its own children should wrap/relay to + * them. Mirrors DocraftLoomLayoutProcessor::incoming_width()'s identical role in + * the layout pass. + */ + float incoming_width() const; + std::shared_ptr<docraft::backend::IDocraftTextRenderingBackend> text_backend_; DocraftLoomTextWrapper text_wrapper_; float content_width_ = 0.0F; diff --git a/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc b/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc index 56603eb..bd6f62b 100644 --- a/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc +++ b/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc @@ -24,6 +24,7 @@ #include "docraft/craft/docraft_craft_language_parser.h" #include "docraft/craft/docraft_craft_language_tokens.h" +#include "docraft/craft/parser/docraft_craft_enum_parsers.h" #include "docraft/craft/parser/docraft_section_parsers.h" #include "docraft/exception/docraft_exceptions.h" #include "docraft/loom/craft/docraft_loom_tree_builder.h" @@ -52,54 +53,16 @@ namespace docraft::craft { return margins; } - docraft::backend::DocraftPageSize parse_page_size(const std::string& size_str) - { - if (size_str == "A3" || size_str == "a3") - { - return docraft::backend::DocraftPageSize::kA3; - } - if (size_str == "A5" || size_str == "a5") - { - return docraft::backend::DocraftPageSize::kA5; - } - if (size_str == "Letter" || size_str == "letter") - { - return docraft::backend::DocraftPageSize::kLetter; - } - if (size_str == "Legal" || size_str == "legal") - { - return docraft::backend::DocraftPageSize::kLegal; - } - if (size_str == "A4" || size_str == "a4") - { - return docraft::backend::DocraftPageSize::kA4; - } - throw docraft::exception::InvalidInputException("Invalid page_size: " + size_str); - } - - docraft::backend::DocraftPageOrientation parse_page_orientation(const std::string& orientation_str) - { - if (orientation_str == "landscape") - { - return docraft::backend::DocraftPageOrientation::kLandscape; - } - if (orientation_str == "portrait") - { - return docraft::backend::DocraftPageOrientation::kPortrait; - } - throw docraft::exception::InvalidInputException("Invalid page_orientation: " + orientation_str); - } - void apply_page(const pugi::xml_node& page_node, loom::DocraftLoomPdfCreator& creator) { const auto size_attr = page_node.attribute(elements::settings::page::attribute::kSize.data()); const auto orientation_attr = page_node.attribute(elements::settings::page::attribute::kOrientation.data()); const auto size = size_attr - ? parse_page_size(size_attr.as_string()) + ? parser::detail::parse_page_size(size_attr.as_string()) : docraft::backend::DocraftPageSize::kA4; const auto orientation = orientation_attr - ? parse_page_orientation(orientation_attr.as_string()) + ? parser::detail::parse_page_orientation(orientation_attr.as_string()) : docraft::backend::DocraftPageOrientation::kPortrait; creator.set_page_format(size, orientation); } diff --git a/docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc b/docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc new file mode 100644 index 0000000..0211d22 --- /dev/null +++ b/docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/craft/parser/docraft_craft_enum_parsers.h" + +#include "docraft/exception/docraft_exceptions.h" +#define A3_STRING "a3" +#define A4_STRING "a4" +#define A5_STRING "a5" +#define LETTER_STRING "letter" +#define LEGAL_STRING "legal" + +#define ORIENTATION_LANDSCAPE_STRING "landscape" +#define ORIENTATION_PORTRAIT_STRING "portrait" + +namespace docraft::craft::parser::detail { + docraft::backend::DocraftPageSize parse_page_size(const std::string& size_str) + { + std::string s = size_str; + std::ranges::transform(s, s.begin(), [](const unsigned char ch) { + return static_cast<char>(std::tolower(ch)); + }); + + if (s == A3_STRING) + { + return docraft::backend::DocraftPageSize::kA3; + } + if (s == A5_STRING) + { + return docraft::backend::DocraftPageSize::kA5; + } + if (s == A4_STRING) + { + return docraft::backend::DocraftPageSize::kA4; + } + if (s == LETTER_STRING) + { + return docraft::backend::DocraftPageSize::kLetter; + } + if (s == LEGAL_STRING) + { + return docraft::backend::DocraftPageSize::kLegal; + } + throw docraft::exception::InvalidInputException("Invalid page_size: " + size_str); + } + + docraft::backend::DocraftPageOrientation parse_page_orientation(const std::string& orientation_str) + { + std::string s = orientation_str; + std::ranges::transform(s, s.begin(), [](const unsigned char ch) { + return static_cast<char>(std::tolower(ch)); + }); + + if (s == ORIENTATION_LANDSCAPE_STRING) + { + return docraft::backend::DocraftPageOrientation::kLandscape; + } + if (s == ORIENTATION_PORTRAIT_STRING) + { + return docraft::backend::DocraftPageOrientation::kPortrait; + } + throw docraft::exception::InvalidInputException("Invalid page_orientation: " + orientation_str); + } +} // namespace docraft::craft::parser::detail diff --git a/docraft/src/docraft/loom/charts/docraft_chart_types.cc b/docraft/src/docraft/loom/charts/docraft_chart_types.cc index a36f4d2..eda6b20 100644 --- a/docraft/src/docraft/loom/charts/docraft_chart_types.cc +++ b/docraft/src/docraft/loom/charts/docraft_chart_types.cc @@ -6,7 +6,23 @@ #include <array> +#include "docraft/exception/docraft_exceptions.h" + namespace docraft::loom::charts { + DocraftChartAxisPosition parse_chart_axis_position(const std::string& raw) + { + if (raw == "left") return DocraftChartAxisPosition::kLeft; + if (raw == "right") return DocraftChartAxisPosition::kRight; + if (raw == "top-left") return DocraftChartAxisPosition::kTopLeft; + if (raw == "top-right") return DocraftChartAxisPosition::kTopRight; + if (raw == "bottom-left") return DocraftChartAxisPosition::kBottomLeft; + if (raw == "bottom-right") return DocraftChartAxisPosition::kBottomRight; + if (raw == "center") return DocraftChartAxisPosition::kCenter; + throw docraft::exception::InvalidInputException( + "<Chart> 'axis_position' must be one of left/right/center/top-left/top-right/" + "bottom-left/bottom-right, got '" + raw + "'"); + } + DocraftChartBuilderRegistry& DocraftChartBuilderRegistry::instance() { static DocraftChartBuilderRegistry registry; diff --git a/docraft/src/docraft/loom/craft/docraft_loom_builtin_tag_handlers.cc b/docraft/src/docraft/loom/craft/docraft_loom_builtin_tag_handlers.cc new file mode 100644 index 0000000..f3c4616 --- /dev/null +++ b/docraft/src/docraft/loom/craft/docraft_loom_builtin_tag_handlers.cc @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include <memory> + +#include "docraft/craft/docraft_craft_language_tokens.h" +#include "docraft/loom/craft/docraft_loom_tag_handler_registry.h" +#include "docraft/loom/craft/handlers/docraft_loom_blank_line_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_canvas_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_chart_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_circle_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_curve_line_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_image_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_layout_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_line_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_list_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_new_page_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_page_number_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_paragraph_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_polygon_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_rectangle_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_section_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_table_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_text_handler.h" +#include "docraft/loom/craft/handlers/docraft_loom_triangle_handler.h" + +namespace docraft::loom::craft { + namespace tokens = docraft::craft; + + void register_builtin_tag_handlers() + { + auto& registry = DocraftLoomTagHandlerRegistry::instance(); + registry.register_handler(std::string{tokens::elements::kRectangle}, + std::make_unique<DocraftLoomRectangleHandler>()); + registry.register_handler(std::string{tokens::elements::kCircle}, + std::make_unique<DocraftLoomCircleHandler>()); + registry.register_handler(std::string{tokens::elements::kTriangle}, + std::make_unique<DocraftLoomTriangleHandler>()); + registry.register_handler(std::string{tokens::elements::kPolygon}, + std::make_unique<DocraftLoomPolygonHandler>()); + registry.register_handler(std::string{tokens::elements::kLine}, + std::make_unique<DocraftLoomLineHandler>()); + registry.register_handler(std::string{tokens::elements::kCurveLine}, + std::make_unique<DocraftLoomCurveLineHandler>()); + registry.register_handler(std::string{tokens::elements::kCanvas}, + std::make_unique<DocraftLoomCanvasHandler>()); + registry.register_handler(std::string{tokens::elements::kChart}, + std::make_unique<DocraftLoomChartHandler>()); + registry.register_handler(std::string{tokens::elements::kText}, + std::make_unique<DocraftLoomTextHandler>()); + registry.register_handler(std::string{tokens::elements::kTitle}, + std::make_unique<DocraftLoomTitleHandler>()); + registry.register_handler(std::string{tokens::elements::kSubtitle}, + std::make_unique<DocraftLoomSubtitleHandler>()); + registry.register_handler(std::string{tokens::elements::kPageNumber}, + std::make_unique<DocraftLoomPageNumberHandler>()); + registry.register_handler(std::string{tokens::elements::kImage}, + std::make_unique<DocraftLoomImageHandler>()); + registry.register_handler(std::string{tokens::elements::kBlankLine}, + std::make_unique<DocraftLoomBlankLineHandler>()); + registry.register_handler(std::string{tokens::elements::kNewPage}, + std::make_unique<DocraftLoomNewPageHandler>()); + registry.register_handler(std::string{tokens::elements::kList}, + std::make_unique<DocraftLoomListHandler>()); + registry.register_handler(std::string{tokens::elements::kUList}, + std::make_unique<DocraftLoomListHandler>()); + registry.register_handler(std::string{tokens::elements::kParagraph}, + std::make_unique<DocraftLoomParagraphHandler>()); + registry.register_handler(std::string{tokens::elements::kLayout}, + std::make_unique<DocraftLoomLayoutHandler>()); + registry.register_handler(std::string{tokens::section::kHeader}, + std::make_unique<DocraftLoomSectionHandler>()); + registry.register_handler(std::string{tokens::section::kBody}, + std::make_unique<DocraftLoomSectionHandler>()); + registry.register_handler(std::string{tokens::section::kFooter}, + std::make_unique<DocraftLoomSectionHandler>()); + registry.register_handler(std::string{tokens::elements::kTable}, + std::make_unique<DocraftLoomTableHandler>()); + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/docraft_loom_tag_handler_registry.cc b/docraft/src/docraft/loom/craft/docraft_loom_tag_handler_registry.cc new file mode 100644 index 0000000..589ed58 --- /dev/null +++ b/docraft/src/docraft/loom/craft/docraft_loom_tag_handler_registry.cc @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/docraft_loom_tag_handler_registry.h" + +namespace docraft::loom::craft { + DocraftLoomTagHandlerRegistry& DocraftLoomTagHandlerRegistry::instance() + { + static DocraftLoomTagHandlerRegistry registry; + return registry; + } + + void DocraftLoomTagHandlerRegistry::register_handler(const std::string& tag, + std::unique_ptr<IDocraftLoomTagHandler> handler) + { + handlers_[tag] = std::move(handler); + } + + IDocraftLoomTagHandler* DocraftLoomTagHandlerRegistry::find(const std::string& tag) const + { + const auto it = handlers_.find(tag); + return it != handlers_.end() ? it->second.get() : nullptr; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/docraft_loom_tree_builder.cc b/docraft/src/docraft/loom/craft/docraft_loom_tree_builder.cc index c65f9ee..d846db6 100644 --- a/docraft/src/docraft/loom/craft/docraft_loom_tree_builder.cc +++ b/docraft/src/docraft/loom/craft/docraft_loom_tree_builder.cc @@ -16,30 +16,18 @@ #include "docraft/loom/craft/docraft_loom_tree_builder.h" -#include <algorithm> #include <any> -#include <cmath> -#include <numbers> -#include <string_view> #include <nlohmann/json.hpp> #include "docraft/craft/docraft_craft_language_tokens.h" -#include "docraft/craft/parser/docraft_circle_parser.h" #include "docraft/craft/parser/docraft_foreach_parser.h" -#include "docraft/craft/parser/docraft_line_parser.h" -#include "docraft/craft/parser/docraft_paragraph_parser.h" #include "docraft/craft/parser/docraft_parser.h" #include "docraft/craft/parser/docraft_parser_helpers.h" -#include "docraft/craft/parser/docraft_curve_line_parser.h" -#include "docraft/craft/parser/docraft_polygon_parser.h" -#include "docraft/craft/parser/docraft_section_parsers.h" -#include "docraft/craft/parser/docraft_triangle_parser.h" #include "docraft/exception/docraft_exceptions.h" -#include "docraft/loom/nodes/docraft_loom_hstack.h" -#include "docraft/loom/nodes/docraft_loom_new_page.h" -#include "docraft/loom/nodes/docraft_loom_table_cell.h" -#include "docraft/loom/nodes/docraft_loom_vstack.h" +#include "docraft/loom/charts/docraft_chart_types.h" +#include "docraft/loom/craft/docraft_loom_tag_handler_registry.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" namespace docraft::loom::craft { DocraftLoomTreeBuilder::DocraftLoomTreeBuilder( @@ -49,6 +37,7 @@ namespace docraft::loom::craft { : std::make_shared<docraft::templating::DocraftTemplateEngine>()) { charts::register_builtin_chart_styles(); + register_builtin_tag_handlers(); } void DocraftLoomTreeBuilder::set_default_font_family(const std::string& font_family) @@ -59,260 +48,6 @@ namespace docraft::loom::craft { namespace tokens = docraft::craft; namespace parser = docraft::craft::parser; - namespace { - // Normalizes a JSON string that may use single quotes for strings instead of - // double quotes (the `.craft` convention for embedding JSON in an XML attribute, - // e.g. `model='[{"a":1}]'` vs `model="[{'a':1}]"`), while preserving apostrophes - // that are part of a word (e.g. "don't") rather than string delimiters. Applied - // after ${variable}/${data(...)} resolution, since a resolved variable's value may - // itself use this convention. - bool is_word_char(char ch) - { - const auto uch = static_cast<unsigned char>(ch); - return std::isalnum(uch) != 0 || ch == '_'; - } - - std::string normalize_single_quoted_json(std::string_view input) - { - std::string output; - output.reserve(input.size()); - bool in_single = false; - bool in_double = false; - for (std::size_t i = 0; i < input.size(); ++i) - { - const char ch = input[i]; - if (ch == '\\' && (i + 1) < input.size()) - { - const char next = input[i + 1]; - if (in_single && (next == '\\' || next == '\'')) - { - // Escaped backslash/single-quote inside a single-quoted literal -- - // drop the backslash, since the bare escaped char is fine once the - // delimiters below are converted to double quotes. - output.push_back(next); - ++i; - continue; - } - if (in_single || in_double) - { - // Any other escape inside a string (`\"`/`\\` while in_double, or a - // JSON escape like `\n` in either) must be consumed as a unit so the - // escaped character -- notably an escaped quote -- can't be read on - // its own as a delimiter and toggle in_single/in_double. - output.push_back(ch); - output.push_back(next); - ++i; - continue; - } - output.push_back(ch); - continue; - } - if (ch == '\'' && !in_double) - { - if (in_single) - { - const char prev = (i > 0) ? input[i - 1] : '\0'; - const char next = ((i + 1) < input.size()) ? input[i + 1] : '\0'; - if (is_word_char(prev) && is_word_char(next)) - { - output.push_back('\''); - continue; - } - } - in_single = !in_single; - output.push_back('"'); - continue; - } - if (ch == '"' && !in_single) - { - in_double = !in_double; - output.push_back(ch); - continue; - } - output.push_back(ch); - } - return output; - } - - void apply_style(nodes::DocraftLoomText& node, parser::ParsedTextStyle style) - { - switch (style) - { - case parser::ParsedTextStyle::kBold: - node.set_bold(true); - node.set_italic(false); - break; - case parser::ParsedTextStyle::kItalic: - node.set_bold(false); - node.set_italic(true); - break; - case parser::ParsedTextStyle::kBoldItalic: - node.set_bold(true); - node.set_italic(true); - break; - case parser::ParsedTextStyle::kNormal: - default: - node.set_bold(false); - node.set_italic(false); - break; - } - } - - nodes::TextAlignment to_loom_alignment(parser::ParsedTextAlignment alignment) - { - switch (alignment) - { - case parser::ParsedTextAlignment::kLeft: - return nodes::TextAlignment::kLeft; - case parser::ParsedTextAlignment::kRight: - return nodes::TextAlignment::kRight; - case parser::ParsedTextAlignment::kJustified: - return nodes::TextAlignment::kJustified; - case parser::ParsedTextAlignment::kCenter: - default: - return nodes::TextAlignment::kCenter; - } - } - - charts::DocraftChartAxisPosition parse_chart_axis_position(const std::string& raw) - { - if (raw == "left") return charts::DocraftChartAxisPosition::kLeft; - if (raw == "right") return charts::DocraftChartAxisPosition::kRight; - if (raw == "top-left") return charts::DocraftChartAxisPosition::kTopLeft; - if (raw == "top-right") return charts::DocraftChartAxisPosition::kTopRight; - if (raw == "bottom-left") return charts::DocraftChartAxisPosition::kBottomLeft; - if (raw == "bottom-right") return charts::DocraftChartAxisPosition::kBottomRight; - if (raw == "center") return charts::DocraftChartAxisPosition::kCenter; - throw docraft::exception::InvalidInputException( - "<Chart> 'axis_position' must be one of left/right/center/top-left/top-right/" - "bottom-left/bottom-right, got '" + raw + "'"); - } - - template <typename NodeT> - void apply_common_attributes(NodeT& node, const docraft::craft::DocraftCommonAttributes& common) - { - if (common.name) - { - node.set_name(*common.name); - } - if (common.z_index) - { - node.set_z_index(*common.z_index); - } - if (common.padding) - { - node.set_padding(*common.padding); - } - if (common.margin) - { - node.set_margin(*common.margin); - } - if (common.margin_top || common.margin_right || common.margin_bottom || common.margin_left) - { - const auto& current = node.margin(); - node.set_margin(common.margin_top.value_or(current.top), common.margin_right.value_or(current.right), - common.margin_bottom.value_or(current.bottom), - common.margin_left.value_or(current.left)); - } - - if constexpr (requires(NodeT& n, float v) { n.set_width(v); }) - { - if (common.width) - { - node.set_width(*common.width); - } - } - if constexpr (requires(NodeT& n, float v) { n.set_height(v); }) - { - if (common.height) - { - node.set_height(*common.height); - } - } - - if (common.position_mode) - { - node.set_position_mode(*common.position_mode == docraft::craft::PositionMode::kAbsolute - ? nodes::DocraftPositionType::kAbsolute - : nodes::DocraftPositionType::kBlock); - } - if (common.x || common.y) - { - nodes::Position pos = node.explicit_position(); - if (common.x) - { - pos.x = *common.x; - } - if (common.y) - { - pos.y = *common.y; - } - node.set_explicit_position(pos); - } - } - - // Applies the background_color/border_color/border_width fields shared by every - // shape's parsed data (Rectangle/Circle/Triangle/Polygon) onto node's own composed - // DocraftLoomShapeStyle. Templated on the resolver rather than taking a - // DocraftLoomTreeBuilder& since resolve_color() is private to it. - template <typename NodeT, typename ShapeDataT, typename ColorResolver> - void apply_shape_style(NodeT& node, const ShapeDataT& data, const ColorResolver& resolve_color) - { - if (data.background_color) - { - node.edit_style().background_color = resolve_color(*data.background_color); - } - if (data.border_color) - { - node.edit_style().border_color = resolve_color(*data.border_color); - } - if (data.border_width) - { - node.edit_style().border_width = *data.border_width; - } - } - - // Direction of `point` as seen from `center`, in degrees from 12 o'clock and - // increasing clockwise on the page -- the convention - // IDocraftShapeRenderingBackend::draw_arc() documents. Both are in the node's own - // Y-down box coordinates, hence negating dy to get the "up is zero" reference. - float arc_angle_from_point(const nodes::Position& point, const nodes::Position& center) - { - const float dx = point.x - center.x; - const float dy = point.y - center.y; - const float degrees = std::atan2(dx, -dy) * 180.0F / std::numbers::pi_v<float>; - return degrees < 0.0F ? degrees + 360.0F : degrees; - } - - std::shared_ptr<nodes::DocraftLoomTableCell> build_matrix_cell(const std::string& text) - { - auto cell = std::make_shared<nodes::DocraftLoomTableCell>(); - cell->set_content(std::make_shared<nodes::DocraftLoomText>(text)); - return cell; - } - - // RAII guard that overrides current_foreach_item_ for its lifetime and restores - // the previous value on scope exit (including via an exception) -- used by - // build_templated_model_rows() to bind each JSON-model object to its cloned row - // template. - class ForeachItemScope - { - public: - ForeachItemScope(const nlohmann::json*& slot, const nlohmann::json* value) - : slot_(slot), previous_(slot) - { - slot_ = value; - } - ~ForeachItemScope() { slot_ = previous_; } - ForeachItemScope(const ForeachItemScope&) = delete; - ForeachItemScope& operator=(const ForeachItemScope&) = delete; - - private: - const nlohmann::json*& slot_; - const nlohmann::json* previous_; - }; - } // namespace - std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomTreeBuilder::build( const std::shared_ptr<docraft::craft::DocraftParsedElement>& element) { @@ -328,86 +63,13 @@ namespace docraft::loom::craft { } const std::string& tag = element->tag_name; - if (tag == std::string{tokens::elements::kRectangle}) - { - return build_rectangle(*element); - } - if (tag == std::string{tokens::elements::kCanvas}) - { - return build_canvas(*element); - } - if (tag == std::string{tokens::elements::kChart}) - { - return build_chart(*element); - } - if (tag == std::string{tokens::elements::kCircle}) - { - return build_circle(*element); - } - if (tag == std::string{tokens::elements::kTriangle}) - { - return build_triangle(*element); - } - if (tag == std::string{tokens::elements::kPolygon}) - { - return build_polygon(*element); - } - if (tag == std::string{tokens::elements::kCurveLine}) - { - return build_curve_line(*element); - } - if (tag == std::string{tokens::elements::kLine}) - { - return build_line(*element); - } - if (tag == std::string{tokens::elements::kText}) - { - return build_text(*element); - } - if (tag == std::string{tokens::elements::kTitle}) - { - return build_title(*element); - } - if (tag == std::string{tokens::elements::kSubtitle}) - { - return build_subtitle(*element); - } - if (tag == std::string{tokens::elements::kPageNumber}) - { - return build_page_number(*element); - } - if (tag == std::string{tokens::elements::kImage}) - { - return build_image(*element); - } - if (tag == std::string{tokens::elements::kBlankLine}) - { - return build_blank_line(*element); - } - if (tag == std::string{tokens::elements::kNewPage}) - { - return build_new_page(*element); - } - if (tag == std::string{tokens::elements::kList} || tag == std::string{tokens::elements::kUList}) - { - return build_list(*element); - } - if (tag == std::string{tokens::elements::kTable}) - { - return build_table(*element); - } - if (tag == std::string{tokens::elements::kLayout}) - { - return build_layout(*element); - } - if (tag == std::string{tokens::elements::kParagraph}) - { - return build_paragraph(*element); - } - if (tag == std::string{tokens::section::kHeader} || tag == std::string{tokens::section::kBody} || - tag == std::string{tokens::section::kFooter}) + + // Every tag dispatches to a per-tag IDocraftLoomTagHandler registered in + // register_builtin_tag_handlers() -- adding a new tag means adding one handler + // class plus one registration line there, not a new branch/method here. + if (auto* handler = DocraftLoomTagHandlerRegistry::instance().find(tag)) { - return build_section(*element); + return handler->build(*element, *this); } throw docraft::exception::DataFormatException("DocraftLoomTreeBuilder: unrecognized tag '" + tag + "'"); @@ -579,902 +241,4 @@ namespace docraft::loom::craft { node.set_path(render_template_text(*data.path)); } } - - std::shared_ptr<nodes::DocraftLoomTableCell> DocraftLoomTreeBuilder::build_title_cell( - const parser::ParsedTableTitleData& title) const - { - auto text = std::make_shared<nodes::DocraftLoomText>(template_engine_->render_template_string(title.text)); - text->set_alignment(to_loom_alignment(title.alignment)); - apply_style(*text, title.style); - if (title.color) - { - text->set_color(resolve_color(*title.color)); - } - if (title.font_size) - { - text->set_font_size(*title.font_size); - } - auto cell = std::make_shared<nodes::DocraftLoomTableCell>(); - cell->set_content(text); - cell->set_is_title(true); - if (title.background) - { - cell->set_background(resolve_color(*title.background)); - } - return cell; - } - - std::shared_ptr<nodes::DocraftLoomTableCell> DocraftLoomTreeBuilder::build_content_cell( - const parser::ParsedTableCellData& cell_data) const - { - auto cell = std::make_shared<nodes::DocraftLoomTableCell>(); - - // Cell content is built directly from ParsedTableCellData rather than via build()'s - // generic dispatcher (Table content isn't recursed into generically -- see - // build_table()), so build()'s "visible" check never runs for it; replicate that - // check here, or an explicitly-invisible Text/Image inside a <Cell> would still - // render, unlike everywhere else in the document. - const bool visible = !cell_data.content_common.visible.has_value() || *cell_data.content_common.visible; - if (visible) - { - std::shared_ptr<nodes::DocraftLoomNode> content; - if (cell_data.content_tag_name == std::string{tokens::elements::kText}) - { - auto text_node = std::make_shared<nodes::DocraftLoomText>(); - fill_text_node(*text_node, std::any_cast<const parser::ParsedTextData&>(cell_data.content)); - apply_common_attributes(*text_node, cell_data.content_common); - content = text_node; - } - else - { - auto image_node = std::make_shared<nodes::DocraftLoomImage>(); - fill_image_node(*image_node, std::any_cast<const parser::ParsedImageData&>(cell_data.content)); - apply_common_attributes(*image_node, cell_data.content_common); - content = image_node; - } - cell->set_content(content); - } - - if (cell_data.background) - { - cell->set_background(resolve_color(*cell_data.background)); - } - if (cell_data.width) - { - cell->set_explicit_width(*cell_data.width); - } - return cell; - } - - // Resolves a `<Table model="...">` attribute (already known not to be the - // "horizontal"/"vertical" keyword) into a rectangular, string-only matrix: - // `${...}` substitution (against current_foreach_item_ if this Table is nested inside - // an outer <Foreach>, same as Foreach's own model resolution), then the same - // single-quote-JSON normalization Foreach's model uses, then JSON-parse. - nlohmann::json DocraftLoomTreeBuilder::resolve_table_model_json(const std::string& raw) const - { - const std::string normalized = normalize_single_quoted_json(render_template_text(raw)); - - nlohmann::json parsed; - try - { - parsed = nlohmann::json::parse(normalized); - } - catch (const nlohmann::json::exception& e) - { - throw docraft::exception::DataFormatException( - std::string{"<Table> 'model' is not valid JSON: "} + e.what()); - } - if (!parsed.is_array() || parsed.empty()) - { - throw docraft::exception::DataFormatException("<Table> 'model' must resolve to a non-empty JSON array"); - } - return parsed; - } - - std::vector<std::vector<std::string>> DocraftLoomTreeBuilder::to_string_matrix(const nlohmann::json& parsed) const - { - std::vector<std::vector<std::string>> matrix; - matrix.reserve(parsed.size()); - for (const auto& row : parsed) - { - if (!row.is_array() || row.empty()) - { - throw docraft::exception::DataFormatException( - "<Table> 'model' rows must be non-empty JSON arrays"); - } - if (!matrix.empty() && row.size() != matrix.front().size()) - { - throw docraft::exception::DataFormatException( - "<Table> 'model' rows must all have the same length"); - } - std::vector<std::string> parsed_row; - parsed_row.reserve(row.size()); - for (const auto& cell : row) - { - if (!cell.is_string()) - { - throw docraft::exception::DataFormatException("<Table> 'model' cells must be strings"); - } - parsed_row.push_back(cell.get<std::string>()); - } - matrix.push_back(std::move(parsed_row)); - } - return matrix; - } - - void DocraftLoomTreeBuilder::add_table_model_header_row( - nodes::DocraftLoomTable& table, const parser::ParsedTableData& data, std::size_t column_count) const - { - if (data.header_data_template) - { - const std::vector<std::string> header = resolve_table_header(*data.header_data_template); - if (header.size() != column_count) - { - throw docraft::exception::DataFormatException( - "<Table> 'header' size must match 'model' column count"); - } - std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> header_row; - header_row.reserve(header.size()); - for (const auto& title : header) - { - parser::ParsedTableTitleData title_data; - title_data.text = title; - header_row.push_back(build_title_cell(title_data)); - } - table.add_row(header_row); - return; - } - - if (!data.header_titles.empty()) - { - // An explicit <THead> can be used instead of the `header` attribute alongside a - // JSON/template `model` -- the parser doesn't reject that combination (only - // `header` + <THead> together is rejected), so it must be honored here too, not - // just the header_data_template path above. - if (data.header_titles.size() != column_count) - { - throw docraft::exception::DataFormatException( - "<Table> THead column count must match 'model' column count"); - } - std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> header_row; - header_row.reserve(data.header_titles.size()); - for (const auto& title : data.header_titles) - { - header_row.push_back(build_title_cell(title)); - } - table.add_row(header_row); - } - } - - void DocraftLoomTreeBuilder::build_templated_model_rows( - nodes::DocraftLoomTable& table, const nlohmann::json& model_json, const parser::ParsedTableData& data) - { - if (data.rows.empty()) - { - throw docraft::exception::InvalidInputException( - "<Table> 'model' resolving to a JSON array of objects requires an explicit <TBody> row template"); - } - - add_table_model_header_row(table, data, data.rows.front().cells.size()); - - for (const auto& item : model_json) - { - if (!item.is_object()) - { - throw docraft::exception::DataFormatException( - "<Table> 'model' must be a JSON array of only objects or only arrays, not mixed"); - } - // Scoped per iteration (rather than a single save/restore around the whole - // loop) so current_foreach_item_ is restored to whatever it was before this - // call -- the outer <Foreach> item, if any -- even if building a row throws. - ForeachItemScope item_scope(current_foreach_item_, &item); - for (const auto& row_data : data.rows) - { - std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> row; - row.reserve(row_data.cells.size()); - for (const auto& cell_data : row_data.cells) - { - row.push_back(build_content_cell(cell_data)); - } - table.add_row(row); - } - } - } - - // Resolves a `<Table header="...">` attribute into a non-empty, string-only array, - // using the same `${...}` substitution + single-quote-JSON normalization as the - // model matrix above. - std::vector<std::string> DocraftLoomTreeBuilder::resolve_table_header(const std::string& raw) const - { - const std::string normalized = normalize_single_quoted_json(render_template_text(raw)); - - nlohmann::json parsed; - try - { - parsed = nlohmann::json::parse(normalized); - } - catch (const nlohmann::json::exception& e) - { - throw docraft::exception::DataFormatException( - std::string{"<Table> 'header' is not valid JSON: "} + e.what()); - } - if (!parsed.is_array() || parsed.empty()) - { - throw docraft::exception::DataFormatException( - "<Table> 'header' must resolve to a non-empty JSON array"); - } - - std::vector<std::string> header; - header.reserve(parsed.size()); - for (const auto& item : parsed) - { - if (!item.is_string()) - { - throw docraft::exception::DataFormatException("<Table> 'header' items must be strings"); - } - header.push_back(item.get<std::string>()); - } - return header; - } - - DocraftLoomTreeBuilder::ResolvedSeriesPoints DocraftLoomTreeBuilder::resolve_series_points( - const std::string& raw) const - { - if (raw.empty()) - { - return {}; - } - const std::string normalized = normalize_single_quoted_json(render_template_text(raw)); - - nlohmann::json parsed; - try - { - parsed = nlohmann::json::parse(normalized); - } - catch (const nlohmann::json::exception& e) - { - throw docraft::exception::DataFormatException( - std::string{"<Series> 'model' is not valid JSON: "} + e.what()); - } - if (!parsed.is_array()) - { - throw docraft::exception::DataFormatException("<Series> 'model' must resolve to a JSON array"); - } - - ResolvedSeriesPoints resolved; - resolved.points.reserve(parsed.size()); - resolved.labels.reserve(parsed.size()); - for (const auto& item : parsed) - { - float x, y; - std::optional<std::string> label; - if (item.is_array() && item.size() == 2 && item[0].is_number() && item[1].is_number()) - { - x = item[0].get<float>(); - y = item[1].get<float>(); - } - else if (item.is_object() && item.contains("x") && item.contains("y") - && item["x"].is_number() && item["y"].is_number()) - { - x = item["x"].get<float>(); - y = item["y"].get<float>(); - } - else if (item.is_object() && item.size() == 1 && item.begin().value().is_number()) - { - // The shape pie/histogram model data always uses: a single "label": - // value entry. There's no explicit x, so its ordinal position in the - // array becomes x (a category index) -- histogram/pie map that back to - // an X-axis tick label / slice label via the parallel `label` below. - x = static_cast<float>(resolved.points.size()); - y = item.begin().value().get<float>(); - label = item.begin().key(); - } - else - { - throw docraft::exception::DataFormatException( - "<Series> 'model' entries must be [x,y] pairs, {\"x\":..,\"y\":..} objects, or " - "{\"label\":value} objects"); - } - resolved.points.push_back({.x = x, .y = y}); - resolved.labels.push_back(std::move(label)); - } - return resolved; - } - - std::shared_ptr<nodes::DocraftLoomRectangle> DocraftLoomTreeBuilder::build_rectangle(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedRectangleData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomRectangle>(); - apply_shape_style(*node, data, [this](const std::string& c) { return resolve_color(c); }); - apply_common_attributes(*node, element.common); - add_children(node, element.children); - return node; - } - - std::shared_ptr<nodes::DocraftLoomCanvas> DocraftLoomTreeBuilder::build_canvas(const ParsedElement& element) - { - // Canvas has no auto-sizing (its children are free-positioned, not stacked), so - // it needs an explicit bound to clip against -- unlike Rectangle, an unset - // width/height is a configuration error, not a "shrink to content" default. - if (!element.common.width || !element.common.height) - { - throw docraft::exception::InvalidInputException( - "<Canvas> requires explicit 'width' and 'height' attributes"); - } - const auto& data = std::any_cast<const parser::ParsedRectangleData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomCanvas>(); - apply_shape_style(*node, data, [this](const std::string& c) { return resolve_color(c); }); - apply_common_attributes(*node, element.common); - add_children(node, element.children); - return node; - } - - std::shared_ptr<nodes::DocraftLoomCanvas> DocraftLoomTreeBuilder::build_chart(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedChartData&>(element.data); - - if (!data.style) - { - throw docraft::exception::InvalidInputException("<Chart> requires a 'style' attribute"); - } - if (!element.common.width || !element.common.height) - { - throw docraft::exception::InvalidInputException( - "<Chart> requires explicit 'width' and 'height' attributes"); - } - - const auto* builder = charts::DocraftChartBuilderRegistry::instance().find(*data.style); - if (!builder) - { - throw docraft::exception::InvalidInputException("<Chart> unknown style: '" + *data.style + "'"); - } - - charts::DocraftChartBuildContext context; - context.width = *element.common.width; - context.height = *element.common.height; - context.axis_position = parse_chart_axis_position(data.axis_position.value_or("left")); - if (data.title) - { - context.title = render_template_text(*data.title); - } - if (data.x_label) - { - context.x_label = render_template_text(*data.x_label); - } - if (data.y_label) - { - context.y_label = render_template_text(*data.y_label); - } - context.show_percentage = data.show_percentage.value_or(true); - context.font_family = default_font_family_; - - context.series.reserve(element.children.size()); - for (const auto& child : element.children) - { - if (child->tag_name != std::string{tokens::elements::kSeries}) - { - throw docraft::exception::InvalidInputException("<Chart> only accepts <Series> children, got <" - + child->tag_name + ">"); - } - const auto& series_data = std::any_cast<const parser::ParsedSeriesData&>(child->data); - charts::DocraftChartSeries series; - series.name = child->common.name.value_or(""); - series.color = series_data.color ? resolve_color(*series_data.color) - : charts::default_series_color(context.series.size()); - ResolvedSeriesPoints resolved = resolve_series_points(series_data.model.value_or("")); - series.points = std::move(resolved.points); - series.point_labels = std::move(resolved.labels); - context.series.push_back(std::move(series)); - } - - auto canvas = (*builder)(context); - apply_shape_style(*canvas, data, [this](const std::string& c) { return resolve_color(c); }); - apply_common_attributes(*canvas, element.common); - return canvas; - } - - std::shared_ptr<nodes::DocraftLoomCircle> DocraftLoomTreeBuilder::build_circle(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedCircleData&>(element.data); - - // `radius` and `width`+`height` are two mutually exclusive ways of sizing the same - // node: `radius` draws a circle, `width`+`height` inscribe an ellipse in that - // bounding box (its four extreme points touch the middle of each box side, so an - // oval is just a Circle whose box isn't square). Honoring both would make the - // result depend on which one an arbitrary precedence rule happens to prefer, and - // honoring neither used to silently draw nothing at all -- both are rejected here - // instead. Note DocraftLoomCircle has no set_width()/set_height(), so - // apply_common_attributes() below leaves the box attributes alone by design; they - // are consumed here and only here. - const bool has_radius = data.radius.has_value(); - const bool has_width = element.common.width.has_value(); - const bool has_height = element.common.height.has_value(); - if (has_radius && (has_width || has_height)) - { - throw docraft::exception::InvalidInputException( - "<Circle> accepts either 'radius' or 'width'+'height', not both -- they are two " - "mutually exclusive ways of sizing the same shape"); - } - if (!has_radius && !has_width && !has_height) - { - throw docraft::exception::InvalidInputException( - "<Circle> requires a 'radius' attribute, or both 'width' and 'height' to inscribe " - "an oval in that bounding box"); - } - if (!has_radius && (!has_width || !has_height)) - { - throw docraft::exception::InvalidInputException( - std::string("<Circle> sized by its bounding box requires both 'width' and 'height', but only '") - + (has_width ? "width" : "height") + "' was given"); - } - - auto node = std::make_shared<nodes::DocraftLoomCircle>(); - apply_shape_style(*node, data, [this](const std::string& c) { return resolve_color(c); }); - if (has_radius) - { - if (*data.radius <= 0.0F) - { - throw docraft::exception::InvalidInputException("<Circle> 'radius' must be greater than zero"); - } - node->set_radius(*data.radius); - } - else - { - if (*element.common.width <= 0.0F || *element.common.height <= 0.0F) - { - throw docraft::exception::InvalidInputException( - "<Circle> 'width' and 'height' must be greater than zero"); - } - node->set_radii(*element.common.width / 2.0F, *element.common.height / 2.0F); - } - - // Arc: the author gives the two endpoints as points in the node's own box, and - // only their *direction* from the center is used -- the distance comes from the - // circle's own radius, so a point that doesn't land exactly on the outline is - // projected onto it rather than being an error. Two points always have two arcs - // between them; the sweep is fixed clockwise from start to finish, which makes - // the other one reachable simply by swapping the two. - const int arc_attribute_count = static_cast<int>(data.start_x.has_value()) - + static_cast<int>(data.start_y.has_value()) + static_cast<int>(data.finish_x.has_value()) - + static_cast<int>(data.finish_y.has_value()); - if (arc_attribute_count != 0) - { - if (arc_attribute_count != 4) - { - throw docraft::exception::InvalidInputException( - "<Circle> arc requires all of 'start_x', 'start_y', 'finish_x' and 'finish_y'"); - } - if (!node->is_circle()) - { - throw docraft::exception::InvalidInputException( - "<Circle> arcs are supported on circles only, not on an oval sized by " - "differing 'width' and 'height'"); - } - if (data.background_color) - { - throw docraft::exception::InvalidInputException( - "<Circle> arc is an open path and cannot be filled -- drop " - "'background_color', or use <Polygon> for a filled sector"); - } - const nodes::Position center = {.x = node->radius_x(), .y = node->radius_y()}; - const float start = arc_angle_from_point({.x = *data.start_x, .y = *data.start_y}, center); - float finish = arc_angle_from_point({.x = *data.finish_x, .y = *data.finish_y}, center); - // The backend wants a strictly increasing sweep, so an arc wrapping past 12 - // o'clock is expressed by carrying the end angle a full turn further round. - if (finish <= start) - { - finish += 360.0F; - } - node->set_arc(start, finish); - } - - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomTriangle> DocraftLoomTreeBuilder::build_triangle(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedTriangleData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomTriangle>(); - apply_shape_style(*node, data, [this](const std::string& c) { return resolve_color(c); }); - if (!data.points.empty()) - { - node->set_points(data.points); - } - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomPolygon> DocraftLoomTreeBuilder::build_polygon(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedPolygonData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomPolygon>(); - apply_shape_style(*node, data, [this](const std::string& c) { return resolve_color(c); }); - if (!data.points.empty()) - { - node->set_points(data.points); - } - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomCurveLine> DocraftLoomTreeBuilder::build_curve_line(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedCurveLineData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomCurveLine>(); - // An open curve is well defined through 2 points, where a closed <Polygon> needs - // 3 -- rejecting fewer here, rather than letting the renderer skip the node, - // keeps a mis-specified curve from silently drawing nothing. - if (data.points.size() < 2U) - { - throw docraft::exception::InvalidInputException( - "<CurveLine> requires at least 2 points, got " + std::to_string(data.points.size())); - } - node->set_points(data.points); - if (data.border_color) - { - node->set_border_color(resolve_color(*data.border_color)); - } - if (data.border_width) - { - node->set_border_width(*data.border_width); - } - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomLine> DocraftLoomTreeBuilder::build_line(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedLineData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomLine>(); - nodes::Position start = node->start(); - nodes::Position end = node->end(); - if (data.x1) - { - start.x = *data.x1; - } - if (data.y1) - { - start.y = *data.y1; - } - if (data.x2) - { - end.x = *data.x2; - } - if (data.y2) - { - end.y = *data.y2; - } - node->set_start(start); - node->set_end(end); - if (data.border_color) - { - node->set_border_color(resolve_color(*data.border_color)); - } - if (data.border_width) - { - node->set_border_width(*data.border_width); - } - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomText> DocraftLoomTreeBuilder::build_text(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedTextData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomText>(); - fill_text_node(*node, data); - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomTitle> DocraftLoomTreeBuilder::build_title(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedTextData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomTitle>(); - // fill_text_node only overwrites fields data actually set (see its own - // if (data.xxx) checks) -- an explicit font-size attribute still wins, - // otherwise DocraftLoomTitle's own constructor default stands. - fill_text_node(*node, data); - // Keep margin coherent with whatever font_size ended up being used (constructor - // default or an explicit attribute) -- 1em, matching the constructor's own - // convention -- so overriding font-size doesn't leave a disproportionate, - // fixed-constant gap. An explicit margin attribute below still overrides this. - node->set_margin(node->font_size()); - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomSubtitle> DocraftLoomTreeBuilder::build_subtitle(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedTextData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomSubtitle>(); - fill_text_node(*node, data); - // See build_title's identical comment above. - node->set_margin(node->font_size()); - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomPageNumber> DocraftLoomTreeBuilder::build_page_number( - const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedPageNumberData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomPageNumber>(); - fill_page_number_node(*node, data); - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomImage> DocraftLoomTreeBuilder::build_image(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedImageData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomImage>(); - fill_image_node(*node, data); - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomBlankLine> DocraftLoomTreeBuilder::build_blank_line(const ParsedElement& element) - { - auto node = std::make_shared<nodes::DocraftLoomBlankLine>(); - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomNewPage> DocraftLoomTreeBuilder::build_new_page(const ParsedElement& element) - { - auto node = std::make_shared<nodes::DocraftLoomNewPage>(); - apply_common_attributes(*node, element.common); - return node; - } - - std::shared_ptr<nodes::DocraftLoomList> DocraftLoomTreeBuilder::build_list(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedListData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomList>(); - node->set_kind(data.kind == parser::ParsedListKind::kOrdered - ? nodes::ListKind::kOrdered - : nodes::ListKind::kUnordered); - if (data.ordered_style) - { - node->set_ordered_style(*data.ordered_style == parser::ParsedOrderedListStyle::kNumber - ? nodes::OrderedListStyle::kNumber - : nodes::OrderedListStyle::kRoman); - } - if (data.unordered_dot) - { - nodes::UnorderedListDot dot = nodes::UnorderedListDot::kCircle; - switch (*data.unordered_dot) - { - case parser::ParsedUnorderedListDot::kDash: - dot = nodes::UnorderedListDot::kDash; - break; - case parser::ParsedUnorderedListDot::kStar: - dot = nodes::UnorderedListDot::kStar; - break; - case parser::ParsedUnorderedListDot::kBox: - dot = nodes::UnorderedListDot::kBox; - break; - case parser::ParsedUnorderedListDot::kCircle: - default: - dot = nodes::UnorderedListDot::kCircle; - break; - } - node->set_unordered_dot(dot); - } - apply_common_attributes(*node, element.common); - add_children(node, element.children); - return node; - } - - std::shared_ptr<nodes::DocraftLoomTable> DocraftLoomTreeBuilder::build_table(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedTableData&>(element.data); - auto table = std::make_shared<nodes::DocraftLoomTable>(); - - // Table structure is parsed specially (no generic child recursion -- see - // DocraftCraftLanguageParser::parse_node's Table early-return), so a <Foreach> - // can't appear as a literal child of a <Table>. That doesn't mean current_foreach_item_ - // should be hidden from this Table's own attributes/content, though: if the Table - // itself is nested inside an outer <Foreach>, its `model`/`header` attributes and its - // cell content must still resolve `${data("field")}` against that outer item -- the - // ambient current_foreach_item_ is left untouched here and simply flows through - // resolve_table_model_json()/resolve_table_header()/build_content_cell() (via - // render_template_text()). build_templated_model_rows() below additionally overrides - // it per JSON-model object, scoped to that object's own rows. - if (data.baseline_offset) - { - table->set_baseline_offset(*data.baseline_offset); - } - if (data.default_cell_background) - { - table->set_default_cell_background( - resolve_color(*data.default_cell_background)); - } - - const bool is_vertical = data.orientation == parser::ParsedTableOrientation::kVertical; - - if (data.model_data_template) - { - const nlohmann::json model_json = resolve_table_model_json(*data.model_data_template); - - if (model_json.front().is_object()) - { - // `model` is a JSON array of objects -- the explicit <TBody> (already parsed - // into data.rows) is used as a per-object row template rather than literal - // rows; see build_templated_model_rows(). - build_templated_model_rows(*table, model_json, data); - apply_common_attributes(*table, element.common); - return table; - } - - // `model` is a JSON array of arrays (a string matrix): an explicit <TBody> has - // no role here (there's no per-object data to bind it against), so require the - // author to pick one or the other rather than silently dropping their <TBody>. - if (!data.rows.empty()) - { - throw docraft::exception::InvalidInputException( - "<Table> 'model' resolving to a JSON array of arrays cannot be combined with an explicit <TBody>"); - } - - const std::vector<std::vector<std::string>> matrix = to_string_matrix(model_json); - add_table_model_header_row(*table, data, matrix.front().size()); - - for (const auto& matrix_row : matrix) - { - std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> row; - row.reserve(matrix_row.size()); - for (const auto& cell_text : matrix_row) - { - row.push_back(build_matrix_cell(cell_text)); - } - table->add_row(row); - } - - apply_common_attributes(*table, element.common); - return table; - } - - if (!data.header_titles.empty()) - { - std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> header_row; - if (is_vertical) - { - // The label column has no header of its own -- pad with a blank title - // cell so the header row aligns under the row-title column. - auto corner = std::make_shared<nodes::DocraftLoomTableCell>(); - corner->set_content(std::make_shared<nodes::DocraftLoomText>("")); - corner->set_is_title(true); - header_row.push_back(corner); - } - for (const auto& title : data.header_titles) - { - header_row.push_back(build_title_cell(title)); - } - table->add_row(header_row); - } - - for (const auto& row_data : data.rows) - { - std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> row; - if (is_vertical && row_data.row_title) - { - row.push_back(build_title_cell(*row_data.row_title)); - } - for (const auto& cell_data : row_data.cells) - { - row.push_back(build_content_cell(cell_data)); - } - table->add_row(row); - } - - apply_common_attributes(*table, element.common); - return table; - } - - std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomTreeBuilder::build_layout(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedLayoutData&>(element.data); - - // The only way to specify per-child weights is `weight="..."` on each child of - // <Layout> itself (already validated at parse time -- `weight` is rejected - // anywhere else). A missing weight defaults to 1.0, matching - // distribute_weighted_amounts()'s own default for a missing/non-positive entry. - std::vector<float> weights; - const bool any_child_weight = std::ranges::any_of( - element.children, - [](const auto& child) { return child->common.weight.has_value(); }); - if (any_child_weight) - { - weights.reserve(element.children.size()); - for (const auto& child : element.children) - { - weights.push_back(child->common.weight.value_or(1.0F)); - } - } - - if (data.orientation == parser::ParsedLayoutOrientation::kHorizontal) - { - auto node = std::make_shared<nodes::DocraftLoomHStack>(); - if (data.spacing) - { - node->set_spacing(*data.spacing); - } - if (!weights.empty()) - { - node->set_weights(weights); - } - apply_common_attributes(*node, element.common); - add_children(node, element.children); - return node; - } - auto node = std::make_shared<nodes::DocraftLoomVStack>(); - if (data.spacing) - { - node->set_spacing(*data.spacing); - } - if (!weights.empty()) - { - node->set_weights(weights); - } - apply_common_attributes(*node, element.common); - add_children(node, element.children); - return node; - } - - std::shared_ptr<nodes::DocraftLoomParagraph> DocraftLoomTreeBuilder::build_paragraph(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedParagraphData&>(element.data); - auto node = std::make_shared<nodes::DocraftLoomParagraph>(); - if (data.line_spacing) - { - node->set_line_spacing(*data.line_spacing); - } - if (data.space_before) - { - node->set_space_before(*data.space_before); - } - if (data.space_after) - { - node->set_space_after(*data.space_after); - } - if (data.alignment) - { - node->set_alignment(to_loom_alignment(*data.alignment)); - } - apply_common_attributes(*node, element.common); - // Bare PCDATA directly inside <Paragraph>text</Paragraph> is invisible to - // add_children() below (it only walks XML element children, see ParsedElement's - // recursion) -- surface it here as an implicit Text child so a Paragraph with no - // explicit <Text> wrapper still renders, matching the natural way authors write - // Craft Language paragraphs. - if (!data.text.empty()) - { - auto text_node = std::make_shared<nodes::DocraftLoomText>(); - text_node->set_text(render_template_text(data.text)); - node->add_child(text_node); - } - add_children(node, element.children); - return node; - } - - std::shared_ptr<nodes::DocraftLoomVStack> DocraftLoomTreeBuilder::build_section(const ParsedElement& element) - { - const auto& data = std::any_cast<const parser::ParsedSectionData&>(element.data); - // Header/Body/Footer only ever use background_color/border_color/border_width - // from ParsedSectionData -- never Rectangle's width_/height_/padding_ -- so a - // VStack (with its own composed DocraftLoomShapeStyle, see - // DocraftLoomVStack::style()) is a closer fit than Rectangle and reuses the - // vertical-stacking flow instead of duplicating it. - auto node = std::make_shared<nodes::DocraftLoomVStack>(); - apply_shape_style(*node, data, [this](const std::string& c) { return resolve_color(c); }); - // Margins (margin_top/bottom/left/right) are not a rectangle concept -- they are - // consumed directly from ParsedSectionData by DocraftLoomCraftLanguageParser to - // build a DocraftLoomPdfCreator::Margins, not applied here. - apply_common_attributes(*node, element.common); - add_children(node, element.children); - return node; - } } // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/docraft_loom_tree_builder_utils.cc b/docraft/src/docraft/loom/craft/docraft_loom_tree_builder_utils.cc new file mode 100644 index 0000000..9728964 --- /dev/null +++ b/docraft/src/docraft/loom/craft/docraft_loom_tree_builder_utils.cc @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" + +#include <cctype> + +namespace docraft::loom::craft { + namespace parser = docraft::craft::parser; + + void apply_style(nodes::DocraftLoomText& node, parser::ParsedTextStyle style) + { + switch (style) + { + case parser::ParsedTextStyle::kBold: + node.set_bold(true); + node.set_italic(false); + break; + case parser::ParsedTextStyle::kItalic: + node.set_bold(false); + node.set_italic(true); + break; + case parser::ParsedTextStyle::kBoldItalic: + node.set_bold(true); + node.set_italic(true); + break; + case parser::ParsedTextStyle::kNormal: + default: + node.set_bold(false); + node.set_italic(false); + break; + } + } + + nodes::TextAlignment to_loom_alignment(parser::ParsedTextAlignment alignment) + { + switch (alignment) + { + case parser::ParsedTextAlignment::kLeft: + return nodes::TextAlignment::kLeft; + case parser::ParsedTextAlignment::kRight: + return nodes::TextAlignment::kRight; + case parser::ParsedTextAlignment::kJustified: + return nodes::TextAlignment::kJustified; + case parser::ParsedTextAlignment::kCenter: + default: + return nodes::TextAlignment::kCenter; + } + } + + namespace { + bool is_word_char(char ch) + { + const auto uch = static_cast<unsigned char>(ch); + return std::isalnum(uch) != 0 || ch == '_'; + } + } // namespace + + std::string normalize_single_quoted_json(std::string_view input) + { + std::string output; + output.reserve(input.size()); + bool in_single = false; + bool in_double = false; + for (std::size_t i = 0; i < input.size(); ++i) + { + const char ch = input[i]; + if (ch == '\\' && (i + 1) < input.size()) + { + const char next = input[i + 1]; + if (in_single && (next == '\\' || next == '\'')) + { + // Escaped backslash/single-quote inside a single-quoted literal -- + // drop the backslash, since the bare escaped char is fine once the + // delimiters below are converted to double quotes. + output.push_back(next); + ++i; + continue; + } + if (in_single || in_double) + { + // Any other escape inside a string (`\"`/`\\` while in_double, or a + // JSON escape like `\n` in either) must be consumed as a unit so the + // escaped character -- notably an escaped quote -- can't be read on + // its own as a delimiter and toggle in_single/in_double. + output.push_back(ch); + output.push_back(next); + ++i; + continue; + } + output.push_back(ch); + continue; + } + if (ch == '\'' && !in_double) + { + if (in_single) + { + const char prev = (i > 0) ? input[i - 1] : '\0'; + const char next = ((i + 1) < input.size()) ? input[i + 1] : '\0'; + if (is_word_char(prev) && is_word_char(next)) + { + output.push_back('\''); + continue; + } + } + in_single = !in_single; + output.push_back('"'); + continue; + } + if (ch == '"' && !in_single) + { + in_double = !in_double; + output.push_back(ch); + continue; + } + output.push_back(ch); + } + return output; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.cc new file mode 100644 index 0000000..0c0990d --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_blank_line_handler.cc @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_blank_line_handler.h" + +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_blank_line.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomBlankLineHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& /*context*/) + { + auto node = std::make_shared<nodes::DocraftLoomBlankLine>(); + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc new file mode 100644 index 0000000..955eb0e --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_canvas_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/exception/docraft_exceptions.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_canvas.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomCanvasHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + // Canvas has no auto-sizing (its children are free-positioned, not stacked), so + // it needs an explicit bound to clip against -- unlike Rectangle, an unset + // width/height is a configuration error, not a "shrink to content" default. + if (!element.common.width || !element.common.height) + { + throw docraft::exception::InvalidInputException( + "<Canvas> requires explicit 'width' and 'height' attributes"); + } + const auto& data = std::any_cast<const docraft::craft::parser::ParsedRectangleData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomCanvas>(); + apply_shape_style(*node, data, [&context](const std::string& c) { return context.resolve_color(c); }); + apply_common_attributes(*node, element.common); + context.add_children(node, element.children); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc new file mode 100644 index 0000000..dc17f3d --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc @@ -0,0 +1,171 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_chart_handler.h" + +#include <any> +#include <optional> +#include <utility> +#include <vector> + +#include <nlohmann/json.hpp> + +#include "docraft/craft/docraft_craft_language_tokens.h" +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/exception/docraft_exceptions.h" +#include "docraft/loom/charts/docraft_chart_types.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_node.h" + +namespace docraft::loom::craft { + namespace tokens = docraft::craft; + namespace parser = docraft::craft::parser; + + struct DocraftLoomChartHandler::ResolvedSeriesPoints + { + std::vector<nodes::Position> points; + std::vector<std::optional<std::string>> labels; + }; + + DocraftLoomChartHandler::ResolvedSeriesPoints DocraftLoomChartHandler::resolve_series_points( + const std::string& raw, DocraftLoomTableHandlerContext& context) + { + if (raw.empty()) + { + return {}; + } + const std::string normalized = normalize_single_quoted_json(context.render_template_text(raw)); + + nlohmann::json parsed; + try + { + parsed = nlohmann::json::parse(normalized); + } + catch (const nlohmann::json::exception& e) + { + throw docraft::exception::DataFormatException( + std::string{"<Series> 'model' is not valid JSON: "} + e.what()); + } + if (!parsed.is_array()) + { + throw docraft::exception::DataFormatException("<Series> 'model' must resolve to a JSON array"); + } + + ResolvedSeriesPoints resolved; + resolved.points.reserve(parsed.size()); + resolved.labels.reserve(parsed.size()); + for (const auto& item : parsed) + { + float x, y; + std::optional<std::string> label; + if (item.is_array() && item.size() == 2 && item[0].is_number() && item[1].is_number()) + { + x = item[0].get<float>(); + y = item[1].get<float>(); + } + else if (item.is_object() && item.contains("x") && item.contains("y") + && item["x"].is_number() && item["y"].is_number()) + { + x = item["x"].get<float>(); + y = item["y"].get<float>(); + } + else if (item.is_object() && item.size() == 1 && item.begin().value().is_number()) + { + // The shape pie/histogram model data always uses: a single "label": + // value entry. There's no explicit x, so its ordinal position in the + // array becomes x (a category index) -- histogram/pie map that back to + // an X-axis tick label / slice label via the parallel `label` below. + x = static_cast<float>(resolved.points.size()); + y = item.begin().value().get<float>(); + label = item.begin().key(); + } + else + { + throw docraft::exception::DataFormatException( + "<Series> 'model' entries must be [x,y] pairs, {\"x\":..,\"y\":..} objects, or " + "{\"label\":value} objects"); + } + resolved.points.push_back({.x = x, .y = y}); + resolved.labels.push_back(std::move(label)); + } + return resolved; + } + + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomChartHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const parser::ParsedChartData&>(element.data); + + if (!data.style) + { + throw docraft::exception::InvalidInputException("<Chart> requires a 'style' attribute"); + } + if (!element.common.width || !element.common.height) + { + throw docraft::exception::InvalidInputException( + "<Chart> requires explicit 'width' and 'height' attributes"); + } + + const auto* builder = charts::DocraftChartBuilderRegistry::instance().find(*data.style); + if (!builder) + { + throw docraft::exception::InvalidInputException("<Chart> unknown style: '" + *data.style + "'"); + } + + charts::DocraftChartBuildContext build_context; + build_context.width = *element.common.width; + build_context.height = *element.common.height; + build_context.axis_position = charts::parse_chart_axis_position(data.axis_position.value_or("left")); + if (data.title) + { + build_context.title = context.render_template_text(*data.title); + } + if (data.x_label) + { + build_context.x_label = context.render_template_text(*data.x_label); + } + if (data.y_label) + { + build_context.y_label = context.render_template_text(*data.y_label); + } + build_context.show_percentage = data.show_percentage.value_or(true); + build_context.font_family = context.default_font_family(); + + build_context.series.reserve(element.children.size()); + for (const auto& child : element.children) + { + if (child->tag_name != std::string{tokens::elements::kSeries}) + { + throw docraft::exception::InvalidInputException("<Chart> only accepts <Series> children, got <" + + child->tag_name + ">"); + } + const auto& series_data = std::any_cast<const parser::ParsedSeriesData&>(child->data); + charts::DocraftChartSeries series; + series.name = child->common.name.value_or(""); + series.color = series_data.color ? context.resolve_color(*series_data.color) + : charts::default_series_color(build_context.series.size()); + ResolvedSeriesPoints resolved = resolve_series_points(series_data.model.value_or(""), context); + series.points = std::move(resolved.points); + series.point_labels = std::move(resolved.labels); + build_context.series.push_back(std::move(series)); + } + + auto canvas = (*builder)(build_context); + apply_shape_style(*canvas, data, [&context](const std::string& c) { return context.resolve_color(c); }); + apply_common_attributes(*canvas, element.common); + return canvas; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc new file mode 100644 index 0000000..0b055ed --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_circle_handler.h" + +#include <any> +#include <cmath> +#include <numbers> + +#include "docraft/craft/parser/docraft_circle_parser.h" +#include "docraft/exception/docraft_exceptions.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_circle.h" + +namespace docraft::loom::craft { + namespace { + // Direction of `point` as seen from `center`, in degrees from 12 o'clock and + // increasing clockwise on the page -- the convention + // IDocraftShapeRenderingBackend::draw_arc() documents. Both are in the node's own + // Y-down box coordinates, hence negating dy to get the "up is zero" reference. + float arc_angle_from_point(const nodes::Position& point, const nodes::Position& center) + { + const float dx = point.x - center.x; + const float dy = point.y - center.y; + const float degrees = std::atan2(dx, -dy) * 180.0F / std::numbers::pi_v<float>; + return degrees < 0.0F ? degrees + 360.0F : degrees; + } + } // namespace + + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomCircleHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedCircleData&>(element.data); + + // `radius` and `width`+`height` are two mutually exclusive ways of sizing the same + // node: `radius` draws a circle, `width`+`height` inscribe an ellipse in that + // bounding box (its four extreme points touch the middle of each box side, so an + // oval is just a Circle whose box isn't square). Honoring both would make the + // result depend on which one an arbitrary precedence rule happens to prefer, and + // honoring neither used to silently draw nothing at all -- both are rejected here + // instead. Note DocraftLoomCircle has no set_width()/set_height(), so + // apply_common_attributes() below leaves the box attributes alone by design; they + // are consumed here and only here. + const bool has_radius = data.radius.has_value(); + const bool has_width = element.common.width.has_value(); + const bool has_height = element.common.height.has_value(); + if (has_radius && (has_width || has_height)) + { + throw docraft::exception::InvalidInputException( + "<Circle> accepts either 'radius' or 'width'+'height', not both -- they are two " + "mutually exclusive ways of sizing the same shape"); + } + if (!has_radius && !has_width && !has_height) + { + throw docraft::exception::InvalidInputException( + "<Circle> requires a 'radius' attribute, or both 'width' and 'height' to inscribe " + "an oval in that bounding box"); + } + if (!has_radius && (!has_width || !has_height)) + { + throw docraft::exception::InvalidInputException( + std::string("<Circle> sized by its bounding box requires both 'width' and 'height', but only '") + + (has_width ? "width" : "height") + "' was given"); + } + + auto node = std::make_shared<nodes::DocraftLoomCircle>(); + apply_shape_style(*node, data, [&context](const std::string& c) { return context.resolve_color(c); }); + if (has_radius) + { + if (*data.radius <= 0.0F) + { + throw docraft::exception::InvalidInputException("<Circle> 'radius' must be greater than zero"); + } + node->set_radius(*data.radius); + } + else + { + if (*element.common.width <= 0.0F || *element.common.height <= 0.0F) + { + throw docraft::exception::InvalidInputException( + "<Circle> 'width' and 'height' must be greater than zero"); + } + node->set_radii(*element.common.width / 2.0F, *element.common.height / 2.0F); + } + + // Arc: the author gives the two endpoints as points in the node's own box, and + // only their *direction* from the center is used -- the distance comes from the + // circle's own radius, so a point that doesn't land exactly on the outline is + // projected onto it rather than being an error. Two points always have two arcs + // between them; the sweep is fixed clockwise from start to finish, which makes + // the other one reachable simply by swapping the two. + const int arc_attribute_count = static_cast<int>(data.start_x.has_value()) + + static_cast<int>(data.start_y.has_value()) + static_cast<int>(data.finish_x.has_value()) + + static_cast<int>(data.finish_y.has_value()); + if (arc_attribute_count != 0) + { + if (arc_attribute_count != 4) + { + throw docraft::exception::InvalidInputException( + "<Circle> arc requires all of 'start_x', 'start_y', 'finish_x' and 'finish_y'"); + } + if (!node->is_circle()) + { + throw docraft::exception::InvalidInputException( + "<Circle> arcs are supported on circles only, not on an oval sized by " + "differing 'width' and 'height'"); + } + if (data.background_color) + { + throw docraft::exception::InvalidInputException( + "<Circle> arc is an open path and cannot be filled -- drop " + "'background_color', or use <Polygon> for a filled sector"); + } + const nodes::Position center = {.x = node->radius_x(), .y = node->radius_y()}; + const float start = arc_angle_from_point({.x = *data.start_x, .y = *data.start_y}, center); + float finish = arc_angle_from_point({.x = *data.finish_x, .y = *data.finish_y}, center); + // The backend wants a strictly increasing sweep, so an arc wrapping past 12 + // o'clock is expressed by carrying the end angle a full turn further round. + if (finish <= start) + { + finish += 360.0F; + } + node->set_arc(start, finish); + } + + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc new file mode 100644 index 0000000..62fdbdf --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_curve_line_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_curve_line_parser.h" +#include "docraft/exception/docraft_exceptions.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_curve_line.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomCurveLineHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedCurveLineData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomCurveLine>(); + // An open curve is well defined through 2 points, where a closed <Polygon> needs + // 3 -- rejecting fewer here, rather than letting the renderer skip the node, + // keeps a mis-specified curve from silently drawing nothing. + if (data.points.size() < 2U) + { + throw docraft::exception::InvalidInputException( + "<CurveLine> requires at least 2 points, got " + std::to_string(data.points.size())); + } + node->set_points(data.points); + if (data.border_color) + { + node->set_border_color(context.resolve_color(*data.border_color)); + } + if (data.border_width) + { + node->set_border_width(*data.border_width); + } + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_image_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_image_handler.cc new file mode 100644 index 0000000..31ae396 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_image_handler.cc @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_image_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_image.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomImageHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedImageData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomImage>(); + context.fill_image_node(*node, data); + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc new file mode 100644 index 0000000..442055a --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_layout_handler.h" + +#include <algorithm> +#include <any> +#include <vector> + +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_hstack.h" +#include "docraft/loom/nodes/docraft_loom_vstack.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomLayoutHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedLayoutData&>(element.data); + + // The only way to specify per-child weights is `weight="..."` on each child of + // <Layout> itself (already validated at parse time -- `weight` is rejected + // anywhere else). A missing weight defaults to 1.0, matching + // distribute_weighted_amounts()'s own default for a missing/non-positive entry. + std::vector<float> weights; + const bool any_child_weight = std::ranges::any_of( + element.children, + [](const auto& child) { return child->common.weight.has_value(); }); + if (any_child_weight) + { + weights.reserve(element.children.size()); + for (const auto& child : element.children) + { + weights.push_back(child->common.weight.value_or(1.0F)); + } + } + + if (data.orientation == docraft::craft::parser::ParsedLayoutOrientation::kHorizontal) + { + auto node = std::make_shared<nodes::DocraftLoomHStack>(); + if (data.spacing) + { + node->set_spacing(*data.spacing); + } + if (!weights.empty()) + { + node->set_weights(weights); + } + apply_common_attributes(*node, element.common); + context.add_children(node, element.children); + return node; + } + auto node = std::make_shared<nodes::DocraftLoomVStack>(); + if (data.spacing) + { + node->set_spacing(*data.spacing); + } + if (!weights.empty()) + { + node->set_weights(weights); + } + apply_common_attributes(*node, element.common); + context.add_children(node, element.children); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc new file mode 100644 index 0000000..ee7143f --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_line_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_line_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_line.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomLineHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedLineData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomLine>(); + nodes::Position start = node->start(); + nodes::Position end = node->end(); + if (data.x1) + { + start.x = *data.x1; + } + if (data.y1) + { + start.y = *data.y1; + } + if (data.x2) + { + end.x = *data.x2; + } + if (data.y2) + { + end.y = *data.y2; + } + node->set_start(start); + node->set_end(end); + if (data.border_color) + { + node->set_border_color(context.resolve_color(*data.border_color)); + } + if (data.border_width) + { + node->set_border_width(*data.border_width); + } + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc new file mode 100644 index 0000000..d3888e3 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_list_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_list.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomListHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedListData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomList>(); + node->set_kind(data.kind == docraft::craft::parser::ParsedListKind::kOrdered + ? nodes::ListKind::kOrdered + : nodes::ListKind::kUnordered); + if (data.ordered_style) + { + node->set_ordered_style(*data.ordered_style == docraft::craft::parser::ParsedOrderedListStyle::kNumber + ? nodes::OrderedListStyle::kNumber + : nodes::OrderedListStyle::kRoman); + } + if (data.unordered_dot) + { + nodes::UnorderedListDot dot = nodes::UnorderedListDot::kCircle; + switch (*data.unordered_dot) + { + case docraft::craft::parser::ParsedUnorderedListDot::kDash: + dot = nodes::UnorderedListDot::kDash; + break; + case docraft::craft::parser::ParsedUnorderedListDot::kStar: + dot = nodes::UnorderedListDot::kStar; + break; + case docraft::craft::parser::ParsedUnorderedListDot::kBox: + dot = nodes::UnorderedListDot::kBox; + break; + case docraft::craft::parser::ParsedUnorderedListDot::kCircle: + default: + dot = nodes::UnorderedListDot::kCircle; + break; + } + node->set_unordered_dot(dot); + } + apply_common_attributes(*node, element.common); + context.add_children(node, element.children); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_new_page_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_new_page_handler.cc new file mode 100644 index 0000000..85f7010 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_new_page_handler.cc @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_new_page_handler.h" + +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_new_page.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomNewPageHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& /*context*/) + { + auto node = std::make_shared<nodes::DocraftLoomNewPage>(); + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_page_number_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_page_number_handler.cc new file mode 100644 index 0000000..99a441b --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_page_number_handler.cc @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_page_number_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_page_number.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomPageNumberHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedPageNumberData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomPageNumber>(); + context.fill_page_number_node(*node, data); + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc new file mode 100644 index 0000000..330dd37 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_paragraph_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_paragraph_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_paragraph.h" +#include "docraft/loom/nodes/docraft_loom_text.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomParagraphHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedParagraphData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomParagraph>(); + if (data.line_spacing) + { + node->set_line_spacing(*data.line_spacing); + } + if (data.space_before) + { + node->set_space_before(*data.space_before); + } + if (data.space_after) + { + node->set_space_after(*data.space_after); + } + if (data.alignment) + { + node->set_alignment(to_loom_alignment(*data.alignment)); + } + apply_common_attributes(*node, element.common); + // Bare PCDATA directly inside <Paragraph>text</Paragraph> is invisible to + // add_children() below (it only walks XML element children, see ParsedElement's + // recursion) -- surface it here as an implicit Text child so a Paragraph with no + // explicit <Text> wrapper still renders, matching the natural way authors write + // Craft Language paragraphs. + if (!data.text.empty()) + { + auto text_node = std::make_shared<nodes::DocraftLoomText>(); + text_node->set_text(context.render_template_text(data.text)); + node->add_child(text_node); + } + context.add_children(node, element.children); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_polygon_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_polygon_handler.cc new file mode 100644 index 0000000..4124382 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_polygon_handler.cc @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_polygon_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_polygon_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_polygon.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomPolygonHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedPolygonData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomPolygon>(); + apply_shape_style(*node, data, [&context](const std::string& c) { return context.resolve_color(c); }); + if (!data.points.empty()) + { + node->set_points(data.points); + } + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.cc new file mode 100644 index 0000000..ebe8574 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_rectangle_handler.cc @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_rectangle_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_rectangle.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomRectangleHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedRectangleData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomRectangle>(); + apply_shape_style(*node, data, [&context](const std::string& c) { return context.resolve_color(c); }); + apply_common_attributes(*node, element.common); + context.add_children(node, element.children); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_section_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_section_handler.cc new file mode 100644 index 0000000..484896b --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_section_handler.cc @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_section_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_section_parsers.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_vstack.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomSectionHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedSectionData&>(element.data); + // Header/Body/Footer only ever use background_color/border_color/border_width + // from ParsedSectionData -- never Rectangle's width_/height_/padding_ -- so a + // VStack (with its own composed DocraftLoomShapeStyle, see + // DocraftLoomVStack::style()) is a closer fit than Rectangle and reuses the + // vertical-stacking flow instead of duplicating it. + auto node = std::make_shared<nodes::DocraftLoomVStack>(); + apply_shape_style(*node, data, [&context](const std::string& c) { return context.resolve_color(c); }); + // Margins (margin_top/bottom/left/right) are not a rectangle concept -- they are + // consumed directly from ParsedSectionData by DocraftLoomCraftLanguageParser to + // build a DocraftLoomPdfCreator::Margins, not applied here. + apply_common_attributes(*node, element.common); + context.add_children(node, element.children); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc new file mode 100644 index 0000000..5cdcb91 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc @@ -0,0 +1,410 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_table_handler.h" + +#include <any> + +#include <nlohmann/json.hpp> + +#include "docraft/craft/docraft_craft_language_tokens.h" +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/exception/docraft_exceptions.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/templating/docraft_template_engine.h" +#include "docraft/loom/nodes/docraft_loom_image.h" +#include "docraft/loom/nodes/docraft_loom_table.h" +#include "docraft/loom/nodes/docraft_loom_table_cell.h" +#include "docraft/loom/nodes/docraft_loom_text.h" + +namespace docraft::loom::craft { + namespace tokens = docraft::craft; + namespace parser = docraft::craft::parser; + + namespace { + std::shared_ptr<nodes::DocraftLoomTableCell> build_matrix_cell(const std::string& text) + { + auto cell = std::make_shared<nodes::DocraftLoomTableCell>(); + cell->set_content(std::make_shared<nodes::DocraftLoomText>(text)); + return cell; + } + + // RAII guard that overrides the context's current Foreach item for its lifetime + // and restores the previous value on scope exit (including via an exception) -- + // used by build_templated_model_rows() to bind each JSON-model object to its + // cloned row template. + class ForeachItemScope + { + public: + ForeachItemScope(DocraftLoomTableHandlerContext& context, const nlohmann::json* value) + : context_(context), previous_(context.current_foreach_item()) + { + context_.set_current_foreach_item(value); + } + ~ForeachItemScope() { context_.set_current_foreach_item(previous_); } + ForeachItemScope(const ForeachItemScope&) = delete; + ForeachItemScope& operator=(const ForeachItemScope&) = delete; + + private: + DocraftLoomTableHandlerContext& context_; + const nlohmann::json* previous_; + }; + } // namespace + + std::shared_ptr<nodes::DocraftLoomTableCell> DocraftLoomTableHandler::build_title_cell( + const parser::ParsedTableTitleData& title, DocraftLoomTableHandlerContext& context) + { + auto text = std::make_shared<nodes::DocraftLoomText>( + context.template_engine().render_template_string(title.text)); + text->set_alignment(to_loom_alignment(title.alignment)); + apply_style(*text, title.style); + if (title.color) + { + text->set_color(context.resolve_color(*title.color)); + } + if (title.font_size) + { + text->set_font_size(*title.font_size); + } + auto cell = std::make_shared<nodes::DocraftLoomTableCell>(); + cell->set_content(text); + cell->set_is_title(true); + if (title.background) + { + cell->set_background(context.resolve_color(*title.background)); + } + return cell; + } + + std::shared_ptr<nodes::DocraftLoomTableCell> DocraftLoomTableHandler::build_content_cell( + const parser::ParsedTableCellData& cell_data, DocraftLoomTableHandlerContext& context) + { + auto cell = std::make_shared<nodes::DocraftLoomTableCell>(); + + // Cell content is built directly from ParsedTableCellData rather than via the + // generic tag-handler dispatch (Table content isn't recursed into generically -- + // see DocraftLoomTableHandler::build()), so the usual "visible" check never runs + // for it; replicate that check here, or an explicitly-invisible Text/Image inside + // a <Cell> would still render, unlike everywhere else in the document. + const bool visible = !cell_data.content_common.visible.has_value() || *cell_data.content_common.visible; + if (visible) + { + std::shared_ptr<nodes::DocraftLoomNode> content; + if (cell_data.content_tag_name == std::string{tokens::elements::kText}) + { + auto text_node = std::make_shared<nodes::DocraftLoomText>(); + context.fill_text_node(*text_node, std::any_cast<const parser::ParsedTextData&>(cell_data.content)); + apply_common_attributes(*text_node, cell_data.content_common); + content = text_node; + } + else + { + auto image_node = std::make_shared<nodes::DocraftLoomImage>(); + context.fill_image_node(*image_node, std::any_cast<const parser::ParsedImageData&>(cell_data.content)); + apply_common_attributes(*image_node, cell_data.content_common); + content = image_node; + } + cell->set_content(content); + } + + if (cell_data.background) + { + cell->set_background(context.resolve_color(*cell_data.background)); + } + if (cell_data.width) + { + cell->set_explicit_width(*cell_data.width); + } + return cell; + } + + nlohmann::json DocraftLoomTableHandler::resolve_table_model_json(const std::string& raw, + DocraftLoomTableHandlerContext& context) + { + const std::string normalized = normalize_single_quoted_json(context.render_template_text(raw)); + + nlohmann::json parsed; + try + { + parsed = nlohmann::json::parse(normalized); + } + catch (const nlohmann::json::exception& e) + { + throw docraft::exception::DataFormatException( + std::string{"<Table> 'model' is not valid JSON: "} + e.what()); + } + if (!parsed.is_array() || parsed.empty()) + { + throw docraft::exception::DataFormatException("<Table> 'model' must resolve to a non-empty JSON array"); + } + return parsed; + } + + std::vector<std::vector<std::string>> DocraftLoomTableHandler::to_string_matrix(const nlohmann::json& parsed) + { + std::vector<std::vector<std::string>> matrix; + matrix.reserve(parsed.size()); + for (const auto& row : parsed) + { + if (!row.is_array() || row.empty()) + { + throw docraft::exception::DataFormatException( + "<Table> 'model' rows must be non-empty JSON arrays"); + } + if (!matrix.empty() && row.size() != matrix.front().size()) + { + throw docraft::exception::DataFormatException( + "<Table> 'model' rows must all have the same length"); + } + std::vector<std::string> parsed_row; + parsed_row.reserve(row.size()); + for (const auto& cell : row) + { + if (!cell.is_string()) + { + throw docraft::exception::DataFormatException("<Table> 'model' cells must be strings"); + } + parsed_row.push_back(cell.get<std::string>()); + } + matrix.push_back(std::move(parsed_row)); + } + return matrix; + } + + std::vector<std::string> DocraftLoomTableHandler::resolve_table_header(const std::string& raw, + DocraftLoomTableHandlerContext& context) + { + const std::string normalized = normalize_single_quoted_json(context.render_template_text(raw)); + + nlohmann::json parsed; + try + { + parsed = nlohmann::json::parse(normalized); + } + catch (const nlohmann::json::exception& e) + { + throw docraft::exception::DataFormatException( + std::string{"<Table> 'header' is not valid JSON: "} + e.what()); + } + if (!parsed.is_array() || parsed.empty()) + { + throw docraft::exception::DataFormatException( + "<Table> 'header' must resolve to a non-empty JSON array"); + } + + std::vector<std::string> header; + header.reserve(parsed.size()); + for (const auto& item : parsed) + { + if (!item.is_string()) + { + throw docraft::exception::DataFormatException("<Table> 'header' items must be strings"); + } + header.push_back(item.get<std::string>()); + } + return header; + } + + void DocraftLoomTableHandler::add_table_model_header_row(nodes::DocraftLoomTable& table, + const parser::ParsedTableData& data, + std::size_t column_count, + DocraftLoomTableHandlerContext& context) + { + if (data.header_data_template) + { + const std::vector<std::string> header = resolve_table_header(*data.header_data_template, context); + if (header.size() != column_count) + { + throw docraft::exception::DataFormatException( + "<Table> 'header' size must match 'model' column count"); + } + std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> header_row; + header_row.reserve(header.size()); + for (const auto& title : header) + { + parser::ParsedTableTitleData title_data; + title_data.text = title; + header_row.push_back(build_title_cell(title_data, context)); + } + table.add_row(header_row); + return; + } + + if (!data.header_titles.empty()) + { + // An explicit <THead> can be used instead of the `header` attribute alongside a + // JSON/template `model` -- the parser doesn't reject that combination (only + // `header` + <THead> together is rejected), so it must be honored here too, not + // just the header_data_template path above. + if (data.header_titles.size() != column_count) + { + throw docraft::exception::DataFormatException( + "<Table> THead column count must match 'model' column count"); + } + std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> header_row; + header_row.reserve(data.header_titles.size()); + for (const auto& title : data.header_titles) + { + header_row.push_back(build_title_cell(title, context)); + } + table.add_row(header_row); + } + } + + void DocraftLoomTableHandler::build_templated_model_rows(nodes::DocraftLoomTable& table, + const nlohmann::json& model_json, + const parser::ParsedTableData& data, + DocraftLoomTableHandlerContext& context) + { + if (data.rows.empty()) + { + throw docraft::exception::InvalidInputException( + "<Table> 'model' resolving to a JSON array of objects requires an explicit <TBody> row template"); + } + + add_table_model_header_row(table, data, data.rows.front().cells.size(), context); + + for (const auto& item : model_json) + { + if (!item.is_object()) + { + throw docraft::exception::DataFormatException( + "<Table> 'model' must be a JSON array of only objects or only arrays, not mixed"); + } + // Scoped per iteration (rather than a single save/restore around the whole + // loop) so the context's current Foreach item is restored to whatever it was + // before this call -- the outer <Foreach> item, if any -- even if building a + // row throws. + ForeachItemScope item_scope(context, &item); + for (const auto& row_data : data.rows) + { + std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> row; + row.reserve(row_data.cells.size()); + for (const auto& cell_data : row_data.cells) + { + row.push_back(build_content_cell(cell_data, context)); + } + table.add_row(row); + } + } + } + + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomTableHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const parser::ParsedTableData&>(element.data); + auto table = std::make_shared<nodes::DocraftLoomTable>(); + + // Table structure is parsed specially (no generic child recursion -- see + // DocraftCraftLanguageParser::parse_node's Table early-return), so a <Foreach> + // can't appear as a literal child of a <Table>. That doesn't mean the context's + // current Foreach item should be hidden from this Table's own attributes/content, + // though: if the Table itself is nested inside an outer <Foreach>, its + // `model`/`header` attributes and its cell content must still resolve + // `${data("field")}` against that outer item -- it is left untouched here and + // simply flows through resolve_table_model_json()/resolve_table_header()/ + // build_content_cell() (via render_template_text()). build_templated_model_rows() + // below additionally overrides it per JSON-model object, scoped to that object's + // own rows. + if (data.baseline_offset) + { + table->set_baseline_offset(*data.baseline_offset); + } + if (data.default_cell_background) + { + table->set_default_cell_background(context.resolve_color(*data.default_cell_background)); + } + + const bool is_vertical = data.orientation == parser::ParsedTableOrientation::kVertical; + + if (data.model_data_template) + { + const nlohmann::json model_json = resolve_table_model_json(*data.model_data_template, context); + + if (model_json.front().is_object()) + { + // `model` is a JSON array of objects -- the explicit <TBody> (already parsed + // into data.rows) is used as a per-object row template rather than literal + // rows; see build_templated_model_rows(). + build_templated_model_rows(*table, model_json, data, context); + apply_common_attributes(*table, element.common); + return table; + } + + // `model` is a JSON array of arrays (a string matrix): an explicit <TBody> has + // no role here (there's no per-object data to bind it against), so require the + // author to pick one or the other rather than silently dropping their <TBody>. + if (!data.rows.empty()) + { + throw docraft::exception::InvalidInputException( + "<Table> 'model' resolving to a JSON array of arrays cannot be combined with an explicit <TBody>"); + } + + const std::vector<std::vector<std::string>> matrix = to_string_matrix(model_json); + add_table_model_header_row(*table, data, matrix.front().size(), context); + + for (const auto& matrix_row : matrix) + { + std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> row; + row.reserve(matrix_row.size()); + for (const auto& cell_text : matrix_row) + { + row.push_back(build_matrix_cell(cell_text)); + } + table->add_row(row); + } + + apply_common_attributes(*table, element.common); + return table; + } + + if (!data.header_titles.empty()) + { + std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> header_row; + if (is_vertical) + { + // The label column has no header of its own -- pad with a blank title + // cell so the header row aligns under the row-title column. + auto corner = std::make_shared<nodes::DocraftLoomTableCell>(); + corner->set_content(std::make_shared<nodes::DocraftLoomText>("")); + corner->set_is_title(true); + header_row.push_back(corner); + } + for (const auto& title : data.header_titles) + { + header_row.push_back(build_title_cell(title, context)); + } + table->add_row(header_row); + } + + for (const auto& row_data : data.rows) + { + std::vector<std::shared_ptr<nodes::DocraftLoomTableCell>> row; + if (is_vertical && row_data.row_title) + { + row.push_back(build_title_cell(*row_data.row_title, context)); + } + for (const auto& cell_data : row_data.cells) + { + row.push_back(build_content_cell(cell_data, context)); + } + table->add_row(row); + } + + apply_common_attributes(*table, element.common); + return table; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_text_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_text_handler.cc new file mode 100644 index 0000000..8f01f43 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_text_handler.cc @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_text_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_subtitle.h" +#include "docraft/loom/nodes/docraft_loom_text.h" +#include "docraft/loom/nodes/docraft_loom_title.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomTextHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedTextData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomText>(); + context.fill_text_node(*node, data); + apply_common_attributes(*node, element.common); + return node; + } + + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomTitleHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedTextData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomTitle>(); + // fill_text_node only overwrites fields data actually set (see its own + // if (data.xxx) checks) -- an explicit font-size attribute still wins, + // otherwise DocraftLoomTitle's own constructor default stands. + context.fill_text_node(*node, data); + // Keep margin coherent with whatever font_size ended up being used (constructor + // default or an explicit attribute) -- 1em, matching the constructor's own + // convention -- so overriding font-size doesn't leave a disproportionate, + // fixed-constant gap. An explicit margin attribute below still overrides this. + node->set_margin(node->font_size()); + apply_common_attributes(*node, element.common); + return node; + } + + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomSubtitleHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedTextData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomSubtitle>(); + context.fill_text_node(*node, data); + // See DocraftLoomTitleHandler::build's identical comment above. + node->set_margin(node->font_size()); + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_triangle_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_triangle_handler.cc new file mode 100644 index 0000000..ee02213 --- /dev/null +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_triangle_handler.cc @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "docraft/loom/craft/handlers/docraft_loom_triangle_handler.h" + +#include <any> + +#include "docraft/craft/parser/docraft_triangle_parser.h" +#include "docraft/loom/craft/docraft_loom_tree_builder_utils.h" +#include "docraft/loom/nodes/docraft_loom_triangle.h" + +namespace docraft::loom::craft { + std::shared_ptr<nodes::DocraftLoomNode> DocraftLoomTriangleHandler::build( + const docraft::craft::DocraftParsedElement& element, DocraftLoomTableHandlerContext& context) + { + const auto& data = std::any_cast<const docraft::craft::parser::ParsedTriangleData&>(element.data); + auto node = std::make_shared<nodes::DocraftLoomTriangle>(); + apply_shape_style(*node, data, [&context](const std::string& c) { return context.resolve_color(c); }); + if (!data.points.empty()) + { + node->set_points(data.points); + } + apply_common_attributes(*node, element.common); + return node; + } +} // namespace docraft::loom::craft diff --git a/docraft/src/docraft/loom/nodes/docraft_loom_vstack.cc b/docraft/src/docraft/loom/nodes/docraft_loom_vstack.cc index 41b5a8a..30e832c 100644 --- a/docraft/src/docraft/loom/nodes/docraft_loom_vstack.cc +++ b/docraft/src/docraft/loom/nodes/docraft_loom_vstack.cc @@ -30,4 +30,22 @@ namespace docraft::loom::nodes { { return weights_; } + + std::vector<float> DocraftLoomVStack::resolve_vertical_child_gaps(const DocraftLoomNode& node, + float container_spacing) + { + const int n = node.children_count(); + std::vector<float> gaps; + if (n <= 1) + { + return gaps; + } + gaps.resize(static_cast<std::size_t>(n - 1)); + for (int i = 0; i < n - 1; ++i) + { + gaps[static_cast<std::size_t>(i)] = resolve_child_gap( + container_spacing, node.child(i)->margin().bottom, node.child(i + 1)->margin().top); + } + return gaps; + } } // namespace docraft::loom::nodes \ No newline at end of file diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc index 8482221..7958248 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc @@ -246,16 +246,7 @@ namespace docraft::loom::pipeline { // Precomputed once, upfront (see the matching comment in // DocraftLoomLayoutProcessor::visit(DocraftLoomHStack*)) so the weighted-height // math and the final placement loop agree on the same gaps. - std::vector<float> gaps; - if (n > 1) - { - gaps.resize(static_cast<std::size_t>(n - 1)); - for (int i = 0; i < n - 1; ++i) - { - gaps[static_cast<std::size_t>(i)] = nodes::DocraftLoomLayoutContainer::resolve_child_gap( - node->spacing(), node->child(i)->margin().bottom, node->child(i + 1)->margin().top); - } - } + const auto gaps = node->resolve_vertical_child_gaps(*node, node->spacing()); float total_gap = 0.0F; for (const float gap : gaps) { diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc index 24ed65b..0bdb50b 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc @@ -58,6 +58,10 @@ namespace docraft::loom::pipeline { inherited_wrap_width_ = 0.0F; } + float DocraftLoomMeasureProcessor::incoming_width() const { + return inherited_wrap_width_ > 0.0F ? inherited_wrap_width_ : content_width_; + } + void DocraftLoomMeasureProcessor::visit(docraft::loom::nodes::DocraftLoomText *text) { if (!text) { return; @@ -109,7 +113,7 @@ namespace docraft::loom::pipeline { // same way a weighted HStack column or Table cell already does for Text, so // plain block content (Text, Paragraph, VStack, ...) wraps to the page/section // width instead of measuring at its unbounded natural width. - const float incoming_width = inherited_wrap_width_ > 0.0F ? inherited_wrap_width_ : content_width_; + const float incoming_width = this->incoming_width(); inherited_wrap_width_ = 0.0F; const float own_width = node->width() > 0.0F ? node->width() : incoming_width; const float padding = node->effective_padding(); @@ -201,7 +205,7 @@ namespace docraft::loom::pipeline { // child (plus 2x padding()) rather than owning a fixed width like Rectangle -- // but it does narrow the width relayed to children by its own padding(), the // same inset Rectangle already applies around its children. - const float incoming_width = inherited_wrap_width_ > 0.0F ? inherited_wrap_width_ : content_width_; + const float incoming_width = this->incoming_width(); inherited_wrap_width_ = 0.0F; const float padding = node->effective_padding(); const float children_wrap_width = std::max(0.0F, incoming_width - (2.0F * padding)); @@ -209,6 +213,7 @@ namespace docraft::loom::pipeline { float total_height = node->resolve_outer_margin(*node, /*leading=*/true); float max_width = 0.0F; const int n = node->children_count(); + const auto gaps = node->resolve_vertical_child_gaps(*node, node->spacing()); for (int i = 0; i < n; ++i) { inherited_wrap_width_ = children_wrap_width; auto child = node->edit_child(i); @@ -216,9 +221,7 @@ namespace docraft::loom::pipeline { const auto &sz = child->layout_box().measured_size; total_height += sz.height; if (i < n - 1) { - const auto next = node->child(i + 1); - total_height += nodes::DocraftLoomLayoutContainer::resolve_child_gap( - node->spacing(), child->margin().bottom, next->margin().top); + total_height += gaps[static_cast<std::size_t>(i)]; } max_width = std::max(max_width, sz.width); } @@ -245,7 +248,7 @@ namespace docraft::loom::pipeline { // stale inherited value must not leak into the first child -- but the weighted // branch below still needs it (an ancestor's constraint, not content_width_, is // what a nested weighted HStack must divide among its columns). - const float incoming_width = inherited_wrap_width_ > 0.0F ? inherited_wrap_width_ : content_width_; + const float incoming_width = this->incoming_width(); inherited_wrap_width_ = 0.0F; // Precomputed once, upfront: margin() is a static per-child property (doesn't @@ -397,7 +400,7 @@ namespace docraft::loom::pipeline { // once here and re-armed before each item below, mirroring // DocraftLoomParagraph's relay (a single accept() call per item would otherwise // only let the first item consume it, leaving the rest unwrapped). - const float incoming_width = inherited_wrap_width_ > 0.0F ? inherited_wrap_width_ : content_width_; + const float incoming_width = this->incoming_width(); inherited_wrap_width_ = 0.0F; float max_width = 0.0F; @@ -488,7 +491,7 @@ namespace docraft::loom::pipeline { // so it doesn't leak into a cell's Text measurement alongside the budget // computed below, but the weight-based estimate can still divide it (rather than // content_width_ unconditionally) for a Table nested in a narrower container. - const float incoming_width = inherited_wrap_width_ > 0.0F ? inherited_wrap_width_ : content_width_; + const float incoming_width = this->incoming_width(); inherited_wrap_width_ = 0.0F; const int rows = table->row_count(); From 40ace55f5e13d9e103a59d7ef7c1b53fd830ad89 Mon Sep 17 00:00:00 2001 From: cadons <matteo.cadoni01@gmail.com> Date: Sun, 30 Aug 2026 16:50:38 +0200 Subject: [PATCH 2/5] refactor(loom): gate LayoutBox::frame/page_index behind phase proofs DocraftLoomTable::row_height() (and every other reader of frame/ page_index) relied on a doc comment ("once Layout has resolved it") to keep call sites from reading pipeline-stage output before that stage ran -- nothing enforced it. Before row_height() existed, exactly that kind of cross-phase drift caused bug #54 (see .local/TABELLE_LOOM.md): Pagination re-derived a row's height differently in different call sites, so try_split_table's fit_rows never made progress and looped forever on an oversized row after a header. Generalize the fix to the whole LayoutBox rather than special-casing Table: `frame` and `page_index` move behind two capability tokens, LayoutProof and PageIndexProof, each mintable only through a dedicated, protected-only accessor -- DocraftLoomLayoutBoxLayoutAccessor and DocraftLoomLayoutBoxPaginationAccessor. DocraftLoomLayoutProcessor inherits only the first (can seal a frame, never a page index); DocraftLoomPaginationProcessor inherits only the second, and its proof constructor requires an existing LayoutProof, so minting one is itself evidence Layout already ran -- Measure -> Layout -> Pagination is now enforced by the type system, not convention. Code with no proof in hand (Measure, or any future pass) simply cannot name the call. The two accessors are the only friends LayoutProof/PageIndexProof declare -- docraft_loom_node.h has no knowledge of pipeline or test class names, and adding a future legitimate minter never means editing it again. Tests needing to fabricate LayoutBox state directly (rather than run the real pipeline) go through docraft::test::utils::LayoutBoxTestAccess, which inherits both accessors privately -- kept out of the production header entirely, the same way charts/handlers keep their own registries out of the core model. measured_size stays ungated (Measure is unconditionally first, so there's no early-read case to guard). page_index gets an additional ungated page_index_or_unpaginated() accessor for DocraftLoomRenderingProcessor::should_render(), since "not yet paginated" has one safe answer (render on every page) -- unlike frame, which has no safe placeholder geometry to fall back to. docraft/loom/nodes/docraft_loom_layout_box_access.h adds sealed_frame()/sealed_edit_frame()/layout_proof_or_throw() for the ~80 production call sites (Pagination, Rendering, Table row/cell splitting, DocraftLoomPdfCreator) that read an already-sealed proof rather than minting a new one. Verified: full test suite green, and showcase.craft/charts.craft render to byte-identical PDFs before and after. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- docraft/CMakeLists.txt | 1 + .../nodes/docraft_loom_layout_box_access.h | 68 +++++++++ .../docraft/loom/nodes/docraft_loom_node.h | 130 ++++++++++++++++-- .../pipeline/docraft_loom_layout_processor.h | 20 ++- .../docraft_loom_pagination_processor.h | 13 +- .../docraft/loom/docraft_loom_pdf_creator.cc | 3 +- .../docraft/loom/nodes/docraft_loom_table.cc | 25 ++-- .../pipeline/docraft_loom_layout_processor.cc | 85 ++++++------ .../docraft_loom_pagination_processor.cc | 37 +++-- .../docraft_loom_rendering_processor.cc | 51 +++---- docraft/test/CMakeLists.txt | 1 + ...docraft_loom_craft_language_parser_test.cc | 7 +- .../nodes/docraft_loom_blank_line_test.cc | 17 +-- .../loom/nodes/docraft_loom_canvas_test.cc | 59 ++++---- .../loom/nodes/docraft_loom_image_test.cc | 13 +- .../loom/nodes/docraft_loom_line_test.cc | 15 +- .../loom/nodes/docraft_loom_list_test.cc | 7 +- .../loom/nodes/docraft_loom_node_test.cc | 33 +++-- .../nodes/docraft_loom_page_number_test.cc | 9 +- .../loom/nodes/docraft_loom_rectangle_test.cc | 15 +- .../nodes/docraft_loom_shape_nodes_test.cc | 13 +- .../nodes/docraft_loom_stack_nodes_test.cc | 45 +++--- .../loom/nodes/docraft_loom_table_test.cc | 49 +++---- .../docraft_loom_layout_processor_test.cc | 21 +-- .../docraft_loom_pagination_processor_test.cc | 59 +++++--- .../docraft_loom_layout_box_test_access.h | 48 +++++++ 26 files changed, 579 insertions(+), 265 deletions(-) create mode 100644 docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h create mode 100644 docraft/test/docraft/utils/docraft_loom_layout_box_test_access.h diff --git a/docraft/CMakeLists.txt b/docraft/CMakeLists.txt index c672aaf..8401922 100644 --- a/docraft/CMakeLists.txt +++ b/docraft/CMakeLists.txt @@ -94,6 +94,7 @@ set(DOCRAFT_SOURCES include/docraft/loom/nodes/docraft_loom_text.h src/docraft/loom/nodes/docraft_loom_node.cc include/docraft/loom/nodes/docraft_loom_node.h + include/docraft/loom/nodes/docraft_loom_layout_box_access.h src/docraft/loom/pipeline/docraft_loom_measure_processor.cc include/docraft/loom/pipeline/docraft_loom_measure_processor.h src/docraft/loom/pipeline/docraft_loom_text_wrapper.cc diff --git a/docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h b/docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h new file mode 100644 index 0000000..3ca53a1 --- /dev/null +++ b/docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/exception/docraft_exceptions.h" +#include "docraft/loom/nodes/docraft_loom_node.h" + +namespace docraft::loom::nodes { + /** + * @brief Returns the LayoutProof `node`'s own LayoutBox was sealed with, for code + * that needs to reuse it -- e.g. to seal a *different*, freshly-created node's + * frame derived from `node` (a pagination-time table-split remainder/blank cell is + * "the same already-laid-out region, reshaped", not a node Layout itself visited). + * @throws docraft::exception::InvalidInputException if Layout hasn't sealed `node` + * yet. + */ + inline LayoutBox::LayoutProof layout_proof_or_throw(const DocraftLoomNode& node) + { + const auto proof = node.layout_box().layout_proof(); + if (!proof) + { + throw docraft::exception::InvalidInputException( + "LayoutBox::frame read for a node DocraftLoomLayoutProcessor hasn't laid out yet"); + } + return *proof; + } + + /** + * @brief Reads `node`'s already-sealed frame, for code that runs after + * DocraftLoomLayoutProcessor (Pagination/Rendering/Table geometry helpers) and + * therefore can't mint its own LayoutBox::LayoutProof. + * @throws docraft::exception::InvalidInputException if Layout hasn't sealed a + * frame for this node yet -- an internal pipeline-ordering bug, not a user input + * error, but this codebase has no dedicated "internal invariant violated" + * exception type. + */ + inline const Rect& sealed_frame(const DocraftLoomNode& node) + { + return node.layout_box().frame(layout_proof_or_throw(node)); + } + + /** + * @brief Mutable counterpart of sealed_frame(): reuses `node`'s own already-sealed + * proof to read/write its frame -- for post-Layout code (Pagination reflow, Table + * row/cell splitting) that legitimately adjusts an already-laid-out node's + * geometry without re-running real Layout. + * @throws docraft::exception::InvalidInputException if Layout hasn't sealed a + * frame for this node yet. + */ + inline Rect& sealed_edit_frame(DocraftLoomNode& node) + { + return node.edit_layout_box().edit_frame(layout_proof_or_throw(node)); + } +} // namespace docraft::loom::nodes diff --git a/docraft/include/docraft/loom/nodes/docraft_loom_node.h b/docraft/include/docraft/loom/nodes/docraft_loom_node.h index e3591e4..d066bb5 100644 --- a/docraft/include/docraft/loom/nodes/docraft_loom_node.h +++ b/docraft/include/docraft/loom/nodes/docraft_loom_node.h @@ -3,6 +3,7 @@ // #pragma once +#include <optional> #include <string> #include <vector> #include <memory> @@ -59,17 +60,130 @@ namespace docraft::loom::nodes { /** * @brief Struct representing a layout box, which encapsulates layout-related data for nodes. * - * The `LayoutBox` struct is used to store the results of key steps in the layout process: - * - Measurement step (`measured_size`): The calculated size of the node. - * - Pagination step (`page_index`): The index of the page where the node is located. - * - Layout step (`frame`): The final position and size of the node to be applied in the layout. + * The `LayoutBox` struct stores the results of the pipeline's three sequential steps: + * - Measurement step (`measured_size`): the calculated size of the node. Ungated -- + * Measure is unconditionally the pipeline's first pass, so there's no "too early" + * caller to guard against. + * - Layout step (`frame`): the final position and size to apply. Only readable/ + * writable with a `LayoutProof`, mintable only by `DocraftLoomLayoutProcessor` -- + * code from an earlier stage (Measure) simply cannot obtain one, so reading a + * frame before Layout has run is a compile error, not a silently wrong 0. + * - Pagination step (`page_index`): which page this node landed on. Gated the same + * way by `PageIndexProof`, whose constructor additionally requires an existing + * `LayoutProof` -- so minting one is itself proof that Layout ran first, enforcing + * the Measure -> Layout -> Pagination order at the type level, not by convention. + * + * This header has no knowledge of the pipeline or of tests: `LayoutProof` and + * `PageIndexProof` each name exactly one friend of their own -- + * `DocraftLoomLayoutBoxLayoutAccessor` and `DocraftLoomLayoutBoxPaginationAccessor` + * respectively (both below) -- two narrow, protected-only minting seams, kept + * separate so that inheriting the ability to seal a frame never also grants the + * ability to seal a page index, or vice versa. Whoever legitimately needs to mint + * one kind of proof -- `DocraftLoomLayoutProcessor` (frames), + * `DocraftLoomPaginationProcessor` (page indices) in production, + * `docraft::test::utils::LayoutBoxTestAccess` (both, for fixtures) in tests -- + * gets there by *inheriting* the matching accessor, not by being named here + * individually; adding a new legitimate minter never requires touching this file + * again. */ - struct LayoutBox + class LayoutBox { - Size measured_size; // measure step result - int page_index; // pagination step result - Rect frame; // layout step result, this is the final result to apply in the layout + public: + class LayoutProof + { + public: + LayoutProof(const LayoutProof&) = default; + LayoutProof& operator=(const LayoutProof&) = default; + + private: + friend class DocraftLoomLayoutBoxLayoutAccessor; + LayoutProof() = default; + }; + + class PageIndexProof + { + public: + PageIndexProof(const PageIndexProof&) = default; + PageIndexProof& operator=(const PageIndexProof&) = default; + + private: + friend class DocraftLoomLayoutBoxPaginationAccessor; + explicit PageIndexProof(const LayoutProof&) {} + }; + LayoutBox() = default; + + Size measured_size; // measure step result -- ungated, see class doc + + const Rect& frame(const LayoutProof&) const { return frame_; } + Rect& edit_frame(const LayoutProof& proof) { layout_proof_ = proof; return frame_; } + + /** + * @brief The proof Layout sealed onto this node, or nullopt if Layout hasn't + * run for it yet -- the one runtime check in this scheme (whether Layout has + * happened at all for this specific node), done once per read site instead of + * trusted implicitly. + */ + std::optional<LayoutProof> layout_proof() const { return layout_proof_; } + + int page_index(const PageIndexProof&) const { return page_index_; } + void set_page_index(int index, const PageIndexProof& proof) + { + page_index_ = index; + page_index_proof_ = proof; + } + std::optional<PageIndexProof> page_index_proof() const { return page_index_proof_; } + + /** + * @brief Returns the sealed page index, or -1 (the existing "render on every + * page" sentinel) if Pagination hasn't sealed one yet. Deliberately needs no + * proof, unlike page_index(const PageIndexProof&): "not yet paginated" has + * exactly one safe answer here (render everywhere), unlike frame()'s case, + * where there is no safe placeholder geometry to fall back to -- so this + * accessor can stay ungated without reintroducing the "silently wrong number" + * failure mode this class exists to close. Used by + * DocraftLoomRenderingProcessor::should_render(), which must keep working for + * nodes/tests that never run real Pagination. + */ + int page_index_or_unpaginated() const { return page_index_proof_ ? page_index_ : -1; } + + private: + Rect frame_{}; + int page_index_ = -1; + std::optional<LayoutProof> layout_proof_; + std::optional<PageIndexProof> page_index_proof_; + }; + + /** + * @brief The sole seam through which a `LayoutBox::LayoutProof` can be minted. + * @details `DocraftLoomLayoutProcessor` inherits (`protected`) from this instead of + * being named as a friend inside `LayoutBox::LayoutProof` itself, so this (model) + * header never needs to know the pipeline's class names. Deliberately separate from + * `DocraftLoomLayoutBoxPaginationAccessor`: a class that can seal a frame has no + * business also being able to seal a page index just because both happened to + * share one base. + */ + class DOCRAFT_LIB DocraftLoomLayoutBoxLayoutAccessor + { + protected: + static LayoutBox::LayoutProof make_layout_proof() { return LayoutBox::LayoutProof{}; } + }; + + /** + * @brief The sole seam through which a `LayoutBox::PageIndexProof` can be minted. + * @details `DocraftLoomPaginationProcessor` inherits (`protected`) from this + * instead of being named as a friend inside `LayoutBox::PageIndexProof` itself. + * Takes an existing `LayoutProof` (obtained via the public `LayoutBox::layout_proof()` + * getter -- reading one requires no special access) rather than minting its own, + * so inheriting this alone grants no ability to seal a frame. + */ + class DOCRAFT_LIB DocraftLoomLayoutBoxPaginationAccessor + { + protected: + static LayoutBox::PageIndexProof make_page_index_proof(const LayoutBox::LayoutProof& layout_proof) + { + return LayoutBox::PageIndexProof{layout_proof}; + } }; /** diff --git a/docraft/include/docraft/loom/pipeline/docraft_loom_layout_processor.h b/docraft/include/docraft/loom/pipeline/docraft_loom_layout_processor.h index 3ff5f01..82bc2a5 100644 --- a/docraft/include/docraft/loom/pipeline/docraft_loom_layout_processor.h +++ b/docraft/include/docraft/loom/pipeline/docraft_loom_layout_processor.h @@ -42,7 +42,8 @@ namespace docraft::loom::pipeline { * @brief Implementation of the DocraftLoomIVisitor interface for processing Loom nodes during the layout phase. * */ - class DocraftLoomLayoutProcessor : public interfaces::DocraftLoomIVisitor + class DocraftLoomLayoutProcessor : public interfaces::DocraftLoomIVisitor, + protected nodes::DocraftLoomLayoutBoxLayoutAccessor { public: /** @@ -105,6 +106,23 @@ namespace docraft::loom::pipeline { */ float incoming_width() const; + /** + * @brief Mints a fresh LayoutBox::LayoutProof (via the inherited + * DocraftLoomLayoutBoxAccessor) to read/write `box`'s frame -- safe to call + * from anywhere in this class, including nested member classes like + * PositionScope, which have the same access to private/protected members as + * any other member. A proof carries no data, so minting a new one is + * equivalent to reusing an existing one. + */ + static nodes::Rect& edit_frame(nodes::LayoutBox& box) + { + return box.edit_frame(make_layout_proof()); + } + static const nodes::Rect& frame_of(const nodes::LayoutBox& box) + { + return box.frame(make_layout_proof()); + } + /** * @brief RAII helper class to manage cursor state during node visits. */ diff --git a/docraft/include/docraft/loom/pipeline/docraft_loom_pagination_processor.h b/docraft/include/docraft/loom/pipeline/docraft_loom_pagination_processor.h index 4eaaa08..8c3095d 100644 --- a/docraft/include/docraft/loom/pipeline/docraft_loom_pagination_processor.h +++ b/docraft/include/docraft/loom/pipeline/docraft_loom_pagination_processor.h @@ -10,7 +10,8 @@ #include "docraft/loom/nodes/docraft_loom_table.h" namespace docraft::loom::pipeline { - class DocraftLoomPaginationProcessor : public interfaces::DocraftLoomIVisitor + class DocraftLoomPaginationProcessor : public interfaces::DocraftLoomIVisitor, + protected nodes::DocraftLoomLayoutBoxPaginationAccessor { public: void visit(docraft::loom::nodes::DocraftLoomText*) override; @@ -71,6 +72,16 @@ namespace docraft::loom::pipeline { static void assign_page_index_recursive(nodes::DocraftLoomNode& node, int page_index); private: + /** + * @brief Seals `index` onto `node`'s LayoutBox, minting the PageIndexProof via + * the inherited DocraftLoomLayoutBoxAccessor -- reuses node's own LayoutProof + * (Layout must have already run for it, or this throws), which is what proves + * the Measure -> Layout -> Pagination order at the type level rather than by + * convention. + */ + static void seal_page_index(nodes::DocraftLoomNode& node, int index); + + /** * @brief Recurses into every child of node, in order, via the visitor * (child->accept(*this)) -- the shared body of every visit() overload for a node diff --git a/docraft/src/docraft/loom/docraft_loom_pdf_creator.cc b/docraft/src/docraft/loom/docraft_loom_pdf_creator.cc index 5e153ee..7a63f0e 100644 --- a/docraft/src/docraft/loom/docraft_loom_pdf_creator.cc +++ b/docraft/src/docraft/loom/docraft_loom_pdf_creator.cc @@ -9,6 +9,7 @@ #include "docraft/backend/pdf/docraft_haru_backend.h" #include "docraft/exception/docraft_exceptions.h" +#include "docraft/loom/nodes/docraft_loom_layout_box_access.h" #include "docraft/utils/docraft_logger.h" #include "docraft/loom/pipeline/docraft_loom_measure_processor.h" #include "docraft/loom/pipeline/docraft_loom_pagination_processor.h" @@ -194,7 +195,7 @@ namespace docraft::loom { executor.run(*header_, page_width - header_margins_.left - header_margins_.right, header_margins_.left, header_margins_.top, /*assign_fixed_page_index=*/true); const float header_content_extent = - header_margins_.top + header_->layout_box().frame.size.height + header_margins_.bottom; + header_margins_.top + nodes::sealed_frame(*header_).size.height + header_margins_.bottom; header_extent = std::max(header_extent, header_content_extent); } diff --git a/docraft/src/docraft/loom/nodes/docraft_loom_table.cc b/docraft/src/docraft/loom/nodes/docraft_loom_table.cc index a391863..5c10500 100644 --- a/docraft/src/docraft/loom/nodes/docraft_loom_table.cc +++ b/docraft/src/docraft/loom/nodes/docraft_loom_table.cc @@ -5,6 +5,7 @@ #include "docraft/exception/docraft_input_exceptions.h" #include "docraft/loom/nodes/docraft_loom_image.h" +#include "docraft/loom/nodes/docraft_loom_layout_box_access.h" #include "docraft/loom/nodes/docraft_loom_text.h" namespace docraft::loom::nodes { @@ -92,7 +93,7 @@ namespace docraft::loom::nodes { // the whole row's split (another cell returns nullopt), and the original // grid_ cell must be left untouched for that to be a true no-op. auto kept_cell = clone_cell(*cell); - kept_cell->edit_layout_box().frame.size.height = natural_height; + sealed_edit_frame(*kept_cell).size.height = natural_height; return CellContentSplit{.kept = kept_cell, .remainder = nullptr}; } @@ -114,7 +115,7 @@ namespace docraft::loom::nodes { // (same reasoning as above) and normalize its frame away from any prior // row-uniform stretch. auto kept_cell = clone_cell(*cell); - kept_cell->edit_layout_box().frame.size.height = cell->layout_box().measured_size.height; + sealed_edit_frame(*kept_cell).size.height = cell->layout_box().measured_size.height; return CellContentSplit{.kept = kept_cell, .remainder = nullptr}; } @@ -126,7 +127,7 @@ namespace docraft::loom::nodes { auto kept_text = std::static_pointer_cast<DocraftLoomText>(kept_cell->content()); kept_text->set_wrapped_lines(kept_lines); kept_text->edit_layout_box().measured_size.height = line_height * static_cast<float>(kept_lines.size()); - kept_cell->edit_layout_box().frame.size.height = + sealed_edit_frame(*kept_cell).size.height = kept_text->layout_box().measured_size.height + (2.0F * DocraftLoomTable::kCellPaddingY); auto remainder_cell = clone_cell(*cell); @@ -134,7 +135,7 @@ namespace docraft::loom::nodes { remainder_text->set_wrapped_lines(remainder_lines); remainder_text->edit_layout_box().measured_size.height = line_height * static_cast<float>(remainder_lines.size()); - remainder_cell->edit_layout_box().frame.size.height = + sealed_edit_frame(*remainder_cell).size.height = remainder_text->layout_box().measured_size.height + (2.0F * DocraftLoomTable::kCellPaddingY); return CellContentSplit{.kept = kept_cell, .remainder = remainder_cell}; @@ -154,10 +155,10 @@ namespace docraft::loom::nodes { float kept_row_height = 0.0F; float remainder_row_height = 0.0F; for (const auto &split: splits) { - kept_row_height = std::max(kept_row_height, split.kept->layout_box().frame.size.height); + kept_row_height = std::max(kept_row_height, sealed_frame(*split.kept).size.height); if (split.remainder) { remainder_row_height = - std::max(remainder_row_height, split.remainder->layout_box().frame.size.height); + std::max(remainder_row_height, sealed_frame(*split.remainder).size.height); } } @@ -165,11 +166,11 @@ namespace docraft::loom::nodes { result.kept_row.reserve(splits.size()); result.remainder_row.reserve(splits.size()); for (const auto &split: splits) { - split.kept->edit_layout_box().frame.size.height = kept_row_height; + sealed_edit_frame(*split.kept).size.height = kept_row_height; result.kept_row.push_back(split.kept); if (split.remainder) { - split.remainder->edit_layout_box().frame.size.height = remainder_row_height; + sealed_edit_frame(*split.remainder).size.height = remainder_row_height; result.remainder_row.push_back(split.remainder); continue; } @@ -189,9 +190,9 @@ namespace docraft::loom::nodes { if (auto explicit_width = split.kept->explicit_width(); explicit_width.has_value()) { blank->set_explicit_width(explicit_width.value()); } - blank->edit_layout_box().frame = { - .position = split.kept->layout_box().frame.position, - .size = {split.kept->layout_box().frame.size.width, remainder_row_height} + blank->edit_layout_box().edit_frame(layout_proof_or_throw(*split.kept)) = { + .position = sealed_frame(*split.kept).position, + .size = {sealed_frame(*split.kept).size.width, remainder_row_height} }; result.remainder_row.push_back(blank); } @@ -242,7 +243,7 @@ namespace docraft::loom::nodes { float DocraftLoomTable::row_height(int row) const { auto reference_cell = cell(row, 0); - return reference_cell ? reference_cell->layout_box().frame.size.height : 0.0F; + return reference_cell ? sealed_frame(*reference_cell).size.height : 0.0F; } void DocraftLoomTable::set_column_weights(std::vector<float> weights) diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc index 7958248..458385c 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc @@ -84,7 +84,7 @@ namespace docraft::loom::pipeline { : processor_(processor), entry_cursor_(processor.cursor_), restore_on_exit_(node.position_mode() == nodes::DocraftPositionType::kAbsolute) { - node.edit_layout_box().frame.position = processor.resolve_position(node); + edit_frame(node.edit_layout_box()).position = processor.resolve_position(node); } DocraftLoomLayoutProcessor::PositionScope::~PositionScope() @@ -110,8 +110,7 @@ namespace docraft::loom::pipeline { // for each top-level child -- leaving this at its zero-initialized default causes // Pagination to treat a bare Text/Title/Subtitle as zero-height and pull the // following sibling back on top of it. - layout_box.frame.size = layout_box.measured_size; - + edit_frame(layout_box).size = layout_box.measured_size; // Move cursor to the right after placing the text; harmless if this node is // absolutely positioned, since the scope above restores the cursor on exit. cursor_.move(layout_box.measured_size.width, 0.0F); @@ -133,7 +132,7 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; const float padding = node->effective_padding(); const float own_width = node->width() > 0.0F ? node->width() : incoming_width(); const float children_width = @@ -142,10 +141,10 @@ namespace docraft::loom::pipeline { const int n = node->children_count(); if (n > 0) { - const float start_x = layout_box.frame.position.x + padding; + const float start_x = edit_frame(layout_box).position.x + padding; // Mirrors the measure pass: the first child's own top margin is reserved // outright, with no preceding sibling to combine it with. - float current_y = layout_box.frame.position.y + padding + float current_y = edit_frame(layout_box).position.y + padding + node->resolve_outer_margin(*node, /*leading=*/true); for (int i = 0; i < n; ++i) { @@ -162,7 +161,7 @@ namespace docraft::loom::pipeline { } } } - cursor_.set_position(layout_box.frame.position.x, layout_box.frame.position.y + layout_box.frame.size.height); + cursor_.set_position(edit_frame(layout_box).position.x, edit_frame(layout_box).position.y + edit_frame(layout_box).size.height); } void DocraftLoomLayoutProcessor::visit(docraft::loom::nodes::DocraftLoomCanvas* node) @@ -171,7 +170,7 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; // Every direct child is positioned by its own x/y relative to the canvas's own // origin (defaulting to (0,0) when omitted), never block-stacked. Forcing @@ -182,7 +181,7 @@ namespace docraft::loom::pipeline { // original mode/position are restored right after accept() so a second layout // pass over the same tree (e.g. after set_page_format()) re-translates from the // same local coordinates instead of compounding the previous pass's translation. - const auto& origin = layout_box.frame.position; + const auto& origin = edit_frame(layout_box).position; for (int i = 0; i < node->children_count(); ++i) { auto child = node->edit_child(i); @@ -194,21 +193,21 @@ namespace docraft::loom::pipeline { child->set_position_mode(original_mode); child->set_explicit_position(local); } - cursor_.set_position(origin.x, origin.y + layout_box.frame.size.height); + cursor_.set_position(origin.x, origin.y + edit_frame(layout_box).size.height); } void DocraftLoomLayoutProcessor::visit(docraft::loom::nodes::DocraftLoomParagraph* node) { PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - const auto& position = layout_box.frame.position; + const auto& position = edit_frame(layout_box).position; // Mirrors every other container node (Rectangle/List/Table): frame.size must be // set from measured_size, since Pagination's own bookkeeping (paginate_body) // advances its next_y using frame.position.y + frame.size.height for each // top-level child -- leaving this at its zero-initialized default causes // Pagination to treat the paragraph as zero-height and pull the following // sibling back on top of it. - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; //layout the children of the paragraph const float start_x = position.x; float current_y = position.y + node->space_before(); @@ -227,11 +226,11 @@ namespace docraft::loom::pipeline { if (!node) return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - const auto& position = layout_box.frame.position; + const auto& position = edit_frame(layout_box).position; // Mirrors Rectangle/List/Table: frame.size must reflect measured_size so that // Pagination's next_y bookkeeping (frame.position.y + frame.size.height) sees // this container's real footprint instead of a zero-initialized default. - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; // A VStack has no explicit width of its own, so it neither narrows to a fixed // width nor owns the constraint like Rectangle does -- but it does narrow the @@ -289,7 +288,7 @@ namespace docraft::loom::pipeline { if (!resolved_heights.empty()) { advance = resolved_heights[static_cast<std::size_t>(i)]; - child->edit_layout_box().frame.size.height = advance; + edit_frame(child->edit_layout_box()).size.height = advance; } current_y += advance; if (i < n - 1) @@ -299,8 +298,8 @@ namespace docraft::loom::pipeline { } const float content_bottom = current_y + trailing_margin + padding; - const float actual_height = std::max(layout_box.frame.size.height, content_bottom - position.y); - if (node->height() > 0.0F && actual_height > layout_box.frame.size.height) + const float actual_height = std::max(edit_frame(layout_box).size.height, content_bottom - position.y); + if (node->height() > 0.0F && actual_height > edit_frame(layout_box).size.height) { const std::string node_label = node->name().empty() ? std::string{"A vertical Layout"} : fmt::format("Layout '{}'", node->name()); @@ -309,9 +308,9 @@ namespace docraft::loom::pipeline { "{:.1f}pt to fit -- the box grew to that instead of clipping them.", node_label, node->height(), actual_height)); } - layout_box.frame.size.height = actual_height; + edit_frame(layout_box).size.height = actual_height; layout_box.measured_size.height = actual_height; - cursor_.set_position(position.x, position.y + layout_box.frame.size.height); + cursor_.set_position(position.x, position.y + edit_frame(layout_box).size.height); } void DocraftLoomLayoutProcessor::visit(docraft::loom::nodes::DocraftLoomHStack* node) @@ -319,9 +318,9 @@ namespace docraft::loom::pipeline { if (!node) return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - const auto& position = layout_box.frame.position; + const auto& position = edit_frame(layout_box).position; // See the matching comment in visit(DocraftLoomVStack*). - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; // Capture whatever an ancestor pushed down before clearing it: without // weights, HStack's contract is shrink-to-fit (each child keeps its own @@ -394,7 +393,7 @@ namespace docraft::loom::pipeline { if (!resolved_widths.empty()) { advance = resolved_widths[static_cast<std::size_t>(i)]; - child->edit_layout_box().frame.size.width = advance; + edit_frame(child->edit_layout_box()).size.width = advance; } current_x += advance; if (i < n - 1) @@ -410,7 +409,7 @@ namespace docraft::loom::pipeline { cursor_.set_position(current_x, start_y); if (!resolved_widths.empty()) { - layout_box.frame.size.width = current_x - position.x; + edit_frame(layout_box).size.width = current_x - position.x; } } @@ -419,7 +418,7 @@ namespace docraft::loom::pipeline { if (!node) return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = {.width = page_size_.width, .height = node->height()}; + edit_frame(layout_box).size = {.width = page_size_.width, .height = node->height()}; cursor_.move(0.0F, node->height()); } @@ -429,7 +428,7 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; cursor_.move(0.0F, layout_box.measured_size.height); } @@ -439,7 +438,7 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; // A line is a block-level element like Text-in-a-VStack: only the vertical extent // participates in flow, its width does not push a following sibling to the right. cursor_.move(0.0F, layout_box.measured_size.height); @@ -451,7 +450,7 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; cursor_.move(0.0F, layout_box.measured_size.height); } @@ -461,7 +460,7 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; cursor_.move(0.0F, layout_box.measured_size.height); } @@ -471,7 +470,7 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; cursor_.move(0.0F, layout_box.measured_size.height); } @@ -481,7 +480,7 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; cursor_.move(0.0F, layout_box.measured_size.height); } @@ -491,10 +490,10 @@ namespace docraft::loom::pipeline { return; PositionScope scope(*this, *node); auto& layout_box = node->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; - const float item_x = layout_box.frame.position.x; - float y = layout_box.frame.position.y; + const float item_x = edit_frame(layout_box).position.x; + float y = edit_frame(layout_box).position.y; auto& markers = node->edit_markers(); for (int i = 0; i < node->children_count(); ++i) { @@ -534,7 +533,7 @@ namespace docraft::loom::pipeline { } PositionScope scope(*this, *cell); auto& layout_box = cell->edit_layout_box(); - layout_box.frame.size = layout_box.measured_size; + edit_frame(layout_box).size = layout_box.measured_size; if (auto content = cell->content()) { // Content is positioned by the cell's own grid slot, same as the cell itself -- @@ -548,8 +547,8 @@ namespace docraft::loom::pipeline { // plus the content's own margin -- mirrors how VStack/HStack apply // resolve_outer_margin to their children instead of only their own padding. cursor_.set_position( - layout_box.frame.position.x + nodes::DocraftLoomTable::kCellPaddingX + content->margin().left, - layout_box.frame.position.y + nodes::DocraftLoomTable::kCellPaddingY + content->margin().top); + edit_frame(layout_box).position.x + nodes::DocraftLoomTable::kCellPaddingX + content->margin().left, + edit_frame(layout_box).position.y + nodes::DocraftLoomTable::kCellPaddingY + content->margin().top); content->accept(*this); } } @@ -698,7 +697,7 @@ namespace docraft::loom::pipeline { nodes::DocraftLoomTable& table, const std::vector<float>& resolved_widths, const std::vector<float>& row_heights) { - const auto& table_position = table.layout_box().frame.position; + const auto& table_position = frame_of(table.layout_box()).position; float row_top = table_position.y + table.padding(); for (int r = 0; r < table.row_count(); ++r) { @@ -710,15 +709,15 @@ namespace docraft::loom::pipeline { cell->accept(*this); auto& cell_layout = cell->edit_layout_box(); - const float natural_width = cell_layout.frame.size.width; - const float natural_height = cell_layout.frame.size.height; + const float natural_width = edit_frame(cell_layout).size.width; + const float natural_height = edit_frame(cell_layout).size.height; const float resolved_width = resolved_widths[static_cast<std::size_t>(c)]; const float resolved_height = row_heights[static_cast<std::size_t>(r)]; - cell_layout.frame.size = {.width = resolved_width, .height = resolved_height}; + edit_frame(cell_layout).size = {.width = resolved_width, .height = resolved_height}; if (auto content = cell->content()) { - auto& content_frame = content->edit_layout_box().frame; + auto& content_frame = edit_frame(content->edit_layout_box()); content_frame.position.x += table_cell_horizontal_offset(*content, resolved_width - natural_width); content_frame.position.y += (resolved_height - natural_height) / 2.0F; } @@ -753,7 +752,7 @@ namespace docraft::loom::pipeline { total_height += h; // Mirrors Measure's own +2*padding() inflation, so frame.size (what Pagination // advances by) matches measured_size exactly, same as every other node type. - table->edit_layout_box().frame.size = { + edit_frame(table->edit_layout_box()).size = { .width = total_width + (2.0F * table->padding()), .height = total_height + (2.0F * table->padding()) }; @@ -771,6 +770,6 @@ namespace docraft::loom::pipeline { { if (!node) return; PositionScope scope(*this, *node); - node->edit_layout_box().frame.size = {.width = 0.0F, .height = 0.0F}; + edit_frame(node->edit_layout_box()).size = {.width = 0.0F, .height = 0.0F}; } } // pipeline diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_pagination_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_pagination_processor.cc index 5653d71..235ef89 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_pagination_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_pagination_processor.cc @@ -14,6 +14,7 @@ #include <fmt/format.h> #include "docraft/loom/nodes/docraft_loom_hstack.h" +#include "docraft/loom/nodes/docraft_loom_layout_box_access.h" #include "docraft/loom/nodes/docraft_loom_list.h" #include "docraft/loom/nodes/docraft_loom_new_page.h" #include "docraft/loom/nodes/docraft_loom_canvas.h" @@ -159,9 +160,15 @@ namespace docraft::loom::pipeline { } } + void DocraftLoomPaginationProcessor::seal_page_index(nodes::DocraftLoomNode& node, int index) + { + node.edit_layout_box().set_page_index( + index, make_page_index_proof(nodes::layout_proof_or_throw(node))); + } + void DocraftLoomPaginationProcessor::assign_page_index_recursive(nodes::DocraftLoomNode& node, int page_index) { - node.edit_layout_box().page_index = page_index; + seal_page_index(node, page_index); // Table cells live in the table's own grid_, not in the inherited children_ // vector, so they need their own recursion branch. @@ -184,7 +191,7 @@ namespace docraft::loom::pipeline { void DocraftLoomPaginationProcessor::shift_subtree_position(nodes::DocraftLoomNode& node, float dy) { - node.edit_layout_box().frame.position.y += dy; + sealed_edit_frame(node).position.y += dy; // A list's marker positions are stored separately from the layout box, so they // need to be shifted in lockstep with the node itself. @@ -230,7 +237,7 @@ namespace docraft::loom::pipeline { continue; } const float row_height = remainder->row_height(r); - const float dy = row_top - reference_cell->layout_box().frame.position.y; + const float dy = row_top - sealed_frame(*reference_cell).position.y; for (int c = 0; c < remainder_cols; ++c) { if (auto cell = remainder->cell(r, c)) @@ -243,9 +250,9 @@ namespace docraft::loom::pipeline { // The remainder's own frame mirrors the kept table's column layout (widths are // unchanged by a row/cell-content split) and spans exactly the rows it now holds. - auto& remainder_frame = remainder->edit_layout_box().frame; - remainder_frame.position = {table.layout_box().frame.position.x, new_page_top_y}; - remainder_frame.size = {table.layout_box().frame.size.width, row_top - new_page_top_y}; + auto& remainder_frame = remainder->edit_layout_box().edit_frame(nodes::layout_proof_or_throw(table)); + remainder_frame.position = {sealed_frame(table).position.x, new_page_top_y}; + remainder_frame.size = {sealed_frame(table).size.width, row_top - new_page_top_y}; // The kept table's own frame must shrink to cover only the rows left behind. float kept_height = 0.0F; @@ -253,7 +260,7 @@ namespace docraft::loom::pipeline { { kept_height += table.row_height(r); } - table.edit_layout_box().frame.size.height = kept_height; + sealed_edit_frame(table).size.height = kept_height; return remainder; } @@ -266,7 +273,7 @@ namespace docraft::loom::pipeline { return nullptr; } - const bool at_fresh_page_top = std::abs(table.layout_box().frame.position.y - new_page_top_y) < 0.01F; + const bool at_fresh_page_top = std::abs(sealed_frame(table).position.y - new_page_top_y) < 0.01F; // A row "fits" on the current page only if every one of its cells clears // page_bottom_y. All cells in a row share the same height (Layout assigns row @@ -279,7 +286,7 @@ namespace docraft::loom::pipeline { for (int r = 0; r < rows; ++r) { auto reference_cell = table.cell(r, 0); const float row_bottom = reference_cell - ? reference_cell->layout_box().frame.position.y + table.row_height(r) + ? sealed_frame(*reference_cell).position.y + table.row_height(r) : -1.0F; //row_bottom can't be negative, if it is, it means the cell is missing or has no layout box yet (e.g. a blank line), so the row doesn't fit. if (row_bottom == -1.0F || row_bottom > page_bottom_y + 0.01F) { @@ -303,7 +310,7 @@ namespace docraft::loom::pipeline { // across this page and the next instead of giving up on it. if (at_fresh_page_top && fit_rows <= table.leading_title_row_count()) { auto reference_cell = table.cell(fit_rows, 0); - const float row_top = reference_cell ? reference_cell->layout_box().frame.position.y : new_page_top_y; + const float row_top = reference_cell ? sealed_frame(*reference_cell).position.y : new_page_top_y; const float available_height = page_bottom_y - row_top; if (auto content_remainder = table.split_row_content(fit_rows, available_height, /*repeat_header_rows=*/true)) { @@ -342,7 +349,7 @@ namespace docraft::loom::pipeline { // The body's own root container (e.g. a VStack) must be visited on every page so // Rendering recurses into it -- the real per-page decision happens at its direct // children, which get concrete indices below. - body_root.edit_layout_box().page_index = -1; + seal_page_index(body_root, -1); std::vector<std::shared_ptr<nodes::DocraftLoomNode>> children; children.reserve(static_cast<std::size_t>(body_root.children_count())); @@ -367,9 +374,9 @@ namespace docraft::loom::pipeline { { continue; } - const auto& box = children[k]->layout_box(); original_gap_after[children[k].get()] = - children[k + 1]->layout_box().frame.position.y - (box.frame.position.y + box.frame.size.height); + sealed_frame(*children[k + 1]).position.y + - (sealed_frame(*children[k]).position.y + sealed_frame(*children[k]).size.height); } auto gap_after = [&](const nodes::DocraftLoomNode* node) -> float { @@ -411,10 +418,10 @@ namespace docraft::loom::pipeline { // Move the child into place for this attempt: from wherever it currently // sits (either Layout's original continuous-canvas position, the first time // it's seen, or a prior failed placement, on a retry) to next_y. - const float current_top = child->layout_box().frame.position.y; + const float current_top = sealed_frame(*child).position.y; shift_subtree_position(*child, next_y - current_top); - const auto& frame = child->layout_box().frame; + const auto& frame = sealed_frame(*child); const float bottom = frame.position.y + frame.size.height; if (const bool fits = bottom <= page_bottom_y + 0.01F) { diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_rendering_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_rendering_processor.cc index a9fdc11..38b11ae 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_rendering_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_rendering_processor.cc @@ -14,6 +14,7 @@ #include "docraft/loom/nodes/docraft_loom_curve_line.h" #include "docraft/loom/nodes/docraft_loom_hstack.h" #include "docraft/loom/nodes/docraft_loom_image.h" +#include "docraft/loom/nodes/docraft_loom_layout_box_access.h" #include "docraft/loom/nodes/docraft_loom_line.h" #include "docraft/loom/nodes/docraft_loom_list.h" #include "docraft/loom/nodes/docraft_loom_new_page.h" @@ -48,7 +49,7 @@ namespace docraft::loom::pipeline { bool DocraftLoomRenderingProcessor::should_render(const nodes::DocraftLoomNode& node) const { - const int page_index = node.layout_box().page_index; + const int page_index = node.layout_box().page_index_or_unpaginated(); return page_index < 0 || page_index == current_page_index_; } @@ -149,10 +150,10 @@ namespace docraft::loom::pipeline { const auto rgba = text->color().toRGB(); if (lines.empty()) { - const float y = text->layout_box().frame.position.y + ascent; + const float y = nodes::sealed_frame(*text).position.y + ascent; text_backend_->begin_text(); text_backend_->set_text_color(rgba.r, rgba.g, rgba.b); - text_backend_->draw_text(text->text(), text->layout_box().frame.position.x, y); + text_backend_->draw_text(text->text(), nodes::sealed_frame(*text).position.x, y); text_backend_->end_text(); // Path painting is not valid inside a BT/ET text object, so the // underline/strikeout strokes are issued after end_text(). @@ -160,7 +161,7 @@ namespace docraft::loom::pipeline { { const float width = text_backend_->measure_text_width(text->text(), text->resolved_font_name(), text->font_size()); - const float x_start = text->layout_box().frame.position.x; + const float x_start = nodes::sealed_frame(*text).position.x; const float x_end = x_start + width; if (text->underline()) { @@ -175,8 +176,8 @@ namespace docraft::loom::pipeline { } const float line_height = text->layout_box().measured_size.height / static_cast<float>(lines.size()); - const float box_x = text->layout_box().frame.position.x; - const float box_top = text->layout_box().frame.position.y; + const float box_x = nodes::sealed_frame(*text).position.x; + const float box_top = nodes::sealed_frame(*text).position.y; TextLineStyle style{ .box_width = text->wrap_width(), .alignment = text->alignment(), @@ -260,7 +261,7 @@ namespace docraft::loom::pipeline { { if (!node || !should_render(*node)) return; - const auto& frame = node->layout_box().frame; + const auto& frame = nodes::sealed_frame(*node); draw_container_background(node->style(), frame.position, frame.size); // Without this clip, a child whose computed size exceeds the rectangle's own // frame (e.g. a Text node whose own explicit wrap_width overrides the width @@ -273,7 +274,7 @@ namespace docraft::loom::pipeline { { if (!node || !should_render(*node)) return; - const auto& frame = node->layout_box().frame; + const auto& frame = nodes::sealed_frame(*node); draw_container_background(node->style(), frame.position, frame.size); // Trims anything that overflows the canvas bounds -- see visit(DocraftLoomCanvas*) // in the layout processor for how children are positioned relative to this @@ -294,7 +295,7 @@ namespace docraft::loom::pipeline { void DocraftLoomRenderingProcessor::visit(docraft::loom::nodes::DocraftLoomVStack* node) { if (!node || !should_render(*node)) return; - draw_container_background(node->style(), node->layout_box().frame.position, node->layout_box().frame.size); + draw_container_background(node->style(), nodes::sealed_frame(*node).position, nodes::sealed_frame(*node).size); for (int i: node->paint_order_indices()) if (auto child = node->edit_child(i)) child->accept(*this); @@ -303,7 +304,7 @@ namespace docraft::loom::pipeline { void DocraftLoomRenderingProcessor::visit(docraft::loom::nodes::DocraftLoomHStack* node) { if (!node || !should_render(*node)) return; - draw_container_background(node->style(), node->layout_box().frame.position, node->layout_box().frame.size); + draw_container_background(node->style(), nodes::sealed_frame(*node).position, nodes::sealed_frame(*node).size); for (int i: node->paint_order_indices()) if (auto child = node->edit_child(i)) child->accept(*this); @@ -320,8 +321,8 @@ namespace docraft::loom::pipeline { return; // frame.position is already engine-space top-left, matching what these backend // calls expect internally -- do not subtract height() here (see coordinate note). - const auto& pos = image->layout_box().frame.position; - const auto& size = image->layout_box().frame.size; + const auto& pos = nodes::sealed_frame(*image).position; + const auto& size = nodes::sealed_frame(*image).size; switch (image->format()) { case nodes::ImageFormat::kPng: @@ -352,7 +353,7 @@ namespace docraft::loom::pipeline { return; } - const auto& pos = line->layout_box().frame.position; + const auto& pos = nodes::sealed_frame(*line).position; // start()/end() are plain offsets from the node's anchor, exactly like the local // points of a Polygon/Triangle: inside a Canvas that anchor is the canvas origin // (plus the line's own x/y, both defaulting to 0), so x1/y1/x2/y2 read as @@ -388,7 +389,7 @@ namespace docraft::loom::pipeline { // frame.position is the bounding box's top-left corner, so the center sits one // semi-axis in on each side -- for a width/height-sized oval that is exactly the // middle of the box its four extreme points touch. - const auto& pos = node->layout_box().frame.position; + const auto& pos = nodes::sealed_frame(*node).position; const nodes::Position center = {.x = pos.x + radius_x, .y = pos.y + radius_y}; if (node->has_arc()) @@ -439,7 +440,7 @@ namespace docraft::loom::pipeline { DocraftLoomShapeDrawUtils::draw_shape_polygon({ .target = {.shape_backend = shape_backend_, .line_backend = line_backend_}, .style = node->style(), - .origin = node->layout_box().frame.position, + .origin = nodes::sealed_frame(*node).position, .points = node->points(), }); } @@ -451,7 +452,7 @@ namespace docraft::loom::pipeline { DocraftLoomShapeDrawUtils::draw_shape_polygon({ .target = {.shape_backend = shape_backend_, .line_backend = line_backend_}, .style = node->style(), - .origin = node->layout_box().frame.position, + .origin = nodes::sealed_frame(*node).position, .points = node->points(), }); } @@ -466,7 +467,7 @@ namespace docraft::loom::pipeline { .target = {.shape_backend = shape_backend_, .line_backend = line_backend_}, .border_color = node->border_color(), .border_width = node->border_width(), - .origin = node->layout_box().frame.position, + .origin = nodes::sealed_frame(*node).position, .points = node->points(), }); } @@ -523,8 +524,8 @@ namespace docraft::loom::pipeline { const auto rgba = cell->background()->toRGB(); if (rgba.a > 0.0F) { - const auto& pos = cell->layout_box().frame.position; - const auto& size = cell->layout_box().frame.size; + const auto& pos = nodes::sealed_frame(*cell).position; + const auto& size = nodes::sealed_frame(*cell).size; shape_backend_->save_state(); if (rgba.a < 1.0F) { @@ -568,8 +569,8 @@ namespace docraft::loom::pipeline { auto cell = table.cell(r, c); if (!cell->background()) { - const auto& pos = cell->layout_box().frame.position; - const auto& size = cell->layout_box().frame.size; + const auto& pos = nodes::sealed_frame(*cell).position; + const auto& size = nodes::sealed_frame(*cell).size; shape_backend_->draw_rectangle(pos.x, pos.y, size.width, size.height); shape_backend_->fill(); } @@ -587,11 +588,11 @@ namespace docraft::loom::pipeline { // -- because that frame's position/size describe the outer padding() footprint // (see LayoutProcessor::visit(DocraftLoomTable*)), while the border must wrap // only the actual grid, inset by that padding from the footprint's edges. - const auto table_pos = table.cell(0, 0)->layout_box().frame.position; + const auto table_pos = nodes::sealed_frame(*table.cell(0, 0)).position; nodes::Size table_size{}; for (int c = 0; c < table.column_count(); ++c) { - table_size.width += table.cell(0, c)->layout_box().frame.size.width; + table_size.width += nodes::sealed_frame(*table.cell(0, c)).size.width; } for (int r = 0; r < table.row_count(); ++r) { @@ -608,12 +609,12 @@ namespace docraft::loom::pipeline { shape_backend_->stroke(); for (int c = 1; c < table.column_count(); ++c) { - const float x = table.cell(0, c)->layout_box().frame.position.x; + const float x = nodes::sealed_frame(*table.cell(0, c)).position.x; line_backend_->draw_line(x, table_pos.y, x, table_pos.y + table_size.height); } for (int r = 1; r < table.row_count(); ++r) { - const float y = table.cell(r, 0)->layout_box().frame.position.y; + const float y = nodes::sealed_frame(*table.cell(r, 0)).position.y; line_backend_->draw_line(table_pos.x, y, table_pos.x + table_size.width, y); } shape_backend_->restore_state(); diff --git a/docraft/test/CMakeLists.txt b/docraft/test/CMakeLists.txt index 1390a25..bd00bdb 100644 --- a/docraft/test/CMakeLists.txt +++ b/docraft/test/CMakeLists.txt @@ -14,6 +14,7 @@ set(TEST_SOURCES docraft/utils/docraft_font_registry_test.cc docraft/utils/docraft_file_utils_test.cc docraft/utils/docraft_test_temp_file.h + docraft/utils/docraft_loom_layout_box_test_access.h docraft/loom/pipeline/docraft_loom_measure_processor_test.cc docraft/loom/pipeline/docraft_loom_text_wrapper_test.cc docraft/loom/pipeline/docraft_loom_layout_processor_test.cc diff --git a/docraft/test/docraft/craft/docraft_loom_craft_language_parser_test.cc b/docraft/test/docraft/craft/docraft_loom_craft_language_parser_test.cc index d5353e2..43455af 100644 --- a/docraft/test/docraft/craft/docraft_loom_craft_language_parser_test.cc +++ b/docraft/test/docraft/craft/docraft_loom_craft_language_parser_test.cc @@ -8,6 +8,7 @@ #include "docraft/loom/nodes/docraft_loom_vstack.h" #include "docraft/utils/docraft_font_registry.h" #include "docraft/utils/docraft_test_temp_file.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" TEST(DocraftLoomCraftLanguageParserTest, ParsesFullDocumentWithHeaderBodyFooter) { @@ -153,14 +154,14 @@ TEST(DocraftLoomCraftLanguageParserTest, HeaderTallerThanRatioPushesBodyDownInst EXPECT_NO_THROW(creator->create()); ASSERT_TRUE(creator->header()); - const auto& header_frame = creator->header()->layout_box().frame; + const auto& header_frame = creator->header()->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); const float header_bottom = header_frame.position.y + header_frame.size.height; const auto body_vstack = std::dynamic_pointer_cast<docraft::loom::nodes::DocraftLoomVStack>(creator->root_node()); ASSERT_TRUE(body_vstack); // Body must start below the header's actual bottom edge -- not at the tiny // header_ratio-allocated position, which would overlap this header's real content. - EXPECT_GT(body_vstack->layout_box().frame.position.y, header_bottom); + EXPECT_GT(body_vstack->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, header_bottom); } TEST(DocraftLoomCraftLanguageParserTest, FooterTallerThanRatioStaysFullyOnThePage) @@ -188,7 +189,7 @@ TEST(DocraftLoomCraftLanguageParserTest, FooterTallerThanRatioStaysFullyOnThePag EXPECT_NO_THROW(creator->create()); ASSERT_TRUE(creator->footer()); - const auto& footer_frame = creator->footer()->layout_box().frame; + const auto& footer_frame = creator->footer()->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); // The footer's whole box must still fit on the (portrait A4, ~841.89pt-tall) page -- // not overflow past the bottom edge just because footer_ratio's allocation was too // small for its real content. diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_blank_line_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_blank_line_test.cc index 4a31a83..f8bdc07 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_blank_line_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_blank_line_test.cc @@ -3,6 +3,7 @@ #include "docraft/loom/nodes/docraft_loom_blank_line.h" #include "docraft/loom/pipeline/docraft_loom_layout_processor.h" #include "docraft/loom/pipeline/docraft_loom_measure_processor.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { class DocraftLoomBlankLineTest : public ::testing::Test @@ -47,16 +48,16 @@ namespace docraft::test { blank_line->accept(*measure_); blank_line->accept(layout); - EXPECT_FLOAT_EQ(blank_line->layout_box().frame.size.width, 200.0F); - EXPECT_FLOAT_EQ(blank_line->layout_box().frame.size.height, 3.0F); + EXPECT_FLOAT_EQ(blank_line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 200.0F); + EXPECT_FLOAT_EQ(blank_line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 3.0F); auto next = std::make_shared<loom::nodes::DocraftLoomBlankLine>(); next->accept(*measure_); next->accept(layout); // The cursor should have advanced by the first blank line's height (plus the // default top margin baked into a fresh cursor). - EXPECT_FLOAT_EQ(next->layout_box().frame.position.y, - blank_line->layout_box().frame.position.y + 3.0F); + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, + blank_line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 3.0F); } TEST_F(DocraftLoomBlankLineTest, LayoutWithDefaultConstructedProcessorHasZeroWidth) @@ -66,7 +67,7 @@ namespace docraft::test { blank_line->accept(*measure_); blank_line->accept(layout); - EXPECT_FLOAT_EQ(blank_line->layout_box().frame.size.width, 0.0F); + EXPECT_FLOAT_EQ(blank_line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 0.0F); } TEST_F(DocraftLoomBlankLineTest, AbsolutePositionDoesNotAdvanceCursor) @@ -78,13 +79,13 @@ namespace docraft::test { blank_line->accept(*measure_); blank_line->accept(layout); - EXPECT_FLOAT_EQ(blank_line->layout_box().frame.position.x, 42.0F); - EXPECT_FLOAT_EQ(blank_line->layout_box().frame.position.y, 42.0F); + EXPECT_FLOAT_EQ(blank_line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 42.0F); + EXPECT_FLOAT_EQ(blank_line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 42.0F); auto next = std::make_shared<loom::nodes::DocraftLoomBlankLine>(); next->accept(*measure_); next->accept(layout); // Untouched by the absolute node: still at the cursor's default top margin. - EXPECT_FLOAT_EQ(next->layout_box().frame.position.y, 10.0F); + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 10.0F); } } // namespace docraft::test \ No newline at end of file diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_canvas_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_canvas_test.cc index e56426d..3f339f7 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_canvas_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_canvas_test.cc @@ -8,6 +8,7 @@ #include "docraft/loom/pipeline/docraft_loom_pagination_processor.h" #include "docraft/loom/pipeline/docraft_loom_rendering_processor.h" #include "docraft/utils/docraft_mock_rendering_backend.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { class DocraftLoomCanvasTest : public ::testing::Test @@ -56,8 +57,8 @@ namespace docraft::test { canvas->accept(*measure_); canvas->accept(*layout_); - EXPECT_FLOAT_EQ(child->layout_box().frame.position.x, canvas->layout_box().frame.position.x); - EXPECT_FLOAT_EQ(child->layout_box().frame.position.y, canvas->layout_box().frame.position.y); + EXPECT_FLOAT_EQ(child->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x); + EXPECT_FLOAT_EQ(child->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y); } TEST_F(DocraftLoomCanvasTest, LayoutPlacesChildRelativeToCanvasOrigin) @@ -75,11 +76,11 @@ namespace docraft::test { canvas->accept(*measure_); canvas->accept(*layout_); - EXPECT_FLOAT_EQ(canvas->layout_box().frame.position.x, 20.0F); - EXPECT_FLOAT_EQ(canvas->layout_box().frame.position.y, 30.0F); + EXPECT_FLOAT_EQ(canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 20.0F); + EXPECT_FLOAT_EQ(canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 30.0F); // 20 (canvas origin x) + 10 (child local x), 30 (canvas origin y) + 20 (child local y) - EXPECT_FLOAT_EQ(child->layout_box().frame.position.x, 30.0F); - EXPECT_FLOAT_EQ(child->layout_box().frame.position.y, 50.0F); + EXPECT_FLOAT_EQ(child->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 30.0F); + EXPECT_FLOAT_EQ(child->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 50.0F); } TEST_F(DocraftLoomCanvasTest, LayoutIsIdempotentAcrossRepeatedPasses) @@ -101,13 +102,13 @@ namespace docraft::test { canvas->accept(*measure_); canvas->accept(*layout_); - EXPECT_FLOAT_EQ(child->layout_box().frame.position.x, 30.0F); - EXPECT_FLOAT_EQ(child->layout_box().frame.position.y, 50.0F); + EXPECT_FLOAT_EQ(child->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 30.0F); + EXPECT_FLOAT_EQ(child->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 50.0F); canvas->accept(*measure_); canvas->accept(*layout_); - EXPECT_FLOAT_EQ(child->layout_box().frame.position.x, 30.0F); - EXPECT_FLOAT_EQ(child->layout_box().frame.position.y, 50.0F); + EXPECT_FLOAT_EQ(child->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 30.0F); + EXPECT_FLOAT_EQ(child->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 50.0F); } TEST_F(DocraftLoomCanvasTest, LayoutComposesForNestedCanvas) @@ -131,10 +132,10 @@ namespace docraft::test { outer->accept(*measure_); outer->accept(*layout_); - EXPECT_FLOAT_EQ(inner->layout_box().frame.position.x, 15.0F); // 5 + 10 - EXPECT_FLOAT_EQ(inner->layout_box().frame.position.y, 15.0F); - EXPECT_FLOAT_EQ(leaf->layout_box().frame.position.x, 16.0F); // 15 + 1 - EXPECT_FLOAT_EQ(leaf->layout_box().frame.position.y, 17.0F); // 15 + 2 + EXPECT_FLOAT_EQ(inner->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 15.0F); // 5 + 10 + EXPECT_FLOAT_EQ(inner->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 15.0F); + EXPECT_FLOAT_EQ(leaf->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 16.0F); // 15 + 1 + EXPECT_FLOAT_EQ(leaf->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 17.0F); // 15 + 2 } TEST_F(DocraftLoomCanvasTest, LayoutAdvancesCursorPastCanvasBottomForNextSibling) @@ -149,8 +150,8 @@ namespace docraft::test { next->accept(*measure_); next->accept(*layout_); - EXPECT_FLOAT_EQ(next->layout_box().frame.position.y, - canvas->layout_box().frame.position.y + canvas->layout_box().frame.size.height); + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, + canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height); } TEST_F(DocraftLoomCanvasTest, PaginationRecursesIntoChildrenWithoutSplitting) @@ -213,10 +214,10 @@ namespace docraft::test { ASSERT_EQ(backend.draw_line_calls().size(), 1U); const auto& call = backend.draw_line_calls()[0]; - EXPECT_FLOAT_EQ(call.x1, canvas->layout_box().frame.position.x + 5.0F); - EXPECT_FLOAT_EQ(call.y1, canvas->layout_box().frame.position.y + 5.0F); - EXPECT_FLOAT_EQ(call.x2, canvas->layout_box().frame.position.x + 5.0F + 30.0F); - EXPECT_FLOAT_EQ(call.y2, canvas->layout_box().frame.position.y + 5.0F + 40.0F); + EXPECT_FLOAT_EQ(call.x1, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x + 5.0F); + EXPECT_FLOAT_EQ(call.y1, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 5.0F); + EXPECT_FLOAT_EQ(call.x2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x + 5.0F + 30.0F); + EXPECT_FLOAT_EQ(call.y2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 5.0F + 40.0F); } TEST_F(DocraftLoomCanvasTest, RenderingKeepsLineEndpointsAtTheirCanvasLocalCoordinates) @@ -272,7 +273,7 @@ namespace docraft::test { canvas->accept(*layout_); canvas->accept(rendering); - const auto& origin = canvas->layout_box().frame.position; + const auto& origin = canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position; ASSERT_EQ(backend.draw_line_calls().size(), 1U); const auto& call = backend.draw_line_calls()[0]; EXPECT_FLOAT_EQ(call.x1, origin.x + 8.0F + 1.0F); @@ -309,10 +310,10 @@ namespace docraft::test { canvas->accept(rendering); ASSERT_EQ(backend.draw_line_calls().size(), 2U); - EXPECT_FLOAT_EQ(backend.draw_line_calls()[0].x2, canvas->layout_box().frame.position.x); - EXPECT_FLOAT_EQ(backend.draw_line_calls()[0].y2, canvas->layout_box().frame.position.y + 30.0F); - EXPECT_FLOAT_EQ(backend.draw_line_calls()[1].x2, canvas->layout_box().frame.position.x + 30.0F); - EXPECT_FLOAT_EQ(backend.draw_line_calls()[1].y2, canvas->layout_box().frame.position.y); + EXPECT_FLOAT_EQ(backend.draw_line_calls()[0].x2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x); + EXPECT_FLOAT_EQ(backend.draw_line_calls()[0].y2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 30.0F); + EXPECT_FLOAT_EQ(backend.draw_line_calls()[1].x2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x + 30.0F); + EXPECT_FLOAT_EQ(backend.draw_line_calls()[1].y2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y); } TEST_F(DocraftLoomCanvasTest, RenderingPreservesDeclarationOrderForEqualZIndex) @@ -339,9 +340,9 @@ namespace docraft::test { canvas->accept(rendering); ASSERT_EQ(backend.draw_line_calls().size(), 2U); - EXPECT_FLOAT_EQ(backend.draw_line_calls()[0].x2, canvas->layout_box().frame.position.x + 30.0F); - EXPECT_FLOAT_EQ(backend.draw_line_calls()[0].y2, canvas->layout_box().frame.position.y); - EXPECT_FLOAT_EQ(backend.draw_line_calls()[1].x2, canvas->layout_box().frame.position.x); - EXPECT_FLOAT_EQ(backend.draw_line_calls()[1].y2, canvas->layout_box().frame.position.y + 30.0F); + EXPECT_FLOAT_EQ(backend.draw_line_calls()[0].x2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x + 30.0F); + EXPECT_FLOAT_EQ(backend.draw_line_calls()[0].y2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y); + EXPECT_FLOAT_EQ(backend.draw_line_calls()[1].x2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x); + EXPECT_FLOAT_EQ(backend.draw_line_calls()[1].y2, canvas->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 30.0F); } } // namespace docraft::test diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_image_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_image_test.cc index a02eaac..62d2bf6 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_image_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_image_test.cc @@ -3,6 +3,7 @@ #include "docraft/loom/nodes/docraft_loom_image.h" #include "docraft/loom/pipeline/docraft_loom_layout_processor.h" #include "docraft/loom/pipeline/docraft_loom_measure_processor.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { class DocraftLoomImageTest : public ::testing::Test @@ -69,14 +70,14 @@ namespace docraft::test { image->accept(*measure_); image->accept(*layout_); - EXPECT_FLOAT_EQ(image->layout_box().frame.size.width, 50.0F); - EXPECT_FLOAT_EQ(image->layout_box().frame.size.height, 30.0F); + EXPECT_FLOAT_EQ(image->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 50.0F); + EXPECT_FLOAT_EQ(image->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 30.0F); auto next = std::make_shared<loom::nodes::DocraftLoomImage>(); next->accept(*measure_); next->accept(*layout_); - EXPECT_FLOAT_EQ(next->layout_box().frame.position.y, - image->layout_box().frame.position.y + 30.0F); + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, + image->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 30.0F); } TEST_F(DocraftLoomImageTest, AbsolutePositionOverridesCursor) @@ -88,7 +89,7 @@ namespace docraft::test { image->accept(*measure_); image->accept(layout); - EXPECT_FLOAT_EQ(image->layout_box().frame.position.x, 15.0F); - EXPECT_FLOAT_EQ(image->layout_box().frame.position.y, 25.0F); + EXPECT_FLOAT_EQ(image->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 15.0F); + EXPECT_FLOAT_EQ(image->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 25.0F); } } // namespace docraft::test \ No newline at end of file diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_line_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_line_test.cc index 85464f6..6f7e372 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_line_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_line_test.cc @@ -3,6 +3,7 @@ #include "docraft/loom/nodes/docraft_loom_line.h" #include "docraft/loom/pipeline/docraft_loom_layout_processor.h" #include "docraft/loom/pipeline/docraft_loom_measure_processor.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { class DocraftLoomLineTest : public ::testing::Test @@ -100,15 +101,15 @@ namespace docraft::test { line->accept(*measure_); line->accept(*layout_); - EXPECT_FLOAT_EQ(line->layout_box().frame.size.width, 100.0F); - EXPECT_FLOAT_EQ(line->layout_box().frame.size.height, 4.0F); + EXPECT_FLOAT_EQ(line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 100.0F); + EXPECT_FLOAT_EQ(line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 4.0F); auto next = std::make_shared<loom::nodes::DocraftLoomLine>(); next->accept(*measure_); next->accept(*layout_); - EXPECT_FLOAT_EQ(next->layout_box().frame.position.x, line->layout_box().frame.position.x); - EXPECT_FLOAT_EQ(next->layout_box().frame.position.y, line->layout_box().frame.position.y + 4.0F); + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x); + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 4.0F); } TEST_F(DocraftLoomLineTest, AbsolutePositionDoesNotAdvanceCursor) @@ -120,12 +121,12 @@ namespace docraft::test { line->accept(*measure_); line->accept(layout); - EXPECT_FLOAT_EQ(line->layout_box().frame.position.x, 30.0F); - EXPECT_FLOAT_EQ(line->layout_box().frame.position.y, 40.0F); + EXPECT_FLOAT_EQ(line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 30.0F); + EXPECT_FLOAT_EQ(line->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 40.0F); auto next = std::make_shared<loom::nodes::DocraftLoomLine>(); next->accept(*measure_); next->accept(layout); - EXPECT_FLOAT_EQ(next->layout_box().frame.position.y, 10.0F); // default top margin, untouched + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 10.0F); // default top margin, untouched } } // namespace docraft::test \ No newline at end of file diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_list_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_list_test.cc index 1e3dbbc..05866d3 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_list_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_list_test.cc @@ -10,6 +10,7 @@ #include "docraft/loom/pipeline/docraft_loom_layout_processor.h" #include "docraft/loom/pipeline/docraft_loom_measure_processor.h" #include "../../backend/docraft_mock_backend.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { using ::testing::Return; @@ -130,8 +131,8 @@ namespace docraft::test { auto item = std::dynamic_pointer_cast<loom::nodes::DocraftLoomText>(list->edit_child(0)); // item_x (list x) + marker width 50 + gap 6 - EXPECT_FLOAT_EQ(item->layout_box().frame.position.x, list->layout_box().frame.position.x + 56.0F); - EXPECT_FLOAT_EQ(list->markers()[0].position.x, list->layout_box().frame.position.x); + EXPECT_FLOAT_EQ(item->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, list->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x + 56.0F); + EXPECT_FLOAT_EQ(list->markers()[0].position.x, list->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x); } TEST_F(DocraftLoomListTest, BoxMarkerAlignsToFirstLineNotWholeWrappedBlock) @@ -161,7 +162,7 @@ namespace docraft::test { const float first_line_height = 10.0F; const float marker_width = 12.0F; const float expected_offset = std::max(0.0F, (first_line_height - marker_width) / 2.0F); - EXPECT_FLOAT_EQ(list->markers()[0].position.y, list->layout_box().frame.position.y + expected_offset); + EXPECT_FLOAT_EQ(list->markers()[0].position.y, list->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + expected_offset); } TEST_F(DocraftLoomListTest, NonTextChildThrowsOnMeasure) diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_node_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_node_test.cc index 9d8d061..8dc653c 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_node_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_node_test.cc @@ -4,6 +4,7 @@ #include "docraft/exception/docraft_input_exceptions.h" #include "docraft/loom/nodes/docraft_loom_node.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test::loom::nodes { namespace { @@ -23,11 +24,13 @@ namespace docraft::test::loom::nodes { EXPECT_EQ(node.children_count(), 0); EXPECT_EQ(node.layout_box().measured_size.width, 0.0F); EXPECT_EQ(node.layout_box().measured_size.height, 0.0F); - EXPECT_EQ(node.layout_box().page_index, 0); - EXPECT_EQ(node.layout_box().frame.position.x, 0.0F); - EXPECT_EQ(node.layout_box().frame.position.y, 0.0F); - EXPECT_EQ(node.layout_box().frame.size.width, 0.0F); - EXPECT_EQ(node.layout_box().frame.size.height, 0.0F); + // A fresh node hasn't been sealed by Pagination yet -- page_index_or_unpaginated() + // is the -1 "render on every page" sentinel, not a real page number. + EXPECT_EQ(node.layout_box().page_index_or_unpaginated(), -1); + EXPECT_EQ(node.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 0.0F); + EXPECT_EQ(node.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 0.0F); + EXPECT_EQ(node.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 0.0F); + EXPECT_EQ(node.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 0.0F); } TEST(DocraftLoomNodeTest, AddsChildAndExposesItThroughChildAccessors) @@ -124,19 +127,19 @@ namespace docraft::test::loom::nodes { node.edit_layout_box().measured_size.width = 42.0F; node.edit_layout_box().measured_size.height = 24.0F; - node.edit_layout_box().page_index = 3; - node.edit_layout_box().frame.position.x = 10.0F; - node.edit_layout_box().frame.position.y = 12.0F; - node.edit_layout_box().frame.size.width = 50.0F; - node.edit_layout_box().frame.size.height = 60.0F; + node.edit_layout_box().set_page_index(3, docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()); + node.edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x = 10.0F; + node.edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y = 12.0F; + node.edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width = 50.0F; + node.edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height = 60.0F; EXPECT_EQ(node.layout_box().measured_size.width, 42.0F); EXPECT_EQ(node.layout_box().measured_size.height, 24.0F); - EXPECT_EQ(node.layout_box().page_index, 3); - EXPECT_EQ(node.layout_box().frame.position.x, 10.0F); - EXPECT_EQ(node.layout_box().frame.position.y, 12.0F); - EXPECT_EQ(node.layout_box().frame.size.width, 50.0F); - EXPECT_EQ(node.layout_box().frame.size.height, 60.0F); + EXPECT_EQ(node.layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 3); + EXPECT_EQ(node.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 10.0F); + EXPECT_EQ(node.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 12.0F); + EXPECT_EQ(node.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 50.0F); + EXPECT_EQ(node.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 60.0F); } } // namespace docraft::test::loom::nodes diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_page_number_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_page_number_test.cc index 55d9494..4960d48 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_page_number_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_page_number_test.cc @@ -3,6 +3,7 @@ #include "docraft/loom/nodes/docraft_loom_page_number.h" #include "docraft/loom/pipeline/docraft_loom_rendering_processor.h" #include "docraft/utils/docraft_mock_rendering_backend.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { TEST(DocraftLoomPageNumberTest, DefaultFormatRendersBareCurrentPageNumber) @@ -12,6 +13,10 @@ namespace docraft::test { rendering.set_current_page(0, 1); // 0-based page index 0 -> "1" loom::nodes::DocraftLoomPageNumber page_number; + // visit(Text*) (which visit(PageNumber*) delegates to) reads the node's frame + // to position the draw -- irrelevant to this test's actual assertion (just the + // formatted text), but still required to have been sealed by Layout. + page_number.edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); page_number.accept(rendering); EXPECT_EQ(page_number.text(), "1"); @@ -27,7 +32,8 @@ namespace docraft::test { // A PageNumber (typically in a Header/Footer) renders on every page, which // DocraftLoomPaginationProcessor marks with page_index == -1; the default // (unpaginated) page_index of 0 would otherwise only match page 1. - page_number.edit_layout_box().page_index = -1; + page_number.edit_layout_box().set_page_index(-1, docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()); + page_number.edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); page_number.set_format("Page {page} of {total}"); page_number.accept(rendering); @@ -41,6 +47,7 @@ namespace docraft::test { rendering.set_current_page(0, 3); loom::nodes::DocraftLoomPageNumber page_number; + page_number.edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); page_number.set_format("{page}/{total} (page {page})"); page_number.accept(rendering); diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_rectangle_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_rectangle_test.cc index d6539bb..6b74f5c 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_rectangle_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_rectangle_test.cc @@ -9,6 +9,7 @@ #include "docraft/loom/pipeline/docraft_loom_rendering_processor.h" #include "docraft/utils/docraft_mock_rendering_backend.h" #include "../../backend/docraft_mock_backend.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { using ::testing::Return; @@ -92,14 +93,14 @@ namespace docraft::test { rect->accept(*measure_); rect->accept(*layout_); - EXPECT_FLOAT_EQ(t1->layout_box().frame.position.x, rect->layout_box().frame.position.x + 4.0F); - EXPECT_FLOAT_EQ(t1->layout_box().frame.position.y, rect->layout_box().frame.position.y + 4.0F); + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, rect->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x + 4.0F); + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, rect->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 4.0F); auto next = std::make_shared<loom::nodes::DocraftLoomText>("next"); next->accept(*measure_); next->accept(*layout_); - EXPECT_FLOAT_EQ(next->layout_box().frame.position.y, - rect->layout_box().frame.position.y + rect->layout_box().frame.size.height); + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, + rect->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + rect->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height); } TEST_F(DocraftLoomRectangleTest, AbsolutePositionOverridesCursor) @@ -112,8 +113,8 @@ namespace docraft::test { rect.accept(*measure_); rect.accept(*layout_); - EXPECT_FLOAT_EQ(rect.layout_box().frame.position.x, 80.0F); - EXPECT_FLOAT_EQ(rect.layout_box().frame.position.y, 90.0F); + EXPECT_FLOAT_EQ(rect.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 80.0F); + EXPECT_FLOAT_EQ(rect.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 90.0F); } TEST_F(DocraftLoomRectangleTest, RenderingClipsChildrenToRectangleBoundsBracketedBySaveRestore) @@ -141,7 +142,7 @@ namespace docraft::test { // Assert against the rectangle's own resolved frame (rather than the // width_/height_ set above) so this test only exercises clipping and stays // agnostic to how that frame is sized. - const auto& frame = rect->layout_box().frame; + const auto& frame = rect->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); ASSERT_EQ(backend.clip_calls().size(), 1U); EXPECT_FLOAT_EQ(backend.clip_calls()[0].x, frame.position.x); EXPECT_FLOAT_EQ(backend.clip_calls()[0].y, frame.position.y); diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_shape_nodes_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_shape_nodes_test.cc index 03396bb..e22fc93 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_shape_nodes_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_shape_nodes_test.cc @@ -10,6 +10,7 @@ #include "docraft/loom/pipeline/docraft_loom_measure_processor.h" #include "docraft/loom/pipeline/docraft_loom_rendering_processor.h" #include "docraft/utils/docraft_mock_rendering_backend.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { class DocraftLoomShapeNodesTest : public ::testing::Test @@ -44,12 +45,12 @@ namespace docraft::test { circle->accept(*measure_); circle->accept(*layout_); - EXPECT_FLOAT_EQ(circle->layout_box().frame.size.width, 20.0F); + EXPECT_FLOAT_EQ(circle->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 20.0F); auto next = std::make_shared<loom::nodes::DocraftLoomCircle>(); next->accept(*measure_); next->accept(*layout_); - EXPECT_FLOAT_EQ(next->layout_box().frame.position.y, circle->layout_box().frame.position.y + 20.0F); + EXPECT_FLOAT_EQ(next->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, circle->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + 20.0F); } TEST_F(DocraftLoomShapeNodesTest, CircleMeasuresToTheBoundingBoxOfItsTwoSemiAxes) @@ -227,8 +228,8 @@ namespace docraft::test { triangle->accept(*measure_); triangle->accept(*layout_); - EXPECT_FLOAT_EQ(triangle->layout_box().frame.size.width, 10.0F); - EXPECT_FLOAT_EQ(triangle->layout_box().frame.size.height, 10.0F); + EXPECT_FLOAT_EQ(triangle->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 10.0F); + EXPECT_FLOAT_EQ(triangle->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 10.0F); } // ── Polygon ────────────────────────────────────────────────────────────────── @@ -334,7 +335,7 @@ namespace docraft::test { circle.accept(*measure_); circle.accept(*layout_); - EXPECT_FLOAT_EQ(circle.layout_box().frame.position.x, 33.0F); - EXPECT_FLOAT_EQ(circle.layout_box().frame.position.y, 44.0F); + EXPECT_FLOAT_EQ(circle.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 33.0F); + EXPECT_FLOAT_EQ(circle.layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 44.0F); } } // namespace docraft::test \ No newline at end of file diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_stack_nodes_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_stack_nodes_test.cc index b416391..817e7dd 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_stack_nodes_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_stack_nodes_test.cc @@ -11,6 +11,7 @@ #include "docraft/loom/pipeline/docraft_loom_rendering_processor.h" #include "docraft/utils/docraft_mock_rendering_backend.h" #include "../../backend/docraft_mock_backend.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { using ::testing::Return; @@ -207,8 +208,8 @@ namespace docraft::test { vstack->accept(*layout_); // VStack placed at cursor origin (0, 10 = default top margin) - EXPECT_FLOAT_EQ(t1->layout_box().frame.position.y, 10.0F); - EXPECT_FLOAT_EQ(t2->layout_box().frame.position.y, 24.0F); // 10 + 10 + 4 + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 10.0F); + EXPECT_FLOAT_EQ(t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 24.0F); // 10 + 10 + 4 } TEST_F(DocraftLoomStackNodesTest, VStack_LayoutChildrenShareSameX) @@ -225,7 +226,7 @@ namespace docraft::test { vstack->accept(*measure_); vstack->accept(*layout_); - EXPECT_FLOAT_EQ(t1->layout_box().frame.position.x, t2->layout_box().frame.position.x); + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x); } // Bug #75: `weight` on a child of a vertical `<Layout>` used to be silently @@ -249,12 +250,12 @@ namespace docraft::test { vstack->accept(*measure_); vstack->accept(*layout_); - EXPECT_FLOAT_EQ(t1->layout_box().frame.size.height, 50.0F); // 200 * 1/4 - EXPECT_FLOAT_EQ(t2->layout_box().frame.size.height, 150.0F); // 200 * 3/4 + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 50.0F); // 200 * 1/4 + EXPECT_FLOAT_EQ(t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 150.0F); // 200 * 3/4 // 10 = the layout processor's default page top-margin cursor start (see // VStack_LayoutChildrenStackedVertically above). - EXPECT_FLOAT_EQ(t1->layout_box().frame.position.y, 10.0F); - EXPECT_FLOAT_EQ(t2->layout_box().frame.position.y, 60.0F); // 10 + 50 + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 10.0F); + EXPECT_FLOAT_EQ(t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 60.0F); // 10 + 50 } // A weighted child is never squeezed shorter than its own natural height, even if @@ -278,8 +279,8 @@ namespace docraft::test { vstack->accept(*measure_); vstack->accept(*layout_); - EXPECT_FLOAT_EQ(t1->layout_box().frame.size.height, 10.0F); - EXPECT_FLOAT_EQ(t2->layout_box().frame.size.height, 10.0F); + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 10.0F); + EXPECT_FLOAT_EQ(t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 10.0F); // Bug: the container's own box used to stay stuck at the too-small declared // height() (12) even though its children's natural-height floor pushed the @@ -288,7 +289,7 @@ namespace docraft::test { // while still inside Layout, e.g. an outer VStack placing this one among its // siblings) must grow to match, or whatever comes after this VStack gets // positioned on top of its overflowed content instead of below it. - EXPECT_FLOAT_EQ(vstack->layout_box().frame.size.height, 20.0F); + EXPECT_FLOAT_EQ(vstack->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 20.0F); EXPECT_FLOAT_EQ(vstack->layout_box().measured_size.height, 20.0F); } @@ -321,7 +322,7 @@ namespace docraft::test { // 10 = the layout processor's default page top-margin cursor start (see // VStack_LayoutChildrenStackedVertically above). - EXPECT_FLOAT_EQ(sibling->layout_box().frame.position.y, 30.0F); // 10 + 20, not 10 + 12 + EXPECT_FLOAT_EQ(sibling->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 30.0F); // 10 + 20, not 10 + 12 } // The box growing past a too-small explicit height() is silent otherwise -- the @@ -395,9 +396,9 @@ namespace docraft::test { vstack->accept(*measure_); vstack->accept(*layout_); - EXPECT_FLOAT_EQ(t1->layout_box().frame.size.height, 10.0F); - EXPECT_FLOAT_EQ(t2->layout_box().frame.size.height, 10.0F); - EXPECT_FLOAT_EQ(t2->layout_box().frame.position.y, 20.0F); // 10 (default page top margin) + 10 + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 10.0F); + EXPECT_FLOAT_EQ(t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 10.0F); + EXPECT_FLOAT_EQ(t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 20.0F); // 10 (default page top margin) + 10 EXPECT_FLOAT_EQ(vstack->layout_box().measured_size.height, 20.0F); } @@ -419,8 +420,8 @@ namespace docraft::test { hstack->accept(*measure_); hstack->accept(*layout_); - EXPECT_FLOAT_EQ(t1->layout_box().frame.position.x, 0.0F); - EXPECT_FLOAT_EQ(t2->layout_box().frame.position.x, 56.0F); // 50 + 6 + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 0.0F); + EXPECT_FLOAT_EQ(t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 56.0F); // 50 + 6 } TEST_F(DocraftLoomStackNodesTest, HStack_LayoutChildrenShareSameY) @@ -437,7 +438,7 @@ namespace docraft::test { hstack->accept(*measure_); hstack->accept(*layout_); - EXPECT_FLOAT_EQ(t1->layout_box().frame.position.y, t2->layout_box().frame.position.y); + EXPECT_FLOAT_EQ(t1->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, t2->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y); } // ── Spacing property ──────────────────────────────────────────────────────── @@ -483,8 +484,8 @@ namespace docraft::test { vstack->accept(*measure_); vstack->accept(*layout_); - EXPECT_FLOAT_EQ(vstack->layout_box().frame.position.x, 50.0F); - EXPECT_FLOAT_EQ(vstack->layout_box().frame.position.y, 50.0F); + EXPECT_FLOAT_EQ(vstack->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 50.0F); + EXPECT_FLOAT_EQ(vstack->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 50.0F); } TEST_F(DocraftLoomStackNodesTest, VStack_AbsolutePositionDoesNotAdvanceCursorForNextSibling) @@ -507,8 +508,8 @@ namespace docraft::test { // The absolute subtree must be fully out of flow: the next sibling is placed as if // the absolutely-positioned VStack (and its two children) had never been visited. - EXPECT_FLOAT_EQ(next_sibling->layout_box().frame.position.x, 0.0F); - EXPECT_FLOAT_EQ(next_sibling->layout_box().frame.position.y, 10.0F); + EXPECT_FLOAT_EQ(next_sibling->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x, 0.0F); + EXPECT_FLOAT_EQ(next_sibling->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, 10.0F); } // Regression tests: an HStack/VStack with no background/border (a purely @@ -626,7 +627,7 @@ namespace docraft::test { vstack->accept(*measure_); vstack->accept(*layout_); - EXPECT_FLOAT_EQ(vstack->layout_box().frame.size.height, 20.0F); + EXPECT_FLOAT_EQ(vstack->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 20.0F); } // ── z-index paint order ───────────────────────────────────────────────────── diff --git a/docraft/test/docraft/loom/nodes/docraft_loom_table_test.cc b/docraft/test/docraft/loom/nodes/docraft_loom_table_test.cc index c7a45cc..6950a0a 100644 --- a/docraft/test/docraft/loom/nodes/docraft_loom_table_test.cc +++ b/docraft/test/docraft/loom/nodes/docraft_loom_table_test.cc @@ -9,6 +9,7 @@ #include "docraft/loom/pipeline/docraft_loom_layout_processor.h" #include "docraft/loom/pipeline/docraft_loom_measure_processor.h" #include "../../backend/docraft_mock_backend.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { using ::testing::Return; @@ -71,8 +72,8 @@ namespace docraft::test { // available_width = 200 (table padding=0; kCellPaddingX is a per-cell inset already // folded into each cell's own measured_size, not a table-wide margin), split evenly // -> 100 each (floored at natural 10) - EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame.size.width, 100.0F); - EXPECT_FLOAT_EQ(table->cell(0, 1)->layout_box().frame.size.width, 100.0F); + EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 100.0F); + EXPECT_FLOAT_EQ(table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 100.0F); } TEST_F(DocraftLoomTableTest, ExplicitWidthIsRespectedAndRemainderRedistributed) @@ -87,13 +88,13 @@ namespace docraft::test { auto layout = prepare_layout(*table, 200.0F); table->accept(layout); - EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame.size.width, 40.0F); + EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 40.0F); // column 1 (the only flexible column) gets everything available_width has left // over after column 0's explicit width is deducted (200 - 40 = 160), not a share // of the full available_width diluted by column 0 -- otherwise the table's total // resolved width would exceed available_width (40 + 100 = 140 happened to fit // here, but the same dilution overflows the margin with more columns/less slack). - EXPECT_FLOAT_EQ(table->cell(0, 1)->layout_box().frame.size.width, 160.0F); + EXPECT_FLOAT_EQ(table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 160.0F); } TEST_F(DocraftLoomTableTest, NaturalWidthFloorIsRespectedWhenNoRescaleIsNeeded) @@ -120,8 +121,8 @@ namespace docraft::test { auto layout = prepare_layout(*table, 200.0F); table->accept(layout); - EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame.size.width, 100.0F); - EXPECT_FLOAT_EQ(table->cell(0, 1)->layout_box().frame.size.width, 100.0F); + EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 100.0F); + EXPECT_FLOAT_EQ(table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 100.0F); } TEST_F(DocraftLoomTableTest, NarrowPageRescalesColumnsProportionally) @@ -146,8 +147,8 @@ namespace docraft::test { // Cell natural widths include the automatic content padding (2*2.5 = 5), so // natural widths are 150+5=155 and 10+5=15. available_width = 20 (table padding=0); // scale = 20 / (155+15) = 20/170. - EXPECT_NEAR(table->cell(0, 0)->layout_box().frame.size.width, 155.0F * 20.0F / 170.0F, 0.001F); - EXPECT_NEAR(table->cell(0, 1)->layout_box().frame.size.width, 15.0F * 20.0F / 170.0F, 0.001F); + EXPECT_NEAR(table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 155.0F * 20.0F / 170.0F, 0.001F); + EXPECT_NEAR(table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 15.0F * 20.0F / 170.0F, 0.001F); } TEST_F(DocraftLoomTableTest, RowHeightIsTallestCellPlusPadding) @@ -162,7 +163,7 @@ namespace docraft::test { auto layout = prepare_layout(*table, 200.0F); table->accept(layout); - EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame.size.height, 35.0F); // 30 + 2*2.5 + EXPECT_FLOAT_EQ(table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 35.0F); // 30 + 2*2.5 } TEST_F(DocraftLoomTableTest, LayoutDoesNotOverlapColumns) @@ -174,12 +175,12 @@ namespace docraft::test { auto layout = prepare_layout(*table, 300.0F); table->accept(layout); - const float col0_right = table->cell(0, 0)->layout_box().frame.position.x - + table->cell(0, 0)->layout_box().frame.size.width; - EXPECT_FLOAT_EQ(col0_right, table->cell(0, 1)->layout_box().frame.position.x); - const float col1_right = table->cell(0, 1)->layout_box().frame.position.x - + table->cell(0, 1)->layout_box().frame.size.width; - EXPECT_FLOAT_EQ(col1_right, table->cell(0, 2)->layout_box().frame.position.x); + const float col0_right = table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x + + table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width; + EXPECT_FLOAT_EQ(col0_right, table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x); + const float col1_right = table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x + + table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width; + EXPECT_FLOAT_EQ(col1_right, table->cell(0, 2)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.x); } TEST_F(DocraftLoomTableTest, PaddingPushesTheWholeGridAwayFromTheTableOrigin) @@ -198,11 +199,11 @@ namespace docraft::test { layout.reset_cursor(10.0F, 20.0F); table->accept(layout); - const auto& table_origin = table->layout_box().frame.position; + const auto& table_origin = table->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position; EXPECT_FLOAT_EQ(table_origin.x, 10.0F); EXPECT_FLOAT_EQ(table_origin.y, 20.0F); - const auto& first_cell_pos = table->cell(0, 0)->layout_box().frame.position; + const auto& first_cell_pos = table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position; EXPECT_FLOAT_EQ(first_cell_pos.x, table_origin.x + 6.0F); EXPECT_FLOAT_EQ(first_cell_pos.y, table_origin.y + 6.0F); @@ -212,7 +213,9 @@ namespace docraft::test { const auto& measured = table->layout_box().measured_size; const auto& last_cell = table->cell(0, 1)->layout_box(); EXPECT_FLOAT_EQ(table_origin.y + measured.height, - first_cell_pos.y + last_cell.frame.size.height + 6.0F); + first_cell_pos.y + + last_cell.frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height + + 6.0F); } TEST_F(DocraftLoomTableTest, LongCellTextWrapsInsteadOfOverflowingItsColumn) @@ -274,9 +277,9 @@ namespace docraft::test { auto layout = prepare_layout(*table, 200.0F); table->accept(layout); - EXPECT_GT(table->layout_box().frame.size.width, 0.0F); - EXPECT_GT(table->layout_box().frame.size.height, 0.0F); - EXPECT_GT(table->cell(0, 0)->layout_box().frame.size.width, 0.0F); + EXPECT_GT(table->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 0.0F); + EXPECT_GT(table->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.height, 0.0F); + EXPECT_GT(table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 0.0F); } TEST_F(DocraftLoomTableTest, RowTitleGridProducesCorrectLayout) @@ -290,8 +293,8 @@ namespace docraft::test { auto layout = prepare_layout(*table, 200.0F); table->accept(layout); - EXPECT_FLOAT_EQ(table->cell(1, 0)->layout_box().frame.position.y, - table->cell(0, 0)->layout_box().frame.position.y + table->cell(0, 0)->layout_box().frame.size. + EXPECT_FLOAT_EQ(table->cell(1, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y, + table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y + table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size. height); EXPECT_TRUE(table->cell(0, 0)->is_title()); EXPECT_FALSE(table->cell(0, 1)->is_title()); diff --git a/docraft/test/docraft/loom/pipeline/docraft_loom_layout_processor_test.cc b/docraft/test/docraft/loom/pipeline/docraft_loom_layout_processor_test.cc index 58e7d76..703b607 100644 --- a/docraft/test/docraft/loom/pipeline/docraft_loom_layout_processor_test.cc +++ b/docraft/test/docraft/loom/pipeline/docraft_loom_layout_processor_test.cc @@ -8,6 +8,7 @@ #include "docraft/loom/nodes/docraft_loom_table.h" #include "docraft/loom/nodes/docraft_loom_table_cell.h" #include "docraft/loom/nodes/docraft_loom_text.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" // Regression tests for confirmed-but-not-yet-fixed bugs from the code review // (.local/CODE_REVIEW_LOOM_MIGRATION.md). These currently FAIL against the @@ -45,8 +46,8 @@ namespace docraft::test { loom::pipeline::DocraftLoomLayoutProcessor processor(500.0F); rectangle->accept(processor); - const float first_width = hstack->child(0)->layout_box().frame.size.width; - const float second_width = hstack->child(1)->layout_box().frame.size.width; + const float first_width = hstack->child(0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width; + const float second_width = hstack->child(1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width; EXPECT_NEAR(first_width, 100.0F, 0.01F); EXPECT_NEAR(second_width, 100.0F, 0.01F); EXPECT_LE(first_width + second_width, 200.0F + 0.01F); @@ -97,8 +98,8 @@ namespace docraft::test { // The inner HStack must divide `column`'s ~300pt resolved share (150 each), // not the outer HStack's own 600pt page width (which is what it would divide // if `column` were laid out against the wrong incoming width). - EXPECT_NEAR(a->layout_box().frame.size.width, 150.0F, 0.01F); - EXPECT_NEAR(b->layout_box().frame.size.width, 150.0F, 0.01F); + EXPECT_NEAR(a->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 150.0F, 0.01F); + EXPECT_NEAR(b->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width, 150.0F, 0.01F); } // Review bug #2 (Layout side, Table): same nesting bug for a weighted Table -- @@ -128,8 +129,8 @@ namespace docraft::test { loom::pipeline::DocraftLoomLayoutProcessor processor(500.0F); rectangle->accept(processor); - const float total_width = table->cell(0, 0)->layout_box().frame.size.width - + table->cell(0, 1)->layout_box().frame.size.width; + const float total_width = table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width + + table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width; EXPECT_LE(total_width, 200.0F + 0.01F); } @@ -158,9 +159,9 @@ namespace docraft::test { // Effective weight is 1.0 for every column (negative treated as the 1.0 // default), so all three columns must end up roughly equal -- not with the // first two inflated because the negative weight dragged total_weight down. - const float w0 = table->cell(0, 0)->layout_box().frame.size.width; - const float w1 = table->cell(0, 1)->layout_box().frame.size.width; - const float w2 = table->cell(0, 2)->layout_box().frame.size.width; + const float w0 = table->cell(0, 0)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width; + const float w1 = table->cell(0, 1)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width; + const float w2 = table->cell(0, 2)->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).size.width; EXPECT_NEAR(w0, w2, 1.0F); EXPECT_NEAR(w1, w2, 1.0F); } @@ -257,7 +258,7 @@ namespace docraft::test { loom::pipeline::DocraftLoomLayoutProcessor processor(300.0F); table->accept(processor); - return content->layout_box().frame.position.y; + return content->layout_box().frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()).position.y; }; const float y_without_margin = make_table_with_margin(0.0F); diff --git a/docraft/test/docraft/loom/pipeline/docraft_loom_pagination_processor_test.cc b/docraft/test/docraft/loom/pipeline/docraft_loom_pagination_processor_test.cc index 006a74e..ef701ad 100644 --- a/docraft/test/docraft/loom/pipeline/docraft_loom_pagination_processor_test.cc +++ b/docraft/test/docraft/loom/pipeline/docraft_loom_pagination_processor_test.cc @@ -7,14 +7,22 @@ #include "docraft/loom/nodes/docraft_loom_vstack.h" #include "docraft/loom/pipeline/docraft_loom_pagination_processor.h" #include "docraft/utils/docraft_mock_rendering_backend.h" +#include "docraft/utils/docraft_loom_layout_box_test_access.h" namespace docraft::test { namespace { std::shared_ptr<loom::nodes::DocraftLoomTableCell> make_positioned_cell(float y, float height, bool is_title) { auto cell = std::make_shared<loom::nodes::DocraftLoomTableCell>(); - cell->set_content(std::make_shared<loom::nodes::DocraftLoomText>("x")); + auto text = std::make_shared<loom::nodes::DocraftLoomText>("x"); + // set_content() makes text a real child (see DocraftLoomTableCell's class + // doc), so shift_subtree_position's generic child recursion reaches it too + // -- it needs its own sealed frame, not just the cell's. + text->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { + .position = {.x = 0.0F, .y = y}, .size = {.width = 50.0F, .height = height} + }; + cell->set_content(text); cell->set_is_title(is_title); - cell->edit_layout_box().frame = { + cell->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = y}, .size = {.width = 50.0F, .height = height} }; return cell; @@ -37,10 +45,11 @@ namespace docraft::test { if (!name.empty()) { node->set_name(name); } - node->edit_layout_box().frame = { + node->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = y}, .size = {.width = 50.0F, .height = height} }; auto body = std::make_shared<loom::nodes::DocraftLoomVStack>(); + body->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); body->add_child(node); return body; } @@ -54,19 +63,22 @@ namespace docraft::test { utils::MockPageBackend page_backend{state}; auto body = std::make_shared<loom::nodes::DocraftLoomVStack>(); + body->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); auto first = std::make_shared<loom::nodes::DocraftLoomText>("first"); - first->edit_layout_box().frame = { + first->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = 10.0F}, .size = {.width = 50.0F, .height = 10.0F} }; body->add_child(first); // Plenty of room left on the current page -- without the forced-break special // case, `second` below would simply continue flowing right after `first`. - body->add_child(std::make_shared<loom::nodes::DocraftLoomNewPage>()); + auto new_page = std::make_shared<loom::nodes::DocraftLoomNewPage>(); + new_page->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); + body->add_child(new_page); auto second = std::make_shared<loom::nodes::DocraftLoomText>("second"); - second->edit_layout_box().frame = { + second->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = 20.0F}, .size = {.width = 50.0F, .height = 10.0F} }; body->add_child(second); @@ -77,8 +89,8 @@ namespace docraft::test { EXPECT_EQ(total_pages, 2); ASSERT_EQ(body->children_count(), 3); - EXPECT_EQ(body->child(0)->layout_box().page_index, 0); - EXPECT_EQ(body->child(2)->layout_box().page_index, 1); + EXPECT_EQ(body->child(0)->layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 0); + EXPECT_EQ(body->child(2)->layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 1); EXPECT_EQ(page_backend.total_page_count(), 2U); } @@ -91,8 +103,9 @@ namespace docraft::test { utils::MockPageBackend page_backend{state}; auto body = std::make_shared<loom::nodes::DocraftLoomVStack>(); + body->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); auto only = std::make_shared<loom::nodes::DocraftLoomText>("only"); - only->edit_layout_box().frame = { + only->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = 10.0F}, .size = {.width = 50.0F, .height = 10.0F} }; body->add_child(only); @@ -120,11 +133,12 @@ namespace docraft::test { auto table = std::make_shared<loom::nodes::DocraftLoomTable>(); table->add_row({make_positioned_cell(10.0F, 10.0F, true), make_positioned_cell(10.0F, 10.0F, true)}); table->add_row({make_positioned_cell(20.0F, 200.0F, false), make_positioned_cell(20.0F, 200.0F, false)}); - table->edit_layout_box().frame = { + table->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = 10.0F}, .size = {.width = 100.0F, .height = 210.0F} }; auto body = std::make_shared<loom::nodes::DocraftLoomVStack>(); + body->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); body->add_child(table); loom::pipeline::DocraftLoomPaginationProcessor processor; @@ -133,7 +147,7 @@ namespace docraft::test { EXPECT_EQ(total_pages, 1); ASSERT_EQ(body->children_count(), 1); - EXPECT_EQ(body->child(0)->layout_box().page_index, 0); + EXPECT_EQ(body->child(0)->layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 0); } // Non-regression: a table with a header row that genuinely needs to split across @@ -150,11 +164,12 @@ namespace docraft::test { table->add_row({make_positioned_cell(10.0F, 10.0F, true), make_positioned_cell(10.0F, 10.0F, true)}); table->add_row({make_positioned_cell(20.0F, 40.0F, false), make_positioned_cell(20.0F, 40.0F, false)}); table->add_row({make_positioned_cell(60.0F, 40.0F, false), make_positioned_cell(60.0F, 40.0F, false)}); - table->edit_layout_box().frame = { + table->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = 10.0F}, .size = {.width = 100.0F, .height = 90.0F} }; auto body = std::make_shared<loom::nodes::DocraftLoomVStack>(); + body->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); body->add_child(table); loom::pipeline::DocraftLoomPaginationProcessor processor; @@ -163,8 +178,8 @@ namespace docraft::test { EXPECT_EQ(total_pages, 2); ASSERT_EQ(body->children_count(), 2); - EXPECT_EQ(body->child(0)->layout_box().page_index, 0); - EXPECT_EQ(body->child(1)->layout_box().page_index, 1); + EXPECT_EQ(body->child(0)->layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 0); + EXPECT_EQ(body->child(1)->layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 1); auto continuation = std::dynamic_pointer_cast<const loom::nodes::DocraftLoomTable>(body->child(1)); ASSERT_NE(continuation, nullptr); @@ -194,24 +209,30 @@ namespace docraft::test { wrapped_text->set_wrap_width(50.0F); wrapped_text->set_wrapped_lines(lines); wrapped_text->edit_layout_box().measured_size = {.width = 50.0F, .height = kLineHeight * kLineCount}; + // set_content() below makes wrapped_text a real child of content_cell, so + // shift_subtree_position's generic child recursion reaches it too. + wrapped_text->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { + .position = {.x = 0.0F, .y = 20.0F}, .size = {.width = 50.0F, .height = kLineHeight * kLineCount} + }; auto content_cell = std::make_shared<loom::nodes::DocraftLoomTableCell>(); content_cell->set_content(wrapped_text); const float content_natural_height = (kLineHeight * kLineCount) + (2.0F * loom::nodes::DocraftLoomTable::kCellPaddingY); content_cell->edit_layout_box().measured_size = {.width = 50.0F, .height = content_natural_height}; - content_cell->edit_layout_box().frame = { + content_cell->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = 20.0F}, .size = {.width = 50.0F, .height = content_natural_height} }; auto table = std::make_shared<loom::nodes::DocraftLoomTable>(); table->add_row({make_positioned_cell(10.0F, 10.0F, true), make_positioned_cell(10.0F, 10.0F, true)}); table->add_row({content_cell, make_positioned_cell(20.0F, 10.0F, false)}); - table->edit_layout_box().frame = { + table->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()) = { .position = {.x = 0.0F, .y = 10.0F}, .size = {.width = 100.0F, .height = 10.0F + content_natural_height} }; auto body = std::make_shared<loom::nodes::DocraftLoomVStack>(); + body->edit_layout_box().edit_frame(docraft::test::utils::LayoutBoxTestAccess::make_layout_proof()); body->add_child(table); loom::pipeline::DocraftLoomPaginationProcessor processor; @@ -228,8 +249,8 @@ namespace docraft::test { auto page1 = std::dynamic_pointer_cast<const loom::nodes::DocraftLoomTable>(body->child(1)); ASSERT_NE(page0, nullptr); ASSERT_NE(page1, nullptr); - EXPECT_EQ(page0->layout_box().page_index, 0); - EXPECT_EQ(page1->layout_box().page_index, 1); + EXPECT_EQ(page0->layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 0); + EXPECT_EQ(page1->layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 1); auto page0_text = std::dynamic_pointer_cast<const loom::nodes::DocraftLoomText>(page0->cell(1, 0)->content()); auto page1_text = std::dynamic_pointer_cast<const loom::nodes::DocraftLoomText>(page1->cell(1, 0)->content()); @@ -261,7 +282,7 @@ namespace docraft::test { EXPECT_EQ(total_pages, 1); ASSERT_EQ(body->children_count(), 1); - EXPECT_EQ(body->child(0)->layout_box().page_index, 0); + EXPECT_EQ(body->child(0)->layout_box().page_index(docraft::test::utils::LayoutBoxTestAccess::make_page_index_proof()), 0); EXPECT_NE(stdout_log.find("[WARNING]"), std::string::npos); EXPECT_NE(stdout_log.find("overflowing_text"), std::string::npos); } diff --git a/docraft/test/docraft/utils/docraft_loom_layout_box_test_access.h b/docraft/test/docraft/utils/docraft_loom_layout_box_test_access.h new file mode 100644 index 0000000..e67f914 --- /dev/null +++ b/docraft/test/docraft/utils/docraft_loom_layout_box_test_access.h @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Matteo Cadoni (https://github.com/cadons) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "docraft/loom/nodes/docraft_loom_node.h" + +namespace docraft::test::utils { + /** + * @brief The only way to fabricate a `docraft::loom::nodes::LayoutBox::LayoutProof` + * or `PageIndexProof` outside of `DocraftLoomLayoutProcessor`/ + * `DocraftLoomPaginationProcessor` themselves. + * @details Exists so unit tests can construct a `LayoutBox` state directly (a + * frame/page_index without actually running the real pipeline) without the + * production `LayoutBox` header exposing any public escape hatch of its own. + * Gets there the same way the real processors do -- by privately inheriting both + * `docraft::loom::nodes::DocraftLoomLayoutBoxLayoutAccessor` and + * `DocraftLoomLayoutBoxPaginationAccessor` (kept separate there too, so a real + * processor inheriting only one never gains the other's capability) -- rather than + * being named as a friend inside `LayoutBox` itself; `LayoutBox` has no knowledge + * this class (or any test code) exists. Include this header only from test (.cc) + * files, never from anything under docraft/include or docraft/src. + */ + class LayoutBoxTestAccess : private docraft::loom::nodes::DocraftLoomLayoutBoxLayoutAccessor, + private docraft::loom::nodes::DocraftLoomLayoutBoxPaginationAccessor + { + public: + using DocraftLoomLayoutBoxLayoutAccessor::make_layout_proof; + + static docraft::loom::nodes::LayoutBox::PageIndexProof make_page_index_proof() + { + return DocraftLoomLayoutBoxPaginationAccessor::make_page_index_proof(make_layout_proof()); + } + }; +} // namespace docraft::test::utils From ca3e65f7090c4e2ebefaec67992a7daa492fc2fd Mon Sep 17 00:00:00 2001 From: cadons <matteo.cadoni01@gmail.com> Date: Sun, 30 Aug 2026 16:55:43 +0200 Subject: [PATCH 3/5] docs(model_nodes): correct doxygen directive for LayoutBox class --- doc/source/api/model_nodes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/api/model_nodes.rst b/doc/source/api/model_nodes.rst index 1ee5777..161427e 100644 --- a/doc/source/api/model_nodes.rst +++ b/doc/source/api/model_nodes.rst @@ -26,7 +26,7 @@ stage: ``measured_size`` (Measure) → ``frame`` (Layout) → ``page_index`` (Pagination). Later stages read only what earlier stages wrote — skipping a stage for a node type breaks whatever reads its field. -.. doxygenstruct:: docraft::loom::nodes::LayoutBox +.. doxygenclass:: docraft::loom::nodes::LayoutBox :project: docraft :members: From 577dac546c9846cb63c0eec5ba1759cfda4f0ac5 Mon Sep 17 00:00:00 2001 From: cadons <matteo.cadoni01@gmail.com> Date: Sun, 30 Aug 2026 16:57:46 +0200 Subject: [PATCH 4/5] refactor(parser): include algorithm and ctype headers for improved parsing --- docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc b/docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc index 0211d22..dc48b5e 100644 --- a/docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc +++ b/docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc @@ -16,6 +16,9 @@ #include "docraft/craft/parser/docraft_craft_enum_parsers.h" +#include <algorithm> +#include <cctype> + #include "docraft/exception/docraft_exceptions.h" #define A3_STRING "a3" #define A4_STRING "a4" From d5cfac41c5977f12a19346423b43c198234f870e Mon Sep 17 00:00:00 2001 From: cadons <matteo.cadoni01@gmail.com> Date: Sun, 30 Aug 2026 17:06:30 +0200 Subject: [PATCH 5/5] refactor(loom): replace optional checks with has_value() for clarity --- .../craft/docraft_loom_tree_builder_utils.h | 29 +++++++------- .../nodes/docraft_loom_layout_box_access.h | 2 +- .../docraft_loom_craft_language_parser.cc | 8 ++-- .../craft/parser/docraft_table_parser.cc | 4 +- .../loom/charts/docraft_chart_builder.cc | 12 +++--- .../loom/charts/docraft_pie_chart_builder.cc | 4 +- .../loom/craft/docraft_loom_tree_builder.cc | 40 +++++++++---------- .../handlers/docraft_loom_canvas_handler.cc | 2 +- .../handlers/docraft_loom_chart_handler.cc | 10 ++--- .../handlers/docraft_loom_circle_handler.cc | 2 +- .../docraft_loom_curve_line_handler.cc | 4 +- .../handlers/docraft_loom_layout_handler.cc | 4 +- .../handlers/docraft_loom_line_handler.cc | 4 +- .../handlers/docraft_loom_list_handler.cc | 4 +- .../docraft_loom_paragraph_handler.cc | 8 ++-- .../handlers/docraft_loom_table_handler.cc | 18 ++++----- .../docraft/loom/nodes/docraft_loom_table.cc | 6 +-- .../pipeline/docraft_loom_layout_processor.cc | 2 +- .../docraft_loom_measure_processor.cc | 2 +- .../docraft_loom_rendering_processor.cc | 6 +-- 20 files changed, 86 insertions(+), 85 deletions(-) diff --git a/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h index d52756c..7142031 100644 --- a/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h +++ b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h @@ -55,23 +55,24 @@ namespace docraft::loom::craft { template <typename NodeT> void apply_common_attributes(NodeT& node, const docraft::craft::DocraftCommonAttributes& common) { - if (common.name) + if (common.name.has_value()) { node.set_name(*common.name); } - if (common.z_index) + if (common.z_index.has_value()) { node.set_z_index(*common.z_index); } - if (common.padding) + if (common.padding.has_value()) { node.set_padding(*common.padding); } - if (common.margin) + if (common.margin.has_value()) { node.set_margin(*common.margin); } - if (common.margin_top || common.margin_right || common.margin_bottom || common.margin_left) + if (common.margin_top.has_value() || common.margin_right.has_value() || common.margin_bottom.has_value() + || common.margin_left.has_value()) { const auto& current = node.margin(); node.set_margin(common.margin_top.value_or(current.top), common.margin_right.value_or(current.right), @@ -81,33 +82,33 @@ namespace docraft::loom::craft { if constexpr (requires(NodeT& n, float v) { n.set_width(v); }) { - if (common.width) + if (common.width.has_value()) { node.set_width(*common.width); } } if constexpr (requires(NodeT& n, float v) { n.set_height(v); }) { - if (common.height) + if (common.height.has_value()) { node.set_height(*common.height); } } - if (common.position_mode) + if (common.position_mode.has_value()) { node.set_position_mode(*common.position_mode == docraft::craft::PositionMode::kAbsolute ? nodes::DocraftPositionType::kAbsolute : nodes::DocraftPositionType::kBlock); } - if (common.x || common.y) + if (common.x.has_value() || common.y.has_value()) { nodes::Position pos = node.explicit_position(); - if (common.x) + if (common.x.has_value()) { pos.x = *common.x; } - if (common.y) + if (common.y.has_value()) { pos.y = *common.y; } @@ -126,15 +127,15 @@ namespace docraft::loom::craft { template <typename NodeT, typename ShapeDataT, typename ColorResolver> void apply_shape_style(NodeT& node, const ShapeDataT& data, const ColorResolver& resolve_color) { - if (data.background_color) + if (data.background_color.has_value()) { node.edit_style().background_color = resolve_color(*data.background_color); } - if (data.border_color) + if (data.border_color.has_value()) { node.edit_style().border_color = resolve_color(*data.border_color); } - if (data.border_width) + if (data.border_width.has_value()) { node.edit_style().border_width = *data.border_width; } diff --git a/docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h b/docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h index 3ca53a1..5384f31 100644 --- a/docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h +++ b/docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h @@ -31,7 +31,7 @@ namespace docraft::loom::nodes { inline LayoutBox::LayoutProof layout_proof_or_throw(const DocraftLoomNode& node) { const auto proof = node.layout_box().layout_proof(); - if (!proof) + if (!proof.has_value()) { throw docraft::exception::InvalidInputException( "LayoutBox::frame read for a node DocraftLoomLayoutProcessor hasn't laid out yet"); diff --git a/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc b/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc index bd6f62b..37239b4 100644 --- a/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc +++ b/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc @@ -34,19 +34,19 @@ namespace docraft::craft { loom::DocraftLoomPdfCreator::Margins to_margins(const parser::ParsedSectionData& data) { loom::DocraftLoomPdfCreator::Margins margins; - if (data.margin_top) + if (data.margin_top.has_value()) { margins.top = *data.margin_top; } - if (data.margin_bottom) + if (data.margin_bottom.has_value()) { margins.bottom = *data.margin_bottom; } - if (data.margin_left) + if (data.margin_left.has_value()) { margins.left = *data.margin_left; } - if (data.margin_right) + if (data.margin_right.has_value()) { margins.right = *data.margin_right; } diff --git a/docraft/src/docraft/craft/parser/docraft_table_parser.cc b/docraft/src/docraft/craft/parser/docraft_table_parser.cc index d39c0aa..7dedd22 100644 --- a/docraft/src/docraft/craft/parser/docraft_table_parser.cc +++ b/docraft/src/docraft/craft/parser/docraft_table_parser.cc @@ -203,7 +203,7 @@ namespace docraft::craft::parser { } if (col_name == std::string{elements::kCell}) { auto cell_data = parse_cell(col); - if (!cell_data.background) + if (!cell_data.background.has_value()) { cell_data.background = row_data.background; } @@ -282,7 +282,7 @@ namespace docraft::craft::parser { if (auto thead = craft_language_source.child(elements::kTHead.data())) { - if (data.header_data_template) + if (data.header_data_template.has_value()) { throw docraft::exception::InvalidInputException( "Table 'header' attribute cannot be combined with an explicit THead"); diff --git a/docraft/src/docraft/loom/charts/docraft_chart_builder.cc b/docraft/src/docraft/loom/charts/docraft_chart_builder.cc index 5292ff6..ed52a0d 100644 --- a/docraft/src/docraft/loom/charts/docraft_chart_builder.cc +++ b/docraft/src/docraft/loom/charts/docraft_chart_builder.cc @@ -219,14 +219,14 @@ namespace docraft::loom::charts { const bool show_legend = ctx.series.size() >= 2 || (ctx.series.size() == 1 && !ctx.series.front().name.empty()); - const float title_h = ctx.title ? kTitleBandHeight : 0.0F; + const float title_h = ctx.title.has_value() ? kTitleBandHeight : 0.0F; // y_label is its own horizontal band stacked below the title, above the plot -- // NOT extra left-column width (there is no rotation to stand it up alongside the // Y-axis, see map_y()'s doc comment), so it never shares a row with the topmost Y // tick's numeric label. - const float y_label_h = ctx.y_label ? kYLabelBandHeight : 0.0F; + const float y_label_h = ctx.y_label.has_value() ? kYLabelBandHeight : 0.0F; const float legend_w = show_legend ? kLegendBandWidth : 0.0F; - const float bottom_h = kBottomAxisBandHeight + (ctx.x_label ? kXLabelHeight : 0.0F); + const float bottom_h = kBottomAxisBandHeight + (ctx.x_label.has_value() ? kXLabelHeight : 0.0F); const float left_w = kLeftAxisBandWidth; PlotRect plot{ @@ -316,7 +316,7 @@ namespace docraft::loom::charts { draw_series(*canvas, ctx, plot, mapped_bounds); // 5. Title. - if (ctx.title) + if (ctx.title.has_value()) { const float title_w = estimate_text_width(*ctx.title, kTitleFontSize); add_text(*canvas, {.text = *ctx.title, .x = std::max(0.0F, (ctx.width - title_w) / 2.0F), @@ -344,14 +344,14 @@ namespace docraft::loom::charts { // rather than rotated 90 degrees -- DocraftLoomText has no rotation support today // (see the rendering processor's visit(DocraftLoomText*)); wiring up the // already-unused draw_text_matrix backend primitive is a separate feature. - if (ctx.x_label) + if (ctx.x_label.has_value()) { const float label_w = estimate_text_width(*ctx.x_label, kTickLabelFontSize); add_text(*canvas, {.text = *ctx.x_label, .x = plot.left + std::max(0.0F, (plot.width() - label_w) / 2.0F), .y = ctx.height - kXLabelHeight, .font_size = kTickLabelFontSize, .color = text_color, .bold = false, .font_family = font_family}); } - if (ctx.y_label) + if (ctx.y_label.has_value()) { // Its own reserved band (plot.top already accounts for y_label_h), aligned // above the plot's left edge -- never shares a row with the topmost Y tick's diff --git a/docraft/src/docraft/loom/charts/docraft_pie_chart_builder.cc b/docraft/src/docraft/loom/charts/docraft_pie_chart_builder.cc index 79605aa..49eea3b 100644 --- a/docraft/src/docraft/loom/charts/docraft_pie_chart_builder.cc +++ b/docraft/src/docraft/loom/charts/docraft_pie_chart_builder.cc @@ -104,8 +104,8 @@ namespace docraft::loom::charts { } } - const float title_h = ctx.title ? kTitleBandHeight : 0.0F; - if (ctx.title) + const float title_h = ctx.title.has_value() ? kTitleBandHeight : 0.0F; + if (ctx.title.has_value()) { const float title_w = estimate_text_width(*ctx.title, kTitleFontSize); add_text(*canvas, {.text = *ctx.title, .x = std::max(0.0F, (ctx.width - title_w) / 2.0F), diff --git a/docraft/src/docraft/loom/craft/docraft_loom_tree_builder.cc b/docraft/src/docraft/loom/craft/docraft_loom_tree_builder.cc index d846db6..2edd98e 100644 --- a/docraft/src/docraft/loom/craft/docraft_loom_tree_builder.cc +++ b/docraft/src/docraft/loom/craft/docraft_loom_tree_builder.cc @@ -97,7 +97,7 @@ namespace docraft::loom::craft { { const auto& data = std::any_cast<const parser::ParsedForeachData&>(foreach_element.data); - if (data.model) + if (data.model.has_value()) { // `model` may itself be (or contain) a `${variable}`/`${data("field")}` // expression -- e.g. model="${employees}", or, nested inside an outer @@ -154,35 +154,35 @@ namespace docraft::loom::craft { void DocraftLoomTreeBuilder::fill_text_node(nodes::DocraftLoomText& node, const parser::ParsedTextData& data) const { node.set_text(render_template_text(data.text)); - if (data.font_size) + if (data.font_size.has_value()) { node.set_font_size(*data.font_size); } - if (data.font_name) + if (data.font_name.has_value()) { node.set_font_family(*data.font_name); } - else if (default_font_family_) + else if (default_font_family_.has_value()) { node.set_font_family(*default_font_family_); } - if (data.color) + if (data.color.has_value()) { node.set_color(resolve_color(*data.color)); } - if (data.style) + if (data.style.has_value()) { apply_style(node, *data.style); } - if (data.alignment) + if (data.alignment.has_value()) { node.set_alignment(to_loom_alignment(*data.alignment)); } - if (data.underline) + if (data.underline.has_value()) { node.set_underline(*data.underline); } - if (data.strikeout) + if (data.strikeout.has_value()) { node.set_strikeout(*data.strikeout); } @@ -191,39 +191,39 @@ namespace docraft::loom::craft { void DocraftLoomTreeBuilder::fill_page_number_node(nodes::DocraftLoomPageNumber& node, const parser::ParsedPageNumberData& data) const { - if (data.font_size) + if (data.font_size.has_value()) { node.set_font_size(*data.font_size); } - if (data.font_name) + if (data.font_name.has_value()) { node.set_font_family(*data.font_name); } - else if (default_font_family_) + else if (default_font_family_.has_value()) { node.set_font_family(*default_font_family_); } - if (data.color) + if (data.color.has_value()) { node.set_color(resolve_color(*data.color)); } - if (data.style) + if (data.style.has_value()) { apply_style(node, *data.style); } - if (data.alignment) + if (data.alignment.has_value()) { node.set_alignment(to_loom_alignment(*data.alignment)); } - if (data.underline) + if (data.underline.has_value()) { node.set_underline(*data.underline); } - if (data.strikeout) + if (data.strikeout.has_value()) { node.set_strikeout(*data.strikeout); } - if (data.format) + if (data.format.has_value()) { node.set_format(*data.format); } @@ -232,11 +232,11 @@ namespace docraft::loom::craft { void DocraftLoomTreeBuilder::fill_image_node(nodes::DocraftLoomImage& node, const parser::ParsedImageData& data) const { - if (data.raw_data && data.raw_pixel_width && data.raw_pixel_height) + if (data.raw_data.has_value() && data.raw_pixel_width.has_value() && data.raw_pixel_height.has_value()) { node.set_raw_data(*data.raw_data, *data.raw_pixel_width, *data.raw_pixel_height); } - else if (data.path) + else if (data.path.has_value()) { node.set_path(render_template_text(*data.path)); } diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc index 955eb0e..436fcd4 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_canvas_handler.cc @@ -30,7 +30,7 @@ namespace docraft::loom::craft { // Canvas has no auto-sizing (its children are free-positioned, not stacked), so // it needs an explicit bound to clip against -- unlike Rectangle, an unset // width/height is a configuration error, not a "shrink to content" default. - if (!element.common.width || !element.common.height) + if (!element.common.width.has_value() || !element.common.height.has_value()) { throw docraft::exception::InvalidInputException( "<Canvas> requires explicit 'width' and 'height' attributes"); diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc index dc17f3d..123166f 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_chart_handler.cc @@ -109,11 +109,11 @@ namespace docraft::loom::craft { { const auto& data = std::any_cast<const parser::ParsedChartData&>(element.data); - if (!data.style) + if (!data.style.has_value()) { throw docraft::exception::InvalidInputException("<Chart> requires a 'style' attribute"); } - if (!element.common.width || !element.common.height) + if (!element.common.width.has_value() || !element.common.height.has_value()) { throw docraft::exception::InvalidInputException( "<Chart> requires explicit 'width' and 'height' attributes"); @@ -129,15 +129,15 @@ namespace docraft::loom::craft { build_context.width = *element.common.width; build_context.height = *element.common.height; build_context.axis_position = charts::parse_chart_axis_position(data.axis_position.value_or("left")); - if (data.title) + if (data.title.has_value()) { build_context.title = context.render_template_text(*data.title); } - if (data.x_label) + if (data.x_label.has_value()) { build_context.x_label = context.render_template_text(*data.x_label); } - if (data.y_label) + if (data.y_label.has_value()) { build_context.y_label = context.render_template_text(*data.y_label); } diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc index 0b055ed..aa515b8 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_circle_handler.cc @@ -118,7 +118,7 @@ namespace docraft::loom::craft { "<Circle> arcs are supported on circles only, not on an oval sized by " "differing 'width' and 'height'"); } - if (data.background_color) + if (data.background_color.has_value()) { throw docraft::exception::InvalidInputException( "<Circle> arc is an open path and cannot be filled -- drop " diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc index 62fdbdf..310843b 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_curve_line_handler.cc @@ -38,11 +38,11 @@ namespace docraft::loom::craft { "<CurveLine> requires at least 2 points, got " + std::to_string(data.points.size())); } node->set_points(data.points); - if (data.border_color) + if (data.border_color.has_value()) { node->set_border_color(context.resolve_color(*data.border_color)); } - if (data.border_width) + if (data.border_width.has_value()) { node->set_border_width(*data.border_width); } diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc index 442055a..f33c758 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_layout_handler.cc @@ -51,7 +51,7 @@ namespace docraft::loom::craft { if (data.orientation == docraft::craft::parser::ParsedLayoutOrientation::kHorizontal) { auto node = std::make_shared<nodes::DocraftLoomHStack>(); - if (data.spacing) + if (data.spacing.has_value()) { node->set_spacing(*data.spacing); } @@ -64,7 +64,7 @@ namespace docraft::loom::craft { return node; } auto node = std::make_shared<nodes::DocraftLoomVStack>(); - if (data.spacing) + if (data.spacing.has_value()) { node->set_spacing(*data.spacing); } diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc index ee7143f..93836df 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_line_handler.cc @@ -48,11 +48,11 @@ namespace docraft::loom::craft { } node->set_start(start); node->set_end(end); - if (data.border_color) + if (data.border_color.has_value()) { node->set_border_color(context.resolve_color(*data.border_color)); } - if (data.border_width) + if (data.border_width.has_value()) { node->set_border_width(*data.border_width); } diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc index d3888e3..f0857a0 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_list_handler.cc @@ -31,13 +31,13 @@ namespace docraft::loom::craft { node->set_kind(data.kind == docraft::craft::parser::ParsedListKind::kOrdered ? nodes::ListKind::kOrdered : nodes::ListKind::kUnordered); - if (data.ordered_style) + if (data.ordered_style.has_value()) { node->set_ordered_style(*data.ordered_style == docraft::craft::parser::ParsedOrderedListStyle::kNumber ? nodes::OrderedListStyle::kNumber : nodes::OrderedListStyle::kRoman); } - if (data.unordered_dot) + if (data.unordered_dot.has_value()) { nodes::UnorderedListDot dot = nodes::UnorderedListDot::kCircle; switch (*data.unordered_dot) diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc index 330dd37..ed7d35d 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_paragraph_handler.cc @@ -29,19 +29,19 @@ namespace docraft::loom::craft { { const auto& data = std::any_cast<const docraft::craft::parser::ParsedParagraphData&>(element.data); auto node = std::make_shared<nodes::DocraftLoomParagraph>(); - if (data.line_spacing) + if (data.line_spacing.has_value()) { node->set_line_spacing(*data.line_spacing); } - if (data.space_before) + if (data.space_before.has_value()) { node->set_space_before(*data.space_before); } - if (data.space_after) + if (data.space_after.has_value()) { node->set_space_after(*data.space_after); } - if (data.alignment) + if (data.alignment.has_value()) { node->set_alignment(to_loom_alignment(*data.alignment)); } diff --git a/docraft/src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc b/docraft/src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc index 5cdcb91..f7277a1 100644 --- a/docraft/src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc +++ b/docraft/src/docraft/loom/craft/handlers/docraft_loom_table_handler.cc @@ -71,18 +71,18 @@ namespace docraft::loom::craft { context.template_engine().render_template_string(title.text)); text->set_alignment(to_loom_alignment(title.alignment)); apply_style(*text, title.style); - if (title.color) + if (title.color.has_value()) { text->set_color(context.resolve_color(*title.color)); } - if (title.font_size) + if (title.font_size.has_value()) { text->set_font_size(*title.font_size); } auto cell = std::make_shared<nodes::DocraftLoomTableCell>(); cell->set_content(text); cell->set_is_title(true); - if (title.background) + if (title.background.has_value()) { cell->set_background(context.resolve_color(*title.background)); } @@ -120,11 +120,11 @@ namespace docraft::loom::craft { cell->set_content(content); } - if (cell_data.background) + if (cell_data.background.has_value()) { cell->set_background(context.resolve_color(*cell_data.background)); } - if (cell_data.width) + if (cell_data.width.has_value()) { cell->set_explicit_width(*cell_data.width); } @@ -223,7 +223,7 @@ namespace docraft::loom::craft { std::size_t column_count, DocraftLoomTableHandlerContext& context) { - if (data.header_data_template) + if (data.header_data_template.has_value()) { const std::vector<std::string> header = resolve_table_header(*data.header_data_template, context); if (header.size() != column_count) @@ -319,18 +319,18 @@ namespace docraft::loom::craft { // build_content_cell() (via render_template_text()). build_templated_model_rows() // below additionally overrides it per JSON-model object, scoped to that object's // own rows. - if (data.baseline_offset) + if (data.baseline_offset.has_value()) { table->set_baseline_offset(*data.baseline_offset); } - if (data.default_cell_background) + if (data.default_cell_background.has_value()) { table->set_default_cell_background(context.resolve_color(*data.default_cell_background)); } const bool is_vertical = data.orientation == parser::ParsedTableOrientation::kVertical; - if (data.model_data_template) + if (data.model_data_template.has_value()) { const nlohmann::json model_json = resolve_table_model_json(*data.model_data_template, context); diff --git a/docraft/src/docraft/loom/nodes/docraft_loom_table.cc b/docraft/src/docraft/loom/nodes/docraft_loom_table.cc index 5c10500..216470c 100644 --- a/docraft/src/docraft/loom/nodes/docraft_loom_table.cc +++ b/docraft/src/docraft/loom/nodes/docraft_loom_table.cc @@ -45,11 +45,11 @@ namespace docraft::loom::nodes { clone->set_content(image_clone); } } - if (source.background()) + if (source.background().has_value()) { clone->set_background(*source.background()); } - if (source.explicit_width()) + if (source.explicit_width().has_value()) { clone->set_explicit_width(*source.explicit_width()); } @@ -184,7 +184,7 @@ namespace docraft::loom::nodes { // No set_content() call -- this cell stays blank -- but background and // explicit_width are still styling of the cell itself and must carry // over, same as clone_cell does for the split.kept/remainder cells. - if (split.kept->background()) { + if (split.kept->background().has_value()) { blank->set_background(*split.kept->background()); } if (auto explicit_width = split.kept->explicit_width(); explicit_width.has_value()) { diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc index 458385c..4f2f300 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_layout_processor.cc @@ -583,7 +583,7 @@ namespace docraft::loom::pipeline { geometry.natural_widths[static_cast<std::size_t>(c)], sz.width); geometry.row_heights[static_cast<std::size_t>(r)] = std::max( geometry.row_heights[static_cast<std::size_t>(r)], sz.height); - if (cell->explicit_width()) + if (cell->explicit_width().has_value()) { geometry.explicit_widths[static_cast<std::size_t>(c)] = std::max( geometry.explicit_widths[static_cast<std::size_t>(c)], *cell->explicit_width()); diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc index 0bdb50b..d3018a3 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc @@ -508,7 +508,7 @@ namespace docraft::loom::pipeline { std::vector column_explicit_widths(static_cast<std::size_t>(cols), 0.0F); for (int r = 0; r < rows; ++r) { for (int c = 0; c < cols; ++c) { - if (auto width = table->cell(r, c)->explicit_width()) { + if (auto width = table->cell(r, c)->explicit_width(); width.has_value()) { column_explicit_widths[static_cast<std::size_t>(c)] = std::max(column_explicit_widths[static_cast<std::size_t>(c)], *width); } diff --git a/docraft/src/docraft/loom/pipeline/docraft_loom_rendering_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_rendering_processor.cc index 38b11ae..6d58597 100644 --- a/docraft/src/docraft/loom/pipeline/docraft_loom_rendering_processor.cc +++ b/docraft/src/docraft/loom/pipeline/docraft_loom_rendering_processor.cc @@ -519,7 +519,7 @@ namespace docraft::loom::pipeline { // Own explicit background, if set -- the table draws the default fallback for // cells that don't set one (see Table's visit()), so there's no precedence logic // to duplicate here. - if (cell->background()) + if (cell->background().has_value()) { const auto rgba = cell->background()->toRGB(); if (rgba.a > 0.0F) @@ -547,7 +547,7 @@ namespace docraft::loom::pipeline { // background (which each cell draws itself in its own visit()). void DocraftLoomRenderingProcessor::draw_table_default_backgrounds(nodes::DocraftLoomTable& table) { - if (!table.default_cell_background()) + if (!table.default_cell_background().has_value()) { return; } @@ -567,7 +567,7 @@ namespace docraft::loom::pipeline { for (int c = 0; c < table.column_count(); ++c) { auto cell = table.cell(r, c); - if (!cell->background()) + if (!cell->background().has_value()) { const auto& pos = nodes::sealed_frame(*cell).position; const auto& size = nodes::sealed_frame(*cell).size;