simetri.helpers.utilities

Simetri graphics library's utility functions.

   1"""Simetri graphics library's utility functions."""
   2
   3import collections
   4import os
   5import re
   6import base64
   7import cmath
   8from functools import wraps, reduce
   9from time import time, monotonic, perf_counter
  10from math import factorial, cos, sin, pi, atan2, sqrt
  11from pathlib import Path
  12from bisect import bisect_left
  13
  14from typing import Sequence
  15
  16from PIL import ImageFont
  17from numpy import array, ndarray
  18import numpy as np
  19from numpy import isclose
  20
  21from ..settings.settings import defaults
  22from ..graphics.common import get_defaults, Point, Line
  23
  24
  25def time_it(func):
  26    """Decorator to time a function"""
  27
  28    @wraps(func)
  29    def time_it_wrapper(*args, **kwargs):
  30        start_time = perf_counter()
  31        result = func(*args, **kwargs)
  32        end_time = perf_counter()
  33        total_time = end_time - start_time
  34        print(f"Function {func.__name__} Took {total_time:.6f} seconds")
  35        return result
  36
  37    return time_it_wrapper
  38
  39
  40def close_logger(logger):
  41    """Close the logger and remove all handlers.
  42
  43    Args:
  44        logger: The logger instance to close.
  45    """
  46    for handler in logger.handlers:
  47        handler.close()
  48        logger.removeHandler(handler)
  49
  50
  51def get_file_path_with_rev(directory, script_path, ext=".pdf"):
  52    """Get the file path with a revision number.
  53
  54    Args:
  55        directory: The directory to search for files.
  56        script_path: The script file path.
  57        ext: The file extension.
  58
  59    Returns:
  60        The file path with a revision number.
  61    """
  62
  63    # Get the file path of the script
  64    def get_rev_number(file_name):
  65        match = re.search(r"_\d+$", file_name)
  66        if match:
  67            rev = match.group()[1:]  # remove the underscore
  68            if rev is not None:
  69                return int(rev)
  70        return 0
  71
  72    # script_path = __file__
  73    filename = os.path.basename(script_path)
  74    filename, _ = os.path.splitext(filename)
  75    # check if the file is in the current directory
  76    files = os.listdir(directory)
  77    file_names = [
  78        os.path.splitext(item)[0]
  79        for item in files
  80        if os.path.isfile(os.path.join(directory, item))
  81    ]
  82    existing = [item for item in file_names if item.startswith(filename)]
  83    if not existing:
  84        return os.path.join(directory, filename + ext)
  85
  86    revs = [get_rev_number(file) for file in existing]
  87    if revs is None:
  88        rev = 1
  89    else:
  90        rev = max(revs) + 1
  91
  92    return os.path.join(directory, f"{filename}_{rev}" + ext)
  93
  94
  95def remove_file_handler(logger, handler):
  96    """Remove a handler from a logger.
  97
  98    Args:
  99        logger: The logger instance.
 100        handler: The handler to remove.
 101    """
 102    logger.removeHandler(handler)
 103    handler.close()
 104
 105
 106def pretty_print_coords(coords: Sequence[Point]) -> str:
 107    """Print the coordinates with a precision of 2.
 108
 109    Args:
 110        coords: A sequence of Point objects.
 111
 112    Returns:
 113        A string representation of the coordinates.
 114    """
 115    return (
 116        "(" + ", ".join([f"({coord[0]:.2f}, {coord[1]:.2f})" for coord in coords]) + ")"
 117    )
 118
 119
 120def is_file_empty(file_path):
 121    """Check if a file is empty.
 122
 123    Args:
 124        file_path: The path to the file.
 125
 126    Returns:
 127        True if the file is empty, False otherwise.
 128    """
 129    return os.path.getsize(file_path) == 0
 130
 131
 132def wait_for_file_availability(file_path, timeout=None, check_interval=1):
 133    """Check if a file is available for writing.
 134
 135    Args:
 136        file_path: The path to the file.
 137        timeout: The timeout period in seconds.
 138        check_interval: The interval to check the file availability.
 139
 140    Returns:
 141        True if the file is available, False otherwise.
 142    """
 143    start_time = monotonic()
 144    while True:
 145        try:
 146            # Attempt to open the file in write mode. This will raise an exception
 147            # if the file is currently locked or being written to.
 148            with open(file_path, "a", encoding="utf-8"):
 149                # If the file was successfully opened, it's available.
 150                return True
 151        except IOError:
 152            # The file is likely in use.
 153            if timeout is not None and (monotonic() - start_time) > timeout:
 154                # Timeout period elapsed.
 155                return False  # Or raise a TimeoutError if you prefer
 156            time.sleep(check_interval)
 157        except Exception as e:
 158            # Handle other potential exceptions (e.g., file not found) as needed
 159            print(f"An error occurred: {e}")
 160            return False
 161
 162
 163def detokenize(text: str) -> str:
 164    """Replace the special Latex characters with their Latex commands.
 165
 166    Args:
 167        text: The text to detokenize.
 168
 169    Returns:
 170        The detokenized text.
 171    """
 172    if text.startswith("$") and text.endswith("$"):
 173        res = text
 174    else:
 175        replacements = {
 176            "\\": r"\textbackslash ",
 177            "{": r"\{",
 178            "}": r"\}",
 179            "$": r"\$",
 180            "&": r"\&",
 181            "%": r"\%",
 182            "#": r"\#",
 183            "_": r"\_",
 184            "^": r"\^{}",
 185            "~": r"\textasciitilde{}",
 186        }
 187        for char, replacement in replacements.items():
 188            text = text.replace(char, replacement)
 189            res = text
 190
 191    return res
 192
 193
 194def get_text_dimensions(text, font_path, font_size):
 195    """Return the width and height of the text.
 196
 197    Args:
 198        text: The text to measure.
 199        font_path: The path to the font file.
 200        font_size: The size of the font.
 201
 202    Returns:
 203        A tuple containing the width and height of the text.
 204    """
 205    font = ImageFont.truetype(font_path, font_size)
 206    _, descent = font.getmetrics()
 207    text_width = font.getmask(text).getbbox()[2]
 208    text_height = font.getmask(text).getbbox()[3] + descent
 209    return text_width, text_height
 210
 211
 212def timing(func):
 213    """Print the execution time of a function.
 214
 215    Args:
 216        func: The function to time.
 217
 218    Returns:
 219        The wrapped function.
 220    """
 221
 222    @wraps(func)
 223    def wrap(*args, **kw):
 224        start_time = time()
 225        result = func(*args, **kw)
 226        end_time = time()
 227        elapsed_time = end_time - start_time
 228        print(f"function:{func.__name__} took: {elapsed_time:.4f} sec")
 229
 230        return result
 231
 232    return wrap
 233
 234
 235def find_nearest_value(values: array, value: float) -> float:
 236    """Find the closest value in an array to a given number.
 237
 238    Args:
 239        values: A NumPy array.
 240        value: The number to find the closest value to.
 241
 242    Returns:
 243        The closest value in the array to the given number.
 244    """
 245    arr = np.asarray(values)
 246    idx = (np.abs(arr - value)).argmin()
 247
 248    return arr[idx]
 249
 250
 251def nested_count(nested_sequence):
 252    """Return the total number of items in a nested sequence.
 253
 254    Args:
 255        nested_sequence: A nested sequence.
 256
 257    Returns:
 258        The total number of items in the nested sequence.
 259    """
 260    return sum(
 261        nested_count(item) if isinstance(item, (list, tuple, ndarray)) else 1
 262        for item in nested_sequence
 263    )
 264
 265
 266def decompose_transformations(transformation_matrix):
 267    """Decompose a 3x3 transformation matrix into translation, rotation, and scale components.
 268
 269    Args:
 270        transformation_matrix: A 3x3 transformation matrix.
 271
 272    Returns:
 273        A tuple containing the translation, rotation, and scale components.
 274    """
 275    xform = transformation_matrix
 276    translation = xform[2, :2]
 277    rotation = np.arctan2(xform[0, 1], xform[0, 0])
 278    scale = np.linalg.norm(xform[:2, 0]), np.linalg.norm(xform[:2, 1])
 279
 280    return translation, rotation, scale
 281
 282
 283def check_directory(dir_path):
 284    """Check if a directory is valid and writable.
 285
 286    Args:
 287        dir_path: The path to the directory.
 288
 289    Returns:
 290        A tuple containing a boolean indicating validity and an error message.
 291    """
 292    error_msg = []
 293
 294    def dir_exists():
 295        nonlocal error_msg
 296        parent_dir = os.path.dirname(dir_path)
 297        if not os.path.exists(parent_dir):
 298            error_msg.append("Error! Parent directory doesn't exist")
 299
 300    def is_writable():
 301        nonlocal error_msg
 302        parent_dir = os.path.dirname(dir_path)
 303        if not os.access(parent_dir, os.W_OK):
 304            error_msg.append("Error! Path is not writable.")
 305
 306    dir_exists()
 307    is_writable()
 308    if error_msg:
 309        res = False, "\n".join(error_msg)
 310    else:
 311        res = True, ""
 312
 313    return res
 314
 315
 316def analyze_path(file_path, overwrite):
 317    """Check if a file path is valid and writable.
 318
 319    Args:
 320        file_path: The path to the file.
 321        overwrite: Whether to overwrite the file if it exists.
 322
 323    Returns:
 324        A tuple containing a boolean indicating validity, the file extension, and an error message.
 325    """
 326    supported_types = (".pdf", ".svg", ".ps", ".eps", ".tex")
 327    error_msg = ""
 328
 329    def is_writable():
 330        nonlocal error_msg
 331        parent_dir = os.path.dirname(file_path)
 332        if os.access(parent_dir, os.W_OK):
 333            res = True
 334        else:
 335            error_msg = "Error! Path is not writable."
 336            res = False
 337
 338        return res
 339
 340    def is_supported():
 341        nonlocal error_msg
 342        extension = Path(file_path).suffix
 343        if extension in supported_types:
 344            res = True
 345        else:
 346            error_msg = f"Error! Only {', '.join(supported_types)} supported."
 347            res = False
 348
 349        return res
 350
 351    def can_overwrite(overwrite):
 352        nonlocal error_msg
 353        if os.path.exists(file_path):
 354            if overwrite is None:
 355                overwrite = defaults["overwrite_files"]
 356            if overwrite:
 357                res = True
 358            else:
 359                error_msg = (
 360                    "Error! File exists. Use canvas."
 361                    "save(f_path, overwrite=True) to overwrite."
 362                )
 363                res = False
 364        else:
 365            res = True
 366
 367        return res
 368
 369    try:
 370        file_path = os.path.abspath(file_path)
 371        if is_writable() and is_supported() and can_overwrite(overwrite):
 372            res = (True, "", Path(file_path).suffix)
 373        else:
 374            res = (False, error_msg, "")
 375
 376        return res
 377    except (
 378        Exception
 379    ) as e:  # Million other ways a file path is not valid but life is short!
 380        return False, f"Path Error! {e}", ""
 381
 382
 383def can_be_xform_matrix(seq):
 384    """Check if a sequence can be converted to a transformation matrix.
 385
 386    Args:
 387        seq: The sequence to check.
 388
 389    Returns:
 390        True if the sequence can be converted to a transformation matrix, False otherwise.
 391    """
 392    # check if it is a sequence that can be
 393    # converted to a transformation matrix
 394    try:
 395        arr = array(seq)
 396        return is_xform_matrix(arr)
 397    except Exception:
 398        return False
 399
 400
 401def is_sequence(value):
 402    """Check if a value is a sequence.
 403
 404    Args:
 405        value: The value to check.
 406
 407    Returns:
 408        True if the value is a sequence, False otherwise.
 409    """
 410    return isinstance(value, (list, tuple, array))
 411
 412
 413def rel_coord(dx: float, dy: float, center: Point) -> Point:
 414    """Return the relative coordinates.
 415
 416    Args:
 417        dx: The x-coordinate difference.
 418        dy: The y-coordinate difference.
 419        center: The center coordinates.
 420
 421    Returns:
 422        The relative coordinates.
 423    """
 424    return dx + center[0], dy + center[1]
 425
 426
 427def rel_polar(r: float, angle: float, center: Point) -> Point:
 428    """Return the coordinates.
 429
 430    Args:
 431        r: The radius.
 432        angle: The angle in radians.
 433        center: The center coordinates.
 434
 435    Returns:
 436        The coordinates.
 437    """
 438    x, y = center[:2]
 439    x1 = x + r * cos(angle)
 440    y1 = y + r * sin(angle)
 441
 442    return x1, y1
 443
 444
 445rc = rel_coord  # alias for rel_coord
 446rp = rel_polar  # alias for rel_polar
 447
 448def axis(angle: float, length: float = 10) -> Line:
 449    """Return a line [(x1, y1), (x2, y2)] with the given angle
 450    and length.
 451    Args:
 452        angle: The angle between the line and the x-axis, in radians.
 453        length: The length of the line.
 454
 455    Returns:
 456        A line represented as a tuple of two points.
 457    """
 458    length2 = length / 2
 459    x1 = cos(angle) * length2
 460    y1 = sin(angle) * length2
 461    x2 = -x1
 462    y2 = -y1
 463
 464    return (x1, y1), (x2, y2)
 465
 466
 467
 468
 469def flatten(points):
 470    """Flatten the points and return it as a list.
 471
 472    Args:
 473        points: A sequence of points.
 474
 475    Returns:
 476        A flattened list of points.
 477    """
 478    if isinstance(points, set):
 479        points = list(points)
 480    if isinstance(points, np.ndarray):
 481        flat = list(points[:, :2].flatten())
 482    elif isinstance(points, collections.abc.Sequence):
 483        if isinstance(points[0], collections.abc.Sequence):
 484            flat = list(reduce(lambda x, y: x + y, [list(pnt[:2]) for pnt in points]))
 485        else:
 486            flat = list(points)
 487    else:
 488        raise TypeError("Error! Invalid data type.")
 489
 490    return flat
 491
 492
 493def find_closest_value(a_sorted_list, value):
 494    """Return the index of the closest value and the value itself in a sorted list.
 495
 496    Args:
 497        a_sorted_list: A sorted list of values.
 498        value: The value to find the closest match for.
 499
 500    Returns:
 501        A tuple containing the closest value and its index.
 502    """
 503    ind = bisect_left(a_sorted_list, value)
 504
 505    if ind == 0:
 506        return a_sorted_list[0]
 507
 508    if ind == len(a_sorted_list):
 509        return a_sorted_list[-1]
 510
 511    left = a_sorted_list[ind - 1]
 512    right = a_sorted_list[ind]
 513
 514    if right - value < value - left:
 515        return right, ind
 516    else:
 517        return left, ind - 1
 518
 519
 520def value_from_intervals(value, values, intervals):
 521    """Return the value from the intervals.
 522    Args:
 523        value: The value to find.
 524        values: The values to search.
 525        intervals: The intervals to search.
 526    Returns:
 527        The value from the intervals.
 528    """
 529
 530    return values[bisect_left(intervals, value)]
 531
 532
 533def get_transform(transform):
 534    """Return the transformation matrix.
 535
 536    Args:
 537        transform: The transformation matrix or sequence.
 538
 539    Returns:
 540        The transformation matrix.
 541    """
 542    if transform is None:
 543        # return identity
 544        res = array([[1.0, 0, 0], [0, 1.0, 0], [0, 0, 1.0]])
 545    else:
 546        if is_xform_matrix(transform):
 547            res = transform
 548        elif can_be_xform_matrix(transform):
 549            res = array(transform)
 550        else:
 551            raise RuntimeError("Invalid transformation matrix!")
 552    return res
 553
 554
 555def is_numeric_numpy_array(array_):
 556    """Check if it is an array of numbers.
 557
 558    Args:
 559        array_: The array to check.
 560
 561    Returns:
 562        True if the array is numeric, False otherwise.
 563    """
 564    if not isinstance(array_, np.ndarray):
 565        return False
 566
 567    numeric_types = {
 568        "u",  # unsigned integer
 569        "i",  # signed integer
 570        "f",  # floating-point
 571        "c",
 572    }  # complex number
 573    try:
 574        return array_.dtype.kind in numeric_types
 575    except AttributeError:
 576        return False
 577
 578
 579def is_xform_matrix(matrix):
 580    """Check if it is a 3x3 transformation matrix.
 581
 582    Args:
 583        matrix: The matrix to check.
 584
 585    Returns:
 586        True if the matrix is a 3x3 transformation matrix, False otherwise.
 587    """
 588    return (
 589        is_numeric_numpy_array(matrix) and matrix.shape == (3, 3) and matrix.size == 9
 590    )
 591
 592
 593def prime_factors(n):
 594    """Prime factorization.
 595
 596    Args:
 597        n: The number to factorize.
 598
 599    Returns:
 600        A list of prime factors.
 601    """
 602    p = 2
 603    factors = []
 604    while n > 1:
 605        if n % p:
 606            p += 1
 607        else:
 608            factors.append(p)
 609            n = n / p
 610    return factors
 611
 612
 613def random_id():
 614    """Generate a random ID.
 615
 616    Returns:
 617        A random ID string.
 618    """
 619    return base64.b64encode(os.urandom(6)).decode("ascii")
 620
 621
 622def decompose_svg_transform(transform):
 623    """Decompose a SVG transformation string.
 624
 625    Args:
 626        transform: The SVG transformation string.
 627
 628    Returns:
 629        A tuple containing the decomposed transformation components.
 630    """
 631    a, b, c, d, e, f = transform
 632    # [[a, c, e],
 633    #  [b, d, f],
 634    #  [0, 0, 1]]
 635    dx = e
 636    dy = f
 637
 638    sx = np.sign(a) * sqrt(a**2 + c**2)
 639    sy = np.sign(d) * sqrt(b**2 + d**2)
 640
 641    angle = atan2(b, d)
 642
 643    return dx, dy, sx, sy, angle
 644
 645
 646def abcdef_svg(transform_matrix):
 647    """Return the a, b, c, d, e, f for SVG transformations.
 648
 649    Args:
 650        transform_matrix: A Numpy array representing the transformation matrix.
 651
 652    Returns:
 653        A tuple containing the a, b, c, d, e, f components.
 654    """
 655    # [[a, c, e],
 656    #  [b, d, f],
 657    #  [0, 0, 1]]
 658    a, b, _, c, d, _, e, f, _ = list(transform_matrix.flat)
 659    return (a, b, c, d, e, f)
 660
 661
 662def abcdef_pil(xform_matrix):
 663    """Return the a, b, c, d, e, f for PIL transformations.
 664
 665    Args:
 666        xform_matrix: A Numpy array representing the transformation matrix.
 667
 668    Returns:
 669        A tuple containing the a, b, c, d, e, f components.
 670    """
 671    a, d, _, b, e, _, c, f, _ = list(xform_matrix.flat)
 672    return (a, b, c, d, e, f)
 673
 674
 675def abcdef_reportlab(xform_matrix):
 676    """Return the a, b, c, d, e, f for Reportlab transformations.
 677
 678    Args:
 679        xform_matrix: A Numpy array representing the transformation matrix.
 680
 681    Returns:
 682        A tuple containing the a, b, c, d, e, f components.
 683    """
 684    # a, b, _, c, d, _, e, f, _ = list(np.transpose(xform_matrix).flat)
 685    a, b, _, c, d, _, e, f, _ = list(xform_matrix.flat)
 686    return (a, b, c, d, e, f)
 687
 688
 689def lerp(start, end, t):
 690    """Linear interpolation of two values.
 691
 692    Args:
 693        start: The start value.
 694        end: The end value.
 695        t: The interpolation factor (0 <= t <= 1).
 696
 697    Returns:
 698        The interpolated value.
 699    """
 700    return start + t * (end - start)
 701
 702
 703def inv_lerp(start, end, value):
 704    """Inverse linear interpolation of two values.
 705
 706    Args:
 707        start: The start value.
 708        end: The end value.
 709        value: The value to interpolate.
 710
 711    Returns:
 712        The interpolation factor (0 <= t <= 1).
 713    """
 714    return (value - start) / (end - start)
 715
 716
 717def sanitize_weighted_graph_edges(edges):
 718    """Sanitize weighted graph edges.
 719
 720    Args:
 721        edges: A list of weighted graph edges.
 722
 723    Returns:
 724        A sanitized list of weighted graph edges.
 725    """
 726    clean_edges = []
 727    s_seen = set()
 728    for edge in edges:
 729        e1, e2, _ = edge
 730        frozen_edge = frozenset((e1, e2))
 731        if frozen_edge in s_seen:
 732            continue
 733        s_seen.add(frozen_edge)
 734        clean_edges.append(edge)
 735    clean_edges.sort()
 736    return clean_edges
 737
 738
 739def sanitize_graph_edges(edges):
 740    """Sanitize graph edges.
 741
 742    Args:
 743        edges: A list of graph edges.
 744
 745    Returns:
 746        A sanitized list of graph edges.
 747    """
 748    s_edge_set = set()
 749    for edge in edges:
 750        s_edge_set.add(frozenset(edge))
 751    edges = [tuple(x) for x in s_edge_set]
 752    edges = [(min(x), max(x)) for x in edges]
 753    edges.sort()
 754    return edges
 755
 756
 757def flatten2(nested_list):
 758    """Flatten a nested list.
 759
 760    Args:
 761        nested_list: The nested list to flatten.
 762
 763    Yields:
 764        The flattened elements.
 765    """
 766    for i in nested_list:
 767        if isinstance(i, (list, tuple)):
 768            yield from flatten2(i)
 769        else:
 770            yield i
 771
 772
 773def round2(n: float, cutoff: int = 25) -> int:
 774    """Round a number to the nearest multiple of cutoff.
 775
 776    Args:
 777        n: The number to round.
 778        cutoff: The cutoff value.
 779
 780    Returns:
 781        The rounded number.
 782    """
 783    return cutoff * round(n / cutoff)
 784
 785
 786def is_nested_sequence(value):
 787    """Check if a value is a nested sequence.
 788
 789    Args:
 790        value: The value to check.
 791
 792    Returns:
 793        True if the value is a nested sequence, False otherwise.
 794    """
 795    if not isinstance(value, (list, tuple, ndarray)):
 796        return False  # Not a sequence
 797
 798    for item in value:
 799        if not isinstance(item, (list, tuple, ndarray)):
 800            return False  # At least one element is not a sequence
 801
 802    return True  # All elements are sequences
 803
 804
 805def group_into_bins(values, delta):
 806    """Group values into bins.
 807
 808    Args:
 809        values: A list of numbers.
 810        delta: The bin size.
 811
 812    Returns:
 813        A list of bins.
 814    """
 815    values.sort()
 816    bins = []
 817    bin_ = [values[0]]
 818    for value in values[1:]:
 819        if value[0] - bin_[0][0] <= delta:
 820            bin_.append(value)
 821        else:
 822            bins.append(bin_)
 823            bin_ = [value]
 824    bins.append(bin_)
 825    return bins
 826
 827
 828def equal_cycles(
 829    cycle1: list[float], cycle2: list[float], rtol=None, atol=None
 830) -> bool:
 831    """Check if two cycles are circularly equal.
 832
 833    Args:
 834        cycle1: The first cycle.
 835        cycle2: The second cycle.
 836        rtol: The relative tolerance.
 837        atol: The absolute tolerance.
 838
 839    Returns:
 840        True if the cycles are circularly equal, False otherwise.
 841    """
 842    rtol, atol = get_defaults(["rtol", "atol"], [rtol, atol])
 843
 844    def check_cycles(cyc1, cyc2, rtol=defaults["rtol"]):
 845        for i, val in enumerate(cyc1):
 846            if not isclose(val, cyc2[i], rtol=rtol, atol=atol):
 847                return False
 848        return True
 849
 850    len_cycle1 = len(cycle1)
 851    len_cycle2 = len(cycle2)
 852    if len_cycle1 != len_cycle2:
 853        return False
 854    cycle1 = cycle1[:]
 855    cycle1.extend(cycle1)
 856    for i in range(len_cycle1):
 857        if check_cycles(cycle2, cycle1[i : i + len_cycle2], rtol):
 858            return True
 859
 860    return False
 861
 862
 863def map_ranges(
 864    value: float,
 865    range1_min: float,
 866    range1_max: float,
 867    range2_min: float,
 868    range2_max: float,
 869) -> float:
 870    """Map a value from one range to another.
 871
 872    Args:
 873        value: The value to map.
 874        range1_min: The minimum of the first range.
 875        range1_max: The maximum of the first range.
 876        range2_min: The minimum of the second range.
 877        range2_max: The maximum of the second range.
 878
 879    Returns:
 880        The mapped value.
 881    """
 882    delta1 = range1_max - range1_min
 883    delta2 = range2_max - range2_min
 884    return (value - range1_min) / delta1 * delta2 + range2_min
 885
 886
 887def binomial(n, k):
 888    """Calculate the binomial coefficient.
 889
 890    Args:
 891        n: The number of trials.
 892        k: The number of successes.
 893
 894    Returns:
 895        The binomial coefficient.
 896    """
 897    if k == 0:
 898        res = 1
 899    else:
 900        res = factorial(n) / (factorial(k) * factorial(n - k))
 901    return res
 902
 903
 904def catalan(n):
 905    """Calculate the nth Catalan number.
 906
 907    Args:
 908        n: The index of the Catalan number.
 909
 910    Returns:
 911        The nth Catalan number.
 912    """
 913    if n <= 1:
 914        res = 1
 915    else:
 916        res = factorial(2 * n) / (factorial(n + 1) * factorial(n))
 917    return res
 918
 919
 920def reg_poly_points(pos: Point, n: int, r: float) -> Sequence[Point]:
 921    """Return a regular polygon points list with n sides, r radius, and pos center.
 922
 923    Args:
 924        pos: The center position of the polygon.
 925        n: The number of sides.
 926        r: The radius.
 927
 928    Returns:
 929        A sequence of points representing the polygon.
 930    """
 931    angle = 2 * pi / n
 932    x, y = pos[:2]
 933    points = [[cos(angle * i) * r + x, sin(angle * i) * r + y] for i in range(n)]
 934    points.append(points[0])
 935    return points
 936
 937
 938def solve_quadratic_eq(a, b, c, tolerance=1e-5):
 939    """Solves a quadratic equation of the form ax^2 + bx + c = 0."""
 940
 941    discr = b**2 - (4 * a * c)  # discriminant
 942
 943    if discr < 0:
 944        res = []
 945    elif isclose(discr, 0, rtol=0, atol=tolerance):
 946        # one solution
 947        res = [(-b + discr) / (2 * a)]
 948    else:
 949        a2 = a * 2
 950        sqrt_discr = sqrt(discr)
 951        x1 = (-b + sqrt_discr) / a2
 952        x2 = (-b - sqrt_discr) / a2
 953        res = [x1, x2]
 954
 955    return res
 956
 957
 958def solve_quartic_eq(a: float, b: float, c: float, d: float, e: float) -> list[float]:
 959    """
 960    Solves a quartic equation of the form ax^4 + bx^3 + cx^2 + dx + e = 0.
 961
 962    Args:
 963        a: The coefficient of x^4.
 964        b: The coefficient of x^3.
 965        c: The coefficient of x^2.
 966        d: The coefficient of x.
 967        e: The constant term.
 968
 969    Returns:
 970        A numpy array containing the four roots of the equation.
 971    """
 972
 973    return np.roots((a, b, c, d, e)).tolist()
 974
 975
 976def solve_complex_quadratic_eq(
 977    a: complex, b: complex, c: complex, tolerance: float = 1e-5
 978) -> list[complex]:
 979    """Solves a quadratic equation of the form ax^2 + bx + c = 0,
 980    where a, b, and c can be complex numbers.
 981
 982    Args:
 983        a: The complex coefficient of x^2.
 984        b: The complex coefficient of x.
 985        c: The complex constant term.
 986        tolerance: The tolerance for floating-point comparisons.
 987    """
 988    discr = (b**2) - 4 * (a * c)  # discriminant
 989    a2 = a * 2
 990    if isclose(discr, 0, rtol=0, atol=tolerance):
 991        x1 = -b / a2
 992        res = [x1]
 993    elif discr > 0:
 994        sqrt_discr = sqrt(discr)
 995        x1 = (-b - sqrt_discr) / a2
 996        x2 = (-b + sqrt_discr) / a2
 997        res = [x1, x2]
 998    else:
 999        sqrt_discr = cmath.sqrt(discr)
