Initial commit
Some checks are pending
test / test (push) Waiting to run

This commit is contained in:
Lily Rose 2025-07-28 01:54:31 +10:00
commit 40cfdf9bc6
12 changed files with 1293 additions and 0 deletions

23
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: test
on:
push:
branches:
- master
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
otp-version: "27.1.2"
gleam-version: "1.11.1"
rebar3-version: "3"
# elixir-version: "1"
- run: gleam deps download
- run: gleam test
- run: gleam format --check src test

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
*.beam
*.ez
/build
erl_crash.dump
**/*.svg

9
LICENSE.md Normal file
View file

@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright © 2025 Lily Rose
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.

24
README.md Normal file
View file

@ -0,0 +1,24 @@
# substrate
[![Package Version](https://img.shields.io/hexpm/v/substrate)](https://hex.pm/packages/substrate)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/substrate/)
```sh
gleam add substrate@1
```
```gleam
import substrate
pub fn main() -> Nil {
// TODO: An example of the project in use
}
```
Further documentation can be found at <https://hexdocs.pm/substrate>.
## Development
```sh
gleam run # Run the project
gleam test # Run the tests
```

14
gleam.toml Normal file
View file

@ -0,0 +1,14 @@
name = "substrate"
version = "1.0.0"
description = "Procedurally generate SVG wallpapers from Kicad footprints"
licences = ["MIT"]
repository = { type = "forgejo", host = "git.7cs.dev", user = "lily", repo = "substrate" }
[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
simplifile = ">= 2.3.0 and < 3.0.0"
lustre = ">= 5.2.1 and < 6.0.0"
kicad_sexpr = ">= 1.0.1 and < 2.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"

22
manifest.toml Normal file
View file

@ -0,0 +1,22 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
{ name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" },
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
{ name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" },
{ name = "gleam_stdlib", version = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" },
{ name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
{ name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
{ name = "kicad_sexpr", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "kicad_sexpr", source = "hex", outer_checksum = "8ACA8737C6B3517BAA9E8A8278C5B88379AE6EFED28C74F30FB9E7A19622CCF9" },
{ name = "lustre", version = "5.2.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "DCD121F8E6B7E179B27D9A8AEB6C828D8380E26DF2E16D078511EDAD1CA9F2A7" },
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
]
[requirements]
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
kicad_sexpr = { version = ">= 1.0.1 and < 2.0.0" }
lustre = { version = ">= 5.2.1 and < 6.0.0" }
simplifile = { version = ">= 2.3.0 and < 3.0.0" }

666
src/substrate.gleam Normal file
View file

@ -0,0 +1,666 @@
import gleam/float
import gleam/list
import gleam/option.{None, Some}
import gleam/order
import kicad_sexpr
import kicad_sexpr/token.{type Footprint, type Pad}
import lustre/attribute.{style} as _attribute
import lustre/element.{type Element, fragment, to_readable_string} as _element
import lustre/element/html.{svg}
import simplifile
import svg_attribute as attribute
import svg_element as element
import svg_path as path
import svg_transform as transform
pub fn get_footprint(
library_location: String,
library: String,
name: String,
) -> Footprint {
let path =
library_location
<> "/footprints/"
<> library
<> ".pretty/"
<> name
<> ".kicad_mod"
let assert Ok(data) = path |> simplifile.read_bits
let assert Ok(footprint_file) = kicad_sexpr.parse_footprint_file(data)
footprint_file.footprint
}
pub fn get_local_footprint(library: String, name: String) -> Footprint {
get_footprint("/home/lily/.local/share/kicad/9.0", library, name)
}
pub fn get_local_footprints(
library: String,
names: List(String),
) -> List(Footprint) {
names |> list.map(get_local_footprint(library, _))
}
pub fn get_global_footprint(library: String, name: String) -> Footprint {
get_footprint("/usr/share/kicad", library, name)
}
pub fn get_global_footprints(
library: String,
names: List(String),
) -> List(Footprint) {
names |> list.map(get_global_footprint(library, _))
}
pub fn main() -> Nil {
// simplifile.get_files("/usr/share/kicad/footprints/")
// |> result.unwrap([])
// |> list.filter_map(fn(file_name) {
// use data <- result.try(
// simplifile.read_bits(file_name) |> result.replace_error(Nil),
// )
// use footprint_file <- result.try(
// kicad_sexpr.parse_footprint_file(data) |> result.replace_error(Nil),
// )
// case
// footprint_file.footprint.pads
// |> list.any(fn(pad) {
// case pad.chamfer {
// option.Some([_, ..]) -> True
// _ -> False
// }
// })
// {
// True -> Ok(file_name)
// False -> Error(Nil)
// }
// })
// |> string.join("\n")
// |> io.println
// let capacitors =
// get_global_footprints("Capacitor_SMD", [
// "C_0603_1608Metric", "C_0805_2012Metric", "C_1210_3225Metric",
// ])
// let resistors =
// get_global_footprints("Resistor_SMD", [
// "R_0603_1608Metric", "R_0805_2012Metric", "R_1210_3225Metric",
// ])
// let sots =
// get_global_footprints("Package_TO_SOT_SMD", [
// "SOT-23", "SOT-23-5", "SOT-23-6", "SOT-23-8",
// ])
// let tos =
// get_global_footprints("Package_TO_SOT_SMD", ["TO-252-2", "TO-252-3_TabPin2"])
// let ssops =
// get_global_footprints("Package_SO", [
// "SSOP-20_4.4x6.5mm_P0.65mm", "SSOP-8_3.9x5.05mm_P1.27mm",
// "SSOP-10-1EP_3.9x4.9mm_P1mm_EP2.1x3.3mm",
// ])
// let tqfps =
// get_global_footprints("Package_QFP", [
// "TQFP-32_7x7mm_P0.8mm", "TQFP-44_10x10mm_P0.8mm", "TQFP-64_10x10mm_P0.5mm",
// ])
// let dfns =
// get_global_footprints("Package_DFN_QFN", [
// "DFN-6-1EP_3x2mm_P0.5mm_EP1.65x1.35mm", "DFN-8_2x2mm_P0.5mm",
// "DFN-14-1EP_3x4mm_P0.5mm_EP1.7x3.3mm",
// ])
// let qfns =
// get_global_footprints("Package_DFN_QFN", [
// "QFN-12-1EP_3x3mm_P0.51mm_EP1.45x1.45mm",
// "QFN-16-1EP_4x4mm_P0.65mm_EP2.5x2.5mm",
// "QFN-24-1EP_4x4mm_P0.5mm_EP2.65x2.65mm",
// "QFN-32-1EP_5x5mm_P0.5mm_EP3.6x3.6mm",
// ])
let test_footprint = get_local_footprint("Testing", "Testing1")
let svg = create_svg(test_footprint)
let assert Ok(_) = simplifile.write("out.svg", svg)
Nil
}
const bg_col = "#001021"
fn create_svg(footprint: Footprint) -> String {
let fill = None
let stroke = None
let stroke_width = None
let transform = None
svg([attribute.view_box(-4.0, -4.0, 8.0, 8.0), style("background", bg_col)], [
element.group(
fill:,
stroke: Some("#BBBBBB"),
stroke_width: Some(0.01),
transform:,
children: [
element.line(
fill:,
stroke:,
stroke_width:,
transform:,
x1: -4.0,
y1: 0.0,
x2: 4.0,
y2: 0.0,
),
element.line(
fill:,
stroke:,
stroke_width:,
transform:,
x1: 0.0,
y1: -4.0,
x2: 0.0,
y2: 4.0,
),
],
),
element.group(
fill:,
stroke: Some("#FF00FF"),
stroke_width: Some(0.01),
transform:,
children: [
element.line(
fill:,
stroke:,
stroke_width:,
transform:,
x1: -0.05,
y1: 0.0,
x2: 0.05,
y2: 0.0,
),
element.line(
fill:,
stroke:,
stroke_width:,
transform:,
x1: 0.0,
y1: -0.05,
x2: 0.0,
y2: 0.05,
),
],
),
element.group(
fill: Some("#CE3431"),
stroke:,
stroke_width:,
transform:,
children: footprint.pads
|> list.filter(fn(pad) {
pad.layers |> list.contains(token.Layer("F.Cu"))
|| pad.layers |> list.contains(token.Layer("*.Cu"))
})
|> list.map(pad_to_element),
),
])
|> to_readable_string
}
fn pad_to_element(pad: Pad) -> Element(a) {
let token.PositionIdentifier(x:, y:, angle:, ..) = pad.position
let token.Size(width: w, height: h) = pad.size
let nw = -1.0 *. w
let nh = -1.0 *. h
let hw = w /. 2.0
let hh = h /. 2.0
let fill = None
let stroke = None
let stroke_width = None
let transform =
angle
|> option.map(fn(angle) {
transform.Transform(
translate: None,
rotate: Some(transform.RotateAround(angle: -1.0 *. angle, x:, y:)),
scale: None,
)
})
let #(ox, oy) = case pad.drill {
Some(token.PadDrillDefinition(offset: Some(token.XY(x: dx, y: dy)), ..)) -> #(
x +. dx,
y +. dy,
)
_ -> #(pad.position.x, pad.position.y)
}
let elem = case pad.shape {
token.CirclePadShape ->
element.circle(
fill:,
stroke:,
stroke_width:,
transform:,
cx: ox,
cy: oy,
r: hw,
)
token.OvalPadShape ->
case float.compare(w, h) {
order.Eq ->
element.circle(
fill:,
stroke:,
stroke_width:,
transform:,
cx: ox,
cy: oy,
r: hw,
)
order.Lt ->
element.path(fill:, stroke:, stroke_width:, transform:, path: [
path.MoveAbsolute(x: ox +. hw, y: oy +. hw -. hh),
path.ArcCurveRelative(
rx: hw,
ry: hw,
angle: 180.0,
large: False,
clockwise: True,
dx: nw,
dy: 0.0,
),
path.VerticalLineRelative(h -. w),
path.ArcCurveRelative(
rx: hw,
ry: hw,
angle: 180.0,
large: False,
clockwise: True,
dx: w,
dy: 0.0,
),
path.ClosePath,
])
order.Gt ->
element.path(fill:, stroke:, stroke_width:, transform:, path: [
path.MoveAbsolute(x: ox +. hh -. hw, y: oy -. hh),
path.ArcCurveRelative(
rx: hh,
ry: hh,
angle: 180.0,
large: False,
clockwise: True,
dx: 0.0,
dy: h,
),
path.HorizontalLineRelative(w -. h),
path.ArcCurveRelative(
rx: hh,
ry: hh,
angle: 180.0,
large: False,
clockwise: True,
dx: 0.0,
dy: nh,
),
path.ClosePath,
])
}
token.RectanglePadShape | token.RoundRectPadShape -> {
let rounding = case pad.shape {
token.RoundRectPadShape -> pad.roundrect_rratio
_ -> None
}
let rounding = case rounding {
Some(value) if value <=. 0.0 -> None
Some(value) if value >. 5.0 -> Some(5.0)
Some(value) -> Some(value)
None -> None
}
let chamfering = case pad.chamfer_ratio {
Some(value) if value <=. 0.0 -> None
Some(value) if value >. 5.0 -> Some(5.0)
Some(value) -> Some(value)
None -> None
}
case rounding, pad.chamfer, chamfering {
None, Some([]), _ | None, None, _ | None, _, None ->
element.centered_rect(
fill:,
stroke:,
stroke_width:,
transform:,
x: ox,
y: oy,
width: w,
height: h,
)
Some(rounding), Some([]), _
| Some(rounding), None, _
| Some(rounding), _, None
-> {
let radius = rounding *. float.min(w, h)
element.centered_rounded_rect(
fill:,
stroke:,
stroke_width:,
transform:,
x: ox,
y: oy,
rx: radius,
ry: radius,
width: w,
height: h,
)
}
None, Some(corners), Some(chamfering) -> {
let #(tl, tr, bl, br) = chamfered_corners(corners)
let o = chamfering *. float.min(w, h)
let no = -1.0 *. o
let path = [path.ClosePath]
let bro = case br {
True -> o
False -> 0.0
}
let path = case tr {
True -> [
path.VerticalLineRelative(dy: nh +. bro +. o),
path.LineRelative(dx: no, dy: no),
..path
]
False -> [path.VerticalLineRelative(dy: nh +. bro), ..path]
}
let blo = case bl {
True -> o
False -> 0.0
}
let path = case br {
True -> [
path.HorizontalLineRelative(dx: w -. blo -. o),
path.LineRelative(dx: o, dy: no),
..path
]
False -> [path.HorizontalLineRelative(dx: w -. blo), ..path]
}
let tlo = case tl {
True -> o
False -> 0.0
}
let path = case bl {
True -> [
path.VerticalLineRelative(dy: h -. tlo -. o),
path.LineRelative(dx: o, dy: o),
..path
]
False -> [path.VerticalLineRelative(dy: h -. tlo), ..path]
}
let path = case tl {
True -> [
path.MoveAbsolute(x: ox -. hw +. o, y: oy -. hh),
path.LineRelative(dx: no, dy: o),
..path
]
False -> [path.MoveAbsolute(x: ox -. hw, y: oy -. hh), ..path]
}
element.path(fill:, stroke:, stroke_width:, transform:, path:)
}
Some(rounding), Some(corners), Some(chamfering) -> {
let #(tl, tr, bl, br) = chamfered_corners(corners)
let o = chamfering *. float.min(w, h)
let no = -1.0 *. o
let r = rounding *. float.min(w, h)
let nr = -1.0 *. r
let path = [path.ClosePath]
let bro = case br {
True -> o
False -> r
}
let path = case tr {
True -> [
path.VerticalLineRelative(dy: nh +. bro +. o),
path.LineRelative(dx: no, dy: no),
..path
]
False -> [
path.VerticalLineRelative(dy: nh +. bro +. r),
path.ArcCurveRelative(
rx: r,
ry: r,
angle: 90.0,
large: True,
clockwise: True,
dx: nr,
dy: nr,
),
..path
]
}
let blo = case bl {
True -> o
False -> r
}
let path = case br {
True -> [
path.HorizontalLineRelative(dx: w -. blo -. o),
path.LineRelative(dx: o, dy: no),
..path
]
False -> [
path.HorizontalLineRelative(dx: w -. blo -. r),
path.ArcCurveRelative(
rx: r,
ry: r,
angle: 90.0,
large: True,
clockwise: True,
dx: r,
dy: nr,
),
..path
]
}
let tlo = case tl {
True -> o
False -> r
}
let path = case bl {
True -> [
path.VerticalLineRelative(dy: h -. tlo -. o),
path.LineRelative(dx: o, dy: o),
..path
]
False -> [
path.VerticalLineRelative(dy: h -. tlo -. r),
path.ArcCurveRelative(
rx: r,
ry: r,
angle: 90.0,
large: True,
clockwise: True,
dx: r,
dy: r,
),
..path
]
}
let path = case tl {
True -> [
path.MoveAbsolute(x: ox -. hw +. o, y: oy -. hh),
path.LineRelative(dx: no, dy: o),
..path
]
False -> [
path.MoveAbsolute(x: ox -. hw +. r, y: oy -. hh),
path.ArcCurveRelative(
rx: r,
ry: r,
angle: 90.0,
large: True,
clockwise: True,
dx: nr,
dy: r,
),
..path
]
}
element.path(fill:, stroke:, stroke_width:, transform:, path:)
}
}
}
token.TrapezoidPadShape ->
case pad.rect_delta {
Some(token.XY(x: 0.0, y: o)) -> {
let no = -1.0 *. o
let ho = o /. 2.0
let path = [
path.MoveAbsolute(x: ox -. hw +. ho, y: oy -. hh),
path.LineRelative(dx: no, dy: h),
path.HorizontalLineRelative(dx: w +. o),
path.LineRelative(dx: no, dy: nh),
path.ClosePath,
]
element.path(fill:, stroke:, stroke_width:, transform:, path:)
}
Some(token.XY(x: o, y: 0.0)) -> {
let no = -1.0 *. o
let ho = o /. 2.0
let path = [
path.MoveAbsolute(x: ox -. hw, y: oy +. hh +. ho),
path.LineRelative(dx: w, dy: no),
path.VerticalLineRelative(dy: nh +. o),
path.LineRelative(dx: nw, dy: no),
path.ClosePath,
]
element.path(fill:, stroke:, stroke_width:, transform:, path:)
}
_ ->
element.centered_rect(
fill:,
stroke:,
stroke_width:,
transform:,
x: ox,
y: oy,
width: w,
height: h,
)
}
token.CustomPadShape ->
case pad.custom_options {
Some(token.CustomPadOptions(anchor: token.CircleAnchorPadShape, ..)) ->
element.circle(
fill:,
stroke:,
stroke_width:,
transform:,
cx: ox,
cy: oy,
r: hw,
)
Some(token.CustomPadOptions(anchor: token.RectangleAnchorPadShape, ..))
| None ->
element.centered_rect(
fill:,
stroke:,
stroke_width:,
transform:,
x: ox,
y: oy,
width: w,
height: h,
)
}
}
let fill = Some(bg_col)
let stroke = Some("#E7B629")
let stroke_width = Some(0.03333)
case pad.drill {
Some(token.PadDrillDefinition(False, Some(d), ..))
| Some(token.PadDrillDefinition(True, Some(d), None, ..)) ->
fragment([
elem,
element.circle(
fill:,
stroke:,
stroke_width:,
transform:,
cx: x,
cy: y,
r: d /. 2.0,
),
])
Some(token.PadDrillDefinition(True, Some(w), Some(h), ..)) -> {
let hw = w /. 2.0
let hh = h /. 2.0
let nw = -1.0 *. w
let nh = -1.0 *. h
fragment([
elem,
case float.compare(w, h) {
order.Eq ->
element.circle(
fill:,
stroke:,
stroke_width:,
transform:,
cx: x,
cy: y,
r: hw,
)
order.Lt ->
element.path(fill:, stroke:, stroke_width:, transform:, path: [
path.MoveAbsolute(x: x +. hw, y: y +. hw -. hh),
path.ArcCurveRelative(
rx: hw,
ry: hw,
angle: 180.0,
large: False,
clockwise: True,
dx: nw,
dy: 0.0,
),
path.VerticalLineRelative(h -. w),
path.ArcCurveRelative(
rx: hw,
ry: hw,
angle: 180.0,
large: False,
clockwise: True,
dx: w,
dy: 0.0,
),
path.ClosePath,
])
order.Gt ->
element.path(fill:, stroke:, stroke_width:, transform:, path: [
path.MoveAbsolute(x: x +. hh -. hw, y: y -. hh),
path.ArcCurveRelative(
rx: hh,
ry: hh,
angle: 180.0,
large: False,
clockwise: True,
dx: 0.0,
dy: h,
),
path.HorizontalLineRelative(w -. h),
path.ArcCurveRelative(
rx: hh,
ry: hh,
angle: 180.0,
large: False,
clockwise: True,
dx: 0.0,
dy: nh,
),
path.ClosePath,
])
},
])
}
_ -> elem
}
}
fn chamfered_corners(corners: List(token.Corner)) {
corners
|> list.fold(#(False, False, False, False), fn(corners, corner) {
let #(top_left, top_right, bottom_left, bottom_right) = corners
case corner {
token.TopLeft -> #(True, top_right, bottom_left, bottom_right)
token.TopRight -> #(top_left, True, bottom_left, bottom_right)
token.BottomLeft -> #(top_left, top_right, True, bottom_right)
token.BottomRight -> #(top_left, top_right, bottom_left, True)
}
})
}

104
src/svg_attribute.gleam Normal file
View file

@ -0,0 +1,104 @@
import gleam/float
import gleam/list
import gleam/string
import lustre/attribute.{type Attribute, attribute}
import svg_path.{type Path}
import svg_transform.{type Transform}
pub fn view_box(
min_x: Float,
min_y: Float,
width: Float,
height: Float,
) -> Attribute(a) {
attribute(
"viewBox",
[min_x, min_y, width, height]
|> list.map(float.to_string)
|> string.join(" "),
)
}
fn float_attribute(name: String, value: Float) -> Attribute(a) {
attribute(name, value |> float.to_precision(6) |> float.to_string)
}
pub fn fill(value: String) -> Attribute(a) {
attribute("fill", value)
}
pub fn stroke(value: String) -> Attribute(a) {
attribute("stroke", value)
}
pub fn stroke_width(value: Float) -> Attribute(a) {
float_attribute("stroke-width", value)
}
pub fn width(value: Float) -> Attribute(a) {
float_attribute("width", value)
}
pub fn height(value: Float) -> Attribute(a) {
float_attribute("height", value)
}
pub fn x(value: Float) -> Attribute(a) {
float_attribute("x", value)
}
pub fn y(value: Float) -> Attribute(a) {
float_attribute("y", value)
}
pub fn dx(value: Float) -> Attribute(a) {
float_attribute("dx", value)
}
pub fn dy(value: Float) -> Attribute(a) {
float_attribute("dy", value)
}
pub fn x1(value: Float) -> Attribute(a) {
float_attribute("x1", value)
}
pub fn y1(value: Float) -> Attribute(a) {
float_attribute("y1", value)
}
pub fn x2(value: Float) -> Attribute(a) {
float_attribute("x2", value)
}
pub fn y2(value: Float) -> Attribute(a) {
float_attribute("y2", value)
}
pub fn rx(value: Float) -> Attribute(a) {
float_attribute("rx", value)
}
pub fn ry(value: Float) -> Attribute(a) {
float_attribute("ry", value)
}
pub fn cx(value: Float) -> Attribute(a) {
float_attribute("cx", value)
}
pub fn cy(value: Float) -> Attribute(a) {
float_attribute("cy", value)
}
pub fn r(value: Float) -> Attribute(a) {
float_attribute("r", value)
}
pub fn d(value: Path) -> Attribute(a) {
attribute("d", svg_path.to_string(value))
}
pub fn transform(value: Transform) -> Attribute(a) {
attribute("transform", svg_transform.to_string(value))
}

221
src/svg_element.gleam Normal file
View file

@ -0,0 +1,221 @@
import gleam/option.{type Option, None, Some}
import lustre/attribute.{type Attribute}
import lustre/element.{type Element, element}
import svg_attribute
import svg_path.{type Path}
import svg_transform.{type Transform}
fn styles(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
) -> List(Attribute(a)) {
let attrs = []
let attrs = case transform {
Some(value) -> [svg_attribute.transform(value), ..attrs]
None -> attrs
}
let attrs = case stroke_width {
Some(value) -> [svg_attribute.stroke_width(value), ..attrs]
None -> attrs
}
let attrs = case stroke {
Some(value) -> [svg_attribute.stroke(value), ..attrs]
None -> attrs
}
let attrs = case fill {
Some(value) -> [svg_attribute.fill(value), ..attrs]
None -> attrs
}
attrs
}
pub fn group(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
children children: List(Element(a)),
) -> Element(a) {
element("g", styles(fill:, stroke:, stroke_width:, transform:), children)
}
pub fn line(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
x1 x1: Float,
y1 y1: Float,
x2 x2: Float,
y2 y2: Float,
) -> Element(a) {
element(
"line",
[
svg_attribute.x1(x1),
svg_attribute.y1(y1),
svg_attribute.x2(x2),
svg_attribute.y2(y2),
..styles(fill:, stroke:, stroke_width:, transform:)
],
[],
)
}
pub fn circle(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
cx cx: Float,
cy cy: Float,
r r: Float,
) -> Element(a) {
element(
"circle",
[
svg_attribute.cx(cx),
svg_attribute.cy(cy),
svg_attribute.r(r),
..styles(fill:, stroke:, stroke_width:, transform:)
],
[],
)
}
pub fn ellipse(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
cx cx: Float,
cy cy: Float,
rx rx: Float,
ry ry: Float,
) -> Element(a) {
element(
"ellipse",
[
svg_attribute.cx(cx),
svg_attribute.cy(cy),
svg_attribute.rx(rx),
svg_attribute.ry(ry),
..styles(fill:, stroke:, stroke_width:, transform:)
],
[],
)
}
pub fn rect(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
x x: Float,
y y: Float,
width width: Float,
height height: Float,
) -> Element(a) {
element(
"rect",
[
svg_attribute.x(x),
svg_attribute.y(y),
svg_attribute.width(width),
svg_attribute.height(height),
..styles(fill:, stroke:, stroke_width:, transform:)
],
[],
)
}
pub fn rounded_rect(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
x x: Float,
y y: Float,
rx rx: Float,
ry ry: Float,
width width: Float,
height height: Float,
) -> Element(a) {
element(
"rect",
[
svg_attribute.x(x),
svg_attribute.y(y),
svg_attribute.rx(rx),
svg_attribute.ry(ry),
svg_attribute.width(width),
svg_attribute.height(height),
..styles(fill:, stroke:, stroke_width:, transform:)
],
[],
)
}
pub fn centered_rect(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
x x: Float,
y y: Float,
width width: Float,
height height: Float,
) -> Element(a) {
rect(
fill:,
stroke:,
stroke_width:,
transform:,
x: x -. width /. 2.0,
y: y -. height /. 2.0,
width:,
height:,
)
}
pub fn centered_rounded_rect(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
x x: Float,
y y: Float,
rx rx: Float,
ry ry: Float,
width width: Float,
height height: Float,
) -> Element(a) {
rounded_rect(
fill:,
stroke:,
stroke_width:,
transform:,
x: x -. width /. 2.0,
y: y -. height /. 2.0,
rx:,
ry:,
width:,
height:,
)
}
pub fn path(
fill fill: Option(String),
stroke stroke: Option(String),
stroke_width stroke_width: Option(Float),
transform transform: Option(Transform),
path path: Path,
) -> Element(a) {
element(
"path",
[svg_attribute.d(path), ..styles(fill:, stroke:, stroke_width:, transform:)],
[],
)
}

138
src/svg_path.gleam Normal file
View file

@ -0,0 +1,138 @@
import gleam/bool
import gleam/float
import gleam/list
import gleam/string
pub type PathCommand {
MoveAbsolute(x: Float, y: Float)
MoveRelative(dx: Float, dy: Float)
LineAbsolute(x: Float, y: Float)
LineRelative(dx: Float, dy: Float)
HorizontalLineAbsolute(x: Float)
HorizontalLineRelative(dx: Float)
VerticalLineAbsolute(y: Float)
VerticalLineRelative(dy: Float)
CubicBezierAbsolute(
x1: Float,
y1: Float,
x2: Float,
y2: Float,
x: Float,
y: Float,
)
CubicBezierRelative(
dx1: Float,
dy1: Float,
dx2: Float,
dy2: Float,
dx: Float,
dy: Float,
)
SmoothCubicBezierAbsolute(x2: Float, y2: Float, x: Float, y: Float)
SmoothCubicBezierRelative(dx2: Float, dy2: Float, dx: Float, dy: Float)
QuadraticBezierAbsolute(x1: Float, y1: Float, x: Float, y: Float)
QuadraticBezierRelative(dx1: Float, dy1: Float, dx: Float, dy: Float)
SmoothQuadraticBezierAbsolute(x: Float, y: Float)
SmoothQuadraticBezierRelative(dx: Float, dy: Float)
ArcCurveAbsolute(
rx: Float,
ry: Float,
angle: Float,
large: Bool,
clockwise: Bool,
x: Float,
y: Float,
)
ArcCurveRelative(
rx: Float,
ry: Float,
angle: Float,
large: Bool,
clockwise: Bool,
dx: Float,
dy: Float,
)
ClosePath
}
fn float_to_string(value: Float) -> String {
value |> float.to_precision(6) |> float.to_string
}
fn mk_str(id: String, values: List(Float)) -> String {
id <> " " <> values |> list.map(float_to_string) |> string.join(" ")
}
fn bool_str(value: Bool) -> String {
case value {
True -> "0"
False -> "1"
}
}
pub fn command_to_string(command: PathCommand) -> String {
case command {
MoveAbsolute(x:, y:) -> mk_str("M", [x, y])
MoveRelative(dx:, dy:) -> mk_str("m", [dx, dy])
LineAbsolute(x:, y:) -> mk_str("L", [x, y])
LineRelative(dx:, dy:) -> mk_str("l", [dx, dy])
HorizontalLineAbsolute(x:) -> mk_str("H", [x])
HorizontalLineRelative(dx:) -> mk_str("h", [dx])
VerticalLineAbsolute(y:) -> mk_str("V", [y])
VerticalLineRelative(dy:) -> mk_str("v", [dy])
CubicBezierAbsolute(x1:, y1:, x2:, y2:, x:, y:) ->
mk_str("C", [x1, y1, x2, y2, x, y])
CubicBezierRelative(dx1:, dy1:, dx2:, dy2:, dx:, dy:) ->
mk_str("c", [dx1, dy1, dx2, dy2, dx, dy])
SmoothCubicBezierAbsolute(x2:, y2:, x:, y:) -> mk_str("S", [x2, y2, x, y])
SmoothCubicBezierRelative(dx2:, dy2:, dx:, dy:) ->
mk_str("s", [dx2, dy2, dx, dy])
QuadraticBezierAbsolute(x1:, y1:, x:, y:) -> mk_str("Q", [x1, y1, x, y])
QuadraticBezierRelative(dx1:, dy1:, dx:, dy:) ->
mk_str("q", [dx1, dy1, dx, dy])
SmoothQuadraticBezierAbsolute(x:, y:) -> mk_str("T", [x, y])
SmoothQuadraticBezierRelative(dx:, dy:) -> mk_str("t", [dx, dy])
ArcCurveAbsolute(rx:, ry:, angle:, large:, clockwise:, x:, y:) ->
mk_str("A", [rx, ry, angle])
<> " "
<> bool_str(large)
<> " "
<> bool_str(clockwise)
<> " "
<> float_to_string(x)
<> " "
<> float_to_string(y)
ArcCurveRelative(rx:, ry:, angle:, large:, clockwise:, dx:, dy:) ->
mk_str("a", [rx, ry, angle])
<> " "
<> bool_str(large)
<> " "
<> bool_str(clockwise)
<> " "
<> float_to_string(dx)
<> " "
<> float_to_string(dy)
ClosePath -> "Z"
}
}
pub type Path =
List(PathCommand)
fn not_redundant(command: PathCommand) {
case command {
MoveRelative(dx: 0.0, dy: 0.0) -> False
LineRelative(dx: 0.0, dy: 0.0) -> False
HorizontalLineRelative(dx: 0.0) -> False
VerticalLineRelative(dy: 0.0) -> False
_ -> True
}
}
pub fn to_string(path: Path) -> String {
use <- bool.guard(path == [], " ")
path
|> list.filter(not_redundant)
|> list.map(command_to_string)
|> string.join(" ")
}

62
src/svg_transform.gleam Normal file
View file

@ -0,0 +1,62 @@
import gleam/float
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
pub type Translate {
Translate(x: Float, y: Float)
}
pub type Rotate {
Rotate(angle: Float)
RotateAround(angle: Float, x: Float, y: Float)
}
pub type Scale {
ScaleProportional(scale: Float)
ScaleStretched(scale_x: Float, scale_y: Float)
}
pub type Transform {
Transform(
translate: Option(Translate),
rotate: Option(Rotate),
scale: Option(Scale),
)
}
fn float_to_string(value: Float) -> String {
value |> float.to_precision(6) |> float.to_string
}
fn mk_str(name: String, values: List(Float)) -> String {
name
<> "("
<> { values |> list.map(float_to_string) |> string.join(" ") }
<> ")"
}
pub fn to_string(transform: Transform) -> String {
let parts = []
let parts = case transform.scale {
Some(ScaleProportional(scale:)) -> [mk_str("scale", [scale]), ..parts]
Some(ScaleStretched(scale_x:, scale_y:)) -> [
mk_str("scale", [scale_x, scale_y]),
..parts
]
None -> parts
}
let parts = case transform.rotate {
Some(Rotate(angle:)) -> [mk_str("rotate", [angle]), ..parts]
Some(RotateAround(angle:, x:, y:)) -> [
mk_str("rotate", [angle, x, y]),
..parts
]
None -> parts
}
let parts = case transform.translate {
Some(Translate(x:, y:)) -> [mk_str("translate", [x, y]), ..parts]
None -> parts
}
parts |> string.join(" ")
}

View file

@ -0,0 +1,5 @@
import gleeunit
pub fn main() -> Nil {
gleeunit.main()
}