#!/usr/bin/python3

# Written By Marcio Teixeira 2018 - Aleph Objects, Inc.
# Edited By Andrew 2025 - ClassicRocker883
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# To view a copy of the GNU General Public License, go to the following
# location: <https://www.gnu.org/licenses/>.

from __future__ import print_function
import argparse, re, sys, os

from html.parser import HTMLParser

USAGE = """
This program extracts line segments from an SVG file and writes
them out as coordinates in a C array. The X and Y values will be
scaled from 0x0000 to 0xFFFE. 0xFFFF is used as path separator.

This program can only interpret straight segments, not curves.
It also cannot handle SVG transform attributes. To convert an
SVG file into the proper format, use the following procedure:

  - Load SVG file into Inkscape
  - Convert all Objects to Paths (Path -> Object to Path)
  - Convert all Strokes to Paths (Path -> Stroke to Path)
  - Combine all paths into one (Path -> Combine) [1]
  - Convert all curves into short line segments [2]
    (Extensions -> Modify Paths -> Appoximate Curves by Straight Lines...)
  - Save as new SVG
  - Convert into a header file using this utility
  - To give paths individual names, break apart paths and
    use the XML Editor to set the "id" attributes.

[1] Combining paths is necessary to remove transforms. You
    could also use the Inkscape extension inkscape-applytransforms.

[2] "Approximate Curves by Straight Lines..." has replaced
    "Flatten Beziers".

"""

header = """
/****************************************************************************
 *   This program is free software: you can redistribute it and/or modify   *
 *   it under the terms of the GNU General Public License as published by   *
 *   the Free Software Foundation, either version 3 of the License, or      *
 *   (at your option) any later version.                                    *
 *                                                                          *
 *   This program is distributed in the hope that it will be useful,        *
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of         *
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the          *
 *   GNU General Public License for more details.                           *
 *                                                                          *
 *   To view a copy of the GNU General Public License, go to the following  *
 *   location: <https://www.gnu.org/licenses/>.                             *
 ****************************************************************************/

/**
 * This file was auto-generated using "svg2cpp.py"
 *
 * The encoding consists of x,y pairs with the min and max scaled to
 * 0x0000 and 0xFFFE. A single 0xFFFF in the data stream indicates the
 * start of a new closed path.
 */

#pragma once
"""

class ComputeBoundingBox:
    def reset(self):
        self.x_min    = float(" inf")
        self.y_min    = float(" inf")
        self.x_max    = float("-inf")
        self.y_max    = float("-inf")
        self.n_points = 0
        self.n_paths  = 0

    def command(self, type, x, y):
        self.x_min = min(self.x_min, x)
        self.x_max = max(self.x_max, x)
        self.y_min = min(self.y_min, y)
        self.y_max = max(self.y_max, y)

        if type == "M":
            self.n_paths += 1
        self.n_points += 1

    def scale(self, x, y):
        x -= self.x_min
        y -= self.y_min
        x /= self.x_max - self.x_min
        y /= self.y_max - self.y_min
        #y = 1 - y # Flip upside down
        return (x, y)

    def path_finished(self, id):
        pass

    def write(self):
        print("constexpr float x_min = %f;" % self.x_min)
        print("constexpr float x_max = %f;" % self.x_max)
        print("constexpr float y_min = %f;" % self.y_min)
        print("constexpr float y_max = %f;" % self.y_max)
        print()

    def from_svg_view_box(self, viewbox):
        m = re.search('([0-9-.]+) ([0-9-.]+) ([0-9-.]+) ([0-9-.]+)', viewbox)
        if m:
            self.x_min = float(m[1])
            self.y_min = float(m[2])
            self.x_max = float(m[3])
            self.y_max = float(m[4])
            return True
        return False

class WriteDataStructure:
    def __init__(self, bounding_box, input_filename):
        self.bounds = bounding_box
        self.reset()
        self.input_filename = input_filename

    def reset(self):
        self.hex_words = []

    def push(self, value):
        self.hex_words.append("0x%04X" % (0xFFFF & int(value)))

    def command(self, type, x, y):
        if type == "M":
            self.push(0xFFFF)
        x, y = self.bounds.scale(x,y)
        self.push(x * 0xFFFE)
        self.push(y * 0xFFFE)

    def path_finished(self, id):
        if self.hex_words and self.hex_words[0] == "0xFFFF":
            self.hex_words.pop(0)
        self.write_to_file(id)
        self.hex_words = []

    def write_to_file(self, id):
        base_filename = os.path.splitext(self.input_filename)[0] + '.h'

        with open(base_filename, "w") as outfile:
            outfile.write(header)
            outfile.write("const PROGMEM uint16_t " + id + "[] = {" + ", ".join(self.hex_words) + "};\n")