1000        x1 = (-b - sqrt_discr) / a2
1001        x2 = (-b + sqrt_discr) / a2
1002        res = [x1, x2]
1003
1004    return res
def time_it(func):
26def time_it(func):
27    """Decorator to time a function"""
28
29    @wraps(func)
30    def time_it_wrapper(*args, **kwargs):
31        start_time = perf_counter()
32        result = func(*args, **kwargs)
33        end_time = perf_counter()
34        total_time = end_time - start_time
35        print(f"Function {func.__name__} Took {total_time:.6f} seconds")
36        return result
37
38    return time_it_wrapper

Decorator to time a function

def close_logger(logger):
41def close_logger(logger):
42    """Close the logger and remove all handlers.
43
44    Args:
45        logger: The logger instance to close.
46    """
47    for handler in logger.handlers:
48        handler.close()
49        logger.removeHandler(handler)

Close the logger and remove all handlers.

Arguments:
  • logger: The logger instance to close.
def get_file_path_with_rev(directory, script_path, ext='.pdf'):
52def get_file_path_with_rev(directory, script_path, ext=".pdf"):
53    """Get the file path with a revision number.
54
55    Args:
56        directory: The directory to search for files.
57        script_path: The script file path.
58        ext: The file extension.
59
60    Returns:
61        The file path with a revision number.
62    """
63
64    # Get the file path of the script
65    def get_rev_number(file_name):
66        match = re.search(r"_\d+$", file_name)
67        if match:
68            rev = match.group()[1:]  # remove the underscore
69            if rev is not None:
70                return int(rev)
71        return 0
72
73    # script_path = __file__
74    filename = os.path.basename(script_path)
75    filename, _ = os.path.splitext(filename)
76    # check if the file is in the current directory
77    files = os.listdir(directory)
78    file_names = [
79        os.path.splitext(item)[0]
80        for item in files
81        if os.path.isfile(os.path.join(directory, item))
82    ]
83    existing = [item for item in file_names if item.startswith(filename)]
84    if not existing:
85        return os.path.join(directory, filename + ext)
86
87    revs = [get_rev_number(file) for file in existing]
88    if revs is None:
89        rev = 1
90    else:
91        rev = max(revs) + 1
92
93    return os.path.join(directory, f"{filename}_{rev}" + ext)

