'''
plot_utils.py

Common plotting utilities for use with EiBotBoard
https://github.com/evil-mad/plotink

Intended to provide some common interfaces that can be used by the
Bantam Tools NextDraw, as well as the EggBot, WaterColorBot, AxiDraw, and
similar machines that use the EiBotBoard.

See __version__ below for version information


The MIT License (MIT)

Copyright (c) 2025 Windell H. Oskay, Bantam Tools

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''

from math import sqrt, isclose

from .plot_utils_import import from_dependency_import
cspsubdiv = from_dependency_import('ink_extensions.cspsubdiv')
simplepath = from_dependency_import('ink_extensions.simplepath')
bezmisc = from_dependency_import('ink_extensions.bezmisc')
ffgeom = from_dependency_import('ink_extensions.ffgeom')


def version():    # Version number for this document
    """Return version number of this script"""
    return "0.5.0"  # Dated 2025-07-13


__version__ = version()

PX_PER_INCH = 96.0
# This value has changed to 96 px per inch, as of version 0.12 of this library.
# Prior versions used 90 PPI, corresponding the value used in Inkscape < 0.92.
# For use with Inkscape 0.91 (or older), use PX_PER_INCH = 90.0

trivial_svg = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
    <svg
       xmlns:dc="http://purl.org/dc/elements/1.1/"
       xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
       xmlns:svg="http://www.w3.org/2000/svg"
       xmlns="http://www.w3.org/2000/svg"
       version="1.1"
       id="svg15158"
       viewBox="0 0 297 210"
       height="210mm"
       width="297mm">
    </svg>
    """


def checkLimits(value, lower_bound, upper_bound):
    """
    Limit a value to within a range.
    Return constrained value with error boolean.
    """
    if value > upper_bound:
        return upper_bound, True
    if value < lower_bound:
        return lower_bound, True
    return value, False


def checkLimitsTol(value, lower_bound, upper_bound, tolerance):
    """
    Limit a value to within a range.
    Return constrained value with error boolean.
    Allow a range of tolerance where we constrain the value without an error message.
    """
    if value > upper_bound:
        if value > (upper_bound + tolerance):
            return upper_bound, True  # Truncate & throw error
        return upper_bound, False  # Truncate with no error
    if value < lower_bound:
        if value < (lower_bound - tolerance):
            return lower_bound, True  # Truncate & throw error
        return lower_bound, False  # Truncate with no error
    return value, False  # Return original value without error


def point_in_bounds(point, bounds, tolerance=1e-9):
    """
    Given an input point [x, y], a rectangular bounding region
    [[x_min, y_min], [x_max, y_max]], and an optional tolerance,
    return True if the point is within the bounds, to within the
    required tolerance, and False otherwise.
    """
    x, y = point
    [[x_min, y_min], [x_max, y_max]] = bounds
    if x < x_min - tolerance:
        return False
    if y < y_min - tolerance:
        return False
    if x > x_max + tolerance:
        return False
    if y > y_max + tolerance:
        return False
    return True


def clip_code(x_in, y_in, x_min, x_max, y_min, y_max):
    """Encode point position with respect to boundary box"""
    code = 0
    if x_in < x_min:
        code = 1  # Left
    if x_in > x_max:
        code |= 2  # Right
    if y_in < y_min:
        code |= 4  # Top
    if y_in > y_max:
        code |= 8  # Bottom
    return code


def clip_segment(segment, bounds):
    """
    Given an input line segment [[x_1, y_1], [x_2, y_2]], as well as a
    rectangular bounding region [[x_min, y_min], [x_max, y_max]], clip and
    keep the part of the segment within the bounding region, using the
    Cohen–Sutherland algorithm.
    Return a boolean value, "accept", indicating that the output
    segment is non-empty, as well as truncated segment,
    [[x_1', y_1'], [x_2', y_2']], giving the portion of the input line segment
    that fits within the bounds.
    """

    x_1 = segment[0][0]
    y_1 = segment[0][1]
    x_2 = segment[1][0]
    y_2 = segment[1][1]

    x_min = bounds[0][0]
    y_min = bounds[0][1]
    x_max = bounds[1][0]
    y_max = bounds[1][1]

    iterations = 0

    while True:  # Repeat until return
        code_1 = clip_code(x_1, y_1, x_min, x_max, y_min, y_max)
        code_2 = clip_code(x_2, y_2, x_min, x_max, y_min, y_max)

        # Trivial accept:
        if code_1 == 0 and code_2 == 0:
            return True, segment  # Both endpoints are within bounds.
        # Trivial reject, if both endpoints are outside, and on the same side:
        if code_1 & code_2:
            return False, segment  # Verify with bitwise AND.
        if iterations > 3:  # Failsafe; exit if the value has not converged;
            return True, segment  # Avoids infinite loops near precision limits.

        # Otherwise, at least one point is out of bounds; not trivial.
        if code_1 != 0:
            code = code_1
        else:
            code = code_2

        # Clip at a single boundary; may need to do this up to twice per vertex

        if code & 1:  # Vertex on LEFT side of bounds:
            x_new = x_min  # Find intersection of our segment with x_min
            slope = (y_2 - y_1) / (x_2 - x_1)
            y_new = slope * (x_min - x_1) + y_1

        elif code & 2:  # Vertex on RIGHT side of bounds:
            x_new = x_max  # Find intersection of our segment with x_max
            slope = (y_2 - y_1) / (x_2 - x_1)
            y_new = slope * (x_max - x_1) + y_1

        elif code & 4:  # Vertex on TOP side of bounds:
            y_new = y_min  # Find intersection of our segment with y_min
            slope = (x_2 - x_1) / (y_2 - y_1)
            x_new = slope * (y_min - y_1) + x_1

        elif code & 8:  # Vertex on BOTTOM side of bounds:
            y_new = y_max  # Find intersection of our segment with y_max
            slope = (x_2 - x_1) / (y_2 - y_1)
            x_new = slope * (y_max - y_1) + x_1

        if code == code_1:
            x_1 = x_new
            y_1 = y_new
        else:
            x_2 = x_new
            y_2 = y_new
        segment = [[x_1, y_1], [x_2, y_2]]  # Now checking this clipped segment
        iterations += 1


def constrainLimits(value, lower_bound, upper_bound):
    """ Limit a value to within a range. """
    return max(lower_bound, min(upper_bound, value))


def distance(x_in, y_in):
    """
    Pythagorean theorem
    """
    return sqrt(x_in * x_in + y_in * y_in)


def dotProductXY(input_vector_first, input_vector_second):
    """Dot product of vectors"""
    temp = input_vector_first[0] * input_vector_second[0] +\
        input_vector_first[1] * input_vector_second[1]
    if temp > 1:
        return 1
    if temp < -1:
        return -1
    return temp


def getLength(altself, name, default):
    """
    Get the <svg> attribute with name "name" and default value "default"
    Parse the attribute into a value and associated units.  Then, accept
    no units (''), units of pixels ('px'), and units of percentage ('%').
    Return value in px.
    """
    string_to_parse = altself.document.getroot().get(name)

    if string_to_parse:
        value, unit = parseLengthWithUnits(string_to_parse)
        if value is None:
            return None
        if unit in ('', 'px'):
            return float(value)
        if unit == 'in':
            return float(value) * PX_PER_INCH
        if unit == 'mm':
            return float(value) * PX_PER_INCH / 25.4
        if unit == 'cm':
            return float(value) * PX_PER_INCH / 2.54
        if unit in ('Q', 'q'):
            return float(value) * PX_PER_INCH / (40.0 * 2.54)
        if unit == 'pc':
            return float(value) * PX_PER_INCH / 6.0
        if unit == 'pt':
            return float(value) * PX_PER_INCH / 72.0
        if unit == '%':
            return float(default) * value / 100.0
        # Unsupported units
        return None
    # No width specified; assume the default value
    return float(default)


def getLengthInches(altself, name):
    """
    Get the <svg> attribute with name "name", and parse it as a length,
    into a value and associated units. Return value in inches.

    As of version 0.11, units of 'px' or no units ('') are interpreted
    as imported px, at a resolution of 96 px per inch, as per the SVG
    specification. (Prior versions returned None in this case.)

    This allows certain imported SVG files, (imported with units of px)
    to plot while they would not previously. However, it may also cause
    new scaling issues in some circumstances. Note, for example, that
    Adobe Illustrator uses 72 px per inch, and Inkscape used 90 px per
    inch prior to version 0.92.
    """
    string_to_parse = altself.document.getroot().get(name)
    if string_to_parse:
        value, unit = parseLengthWithUnits(string_to_parse)
        if value is None:
            return None
        if unit == 'in':
            return float(value)
        if unit == 'mm':
            return float(value) / 25.4
        if unit == 'cm':
            return float(value) / 2.54
        if unit in ('Q', 'q'):
            return float(value) / (40.0 * 2.54)
        if unit == 'pc':
            return float(value) / 6.0
        if unit == 'pt':
            return float(value) / 72.0
        if unit in ('', 'px'):
            return float(value) / 96.0
    # Unsupported units (including '%') or no string to parse:
    return None


def parseLengthWithUnits(string_to_parse):
    """
    Parse an SVG value which may or may not have units attached.
    There is a more general routine to consider in scour.py if more
    generality is ever needed.
    """

    if string_to_parse is None:
        return None, None
    units = 'px'
    string = string_to_parse.strip()
    if string[-2:] == 'px':  # pixels, at a size of PX_PER_INCH per inch
        string = string[:-2]
    elif string[-2:] == 'in':  # inches
        string = string[:-2]
        units = 'in'
    elif string[-2:] == 'mm':  # millimeters
        string = string[:-2]
        units = 'mm'
    elif string[-2:] == 'cm':  # centimeters
        string = string[:-2]
        units = 'cm'
    elif string[-2:] == 'pt':  # points; 1pt = 1/72th of 1in
        string = string[:-2]
        units = 'pt'
    elif string[-2:] == 'pc':  # picas; 1pc = 1/6th of 1in
        string = string[:-2]
        units = 'pc'
    elif string[-1:] == 'Q' or string[-1:] == 'q':  # quarter-millimeters. 1q = 1/40th of 1cm
        string = string[:-1]
        units = 'Q'
    elif string[-1:] == '%':
        units = '%'
        string = string[:-1]

    try:
        value = float(string)
    except ValueError:
        return None, None

    return value, units


def unitsToUserUnits(input_string, percent_ref=None):
    """
    Custom replacement for the unittouu routine in inkex.py

    Parse input_string into a value and units. Return value in user units (typically "px").
    If input_string units are '%', then return (value * percent_ref / 100), where percent_ref
    is a number corresponding to a length of 100% in in user units, e.g., document width.
    """
    value, unit = parseLengthWithUnits(input_string)
    if value is None:
        return None
    if unit in ('', 'px'):
        return float(value)
    if unit == 'in':
        return float(value) * PX_PER_INCH
    if unit == 'mm':
        return float(value) * PX_PER_INCH / 25.4
    if unit == 'cm':
        return float(value) * PX_PER_INCH / 2.54
    if unit in ('Q', 'q'):
        return float(value) * PX_PER_INCH / 101.6
    if unit == 'pc':
        return float(value) * PX_PER_INCH / 6.0
    if unit == 'pt':
        return float(value) * PX_PER_INCH / 72.0
    if unit == '%':
        if percent_ref:
            return float(value) * float(percent_ref) / 100.0
        return float(value) / 100.0
    return None  # Handle case of cnsupported units


def position_scale(x_value, y_value, units_code):
    '''
    Format XY position data to be returned to user
    x_value, y_value inputs are in inches.
    Output set by units_code: 1 for cm, 2 for mm, 0 (or otherwise) for inch.
    '''
    if units_code == 1:  # If using centimeter units
        x_value = x_value * 2.54
        y_value = y_value * 2.54
    if units_code == 2:  # If using millimeter units
        x_value = x_value * 25.4
        y_value = y_value * 25.4
    return x_value, y_value


def subdivideCubicPath(s_p, flat, i=1):
    """
    Break up a bezier curve into smaller curves, each of which
    is approximately a straight line within a given tolerance
    (the "smoothness" defined by [flat]).

    This is a modified version of cspsubdiv.cspsubdiv(). I rewrote the recursive
    call because it caused recursion-depth errors on complicated line segments.
    """

    if flat <= 0:   # Prevent infinite loop with zero or negative tolerance
        return      # No subdivision needed/possible with invalid tolerance

    while True:
        while True:
            if i >= len(s_p):
                return
            p_0 = s_p[i - 1][1]
            p_1 = s_p[i - 1][2]
            p_2 = s_p[i][0]
            p_3 = s_p[i][1]

            b_list = (p_0, p_1, p_2, p_3)

            if not points_in_tolerance(b_list, flat):
                break
            i += 1

        one, two = bezmisc.beziersplitatt(b_list, 0.5)
        s_p[i - 1][2] = one[1]
        s_p[i][0] = two[2]
        p_list = [one[2], one[3], two[1]]
        s_p[i:1] = [p_list]


def points_in_tolerance(input_points, tolerance):  # pylint: disable=too-many-locals
    """
    Return True if a set of points is within tolerance of a line segment.

    Given `input_points`, an ordered collection of xy vertices, we define a segment from the
    first to last vertex. For each other vertex, measure its distance from the segment, and
    return True if every distance is < `tolerance`. This function is a modified version
    of max_dist_from_n_points, that returns a boolean, rather than the maximum value.

    Does not mutate `input_points`.

    This is a performance sensitive "inner loop" function that may be called thousands or
    millions of times while the user is waiting. It is "unrolled" to reduce execution time
    with some duplicate code, more local variables, no function calls, and few array lookups.
    Previous code based on max_dist_from_n_points() was more "pythonic", using the Point
    and Segment classes in ffgeom along with their methods. But, this is much faster.
    """
    assert len(input_points) >= 3, "There must be points (other than begin/end) to check."

    tol_squared = tolerance * tolerance
    seg_0x, seg_0y = input_points[0]
    seg_1x, seg_1y = input_points[-1]
    s_delta_x = seg_1x - seg_0x
    s_delta_y = seg_1y - seg_0y

    for point in input_points[1:-1]:  # All vertices except first and last
        p_x, p_y = point
        dx_p_s0 = p_x - seg_0x
        dy_p_s0 = p_y - seg_0y

        temp1 = dx_p_s0 * s_delta_x + dy_p_s0 * s_delta_y  # First dot product
        if temp1 <= 0:
            if (dx_p_s0 * dx_p_s0 + dy_p_s0 * dy_p_s0) >= tol_squared:
                return False
            continue

        seg_length_squared = s_delta_x * s_delta_x + s_delta_y * s_delta_y
        if seg_length_squared <= temp1:
            if ((p_x - seg_1x)*(p_x - seg_1x) + (p_y - seg_1y)*(p_y - seg_1y)) >= tol_squared:
                return False
            continue

        if seg_length_squared == 0:
            return False  # Zero-length segment; exit.
        temp = dx_p_s0 * s_delta_y - s_delta_x * dy_p_s0
        if (temp * temp / seg_length_squared) >= tol_squared:
            return False
    return True


def max_dist_from_n_points(input_points):
    """
    Like cspsubdiv.maxdist, but it can check for distances of any number of points >= 0.

    `input_points` is an ordered collection of xy vertices.
    The first point and the last point define the segment we are finding distances from.

    does not mutate `input_points`
    """
    assert len(input_points) >= 3, "There must be points (other than begin/end) to check."

    points = [ffgeom.Point(point[0], point[1]) for point in input_points]
    segment = ffgeom.Segment(points.pop(0), points.pop())

    distances = [segment.distanceToPoint(point) for point in points]
    return max(distances)


def supersample(vertices, tolerance):
    """
    Given a list of vertices, remove some according to the following algorithm.

    Suppose that the vertex list consists of points A, B, C, D, E, and so forth,
    which define segments AB, BC, CD, DE, EF, and so on.

    We first test to see if vertex B can be removed, by using perpDistanceToPoint
    to check whether the distance between B and segment AC is less than tolerance.

    If B can be removed, then check to see if the next vertex, C, can be removed.
    Both B and C can be removed if the both the distance between B and AD is less
    than Tolerance and the distance between C and AD is less than Tolerance. Continue
    removing additional vertices, so long as the perpendicular distance between every
    point removed and the resulting segment is less than tolerance (and the end of the
    vertex list is not reached).

    If B cannot be removed, then move onto vertex C, and perform the same checks,
    until the end of the vertex list is reached.
    """
    if len(vertices) <= 2:  # there is nothing to delete
        return

    if tolerance <= 0:
        return

    start_index = 0  # can't remove first vertex
    while start_index < len(vertices) - 2:
        end_index = start_index + 2
        # test the removal of (start_index, end_index), exclusive until we can't advance end_index

        while (points_in_tolerance(vertices[start_index:end_index + 1], tolerance)
               and end_index < len(vertices)):
            end_index += 1  # try removing the next vertex too

        vertices[start_index + 1:end_index - 1] = []  # delete (start_index, end_index), exclusive
        start_index += 1


def userUnitToUnits(distance_uu, unit_string):
    """
    Custom replacement for the uutounit routine in inkex.py

    Parse the attribute into a value and associated units.
    Return value in user units (typically "px").
    """

    if distance_uu is None:  # Couldn't parse the value
        return None
    if unit_string in ('', 'px'):
        return float(distance_uu)
    if unit_string == 'in':
        return float(distance_uu) / PX_PER_INCH
    if unit_string == 'mm':
        return float(distance_uu) / (PX_PER_INCH / 25.4)
    if unit_string == 'cm':
        return float(distance_uu) / (PX_PER_INCH / 2.54)
    if unit_string in ('Q', 'q'):
        return float(distance_uu) / (PX_PER_INCH / (40.0 * 2.54))
    if unit_string == 'pc':
        return float(distance_uu) / (PX_PER_INCH / 6.0)
    if unit_string == 'pt':
        return float(distance_uu) / (PX_PER_INCH / 72.0)
    if unit_string == '%':
        return float(distance_uu) * 100.0
    # Unsupported units
    return None


def vb_scale(v_b, p_a_r, doc_width, doc_height):
    """"
    [Deprecated in favor of vb_scale_2()]
    Parse SVG viewbox and generate scaling parameters.
    Reference documentation: https://www.w3.org/TR/SVG11/coords.html

    Inputs:
        v_b:         Contents of SVG viewbox attribute
        p_a_r:      Contents of SVG preserveAspectRatio attribute
        doc_width:  Width of SVG document
        doc_height: Height of SVG document

    Output: s_x, s_y, o_x, o_y
        Scale parameters (s_x,s_y) and offset parameters (o_x,o_y)
        Returns default transform values (1, 1, 0, 0) in case of invalid viewbox.

    This function is _deprecated_ in favor of vb_scale_2(),
        which functions identically except that it returns None in the case of an
        invalid viewbox. Presently maintained for backward compatibility.
    """
    if v_b is None:
        return 1, 1, 0, 0  # No viewbox; return default transform
    vb_array = v_b.strip().replace(',', ' ').split()

    if len(vb_array) < 4:
        return 1, 1, 0, 0  # invalid viewbox; return default transform

    min_x = float(vb_array[0])  # viewbox offset: x
    min_y = float(vb_array[1])  # viewbox offset: y
    width = float(vb_array[2])  # viewbox width
    height = float(vb_array[3])  # viewbox height

    if width <= 0 or height <= 0:
        return 1, 1, 0, 0  # invalid viewbox; return default transform

    d_width = float(doc_width)
    d_height = float(doc_height)

    if d_width <= 0 or d_height <= 0:
        return 1, 1, 0, 0  # invalid document size; return default transform

    ar_doc = d_height / d_width  # Document aspect ratio
    ar_vb = height / width  # viewbox aspect ratio

    # Default values of the two preserveAspectRatio parameters:
    par_align = "xmidymid"  # "align" parameter (lowercased)
    par_mos = "meet"       # "meetOrSlice" parameter

    if p_a_r is not None:
        par_array = p_a_r.strip().replace(',', ' ').lower().split()
        if len(par_array) > 0:
            par0 = par_array[0]
            if par0 == "defer":
                if len(par_array) > 1:
                    par_align = par_array[1]
                    if len(par_array) > 2:
                        par_mos = par_array[2]
            else:
                par_align = par0
                if len(par_array) > 1:
                    par_mos = par_array[1]

    if par_align == "none":
        # Scale document to fill page. Do not preserve aspect ratio.
        # This is not default behavior, nor what happens if par_align
        # is not given; the "none" value must be _explicitly_ specified.

        s_x = d_width / width
        s_y = d_height / height
        o_x = -min_x
        o_y = -min_y
        return s_x, s_y, o_x, o_y

    # Other than "none", all situations fall into two classes:
    #
    # 1)   (ar_doc >= ar_vb AND par_mos == "meet")
    #        or  (ar_doc < ar_vb AND par_mos == "slice")
    #     -> In these cases, scale document up until viewbox fills doc in X.
    #
    # 2)   All other cases, i.e.,
    #     (ar_doc < ar_vb AND par_mos == "meet")
    #        or  (ar_doc >= ar_vb AND par_mos == "slice")
    #     -> In these cases, scale document up until viewbox fills doc in Y.
    #
    # Note in cases where the scaled viewbox exceeds the document
    # (page) boundaries (all "slice" cases and many "meet" cases where
    # an offset value is given) that this routine does not perform
    # any clipping, but subsequent clipping to the page boundary
    # is appropriate.
    #
    # Besides "none", there are 9 possible values of par_align:
    #     xminymin xmidymin xmaxymin
    #     xminymid xmidymid xmaxymid
    #     xminymax xmidymax xmaxymax

    if (((ar_doc >= ar_vb) and (par_mos == "meet"))
            or ((ar_doc < ar_vb) and (par_mos == "slice"))):
        # Case 1: Scale document up until viewbox fills doc in X.

        s_x = d_width / width
        s_y = s_x  # Uniform aspect ratio
        o_x = -min_x

        scaled_vb_height = ar_doc * width
        excess_height = scaled_vb_height - height

        if par_align in {"xminymin", "xmidymin", "xmaxymin"}:
            # Case: Y-Min: Align viewbox to minimum Y of the viewport.
            o_y = -min_y

        elif par_align in {"xminymax", "xmidymax", "xmaxymax"}:
            # Case: Y-Max: Align viewbox to maximum Y of the viewport.
            o_y = -min_y + excess_height

        else:  # par_align in {"xminymid", "xmidymid", "xmaxymid"}:
            # Default case: Y-Mid: Center viewbox on page in Y
            o_y = -min_y + excess_height / 2

        return s_x, s_y, o_x, o_y

    # Case 2: Scale document up until viewbox fills doc in Y.

    s_y = d_height / height
    s_x = s_y  # Uniform aspect ratio
    o_y = -min_y

    scaled_vb_width = height / ar_doc
    excess_width = scaled_vb_width - width

    if par_align in {"xminymin", "xminymid", "xminymax"}:
        o_x = -min_x  # Case: X-Min: Align viewbox to minimum X of the viewport.

    elif par_align in {"xmaxymin", "xmaxymid", "xmaxymax"}:
        o_x = -min_x + excess_width  # Case: X-Max: Align viewbox to maximum X of the viewport.

    else:  # par_align in {"xmidymin", "xmidymid", "xmidymax"}:
        o_x = -min_x + excess_width / 2  # Default case: X-Mid: Center viewbox on page in X

    return s_x, s_y, o_x, o_y
    # return 1, 1, 0, 0  # Catch-all: return default transform


def _validate_viewbox(viewbox_string, doc_width, doc_height):
    """
    Validate viewBox parameters and return parsed values or None if invalid.

    Returns:
        tuple (min_x, min_y, width, height, d_width, d_height) for valid viewBox
        None for invalid viewBox
    """
    if viewbox_string is None:
        return None

    try:
        vb_array = viewbox_string.strip().replace(',', ' ').split()
    except AttributeError:
        return None

    if len(vb_array) < 4:
        return None

    try:
        min_x = float(vb_array[0])
        min_y = float(vb_array[1])
        width = float(vb_array[2])
        height = float(vb_array[3])
    except (ValueError, IndexError):
        return None

    if width <= 0 or height <= 0:
        return None

    d_width = float(doc_width)
    d_height = float(doc_height)

    if d_width <= 0 or d_height <= 0:
        return None

    return min_x, min_y, width, height, d_width, d_height


def vb_scale_2(viewbox_string, preserve_aspect_ratio, doc_width, doc_height):
    """
    Enhanced version of vb_scale that returns None for invalid viewBox.

    Parse SVG viewbox and generate scaling parameters.
    Reference documentation: https://www.w3.org/TR/SVG11/coords.html

    Inputs:
        viewbox_string:      Contents of SVG viewbox attribute
        preserve_aspect_ratio: Contents of SVG preserveAspectRatio attribute
        doc_width:          Width of SVG document (in inches)
        doc_height:         Height of SVG document (in inches)

    Returns:
        tuple (s_x, s_y, o_x, o_y) for valid viewBox - scale and offset parameters
        None for invalid viewBox
    """
    validated = _validate_viewbox(viewbox_string, doc_width, doc_height)
    if validated is None:
        return None

    min_x, min_y, width, height, d_width, d_height = validated

    # Use the same logic as the original vb_scale function
    ar_doc = d_height / d_width  # Document aspect ratio
    ar_vb = height / width  # viewbox aspect ratio

    # Default values of the two preserveAspectRatio parameters:
    par_mos = "meet"  # meetOrSlice parameter can be "meet" or "slice"
    par_align = "xmidymid"  # alignment parameter

    if preserve_aspect_ratio is not None:
        p_a_r_string = preserve_aspect_ratio.strip()
        par_list = p_a_r_string.split()
        if len(par_list) > 0:
            # First parameter of preserveAspectRatio is the alignment parameter
            # Values like "none", "xMinYMin", "xMidYMin", etc. are valid values.
            par_align = par_list[0].lower()
        if len(par_list) > 1:
            # Second parameter of preserveAspectRatio is the meetOrSlice parameter
            # Values include "meet" and "slice"
            par_mos = par_list[1].lower()

    if par_align == "none":
        # Easy case -- no aspect ratio preservation needed
        s_x = d_width / width
        s_y = d_height / height
        o_x = -min_x
        o_y = -min_y
        return s_x, s_y, o_x, o_y

    # Preserve aspect ratio:
    # Apply scaling to align the viewbox to the viewport.

    if (((ar_doc >= ar_vb) and (par_mos == "meet"))
            or ((ar_doc < ar_vb) and (par_mos == "slice"))):
        # Case 1: Scale document up until viewbox fills doc in X.

        s_x = d_width / width
        s_y = s_x  # Uniform aspect ratio
        o_x = -min_x

        scaled_vb_height = ar_doc * width
        excess_height = scaled_vb_height - height

        if par_align in {"xminymin", "xmidymin", "xmaxymin"}:
            # Case: Y-Min: Align viewbox to minimum Y of the viewport.
            o_y = -min_y

        elif par_align in {"xminymax", "xmidymax", "xmaxymax"}:
            # Case: Y-Max: Align viewbox to maximum Y of the viewport.
            o_y = -min_y + excess_height

        else:  # par_align in {"xminymid", "xmidymid", "xmaxymid"}:
            # Default case: Y-Mid: Center viewbox on page in Y
            o_y = -min_y + excess_height / 2

        return s_x, s_y, o_x, o_y

    # Case 2: Scale document up until viewbox fills doc in Y.

    s_y = d_height / height
    s_x = s_y  # Uniform aspect ratio
    o_y = -min_y

    scaled_vb_width = height / ar_doc
    excess_width = scaled_vb_width - width

    if par_align in {"xminymin", "xminymid", "xminymax"}:
        o_x = -min_x  # Case: X-Min: Align viewbox to minimum X of the viewport.

    elif par_align in {"xmaxymin", "xmaxymid", "xmaxymax"}:
        o_x = -min_x + excess_width  # Case: X-Max: Align viewbox to maximum X of the viewport.

    else:  # par_align in {"xmidymin", "xmidymid", "xmidymax"}:
        o_x = -min_x + excess_width / 2  # Default case: X-Mid: Center viewbox on page in X

    return s_x, s_y, o_x, o_y


def points_equal(point_a, point_b):
    """
    Given two vertices point_a and point_b, each a 2-tuple,
    determine if the two points are close enough to be considered "equal"
    with a floating-point-friendly "fuzzy" comparison.
    """
    return isclose(point_a[0], point_b[0]) and isclose(point_a[1], point_b[1])


def points_near(point_a, point_b, squared_tolerance):
    """
    Given two vertices point_a and point_b, each a 2-tuple, return True if the two
    points are coincident to within a certain tolerance.

    Arguments:
        point_a, point_b:  Vertex (x,y), 2-tuples of floats
        squared_tolerance: Square of maximum allowed distance between vertices

    if (point_a.x - point_b.x)^2 + (point_a.y - point_b.y)^2 < tolerance^2,
        then return True.
    """
    delta_x = point_a[0] - point_b[0]
    delta_y = point_a[1] - point_b[1]

    return (delta_x * delta_x + delta_y * delta_y) < squared_tolerance


def square_dist(point_a, point_b):
    """
    Given two vertices point_a and point_b, each a 2-tuple,
    return the square of the distance between them.
    """
    delta_x = point_a[0] - point_b[0]
    delta_y = point_a[1] - point_b[1]
    return delta_x * delta_x + delta_y * delta_y


def vInitial_VF_A_Dx(v_final, acceleration, delta_x):
    """
    Kinematic calculation: Maximum allowed initial velocity to arrive at distance X
    with specified final velocity, and given maximum linear acceleration.

    Calculate and return the (real) initial velocity, given an final velocity,
        acceleration rate, and distance interval.

    Uses the kinematic equation Vi^2 = Vf^2 - 2 a D_x , where
            Vf is the final velocity,
            a is the acceleration rate,
            D_x (delta x) is the distance interval, and
            Vi is the initial velocity.

    We are looking at the positive root only-- if the argument of the sqrt
        is less than zero, return -1, to indicate a failure.
    """
    initial_v_squared = (v_final * v_final) - (2 * acceleration * delta_x)
    if initial_v_squared >= 0:
        return sqrt(initial_v_squared)
    return -1


def vFinal_Vi_A_Dx(v_initial, acceleration, delta_x):
    """
    Kinematic calculation: Final velocity with constant linear acceleration.

    Calculate and return the (real) final velocity, given an initial velocity,
        acceleration rate, and distance interval.

    Uses the kinematic equation Vf^2 = 2 a D_x + Vi^2, where
            Vf is the final velocity,
            a is the acceleration rate,
            D_x (delta x) is the distance interval, and
            Vi is the initial velocity.

    We are looking at the positive root only-- if the argument of the sqrt
        is less than zero, return -1, to indicate a failure.
    """
    final_v_squared = (2 * acceleration * delta_x) + (v_initial * v_initial)
    if final_v_squared >= 0:
        return sqrt(final_v_squared)
    return -1


def pathdata_first_point(path):
    """
    Return the first (X,Y) point from an SVG path data string

    Input:  A path data string; the text of the 'd' attribute of an SVG path
    Output: Two floats in a list representing the x and y coordinates of the first point
    """

    parsed_path = simplepath.parsePath(path)  # parsePath splits path into segments

    for command, params in parsed_path:
        if command == 'M':
            return [params[0], params[1]]

    # Properly constructed paths begin with a moveto ('M') command.
    return None


def pathdata_last_point(path):
    """
    Return the last (X,Y) point from an SVG path data string

    Input:  A path data string; the text of the 'd' attribute of an SVG path
    Output: Two floats in a list representing the x and y coordinates of the last point
    """
    parsed_path = simplepath.parsePath(path)  # parsePath splits path into segments
    command, params = parsed_path[-1]  # look at the last command to determine the last point

    if command.upper() == 'Z':
        # find the last move command, since Z moves to the start of the last subpath
        for command, params in reversed(parsed_path[:-1]):
            if command == 'M':
                return [params[0], params[1]]

        # paths must have at least one moveto, so we should never get here.
        return None

    # Otherwise: The last command should be in the set 'MLCQA'
    #     - All commands converted to absolute by parsePath.
    #     - Can ignore Z (case handled)
    #     - Can ignore H,V, since those are converted to L by parsePath.
    #     - Can ignore S, converted to C by parsePath.
    #     - Can ignore T, converted to Q by parsePath.
    #
    #     MLCQA: Commands all ending in (X,Y) pair.

    x_val = params[-2]  # Second to last parameter given
    y_val = params[-1]  # Last parameter given

    return [x_val, y_val]