class SVGParser(HTMLParser):
    def __init__(self, args):
        super().__init__()
        self.args   = args
        self.tags   = []
        self.groups = []
        self.op     = None
        self.restart()

    def set_consumer(self, op):
        self.op = op
        if self.op:
            self.op.reset()

    def restart(self):
        self.last_x = 0
        self.last_y = 0
        self.initial_x = 0
        self.initial_y = 0

    def process_svg_path_L_or_M(self, cmd, x, y):
        if self.op:
            self.op.command(cmd, x, y)
        self.last_x = x
        self.last_y = y
        if cmd == "M":
            self.initial_x = x
            self.initial_y = y

    def process_svg_path_data_cmd(self, id, cmd, a, b):
        """Converts the various types of moves into L or M commands
        and dispatches to process_svg_path_L_or_M for further processing."""
        if cmd == "Z" or cmd == "z":
            self.process_svg_path_L_or_M("L", self.initial_x, self.initial_y)
        elif cmd == "H":
            self.process_svg_path_L_or_M("L", a, self.last_y)
        elif cmd == "V":
            self.process_svg_path_L_or_M("L", self.last_x, a)
        elif cmd == "h":
            self.process_svg_path_L_or_M("L", self.last_x + a, self.last_y)
        elif cmd == "v":
            self.process_svg_path_L_or_M("L", self.last_x, self.last_y + a)
        elif cmd == "L":
            self.process_svg_path_L_or_M("L", a, b)
        elif cmd == "l":
            self.process_svg_path_L_or_M("L", self.last_x + a, self.last_y + b)
        elif cmd == "M":
            self.process_svg_path_L_or_M("M", a, b)
        elif cmd == "m":
            self.process_svg_path_L_or_M("M", self.last_x + a, self.last_y + b)
        else:
            print("Unsupported path data command:", cmd, "in path", id, "\n", file=sys.stderr)
            quit()

    def eat_token(self, regex):
        """Looks for a token at the start of self.d.
             If found, the token is removed."""
        self.m = re.match(regex,self.d)
        if self.m:
            self.d = self.d[self.m.end():]
        return self.m

    def process_svg_path_data(self, id, d):
        """Breaks up the "d" attribute into individual commands
             and calls "process_svg_path_data_cmd" for each"""

        self.d = d
        while (self.d):
            if self.eat_token(r'\s+'):
                pass # Just eat the spaces

            elif self.eat_token('([LMHVZlmhvz])'):
                cmd = self.m[1]
                # The following commands take no arguments
                if cmd == "Z" or cmd == "z":
                    self.process_svg_path_data_cmd(id, cmd, 0, 0)

            elif self.eat_token('([CScsQqTtAa])'):
                print("Unsupported path data command:", self.m[1], "in path", id, "\n", file=sys.stderr)
                quit()

            elif self.eat_token('([ ,]*[-0-9e.]+)+'):
                # Process list of coordinates following command
                coords = re.split('[ ,]+', self.m[0])
                # The following commands take two arguments
                if cmd == "L" or cmd == "l":
                    while coords:
                        self.process_svg_path_data_cmd(id, cmd, float(coords.pop(0)), float(coords.pop(0)))
                elif cmd == "M":
                    while coords:
                        self.process_svg_path_data_cmd(id, cmd, float(coords.pop(0)), float(coords.pop(0)))
                        # If a MOVETO has multiple points, the subsequent ones are assumed to be LINETO
                        cmd = "L"
                elif cmd == "m":
                    while coords:
                        self.process_svg_path_data_cmd(id, cmd, float(coords.pop(0)), float(coords.pop(0)))
                        # If a MOVETO has multiple points, the subsequent ones are assumed to be LINETO
                        cmd = "l"
                # Assume all other commands are single argument
                else:
                    while coords:
                        self.process_svg_path_data_cmd(id, cmd, float(coords.pop(0)), 0)
            else:
                print("Syntax error:", d, "in path", id, "\n", file=sys.stderr)
                quit()

    def find_attr(self, attrs, what):
        for attr, value in attrs:
            if attr == what:
                return value

    def layer_matches(self):
        """ Are we in the correct layer?"""
        if not self.args.layer:
                return True
        for l in self.groups:
            if l and l.find(self.args.layer) != -1:
                return True
        return False

    def handle_starttag(self, tag, attrs):
        self.tags.append(tag)
        if tag == 'svg':
            self.viewbox = self.find_attr(attrs, 'viewbox')
        if tag == 'g':
            label = self.find_attr(attrs, 'inkscape:label')
            self.groups.append(label)
            if label and self.layer_matches():
                print("Reading layer:", label, file=sys.stderr)
        if tag == 'path' and self.layer_matches():
            id = self.find_attr(attrs, 'id')
            transform = self.find_attr(attrs, 'transform')
            if transform:
                print("Found transform in path", id, "! Cannot process file!", file=sys.stderr)
                quit()
            d = self.find_attr(attrs, 'd')
            if d:
                self.process_svg_path_data(id, d)
                if self.op:
                    self.op.path_finished(id)
                self.restart()

    def handle_endtag(self, tag):
        if tag == 'g':
            self.groups.pop()
        if tag != self.tags.pop():
            print("Error popping tag off list")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=USAGE, formatter_class=argparse.RawTextHelpFormatter)
    parser.add_argument("filename")
    parser.add_argument('--layer', help='Only include layers which have this string in their names')
    args = parser.parse_args()

    f = open(args.filename, "r", encoding="utf-8")
    data = f.read()

    # First pass to grab viewbox
    p = SVGParser(args)
    p.feed(data)

    b = ComputeBoundingBox()
    if not b.from_svg_view_box(p.viewbox):
        # Can't find the view box, so use the bounding box of the elements themselves.
        p = SVGParser(args)
        p.set_consumer(b)
        p.feed(data)
        b.write()

    # Last pass to process paths
    w = WriteDataStructure(b, args.filename)
    p = SVGParser(args)
    p.set_consumer(w)
    p.feed(data)