Get the file path with a revision number.

Arguments:
  • directory: The directory to search for files.
  • script_path: The script file path.
  • ext: The file extension.
Returns:

The file path with a revision number.

def remove_file_handler(logger, handler):
 96def remove_file_handler(logger, handler):
 97    """Remove a handler from a logger.
 98
 99    Args:
100        logger: The logger instance.
101        handler: The handler to remove.
102    """
103    logger.removeHandler(handler)
104    handler.close()

Remove a handler from a logger.

Arguments:
  • logger: The logger instance.
  • handler: The handler to remove.
def pretty_print_coords(coords: Sequence[Sequence[float]]) -> str:
107def pretty_print_coords(coords: Sequence[Point]) -> str:
108    """Print the coordinates with a precision of 2.
109
110    Args:
111        coords: A sequence of Point objects.
112
113    Returns:
114        A string representation of the coordinates.
115    """
116    return (
117        "(" + ", ".join([f"({coord[0]:.2f}, {coord[1]:.2f})" for coord in coords]) + ")"
118    )

Print the coordinates with a precision of 2.

Arguments:
  • coords: A sequence of Point objects.
Returns:

A string representation of the coordinates.

def is_file_empty(file_path):
121def is_file_empty(file_path):
122    """Check if a file is empty.
123
124    Args:
125        file_path: The path to the file.
126
127    Returns:
128        True if the file is empty, False otherwise.
129    """
130    return os.path.getsize(file_path) == 0

