diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d8e0b7b9..82b0ab3d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -180,6 +180,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/odf/odf_crypto.cpp" "src/odr/internal/odf/odf_document.cpp" "src/odr/internal/odf/odf_element_registry.cpp" + "src/odr/internal/odf/odf_enhanced_geometry.cpp" "src/odr/internal/odf/odf_file.cpp" "src/odr/internal/odf/odf_flat_file.cpp" "src/odr/internal/odf/odf_geometry.cpp" diff --git a/src/odr/internal/odf/PLAN.md b/src/odr/internal/odf/PLAN.md index 0e72cb426..529510e39 100644 --- a/src/odr/internal/odf/PLAN.md +++ b/src/odr/internal/odf/PLAN.md @@ -120,13 +120,18 @@ label comes out empty. `vector-effect="non-scaling-stroke"` on the path: the view box scales, and with `preserveAspectRatio="none"` unevenly, which the stroke must not follow. -### 3 — `draw:enhanced-path` and `draw:equation`, parser only - -The formula mini-language (`$N` modifiers, `?fN` references, the eleven -functions the corpus uses plus the rest of 20.36) and the path grammar -(`M L C Z N U X Y` plus the commands 19.145 defines and the corpus does not -use). Pure functions over strings, unit-tested from string literals, no -rendering and no element-model change. The largest single piece. +### 3 — `draw:enhanced-path` and `draw:equation`, parser only — landed + +`odf_enhanced_geometry.cpp`: the formula language of 20.36 (`$N` modifiers, +`?name` references, the named view-box values, `abs sqrt sin cos tan atan min +max atan2 if`) and every command of 19.145, converted to an svg `d`. Pure +functions over strings, unit-tested from string literals. + +Decisions worth knowing: `sin`/`cos` take radians, which the corpus confirms by +writing `sin(105*(pi/180))`; an arc is emitted in segments of at most a half +turn, so the large-arc flag is never needed and a full `U … 0 360` — which one +svg `A` cannot express — still draws; `F` and `S` are read and dropped, since +painting one subpath differently is more than one `d` can say. ### 4 — enhanced geometry, rendered diff --git a/src/odr/internal/odf/odf_enhanced_geometry.cpp b/src/odr/internal/odf/odf_enhanced_geometry.cpp new file mode 100644 index 000000000..5a61ad34a --- /dev/null +++ b/src/odr/internal/odf/odf_enhanced_geometry.cpp @@ -0,0 +1,517 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace odr::internal::odf { + +namespace { + +/// Recursive descent over 20.36's grammar: sums of products of unary terms, +/// with `$N` modifiers, `?name` equations, named values and functions. +class FormulaParser : private Scanner { +public: + FormulaParser(const std::string_view formula, + const EnhancedGeometryContext &context, + const EquationResolver &equations) + : Scanner{formula}, m_context{&context}, m_equations{&equations} {} + + [[nodiscard]] std::optional parse() { + const std::optional value = expression(); + skip_space(); + if (!value.has_value() || !empty()) { + return {}; + } + return value; + } + +private: + const EnhancedGeometryContext *m_context{nullptr}; + const EquationResolver *m_equations{nullptr}; + + [[nodiscard]] std::optional expression() { + std::optional result = term(); + while (result.has_value()) { + skip_space(); + const char op = peek(); + if (op != '+' && op != '-') { + break; + } + take(); + const std::optional rhs = term(); + if (!rhs.has_value()) { + return {}; + } + result = op == '+' ? *result + *rhs : *result - *rhs; + } + return result; + } + + [[nodiscard]] std::optional term() { + std::optional result = unary(); + while (result.has_value()) { + skip_space(); + const char op = peek(); + if (op != '*' && op != '/') { + break; + } + take(); + const std::optional rhs = unary(); + if (!rhs.has_value()) { + return {}; + } + if (op == '/' && *rhs == 0) { + return {}; + } + result = op == '*' ? *result * *rhs : *result / *rhs; + } + return result; + } + + [[nodiscard]] std::optional unary() { + skip_space(); + if (peek() == '-') { + take(); + const std::optional value = unary(); + return value.has_value() ? std::optional(-*value) : std::nullopt; + } + if (peek() == '+') { + take(); + return unary(); + } + return primary(); + } + + [[nodiscard]] std::optional primary() { + skip_space(); + + if (peek() == '(') { + take(); + const std::optional value = expression(); + if (!value.has_value() || !consume(')')) { + return {}; + } + return value; + } + + if (peek() == '$') { + take(); + const std::string_view digits = take_while(is_digit); + std::size_t index = 0; + const std::from_chars_result read = + std::from_chars(digits.data(), digits.data() + digits.size(), index); + if (read.ec != std::errc() || read.ptr != digits.data() + digits.size() || + index >= m_context->modifiers.size()) { + return {}; + } + return m_context->modifiers[index]; + } + + if (peek() == '?') { + take(); + const std::string_view name = take_while(is_letter_or_digit); + if (name.empty()) { + return {}; + } + return (*m_equations)(name); + } + + if (peek() == '.' || is_digit(peek())) { + return read_number(); + } + + const std::string_view name = take_while(is_letter_or_digit); + if (name.empty()) { + return {}; + } + skip_space(); + return peek() == '(' ? function(name) : named(name); + } + + [[nodiscard]] std::optional named(const std::string_view name) const { + if (name == "pi") { + return std::numbers::pi; + } + if (name == "left") { + return m_context->left; + } + if (name == "top") { + return m_context->top; + } + if (name == "right") { + return m_context->right; + } + if (name == "bottom") { + return m_context->bottom; + } + if (name == "width") { + return m_context->right - m_context->left; + } + if (name == "height") { + return m_context->bottom - m_context->top; + } + if (name == "logwidth") { + return m_context->logical_width; + } + if (name == "logheight") { + return m_context->logical_height; + } + if (name == "xstretch") { + return m_context->x_stretch; + } + if (name == "ystretch") { + return m_context->y_stretch; + } + if (name == "hasstroke") { + return m_context->has_stroke ? 1 : 0; + } + if (name == "hasfill") { + return m_context->has_fill ? 1 : 0; + } + return {}; + } + + [[nodiscard]] std::optional function(const std::string_view name) { + if (!consume('(')) { + return {}; + } + std::array arguments{}; + std::size_t count = 0; + while (true) { + const std::optional argument = expression(); + if (!argument.has_value() || count >= arguments.size()) { + return {}; + } + arguments[count++] = *argument; + if (!consume(',')) { + break; + } + } + if (!consume(')')) { + return {}; + } + + if (count == 1) { + if (name == "abs") { + return std::abs(arguments[0]); + } + if (name == "sqrt") { + return arguments[0] < 0 ? std::nullopt + : std::optional(std::sqrt(arguments[0])); + } + if (name == "sin") { + return std::sin(arguments[0]); + } + if (name == "cos") { + return std::cos(arguments[0]); + } + if (name == "tan") { + return std::tan(arguments[0]); + } + if (name == "atan") { + return std::atan(arguments[0]); + } + } else if (count == 2) { + if (name == "min") { + return std::min(arguments[0], arguments[1]); + } + if (name == "max") { + return std::max(arguments[0], arguments[1]); + } + if (name == "atan2") { + return std::atan2(arguments[0], arguments[1]); + } + } else if (count == 3 && name == "if") { + return arguments[0] > 0 ? arguments[1] : arguments[2]; + } + return {}; + } +}; + +/// Reads 19.145's commands and writes the svg `d` they trace. +class EnhancedPathParser : private Scanner { +public: + EnhancedPathParser(const std::string_view path, + const EnhancedGeometryContext &context, + const EquationResolver &equations) + : Scanner{path}, m_context{&context}, m_equations{&equations} {} + + [[nodiscard]] std::optional parse() { + while (true) { + skip_separators(); + if (empty()) { + break; + } + if (!parse_command()) { + return {}; + } + } + if (m_out.empty()) { + return {}; + } + return m_out; + } + +private: + const EnhancedGeometryContext *m_context{nullptr}; + const EquationResolver *m_equations{nullptr}; + + std::string m_out; + double m_x{0}; + double m_y{0}; + + /// A number, a `$N` modifier or a `?name` equation; only the last two need + /// the formula machinery. + [[nodiscard]] std::optional read_value() { + skip_separators(); + if (peek() == '$' || peek() == '?') { + const char kind = take(); + const std::string_view name = take_while(is_letter_or_digit); + if (name.empty()) { + return {}; + } + return evaluate_formula(std::string(1, kind) + std::string(name), + *m_context, *m_equations); + } + return read_number(); + } + + [[nodiscard]] bool peek_value() const { + return peek() == '$' || peek() == '?' || starts_number(); + } + + void write(const char command) { + if (!m_out.empty()) { + m_out += ' '; + } + m_out += command; + } + + void write(const double value) { + m_out += ' '; + // A cancelled sine or cosine lands on negative zero, which prints as `-0`. + m_out += util::number::to_string_significant(value == 0 ? 0 : value, 7); + } + + void write_point(const double x, const double y) { + write(x); + write(y); + m_x = x; + m_y = y; + } + + /// An elliptical arc from @p from to @p to degrees, in segments of at most + /// a half turn, so a full turn — which one `A` cannot express — still draws. + void write_arc(const double cx, const double cy, const double rx, + const double ry, const double from, const double to) { + const auto point = [&](const double degrees) { + const double radians = degrees * std::numbers::pi / 180; + return std::array{cx + rx * std::cos(radians), + cy + ry * std::sin(radians)}; + }; + const double swept = to - from; + const auto segments = + static_cast(std::ceil(std::abs(swept) / 180.0 - 1e-9)); + for (int i = 1; i <= std::max(1, segments); ++i) { + const std::array end = + point(from + swept * i / std::max(1, segments)); + write('A'); + write(rx); + write(ry); + m_out += " 0 0 "; + m_out += swept < 0 ? '0' : '1'; + write_point(end[0], end[1]); + } + } + + /// `A`, `B`, `W`, `V` (19.145): a box, and two points whose direction from + /// its centre gives the start and end angles. + bool write_box_arc(const bool move_first, const bool clockwise) { + std::array values{}; + for (double &value : values) { + const std::optional read = read_value(); + if (!read.has_value()) { + return false; + } + value = *read; + } + const double cx = (values[0] + values[2]) / 2; + const double cy = (values[1] + values[3]) / 2; + const double rx = std::abs(values[2] - values[0]) / 2; + const double ry = std::abs(values[3] - values[1]) / 2; + if (rx == 0 || ry == 0) { + return false; + } + const auto angle = [&](const double x, const double y) { + return std::atan2((y - cy) / ry, (x - cx) / rx) * 180 / std::numbers::pi; + }; + const double from = angle(values[4], values[5]); + double to = angle(values[6], values[7]); + // A positive angle turns clockwise here, the y axis pointing down. + if (clockwise) { + while (to < from) { + to += 360; + } + } else { + while (to > from) { + to -= 360; + } + } + + const double start_x = cx + rx * std::cos(from * std::numbers::pi / 180); + const double start_y = cy + ry * std::sin(from * std::numbers::pi / 180); + write(move_first ? 'M' : 'L'); + write_point(start_x, start_y); + write_arc(cx, cy, rx, ry, from, to); + return true; + } + + /// `T`, `U` (19.145): a centre, radii and the two angles to sweep between. + bool write_angle_ellipse(const bool move_first) { + std::array values{}; + for (double &value : values) { + const std::optional read = read_value(); + if (!read.has_value()) { + return false; + } + value = *read; + } + const double cx = values[0]; + const double cy = values[1]; + const double rx = values[2]; + const double ry = values[3]; + const double from = values[4]; + const double to = values[5]; + + const double start_x = cx + rx * std::cos(from * std::numbers::pi / 180); + const double start_y = cy + ry * std::sin(from * std::numbers::pi / 180); + write(move_first ? 'M' : 'L'); + write_point(start_x, start_y); + write_arc(cx, cy, rx, ry, from, to); + return true; + } + + /// `X`, `Y` (19.145): a quarter ellipse to the given point, leaving the + /// current one along the x or the y axis. + bool write_quadrant(const bool x_first) { + const std::optional x = read_value(); + const std::optional y = read_value(); + if (!x.has_value() || !y.has_value()) { + return false; + } + const double rx = std::abs(*x - m_x); + const double ry = std::abs(*y - m_y); + const bool descending = (*x - m_x) * (*y - m_y) > 0; + write('A'); + write(rx); + write(ry); + m_out += " 0 0 "; + m_out += (descending == x_first) ? '1' : '0'; + write_point(*x, *y); + return true; + } + + bool write_points(const char command, const std::size_t per_command) { + do { + std::array values{}; + for (std::size_t i = 0; i < per_command; ++i) { + const std::optional value = read_value(); + if (!value.has_value()) { + return false; + } + values[i] = *value; + } + write(command); + for (std::size_t i = 0; i + 1 < per_command; i += 2) { + write_point(values[i], values[i + 1]); + } + skip_separators(); + } while (peek_value()); + return true; + } + + [[nodiscard]] bool parse_command() { + const char command = take(); + skip_separators(); + + switch (command) { + case 'M': + return write_points('M', 2); + case 'L': + return write_points('L', 2); + case 'C': + return write_points('C', 6); + case 'Q': + return write_points('Q', 4); + case 'Z': + write('Z'); + return true; + case 'N': + case 'F': + case 'S': + return true; + case 'T': + case 'U': + do { + if (!write_angle_ellipse(command == 'U')) { + return false; + } + skip_separators(); + } while (peek_value()); + return true; + case 'A': + case 'B': + case 'W': + case 'V': + do { + if (!write_box_arc(command == 'B' || command == 'V', + command == 'W' || command == 'V')) { + return false; + } + skip_separators(); + } while (peek_value()); + return true; + case 'X': + case 'Y': + do { + if (!write_quadrant(command == 'X')) { + return false; + } + skip_separators(); + } while (peek_value()); + return true; + default: + return false; + } + } +}; + +} // namespace + +} // namespace odr::internal::odf + +namespace odr::internal { + +std::optional +odf::evaluate_formula(const std::string_view formula, + const EnhancedGeometryContext &context, + const EquationResolver &equations) { + return odf::FormulaParser(formula, context, equations).parse(); +} + +std::optional +odf::convert_enhanced_path(const std::string_view path, + const EnhancedGeometryContext &context, + const EquationResolver &equations) { + return odf::EnhancedPathParser(path, context, equations).parse(); +} + +} // namespace odr::internal diff --git a/src/odr/internal/odf/odf_enhanced_geometry.hpp b/src/odr/internal/odf/odf_enhanced_geometry.hpp new file mode 100644 index 000000000..cd9e1c38a --- /dev/null +++ b/src/odr/internal/odf/odf_enhanced_geometry.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace odr::internal::odf { + +/// What a `draw:enhanced-geometry` formula can name (20.36) besides its own +/// equations. The defaults are ODF's own 21600 square. +struct EnhancedGeometryContext final { + double left{0}; + double top{0}; + double right{21600}; + double bottom{21600}; + /// What `logwidth` and `logheight` name, in the view box's own units. + double logical_width{21600}; + double logical_height{21600}; + double x_stretch{0}; + double y_stretch{0}; + bool has_stroke{true}; + bool has_fill{true}; + /// `draw:modifiers`, which `$0`, `$1`, … index. + std::vector modifiers; +}; + +/// Resolves a `?name` reference to the `draw:equation` it names. +using EquationResolver = + std::function(std::string_view name)>; + +/// `draw:formula` (20.36). Nothing where it does not parse, or names something +/// that does not resolve. +[[nodiscard]] std::optional +evaluate_formula(std::string_view formula, + const EnhancedGeometryContext &context, + const EquationResolver &equations); + +/// `draw:enhanced-path` (19.145) as an svg `d`. Nothing where it does not +/// parse, or names a value that does not resolve; `F` and `S` are dropped. +[[nodiscard]] std::optional +convert_enhanced_path(std::string_view path, + const EnhancedGeometryContext &context, + const EquationResolver &equations); + +} // namespace odr::internal::odf diff --git a/src/odr/internal/odf/odf_geometry.cpp b/src/odr/internal/odf/odf_geometry.cpp index 24d3678d0..1b9477541 100644 --- a/src/odr/internal/odf/odf_geometry.cpp +++ b/src/odr/internal/odf/odf_geometry.cpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -47,96 +48,6 @@ double centimetres_per(const std::string_view unit) { return 0.0; } -/// A cursor over the input every reader here shares. Reads are bounded by what -/// remains, which carries no terminator. -class Scanner { -public: - explicit Scanner(const std::string_view input) : m_rest{input} {} - - [[nodiscard]] bool empty() const { return m_rest.empty(); } - - /// The next character, or `\0` where the input ended. - [[nodiscard]] char peek() const { - return m_rest.empty() ? '\0' : m_rest.front(); - } - - /// The next character, consumed. - char take() { - const char c = peek(); - if (!m_rest.empty()) { - m_rest.remove_prefix(1); - } - return c; - } - - void skip_separators() { - while (is_separator(peek())) { - m_rest.remove_prefix(1); - } - } - - [[nodiscard]] bool consume(const char c) { - skip_separators(); - if (peek() != c) { - return false; - } - m_rest.remove_prefix(1); - return true; - } - - /// The leading run of characters @p accept admits, left in place. - [[nodiscard]] std::string_view peek_while(bool (*accept)(char)) const { - std::size_t length = 0; - while (length < m_rest.size() && accept(m_rest[length])) { - ++length; - } - return m_rest.substr(0, length); - } - - /// The same run, consumed. - [[nodiscard]] std::string_view take_while(bool (*accept)(char)) { - const std::string_view taken = peek_while(accept); - m_rest.remove_prefix(taken.size()); - return taken; - } - - /// `std::strtod` wants a terminator, which the view does not promise, so the - /// run it bounds is copied out. - [[nodiscard]] std::optional read_number() { - skip_separators(); - const std::string number(peek_while(is_number_char)); - char *end = nullptr; - const double value = std::strtod(number.c_str(), &end); - if (end == number.c_str()) { - return {}; - } - // `strtod` may stop short of the run, on a trailing `e` say - m_rest.remove_prefix(static_cast(end - number.c_str())); - return value; - } - - [[nodiscard]] bool starts_number() const { - const char c = peek(); - return c == '-' || c == '+' || c == '.' || (c >= '0' && c <= '9'); - } - - static bool is_letter(const char c) { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); - } - -private: - static bool is_separator(const char c) { - return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ','; - } - /// A superset of a number's characters, to bound the run `std::strtod` reads. - static bool is_number_char(const char c) { - return (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || - c == 'e' || c == 'E'; - } - - std::string_view m_rest; -}; - /// Composes the operation list, holding the translation in centimetres. class TransformParser : private Scanner { public: diff --git a/src/odr/internal/odf/odf_scanner.hpp b/src/odr/internal/odf/odf_scanner.hpp new file mode 100644 index 000000000..961532c45 --- /dev/null +++ b/src/odr/internal/odf/odf_scanner.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace odr::internal::odf { + +/// A cursor over the input every reader here shares. Reads are bounded by what +/// remains, which carries no terminator. +class Scanner { +public: + explicit Scanner(const std::string_view input) : m_rest{input} {} + + [[nodiscard]] bool empty() const { return m_rest.empty(); } + + /// The next character, or `\0` where the input ended. + [[nodiscard]] char peek() const { + return m_rest.empty() ? '\0' : m_rest.front(); + } + + /// The next character, consumed. + char take() { + const char c = peek(); + if (!m_rest.empty()) { + m_rest.remove_prefix(1); + } + return c; + } + + /// Whitespace only: a comma separates the arguments of a formula. + void skip_space() { + while (is_space(peek())) { + m_rest.remove_prefix(1); + } + } + + /// Whitespace and the commas a coordinate list may be written with. + void skip_separators() { + while (is_space(peek()) || peek() == ',') { + m_rest.remove_prefix(1); + } + } + + /// Only spaces are skipped ahead of @p c: a comma is an argument separator + /// where a formula is concerned, not filler. + [[nodiscard]] bool consume(const char c) { + skip_space(); + if (peek() != c) { + return false; + } + m_rest.remove_prefix(1); + return true; + } + + /// The leading run of characters @p accept admits, left in place. + [[nodiscard]] std::string_view peek_while(bool (*accept)(char)) const { + std::size_t length = 0; + while (length < m_rest.size() && accept(m_rest[length])) { + ++length; + } + return m_rest.substr(0, length); + } + + /// The same run, consumed. + [[nodiscard]] std::string_view take_while(bool (*accept)(char)) { + const std::string_view taken = peek_while(accept); + m_rest.remove_prefix(taken.size()); + return taken; + } + + /// `std::strtod` wants a terminator, which the view does not promise, so the + /// run it bounds is copied out. + [[nodiscard]] std::optional read_number() { + skip_separators(); + const std::string number(peek_while(is_number_char)); + char *end = nullptr; + const double value = std::strtod(number.c_str(), &end); + if (end == number.c_str()) { + return {}; + } + // `strtod` may stop short of the run, on a trailing `e` say + m_rest.remove_prefix(static_cast(end - number.c_str())); + return value; + } + + [[nodiscard]] bool starts_number() const { + const char c = peek(); + return c == '-' || c == '+' || c == '.' || is_digit(c); + } + + static bool is_letter(const char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + static bool is_digit(const char c) { return c >= '0' && c <= '9'; } + static bool is_letter_or_digit(const char c) { + return is_letter(c) || is_digit(c); + } + +private: + static bool is_space(const char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; + } + /// A superset of a number's characters, to bound the run `std::strtod` reads. + static bool is_number_char(const char c) { + return is_digit(c) || c == '+' || c == '-' || c == '.' || c == 'e' || + c == 'E'; + } + + std::string_view m_rest; +}; + +} // namespace odr::internal::odf diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a451a1b6b..19235d252 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -73,6 +73,7 @@ add_executable(odr_test "src/internal/rtf/rtf_document_test.cpp" "src/internal/rtf/rtf_tokenizer_test.cpp" + "src/internal/odf/odf_enhanced_geometry_test.cpp" "src/internal/odf/odf_flat_file_test.cpp" "src/internal/odf/odf_geometry_test.cpp" "src/internal/odf/odf_sheet_repeat_test.cpp" diff --git a/test/src/internal/odf/odf_enhanced_geometry_test.cpp b/test/src/internal/odf/odf_enhanced_geometry_test.cpp new file mode 100644 index 000000000..078f9175e --- /dev/null +++ b/test/src/internal/odf/odf_enhanced_geometry_test.cpp @@ -0,0 +1,184 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace odr::internal::odf; + +namespace { + +EnhancedGeometryContext square(std::vector modifiers = {}) { + EnhancedGeometryContext context; + context.modifiers = std::move(modifiers); + return context; +} + +/// Resolves `?name` against a fixed table, which is all a formula sees of the +/// equations around it. +EquationResolver table(std::map equations) { + return [equations = std::move(equations)]( + const std::string_view name) -> std::optional { + const auto it = equations.find(std::string(name)); + return it == equations.end() ? std::nullopt + : std::optional(it->second); + }; +} + +EquationResolver none() { + return [](std::string_view) { return std::optional(); }; +} + +std::optional evaluate(const std::string &formula, + const EnhancedGeometryContext &context, + const EquationResolver &equations) { + return evaluate_formula(formula, context, equations); +} + +} // namespace + +TEST(OdfFormula, arithmetic_binds_the_way_it_reads) { + EXPECT_EQ(7, evaluate("1+2*3", square(), none())); + EXPECT_EQ(9, evaluate("(1+2)*3", square(), none())); + EXPECT_EQ(-5, evaluate("-1-4", square(), none())); + EXPECT_EQ(2.5, evaluate("10/4", square(), none())); +} + +TEST(OdfFormula, a_modifier_is_indexed_by_its_dollar) { + const EnhancedGeometryContext context = square({10800, 5400}); + EXPECT_EQ(10800, evaluate("$0 ", context, none())); + EXPECT_EQ(5400, evaluate("$1", context, none())); + EXPECT_EQ(5400, evaluate("$0 /2", context, none())); + EXPECT_FALSE(evaluate("$2", context, none()).has_value()); +} + +TEST(OdfFormula, a_reference_resolves_through_the_equations) { + const EquationResolver equations = table({{"f0", 10800}, {"f1", 3600}}); + EXPECT_EQ(10800, evaluate("21600-?f0 ", square(), equations)); + EXPECT_EQ(14400, evaluate("?f1 +10800", square(), equations)); + EXPECT_FALSE(evaluate("?f9", square(), equations).has_value()); +} + +TEST(OdfFormula, the_view_box_is_named) { + EnhancedGeometryContext context = square(); + context.left = 10; + context.top = 20; + context.right = 110; + context.bottom = 220; + context.logical_width = 5000; + EXPECT_EQ(10, evaluate("left", context, none())); + EXPECT_EQ(100, evaluate("width", context, none())); + EXPECT_EQ(200, evaluate("height", context, none())); + EXPECT_EQ(5000, evaluate("logwidth", context, none())); +} + +TEST(OdfFormula, if_takes_the_second_argument_for_a_positive_first) { + EXPECT_EQ(2, evaluate("if(1,2,3)", square(), none())); + EXPECT_EQ(3, evaluate("if(0,2,3)", square(), none())); + EXPECT_EQ(3, evaluate("if(-1,2,3)", square(), none())); +} + +TEST(OdfFormula, the_trigonometric_functions_take_radians) { + const std::optional value = + evaluate("sin(90*(pi/180))", square(), none()); + ASSERT_TRUE(value.has_value()); + EXPECT_NEAR(1, *value, 1e-9); + EXPECT_EQ(5, evaluate("abs(0-5)", square(), none())); + EXPECT_EQ(4, evaluate("sqrt(16)", square(), none())); + EXPECT_EQ(3, evaluate("min(3,7)", square(), none())); + EXPECT_EQ(7, evaluate("max(3,7)", square(), none())); +} + +TEST(OdfFormula, an_unreadable_formula_is_dropped) { + EXPECT_FALSE(evaluate("", square(), none()).has_value()); + EXPECT_FALSE(evaluate("1+", square(), none()).has_value()); + EXPECT_FALSE(evaluate("(1", square(), none()).has_value()); + EXPECT_FALSE(evaluate("wobble", square(), none()).has_value()); + EXPECT_FALSE(evaluate("sin(1,2)", square(), none()).has_value()); + EXPECT_FALSE(evaluate("1/0", square(), none()).has_value()); + EXPECT_FALSE(evaluate("1 2", square(), none()).has_value()); +} + +TEST(OdfEnhancedPath, a_line_run_repeats_its_command) { + const std::optional path = convert_enhanced_path( + "M 0 0 L 21600 0 21600 21600 0 21600 Z N", square(), none()); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 0 0 L 21600 0 L 21600 21600 L 0 21600 Z", *path); +} + +TEST(OdfEnhancedPath, a_value_may_be_a_modifier_or_a_reference) { + const std::optional path = + convert_enhanced_path("M ?f0 0 L 21600 21600 0 21600 Z N", + square({10800}), table({{"f0", 10800}})); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 10800 0 L 21600 21600 L 0 21600 Z", *path); +} + +TEST(OdfEnhancedPath, a_curve_run_takes_six_values_at_a_time) { + const std::optional path = convert_enhanced_path( + "M 0 0 C 1 2 3 4 5 6 7 8 9 10 11 12", square(), none()); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 0 0 C 1 2 3 4 5 6 C 7 8 9 10 11 12", *path); +} + +TEST(OdfEnhancedPath, a_full_angle_ellipse_is_split_so_svg_can_draw_it) { + const std::optional path = convert_enhanced_path( + "U 10800 10800 10800 10800 0 360 Z N", square(), none()); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 21600 10800 A 10800 10800 0 0 1 0 10800 " + "A 10800 10800 0 0 1 21600 10800 Z", + *path); +} + +TEST(OdfEnhancedPath, an_angle_ellipse_to_reaches_its_start_with_a_line) { + const std::optional path = + convert_enhanced_path("M 0 0 T 100 100 50 50 0 90", square(), none()); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 0 0 L 150 100 A 50 50 0 0 1 100 150", *path); +} + +TEST(OdfEnhancedPath, a_quadrant_leaves_along_the_axis_it_names) { + const std::optional x = + convert_enhanced_path("M 3590 0 X 0 3590", square(), none()); + ASSERT_TRUE(x.has_value()); + EXPECT_EQ("M 3590 0 A 3590 3590 0 0 0 0 3590", *x); + + const std::optional y = + convert_enhanced_path("M 0 18010 Y 3590 21600", square(), none()); + ASSERT_TRUE(y.has_value()); + EXPECT_EQ("M 0 18010 A 3590 3590 0 0 0 3590 21600", *y); +} + +TEST(OdfEnhancedPath, an_arc_runs_the_way_its_command_says) { + const std::optional counter_clockwise = + convert_enhanced_path("B 0 0 100 100 100 50 50 0", square(), none()); + ASSERT_TRUE(counter_clockwise.has_value()); + EXPECT_EQ("M 100 50 A 50 50 0 0 0 50 0", *counter_clockwise); + + const std::optional clockwise = + convert_enhanced_path("V 0 0 100 100 100 50 50 0", square(), none()); + ASSERT_TRUE(clockwise.has_value()); + // Three quarters of a turn, split in two so neither needs the large-arc flag. + EXPECT_EQ("M 100 50 A 50 50 0 0 1 14.64466 85.35534 A 50 50 0 0 1 50 0", + *clockwise); +} + +TEST(OdfEnhancedPath, the_paint_modifiers_are_read_and_dropped) { + const std::optional path = + convert_enhanced_path("F M 0 0 L 10 10 S N", square(), none()); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ("M 0 0 L 10 10", *path); +} + +TEST(OdfEnhancedPath, an_unreadable_path_is_dropped_whole) { + EXPECT_FALSE(convert_enhanced_path("", square(), none()).has_value()); + EXPECT_FALSE(convert_enhanced_path("N", square(), none()).has_value()); + EXPECT_FALSE(convert_enhanced_path("M 0", square(), none()).has_value()); + EXPECT_FALSE(convert_enhanced_path("R 0 0", square(), none()).has_value()); + EXPECT_FALSE(convert_enhanced_path("M ?f0 0", square(), none()).has_value()); +}