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: diff --git a/docraft/CMakeLists.txt b/docraft/CMakeLists.txt index a342628..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 @@ -136,6 +137,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 +174,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..7142031 --- /dev/null +++ b/docraft/include/docraft/loom/craft/docraft_loom_tree_builder_utils.h @@ -0,0 +1,143 @@ +/* + * 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.has_value()) + { + node.set_name(*common.name); + } + if (common.z_index.has_value()) + { + node.set_z_index(*common.z_index); + } + if (common.padding.has_value()) + { + node.set_padding(*common.padding); + } + if (common.margin.has_value()) + { + node.set_margin(*common.margin); + } + 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), + 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.has_value()) + { + node.set_width(*common.width); + } + } + if constexpr (requires(NodeT& n, float v) { n.set_height(v); }) + { + if (common.height.has_value()) + { + node.set_height(*common.height); + } + } + + 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.has_value() || common.y.has_value()) + { + nodes::Position pos = node.explicit_position(); + if (common.x.has_value()) + { + pos.x = *common.x; + } + if (common.y.has_value()) + { + 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.has_value()) + { + node.edit_style().background_color = resolve_color(*data.background_color); + } + if (data.border_color.has_value()) + { + node.edit_style().border_color = resolve_color(*data.border_color); + } + if (data.border_width.has_value()) + { + 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_layout_box_access.h b/docraft/include/docraft/loom/nodes/docraft_loom_layout_box_access.h new file mode 100644 index 0000000..5384f31 --- /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.has_value()) + { + 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/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_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_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/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/craft/docraft_loom_craft_language_parser.cc b/docraft/src/docraft/craft/docraft_loom_craft_language_parser.cc index 56603eb..37239b4 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" @@ -33,73 +34,35 @@ 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; } 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..dc48b5e --- /dev/null +++ b/docraft/src/docraft/craft/parser/docraft_craft_enum_parsers.cc @@ -0,0 +1,80 @@ +/* + * 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 <algorithm> +#include <cctype> + +#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/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_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/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_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..2edd98e 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 + "'"); @@ -435,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 @@ -492,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); } @@ -529,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); } @@ -570,911 +232,13 @@ 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)); } } - - 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..436fcd4 --- /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.has_value() || !element.common.height.has_value()) + { + 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..123166f --- /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.has_value()) + { + throw docraft::exception::InvalidInputException("<Chart> requires a 'style' attribute"); + } + if (!element.common.width.has_value() || !element.common.height.has_value()) + { + 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.has_value()) + { + build_context.title = context.render_template_text(*data.title); + } + if (data.x_label.has_value()) + { + build_context.x_label = context.render_template_text(*data.x_label); + } + if (data.y_label.has_value()) + { + 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..aa515b8 --- /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.has_value()) + { + 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..310843b --- /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.has_value()) + { + node->set_border_color(context.resolve_color(*data.border_color)); + } + if (data.border_width.has_value()) + { + 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..f33c758 --- /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.has_value()) + { + 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.has_value()) + { + 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..93836df --- /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.has_value()) + { + node->set_border_color(context.resolve_color(*data.border_color)); + } + if (data.border_width.has_value()) + { + 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..f0857a0 --- /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.has_value()) + { + node->set_ordered_style(*data.ordered_style == docraft::craft::parser::ParsedOrderedListStyle::kNumber + ? nodes::OrderedListStyle::kNumber + : nodes::OrderedListStyle::kRoman); + } + if (data.unordered_dot.has_value()) + { + 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..ed7d35d --- /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.has_value()) + { + node->set_line_spacing(*data.line_spacing); + } + if (data.space_before.has_value()) + { + node->set_space_before(*data.space_before); + } + if (data.space_after.has_value()) + { + node->set_space_after(*data.space_after); + } + if (data.alignment.has_value()) + { + 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..f7277a1 --- /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.has_value()) + { + text->set_color(context.resolve_color(*title.color)); + } + 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.has_value()) + { + 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.has_value()) + { + cell->set_background(context.resolve_color(*cell_data.background)); + } + if (cell_data.width.has_value()) + { + 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.has_value()) + { + 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.has_value()) + { + table->set_baseline_offset(*data.baseline_offset); + } + 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.has_value()) + { + 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/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..216470c 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 { @@ -44,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()); } @@ -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; } @@ -183,15 +184,15 @@ 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()) { 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/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..4f2f300 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 @@ -246,16 +245,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) { @@ -298,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) @@ -308,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()); @@ -318,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) @@ -328,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 @@ -403,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) @@ -419,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; } } @@ -428,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()); } @@ -438,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); } @@ -448,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); @@ -460,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); } @@ -470,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); } @@ -480,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); } @@ -490,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); } @@ -500,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) { @@ -543,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 -- @@ -557,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); } } @@ -593,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()); @@ -707,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) { @@ -719,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; } @@ -762,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()) }; @@ -780,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_measure_processor.cc b/docraft/src/docraft/loom/pipeline/docraft_loom_measure_processor.cc index 24ed65b..d3018a3 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(); @@ -505,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_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..6d58597 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(), }); } @@ -518,13 +519,13 @@ 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) { - 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) { @@ -546,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; } @@ -566,10 +567,10 @@ 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 = 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