Check if a file is empty.

Arguments:
  • file_path: The path to the file.
Returns:

True if the file is empty, False otherwise.

def wait_for_file_availability(file_path, timeout=None, check_interval=1):
133def wait_for_file_availability(file_path, timeout=None, check_interval=1):
134    """Check if a file is available for writing.
135
136    Args:
137        file_path: The path to the file.
138        timeout: The timeout period in seconds.
139        check_interval: The interval to check the file availability.
140
141    Returns:
142        True if the file is available, False otherwise.
143    """
144    start_time = monotonic()
145    while True:
146        try:
147            # Attempt to open the file in write mode. This will raise an exception
148            # if the file is currently locked or being written to.
149            with open(file_path, "a", encoding="utf-8"):
150                # If the file was successfully opened, it's available.
151                return True
152        except IOError:
153            # The file is likely in use.
154            if timeout is not None and (monotonic() - start_time) > timeout:
155                # Timeout period elapsed.
156                return False  # Or raise a TimeoutError if you prefer
157            time.sleep(check_interval)
158        except Exception as e:
159            # Handle other potential exceptions (e.g., file not found) as needed
160            print(f"An error occurred: {e}")
161            return False

Check if a file is available for writing.

Arguments:
  • file_path: The path to the file.
  • timeout: The timeout period in seconds.
  • check_interval: The interval to check the file availability.
Returns:

True if the file is available, False otherwise.

def detokenize(text: str) -> str:
164def detokenize(text: str) -> str:
165    """Replace the special Latex characters with their Latex commands.
166
167    Args:
168        text: The text to detokenize.
169
170    Returns:
171        The detokenized text.
172    """
173    if text.startswith("$") and text.endswith("$"):
174        res = text
175    else:
176        replacements = {
177            "\\": r"\textbackslash ",
178            "{": r"\{",
179            "}": r"\}",
180            "$": r"\$",
181            "&": r"\&",
182            "%": r"\%",
183            "#": r"\#",
184            "_": r"\_",
185            "^": r"\^{}",
186            "~": r"\textasciitilde{}",
187        }
188        for char, replacement in replacements.items():
189            text = text.replace(char, replacement)
190            res = text
191
192    return res

Replace the special Latex characters with their Latex commands.

Arguments:
  • text: The text to detokenize.
Returns:

The detokenized text.

def get_text_dimensions(text, font_path, font_size):
195def get_text_dimensions(text, font_path, font_size):
196    """Return the width and height of the text.
197
198    Args:
199        text: The text to measure.
200        font_path: The path to the font file.
201        font_size: The size of the font.
202
203    Returns:
204        A tuple containing the width and height of the text.
205    """
206    font = ImageFont.truetype(font_path, font_size)
207    _, descent = font.getmetrics()
208    text_width = font.getmask(text).getbbox()[2]
209    text_height = font.getmask(text).getbbox()[3] + descent
210    return text_width, text_height

Return the width and height of the text.

Arguments:
  • text: The text to measure.
  • font_path: The path to the font file.
  • font_size: The size of the font.
Returns:

A tuple containing the width and height of the text.

def timing(func):
213def timing(func):
214    """Print the execution time of a function.
215
216    Args:
217        func: The function to time.
218
219    Returns:
220        The wrapped function.
221    """
222
223    @wraps(func)
224    def wrap(*args, **kw):
225        start_time = time()
226        result = func(*args, **kw)
227        end_time = time()
228        elapsed_time = end_time - start_time
229        print(f"function:{func.__name__} took: {elapsed_time:.4f} sec")
230
231        return result
232
233    return wrap

Print the execution time of a function.

Arguments:
  • func: The function to time.
Returns:

The wrapped function.

def find_nearest_value(values: <built-in function array>, value: float) -> float:
236def find_nearest_value(values: array, value: float) -> float:
237    """Find the closest value in an array to a given number.
238
239    Args:
240        values: A NumPy array.
241        value: The number to find the closest value to.
242
243    Returns:
244        The closest value in the array to the given number.
245    """
246    arr = np.asarray(values)
247    idx = (np.abs(arr - value)).argmin()
248
249    return arr[idx]

Find the closest value in an array to a given number.

Arguments:
  • values: A NumPy array.
  • value: The number to find the closest value to.
Returns:

The closest value in the array to the given number.

def nested_count(nested_sequence):
252def nested_count(nested_sequence):
253    """Return the total number of items in a nested sequence.
254
255    Args:
256        nested_sequence: A nested sequence.
257
258    Returns:
259        The total number of items in the nested sequence.
260    """
261    return sum(
262        nested_count(item) if isinstance(item, (list, tuple, ndarray)) else 1
263        for item in nested_sequence
264    )

Return the total number of items in a nested sequence.

Arguments:
  • nested_sequence: A nested sequence.
Returns:

The total number of items in the nested sequence.

def decompose_transformations(transformation_matrix):
267def decompose_transformations(transformation_matrix):
268    """Decompose a 3x3 transformation matrix into translation, rotation, and scale components.
269
270    Args:
271        transformation_matrix: A 3x3 transformation matrix.
272
273    Returns:
274        A tuple containing the translation, rotation, and scale components.
275    """
276    xform = transformation_matrix
277    translation = xform[2, :2]
278    rotation = np.arctan2(xform[0, 1], xform[0, 0])
279    scale = np.linalg.norm(xform[:2, 0]), np.linalg.norm(xform[:2, 1])
280
281    return translation, rotation, scale

Decompose a 3x3 transformation matrix into translation, rotation, and scale components.

Arguments:
  • transformation_matrix: A 3x3 transformation matrix.
Returns:

A tuple containing the translation, rotation, and scale components.

def check_directory(dir_path):
284def check_directory(dir_path):
285    """Check if a directory is valid and writable.
286
287    Args:
288        dir_path: The path to the directory.
289
290    Returns:
291        A tuple containing a boolean indicating validity and an error message.
292    """
293    error_msg = []
294
295    def dir_exists():
296        nonlocal error_msg
297        parent_dir = os.path.dirname(dir_path)
298        if not os.path.exists(parent_dir):
299            error_msg.append("Error! Parent directory doesn't exist")
300
301    def is_writable():
302        nonlocal error_msg
303        parent_dir = os.path.dirname(dir_path)
304        if not os.access(parent_dir, os.W_OK):
305            error_msg.append("Error! Path is not writable.")
306
307    dir_exists()
308    is_writable()
309    if error_msg:
310        res = False, "\n".join(error_msg)
311    else:
312        res = True, ""
313
314    return res

Check if a directory is valid and writable.

Arguments:
  • dir_path: The path to the directory.
Returns:

A tuple containing a boolean indicating validity and an error message.

def analyze_path(file_path, overwrite):
317def analyze_path(file_path, overwrite):
318    """Check if a file path is valid and writable.
319
320    Args:
321        file_path: The path to the file.
322        overwrite: Whether to overwrite the file if it exists.
323
324    Returns:
325        A tuple containing a boolean indicating validity, the file extension, and an error message.
326    """
327    supported_types = (".pdf", ".svg", ".ps", ".eps", ".tex")
328    error_msg = ""
329
330    def is_writable():
331        nonlocal error_msg
332        parent_dir = os.path.dirname(file_path)
333        if os.access(parent_dir, os.W_OK):
334            res = True
335        else:
336            error_msg = "Error! Path is not writable."
337            res = False
338
339        return res
340
341    def is_supported():
342        nonlocal error_msg
343        extension = Path(file_path).suffix
344        if extension in supported_types:
345            res = True
346        else:
347            error_msg = f"Error! Only {', '.join(supported_types)} supported."
348            res = False
349
350        return res
351
352    def can_overwrite(overwrite):
353        nonlocal error_msg
354        if os.path.exists(file_path):
355            if overwrite is None:
356                overwrite = defaults["overwrite_files"]
357            if overwrite:
358                res = True
359            else:
360                error_msg = (
361                    "Error! File exists. Use canvas."
362                    "save(f_path, overwrite=True) to overwrite."
363                )
364                res = False
365        else:
366            res = True
367
368        return res
369
370    try:
371        file_path = os.path.abspath(file_path)
372        if is_writable() and is_supported() and can_overwrite(overwrite):
373            res = (True, "", Path(file_path).suffix)
374        else:
375            res = (False, error_msg, "")
376
377        return res
378    except (
379        Exception
380    ) as e:  # Million other ways a file path is not valid but life is short!
381        return False, f"Path Error! {e}", ""

Check if a file path is valid and writable.

Arguments:
  • file_path: The path to the file.
  • overwrite: Whether to overwrite the file if it exists.
Returns:

A tuple containing a boolean indicating validity, the file extension, and an error message.

def can_be_xform_matrix(seq):
384def can_be_xform_matrix(seq):
385    """Check if a sequence can be converted to a transformation matrix.
386
387    Args:
388        seq: The sequence to check.
389
390    Returns:
391        True if the sequence can be converted to a transformation matrix, False otherwise.
392    """
393    # check if it is a sequence that can be
394    # converted to a transformation matrix
395    try:
396        arr = array(seq)
397        return is_xform_matrix(arr)
398    except Exception:
399        return False

Check if a sequence can be converted to a transformation matrix.

Arguments:
  • seq: The sequence to check.
Returns:

True if the sequence can be converted to a transformation matrix, False otherwise.

def is_sequence(value):
402def is_sequence(value):
403    """Check if a value is a sequence.
404
405    Args:
406        value: The value to check.
407
408    Returns:
409        True if the value is a sequence, False otherwise.
410    """
411    return isinstance(value, (list, tuple, array))

Check if a value is a sequence.

Arguments:
  • value: The value to check.
Returns:

True if the value is a sequence, False otherwise.

def rel_coord(dx: float, dy: float, center: Sequence[float]) -> Sequence[float]:
414def rel_coord(dx: float, dy: float, center: Point) -> Point:
415    """Return the relative coordinates.
416
417    Args:
418        dx: The x-coordinate difference.
419        dy: The y-coordinate difference.
420        center: The center coordinates.
421
422    Returns:
423        The relative coordinates.
424    """
425    return dx + center[0], dy + center[1]

Return the relative coordinates.

Arguments:
  • dx: The x-coordinate difference.
  • dy: The y-coordinate difference.
  • center: The center coordinates.
Returns:

The relative coordinates.

def rel_polar(r: float, angle: float, center: Sequence[float]) -> Sequence[float]:
428def rel_polar(r: float, angle: float, center: Point) -> Point:
429    """Return the coordinates.
430
431    Args:
432        r: The radius.
433        angle: The angle in radians.
434        center: The center coordinates.
435
436    Returns:
437        The coordinates.
438    """
439    x, y = center[:2]
440    x1 = x + r * cos(angle)
441    y1 = y + r * sin(angle)
442
443    return x1, y1

Return the coordinates.

Arguments:
  • r: The radius.
  • angle: The angle in radians.
  • center: The center coordinates.
Returns:

The coordinates.

def rc(dx: float, dy: float, center: Sequence[float]) -> Sequence[float]:
414def rel_coord(dx: float, dy: float, center: Point) -> Point:
415    """Return the relative coordinates.
416
417    Args:
418        dx: The x-coordinate difference.
419        dy: The y-coordinate difference.
420        center: The center coordinates.
421
422    Returns:
423        The relative coordinates.
424    """
425    return dx + center[0], dy + center[1]

Return the relative coordinates.

Arguments:
  • dx: The x-coordinate difference.
  • dy: The y-coordinate difference.
  • center: The center coordinates.
Returns:

The relative coordinates.

def rp(r: float, angle: float, center: Sequence[float]) -> Sequence[float]:
428def rel_polar(r: float, angle: float, center: Point) -> Point:
429    """Return the coordinates.
430
431    Args:
432        r: The radius.
433        angle: The angle in radians.
434        center: The center coordinates.
435
436    Returns:
437        The coordinates.
438    """
439    x, y = center[:2]
440    x1 = x + r * cos(angle)
441    y1 = y + r * sin(angle)
442
443    return x1, y1

Return the coordinates.

Arguments:
  • r: The radius.
  • angle: The angle in radians.
  • center: The center coordinates.
Returns:

The coordinates.

def axis(angle: float, length: float = 10) -> Sequence[Sequence]:
449def axis(angle: float, length: float = 10) -> Line:
450    """Return a line [(x1, y1), (x2, y2)] with the given angle
451    and length.
452    Args:
453        angle: The angle between the line and the x-axis, in radians.
454        length: The length of the line.
455
456    Returns:
457        A line represented as a tuple of two points.
458    """
459    length2 = length / 2
460    x1 = cos(angle) * length2
461    y1 = sin(angle) * length2
462    x2 = -x1
463    y2 = -y1
464
465    return (x1, y1), (x2, y2)

Return a line [(x1, y1), (x2, y2)] with the given angle and length.

Arguments:
  • angle: The angle between the line and the x-axis, in radians.
  • length: The length of the line.
Returns:

A line represented as a tuple of two points.

def flatten(points):
470def flatten(points):
471    """Flatten the points and return it as a list.
472
473    Args:
474        points: A sequence of points.
475
476    Returns:
477        A flattened list of points.
478    """
479    if isinstance(points, set):
480        points = list(points)
481    if isinstance(points, np.ndarray):
482        flat = list(points[:, :2].flatten())
483    elif isinstance(points, collections.abc.Sequence):
484        if isinstance(points[0], collections.abc.Sequence):
485            flat = list(reduce(lambda x, y: x + y, [list(pnt[:2]) for pnt in points]))
486        else:
487            flat = list(points)
488    else:
489        raise TypeError("Error! Invalid data type.")
490
491    return flat

Flatten the points and return it as a list.

Arguments:
  • points: A sequence of points.
Returns:

A flattened list of points.

def find_closest_value(a_sorted_list, value):
494def find_closest_value(a_sorted_list, value):
495    """Return the index of the closest value and the value itself in a sorted list.
496
497    Args:
498        a_sorted_list: A sorted list of values.
499        value: The value to find the closest match for.
500
501    Returns:
502        A tuple containing the closest value and its index.
503    """
504    ind = bisect_left(a_sorted_list, value)
505
506    if ind == 0:
507        return a_sorted_list[0]
508
509    if ind == len(a_sorted_list):
510        return a_sorted_list[-1]
511
512    left = a_sorted_list[ind - 1]
513    right = a_sorted_list[ind]
514
515    if right - value < value - left:
516        return right, ind
517    else:
518        return left, ind - 1

Return the index of the closest value and the value itself in a sorted list.

Arguments:
  • a_sorted_list: A sorted list of values.
  • value: The value to find the closest match for.
Returns:

A tuple containing the closest value and its index.

def value_from_intervals(value, values, intervals):
521def value_from_intervals(value, values, intervals):
522    """Return the value from the intervals.
523    Args:
524        value: The value to find.
525        values: The values to search.
526        intervals: The intervals to search.
527    Returns:
528        The value from the intervals.
529    """
530
531    return values[bisect_left(intervals, value)]

Return the value from the intervals.

Arguments:
  • value: The value to find.
  • values: The values to search.
  • intervals: The intervals to search.
Returns:

The value from the intervals.

def get_transform(transform):
534def get_transform(transform):
535    """Return the transformation matrix.
536
537    Args:
538        transform: The transformation matrix or sequence.
539
540    Returns:
541        The transformation matrix.
542    """
543    if transform is None:
544        # return identity
545        res = array([[1.0, 0, 0], [0, 1.0, 0], [0, 0, 1.0]])
546    else:
547        if is_xform_matrix(transform):
548            res = transform
549        elif can_be_xform_matrix(transform):
550            res = array(transform)
551        else:
552            raise RuntimeError("Invalid transformation matrix!")
553    return res

Return the transformation matrix.

Arguments:
  • transform: The transformation matrix or sequence.
Returns:

The transformation matrix.

def is_numeric_numpy_array(array_):
556def is_numeric_numpy_array(array_):
557    """Check if it is an array of numbers.
558
559    Args:
560        array_: The array to check.
561
562    Returns:
563        True if the array is numeric, False otherwise.
564    """
565    if not isinstance(array_, np.ndarray):
566        return False
567
568    numeric_types = {
569        "u",  # unsigned integer
570        "i",  # signed integer
571        "f",  # floating-point
572        "c",
573    }  # complex number
574    try:
575        return array_.dtype.kind in numeric_types
576    except AttributeError:
577        return False

Check if it is an array of numbers.

Arguments:
  • array_: The array to check.
Returns:

True if the array is numeric, False otherwise.

def is_xform_matrix(matrix):
580def is_xform_matrix(matrix):
581    """Check if it is a 3x3 transformation matrix.
582
583    Args:
584        matrix: The matrix to check.
585
586    Returns:
587        True if the matrix is a 3x3 transformation matrix, False otherwise.
588    """
589    return (
590        is_numeric_numpy_array(matrix) and matrix.shape == (3, 3) and matrix.size == 9
591    )

Check if it is a 3x3 transformation matrix.

Arguments:
  • matrix: The matrix to check.
Returns:

True if the matrix is a 3x3 transformation matrix, False otherwise.

def prime_factors(n):
594def prime_factors(n):
595    """Prime factorization.
596
597    Args:
598        n: The number to factorize.
599
600    Returns:
601        A list of prime factors.
602    """
603    p = 2
604    factors = []
605    while n > 1:
606        if n % p:
607            p += 1
608        else:
609            factors.append(p)
610            n = n / p
611    return factors

Prime factorization.

Arguments:
  • n: The number to factorize.
Returns:

A list of prime factors.

def random_id():
614def random_id():
615    """Generate a random ID.
616
617    Returns:
618        A random ID string.
619    """
620    return base64.b64encode(os.urandom(6)).decode("ascii")

Generate a random ID.

Returns:

A random ID string.

def decompose_svg_transform(transform):
623def decompose_svg_transform(transform):
624    """Decompose a SVG transformation string.
625
626    Args:
627        transform: The SVG transformation string.
628
629    Returns:
630        A tuple containing the decomposed transformation components.
631    """
632    a, b, c, d, e, f = transform
633    # [[a, c, e],
634    #  [b, d, f],
635    #  [0, 0, 1]]
636    dx = e
637    dy = f
638
639    sx = np.sign(a) * sqrt(a**2 + c**2)
640    sy = np.sign(d) * sqrt(b**2 + d**2)
641
642    angle = atan2(b, d)
643
644    return dx, dy, sx, sy, angle

Decompose a SVG transformation string.

Arguments:
  • transform: The SVG transformation string.
Returns:

A tuple containing the decomposed transformation components.

def abcdef_svg(transform_matrix):
647def abcdef_svg(transform_matrix):
648    """Return the a, b, c, d, e, f for SVG transformations.
649
650    Args:
651        transform_matrix: A Numpy array representing the transformation matrix.
652
653    Returns:
654        A tuple containing the a, b, c, d, e, f components.
655    """
656    # [[a, c, e],
657    #  [b, d, f],
658    #  [0, 0, 1]]
659    a, b, _, c, d, _, e, f, _ = list(transform_matrix.flat)
660    return (a, b, c, d, e, f)

Return the a, b, c, d, e, f for SVG transformations.

Arguments:
  • transform_matrix: A Numpy array representing the transformation matrix.
Returns:

A tuple containing the a, b, c, d, e, f components.

def abcdef_pil(xform_matrix):
663def abcdef_pil(xform_matrix):
664    """Return the a, b, c, d, e, f for PIL transformations.
665
666    Args:
667        xform_matrix: A Numpy array representing the transformation matrix.
668
669    Returns:
670        A tuple containing the a, b, c, d, e, f components.
671    """
672    a, d, _, b, e, _, c, f, _ = list(xform_matrix.flat)
673    return (a, b, c, d, e, f)

Return the a, b, c, d, e, f for PIL transformations.

Arguments:
  • xform_matrix: A Numpy array representing the transformation matrix.
Returns:

A tuple containing the a, b, c, d, e, f components.

def abcdef_reportlab(xform_matrix):
676def abcdef_reportlab(xform_matrix):
677    """Return the a, b, c, d, e, f for Reportlab transformations.
678
679    Args:
680        xform_matrix: A Numpy array representing the transformation matrix.
681
682    Returns:
683        A tuple containing the a, b, c, d, e, f components.
684    """
685    # a, b, _, c, d, _, e, f, _ = list(np.transpose(xform_matrix).flat)
686    a, b, _, c, d, _, e, f, _ = list(xform_matrix.flat)
687    return (a, b, c, d, e, f)

Return the a, b, c, d, e, f for Reportlab transformations.

Arguments:
  • xform_matrix: A Numpy array representing the transformation matrix.
Returns:

A tuple containing the a, b, c, d, e, f components.

def lerp(start, end, t):
690def lerp(start, end, t):
691    """Linear interpolation of two values.
692
693    Args:
694        start: The start value.
695        end: The end value.
696        t: The interpolation factor (0 <= t <= 1).
697
698    Returns:
699        The interpolated value.
700    """
701    return start + t * (end - start)

Linear interpolation of two values.

Arguments:
  • start: The start value.
  • end: The end value.
  • t: The interpolation factor (0 <= t <= 1).
Returns:

The interpolated value.

def inv_lerp(start, end, value):
704def inv_lerp(start, end, value):
705    """Inverse linear interpolation of two values.
706
707    Args:
708        start: The start value.
709        end: The end value.
710        value: The value to interpolate.
711
712    Returns:
713        The interpolation factor (0 <= t <= 1).
714    """
715    return (value - start) / (end - start)

Inverse linear interpolation of two values.

Arguments:
  • start: The start value.
  • end: The end value.
  • value: The value to interpolate.
Returns:

The interpolation factor (0 <= t <= 1).

def sanitize_weighted_graph_edges(edges):
718def sanitize_weighted_graph_edges(edges):
719    """Sanitize weighted graph edges.
720
721    Args:
722        edges: A list of weighted graph edges.
723
724    Returns:
725        A sanitized list of weighted graph edges.
726    """
727    clean_edges = []
728    s_seen = set()
729    for edge in edges:
730        e1, e2, _ = edge
731        frozen_edge = frozenset((e1, e2))
732        if frozen_edge in s_seen:
733            continue
734        s_seen.add(frozen_edge)
735        clean_edges.append(edge)
736    clean_edges.sort()
737    return clean_edges

Sanitize weighted graph edges.

Arguments:
  • edges: A list of weighted graph edges.
Returns:

A sanitized list of weighted graph edges.

def sanitize_graph_edges(edges):
740def sanitize_graph_edges(edges):
741    """Sanitize graph edges.
742
743    Args:
744        edges: A list of graph edges.
745
746    Returns:
747        A sanitized list of graph edges.
748    """
749    s_edge_set = set()
750    for edge in edges:
751        s_edge_set.add(frozenset(edge))
752    edges = [tuple(x) for x in s_edge_set]
753    edges = [(min(x), max(x)) for x in edges]
754    edges.sort()
755    return edges

Sanitize graph edges.

Arguments:
  • edges: A list of graph edges.
Returns:

A sanitized list of graph edges.

def flatten2(nested_list):
758def flatten2(nested_list):
759    """Flatten a nested list.
760
761    Args:
762        nested_list: The nested list to flatten.
763
764    Yields:
765        The flattened elements.
766    """
767    for i in nested_list:
768        if isinstance(i, (list, tuple)):
769            yield from flatten2(i)
770        else:
771            yield i

Flatten a nested list.

Arguments:
  • nested_list: The nested list to flatten.
Yields:

The flattened elements.

def round2(n: float, cutoff: int = 25) -> int:
774def round2(n: float, cutoff: int = 25) -> int:
775    """Round a number to the nearest multiple of cutoff.
776
777    Args:
778        n: The number to round.
779        cutoff: The cutoff value.
780
781    Returns:
782        The rounded number.
783    """
784    return cutoff * round(n / cutoff)

Round a number to the nearest multiple of cutoff.

Arguments:
  • n: The number to round.
  • cutoff: The cutoff value.
Returns:

The rounded number.

def is_nested_sequence(value):
787def is_nested_sequence(value):
788    """Check if a value is a nested sequence.
789
790    Args:
791        value: The value to check.
792
793    Returns:
794        True if the value is a nested sequence, False otherwise.
795    """
796    if not isinstance(value, (list, tuple, ndarray)):
797        return False  # Not a sequence
798
799    for item in value:
800        if not isinstance(item, (list, tuple, ndarray)):
801            return False  # At least one element is not a sequence
802
803    return True  # All elements are sequences

Check if a value is a nested sequence.

Arguments:
  • value: The value to check.
Returns:

True if the value is a nested sequence, False otherwise.

def group_into_bins(values, delta):
806def group_into_bins(values, delta):
807    """Group values into bins.
808
809    Args:
810        values: A list of numbers.
811        delta: The bin size.
812
813    Returns:
814        A list of bins.
815    """
816    values.sort()
817    bins = []
818    bin_ = [values[0]]
819    for value in values[1:]:
820        if value[0] - bin_[0][0] <= delta:
821            bin_.append(value)
822        else:
823            bins.append(bin_)
824            bin_ = [value]
825    bins.append(bin_)
826    return bins

Group values into bins.

Arguments:
  • values: A list of numbers.
  • delta: The bin size.
Returns:

A list of bins.

def equal_cycles(cycle1: list[float], cycle2: list[float], rtol=None, atol=None) -> bool:
829def equal_cycles(
830    cycle1: list[float], cycle2: list[float], rtol=None, atol=None
831) -> bool:
832    """Check if two cycles are circularly equal.
833
834    Args:
835        cycle1: The first cycle.
836        cycle2: The second cycle.
837        rtol: The relative tolerance.
838        atol: The absolute tolerance.
839
840    Returns:
841        True if the cycles are circularly equal, False otherwise.
842    """
843    rtol, atol = get_defaults(["rtol", "atol"], [rtol, atol])
844
845    def check_cycles(cyc1, cyc2, rtol=defaults["rtol"]):
846        for i, val in enumerate(cyc1):
847            if not isclose(val, cyc2[i], rtol=rtol, atol=atol):
848                return False
849        return True
850
851    len_cycle1 = len(cycle1)
852    len_cycle2 = len(cycle2)
853    if len_cycle1 != len_cycle2:
854        return False
855    cycle1 = cycle1[:]
856    cycle1.extend(cycle1)
857    for i in range(len_cycle1):
858        if check_cycles(cycle2, cycle1[i : i + len_cycle2], rtol):
859            return True
860
861    return False

Check if two cycles are circularly equal.

Arguments:
  • cycle1: The first cycle.
  • cycle2: The second cycle.
  • rtol: The relative tolerance.
  • atol: The absolute tolerance.
Returns:

True if the cycles are circularly equal, False otherwise.

def map_ranges( value: float, range1_min: float, range1_max: float, range2_min: float, range2_max: float) -> float:
864def map_ranges(
865    value: float,
866    range1_min: float,
867    range1_max: float,
868    range2_min: float,
869    range2_max: float,
870) -> float:
871    """Map a value from one range to another.
872
873    Args:
874        value: The value to map.
875        range1_min: The minimum of the first range.
876        range1_max: The maximum of the first range.
877        range2_min: The minimum of the second range.
878        range2_max: The maximum of the second range.
879
880    Returns:
881        The mapped value.
882    """
883    delta1 = range1_max - range1_min
884    delta2 = range2_max - range2_min
885    return (value - range1_min) / delta1 * delta2 + range2_min

Map a value from one range to another.

Arguments:
  • value: The value to map.
  • range1_min: The minimum of the first range.
  • range1_max: The maximum of the first range.
  • range2_min: The minimum of the second range.
  • range2_max: The maximum of the second range.
Returns:

The mapped value.

def binomial(n, k):
888def binomial(n, k):
889    """Calculate the binomial coefficient.
890
891    Args:
892        n: The number of trials.
893        k: The number of successes.
894
895    Returns:
896        The binomial coefficient.
897    """
898    if k == 0:
899        res = 1
900    else:
901        res = factorial(n) / (factorial(k) * factorial(n - k))
902    return res

Calculate the binomial coefficient.

Arguments:
  • n: The number of trials.
  • k: The number of successes.
Returns:

The binomial coefficient.

def catalan(n):
905def catalan(n):
906    """Calculate the nth Catalan number.
907
908    Args:
909        n: The index of the Catalan number.
910
911    Returns:
912        The nth Catalan number.
913    """
914    if n <= 1:
915        res = 1
916    else:
917        res = factorial(2 * n) / (factorial(n + 1) * factorial(n))
918    return res

Calculate the nth Catalan number.

Arguments:
  • n: The index of the Catalan number.
Returns:

The nth Catalan number.

def reg_poly_points(pos: Sequence[float], n: int, r: float) -> Sequence[Sequence[float]]:
921def reg_poly_points(pos: Point, n: int, r: float) -> Sequence[Point]:
922    """Return a regular polygon points list with n sides, r radius, and pos center.
923
924    Args:
925        pos: The center position of the polygon.
926        n: The number of sides.
927        r: The radius.
928
929    Returns:
930        A sequence of points representing the polygon.
931    """
932    angle = 2 * pi / n
933    x, y = pos[:2]
934    points = [[cos(angle * i) * r + x, sin(angle * i) * r + y] for i in range(n)]
935    points.append(points[0])
936    return points

Return a regular polygon points list with n sides, r radius, and pos center.

Arguments:
  • pos: The center position of the polygon.
  • n: The number of sides.
  • r: The radius.
Returns:

A sequence of points representing the polygon.

def solve_quadratic_eq(a, b, c, tolerance=1e-05):
939def solve_quadratic_eq(a, b, c, tolerance=1e-5):
940    """Solves a quadratic equation of the form ax^2 + bx + c = 0."""
941
942    discr = b**2 - (4 * a * c)  # discriminant
943
944    if discr < 0:
945        res = []
946    elif isclose(discr, 0, rtol=0, atol=tolerance):
947        # one solution
948        res = [(-b + discr) / (2 * a)]
949    else:
950        a2 = a * 2
951        sqrt_discr = sqrt(discr)
952        x1 = (-b + sqrt_discr) / a2
953        x2 = (-b - sqrt_discr) / a2
954        res = [x1, x2]
955
956    return res

Solves a quadratic equation of the form ax^2 + bx + c = 0.

def solve_quartic_eq(a: float, b: float, c: float, d: float, e: float) -> list[float]:
959def solve_quartic_eq(a: float, b: float, c: float, d: float, e: float) -> list[float]:
960    """
961    Solves a quartic equation of the form ax^4 + bx^3 + cx^2 + dx + e = 0.
962
963    Args:
964        a: The coefficient of x^4.
965        b: The coefficient of x^3.
966        c: The coefficient of x^2.
967        d: The coefficient of x.
968        e: The constant term.
969
970    Returns:
971        A numpy array containing the four roots of the equation.
972    """
973
974    return np.roots((a, b, c, d, e)).tolist()

Solves a quartic equation of the form ax^4 + bx^3 + cx^2 + dx + e = 0.

Arguments:
  • a: The coefficient of x^4.
  • b: The coefficient of x^3.
  • c: The coefficient of x^2.
  • d: The coefficient of x.
  • e: The constant term.
Returns:

A numpy array containing the four roots of the equation.

def solve_complex_quadratic_eq( a: complex, b: complex, c: complex, tolerance: float = 1e-05) -> list[complex]:
 977def solve_complex_quadratic_eq(
 978    a: complex, b: complex, c: complex, tolerance: float = 1e-5
 979) -> list[complex]:
 980    """Solves a quadratic equation of the form ax^2 + bx + c = 0,
 981    where a, b, and c can be complex numbers.
 982
 983    Args:
 984        a: The complex coefficient of x^2.
 985        b: The complex coefficient of x.
 986        c: The complex constant term.
 987        tolerance: The tolerance for floating-point comparisons.
 988    """
 989    discr = (b**2) - 4 * (a * c)  # discriminant
 990    a2 = a * 2
 991    if isclose(discr, 0, rtol=0, atol=tolerance):
 992        x1 = -b / a2
 993        res = [x1]
 994    elif discr > 0:
 995        sqrt_discr = sqrt(discr)
 996        x1 = (-b - sqrt_discr) / a2
 997        x2 = (-b + sqrt_discr) / a2
 998        res = [x1, x2]
 999    else:
1000        sqrt_discr = cmath.sqrt(discr)
1001        x1 = (-b - sqrt_discr) / a2
1002        x2 = (-b + sqrt_discr) / a2
1003        res = [x1, x2]
1004
1005    return res

Solves a quadratic equation of the form ax^2 + bx + c = 0, where a, b, and c can be complex numbers.

Arguments:
  • a: The complex coefficient of x^2.
  • b: The complex coefficient of x.
  • c: The complex constant term.
  • tolerance: The tolerance for floating-point comparisons.