///|
/// A stable identifier and export hint for a generated SVG asset.
pub(all) struct SvgAssetId {
space : String
name : String
revision : Int
} derive(Eq, Debug, ToJson)
///|
pub(all) struct SvgAsset {
id : SvgAssetId
document : SvgDocument
tags : Array[String]
usage : String
view_box : Rect
} derive(Debug)
///|
pub(all) struct SvgSpriteSymbol {
id : String
content : String
view_box : Rect
width : Double
height : Double
} derive(Eq, Debug, ToJson)
///|
pub(all) struct SvgCssRule {
selector : String
declarations : Array[(String, String)]
} derive(Eq, Debug, ToJson)
///|
pub(all) struct SvgAssetBundle {
name : String
assets : Array[SvgAsset]
symbols : Array[SvgSpriteSymbol]
styles : Array[SvgCssRule]
version : String
} derive(Debug)
///|
pub(all) struct SvgOptimizationReport {
source_bytes : Int
optimized_bytes : Int
path_count : Int
group_count : Int
removed_empty_groups : Int
compression_ratio : Double
} derive(Eq, Debug, ToJson)
///|
pub fn SvgAssetId::new(
space : String,
name : String,
revision : Int,
) -> SvgAssetId {
{ space, name, revision: revision.max(0) }
}
///|
pub fn SvgAssetId::key(self : SvgAssetId) -> String {
self.space + ":" + self.name + "@" + self.revision.to_string()
}
///|
pub fn SvgAssetId::file_name(self : SvgAssetId) -> String {
self.space + "-" + self.name + "-v" + self.revision.to_string() + ".svg"
}
///|
pub fn SvgAsset::new(
id : SvgAssetId,
document : SvgDocument,
usage : String,
) -> SvgAsset {
{
id,
document,
tags: [],
usage,
view_box: Rect::new(0.0, 0.0, document.width, document.height),
}
}
///|
pub fn SvgAsset::tag(self : SvgAsset, value : String) -> SvgAsset {
if value.length() > 0 && !self.tags.contains(value) {
self.tags.push(value)
}
self
}
///|
pub fn SvgAsset::render(self : SvgAsset) -> String {
self.document.to_svg_string()
}
///|
pub fn SvgAsset::bytes(self : SvgAsset) -> Int {
self.render().length()
}
///|
pub fn SvgAsset::with_view_box(self : SvgAsset, view_box : Rect) -> SvgAsset {
{ ..self, view_box, }
}
///|
pub fn SvgSpriteSymbol::new(
id : String,
content : String,
view_box : Rect,
) -> SvgSpriteSymbol {
{ id, content, view_box, width: view_box.width, height: view_box.height }
}
///|
pub fn SvgSpriteSymbol::sized(
self : SvgSpriteSymbol,
width : Double,
height : Double,
) -> SvgSpriteSymbol {
{ ..self, width: width.max(0.0), height: height.max(0.0) }
}
///|
pub fn SvgCssRule::new(selector : String) -> SvgCssRule {
{ selector, declarations: [] }
}
///|
pub fn SvgCssRule::set(
self : SvgCssRule,
property : String,
value : String,
) -> SvgCssRule {
let mut replaced = false
for index, declaration in self.declarations {
if declaration.0 == property {
self.declarations[index] = (property, value)
replaced = true
break
}
}
if !replaced {
self.declarations.push((property, value))
}
self
}
///|
pub fn SvgCssRule::to_css(self : SvgCssRule) -> String {
let builder = StringBuilder()
builder.write_string(self.selector + " {")
for declaration in self.declarations {
builder.write_string(declaration.0 + ":" + declaration.1 + ";")
}
builder.write_string("}")
builder.to_string()
}
///|
pub fn SvgAssetBundle::new(name : String, version : String) -> SvgAssetBundle {
{ name, assets: [], symbols: [], styles: [], version }
}
///|
pub fn SvgAssetBundle::add(self : SvgAssetBundle, asset : SvgAsset) -> Bool {
for existing in self.assets {
if existing.id.key() == asset.id.key() {
return false
}
}
self.assets.push(asset)
true
}
///|
pub fn SvgAssetBundle::add_symbol(
self : SvgAssetBundle,
symbol : SvgSpriteSymbol,
) -> Bool {
for existing in self.symbols {
if existing.id == symbol.id {
return false
}
}
self.symbols.push(symbol)
true
}
///|
pub fn SvgAssetBundle::add_style(
self : SvgAssetBundle,
rule : SvgCssRule,
) -> Unit {
self.styles.push(rule)
}
///|
pub fn SvgAssetBundle::asset(self : SvgAssetBundle, key : String) -> SvgAsset? {
for asset in self.assets {
if asset.id.key() == key {
return Some(asset)
}
}
None
}
///|
pub fn SvgAssetBundle::tagged(
self : SvgAssetBundle,
tag : String,
) -> Array[SvgAsset] {
let result = []
for asset in self.assets {
if asset.tags.contains(tag) {
result.push(asset)
}
}
result
}
///|
pub fn SvgAssetBundle::manifest(self : SvgAssetBundle) -> String {
let builder = StringBuilder()
builder.write_string("name,version,key,file,usage,width,height,tags,bytes\n")
for asset in self.assets {
builder.write_string(
self.name +
"," +
self.version +
"," +
asset.id.key() +
"," +
asset.id.file_name() +
"," +
asset.usage +
"," +
asset.view_box.width.to_string() +
"," +
asset.view_box.height.to_string() +
"," +
asset.tags.join("|") +
"," +
asset.bytes().to_string() +
"\n",
)
}
builder.to_string()
}
///|
pub fn SvgAssetBundle::render_sprite(self : SvgAssetBundle) -> String {
let builder = StringBuilder()
builder.write_string(
"")
builder.to_string()
}
///|
pub fn SvgAssetBundle::render_styles(self : SvgAssetBundle) -> String {
let builder = StringBuilder()
builder.write_string("")
builder.to_string()
}
///|
pub fn SvgAssetBundle::size(self : SvgAssetBundle) -> Int {
self.assets.length()
}
///|
pub fn SvgAssetBundle::symbol_size(self : SvgAssetBundle) -> Int {
self.symbols.length()
}
///|
pub fn svg_asset_from_document(
space : String,
name : String,
revision : Int,
document : SvgDocument,
usage : String,
) -> SvgAsset {
SvgAsset::new(SvgAssetId::new(space, name, revision), document, usage)
}
///|
pub fn svg_symbol_from_document(
id : String,
document : SvgDocument,
) -> SvgSpriteSymbol {
let content = document.to_svg_string()
SvgSpriteSymbol::new(
id,
content,
Rect::new(0.0, 0.0, document.width, document.height),
)
}
///|
pub fn svg_use_symbol(
id : String,
x : Double,
y : Double,
width : Double,
height : Double,
) -> String {
""
}
///|
pub fn svg_asset_link(
asset : SvgAsset,
x : Double,
y : Double,
width : Double,
height : Double,
) -> String {
""
}
///|
fn svg_count_elements(element : SvgElement) -> (Int, Int) {
match element {
SvgPath(_, _) => (1, 0)
SvgGroup(children, ..) => {
let mut paths = 0
let mut groups = 1
for child in children {
let (child_paths, child_groups) = svg_count_elements(child)
paths += child_paths
groups += child_groups
}
(paths, groups)
}
}
}
///|
pub fn svg_document_element_counts(document : SvgDocument) -> (Int, Int) {
let mut paths = 0
let mut groups = 0
for element in document.elements {
let (element_paths, element_groups) = svg_count_elements(element)
paths += element_paths
groups += element_groups
}
(paths, groups)
}
///|
pub fn svg_document_bounds(document : SvgDocument) -> Rect {
Rect::new(0.0, 0.0, document.width, document.height)
}
///|
pub fn svg_document_has_transparency(document : SvgDocument) -> Bool {
for element in document.elements {
match element {
SvgPath(_, style) => if style.opacity < 0.999 { return true }
SvgGroup(children, ..) =>
for child in children {
match child {
SvgPath(_, style) => if style.opacity < 0.999 { return true }
_ => ()
}
}
}
}
false
}
///|
pub fn svg_document_to_data_uri(document : SvgDocument) -> String {
"data:image/svg+xml," + document.to_svg_string()
}
///|
pub fn svg_document_to_download_name(
prefix : String,
width : Double,
height : Double,
) -> String {
prefix +
"-" +
width.to_int().to_string() +
"x" +
height.to_int().to_string() +
".svg"
}
///|
pub fn svg_style_for_color(
color : Color,
stroke : Bool,
width : Double,
) -> SvgStyle {
if stroke {
SvgStyle::new(
fill="none",
stroke=color.to_hex(),
stroke_width=width.max(0.0),
)
} else {
SvgStyle::new(fill=color.to_hex(), stroke="none", stroke_width=0.0)
}
}
///|
pub fn svg_style_opacity(style : SvgStyle, opacity : Double) -> SvgStyle {
{ ..style, opacity: opacity.max(0.0).min(1.0) }
}
///|
pub fn svg_style_scaled_stroke(style : SvgStyle, scale : Double) -> SvgStyle {
{ ..style, stroke_width: style.stroke_width * scale.max(0.0) }
}
///|
pub fn svg_group_with_transform(
children : Array[SvgElement],
transform : Matrix2D,
) -> SvgElement {
SvgElement::SvgGroup(children, transform~)
}
///|
pub fn svg_document_group(
document : SvgDocument,
transform : Matrix2D,
) -> SvgDocument {
let result = SvgDocument::new(document.width, document.height)
result.add(SvgElement::SvgGroup(document.elements, transform~))
result
}
///|
pub fn svg_document_tile(
document : SvgDocument,
columns : Int,
rows : Int,
gap : Double,
) -> SvgDocument {
let result = SvgDocument::new(
document.width * columns.max(0).to_double() +
gap.max(0.0) * (columns - 1).max(0).to_double(),
document.height * rows.max(0).to_double() +
gap.max(0.0) * (rows - 1).max(0).to_double(),
)
if columns > 0 && rows > 0 {
for row in 0.. SvgDocument {
let factor = scale.max(0.0)
let result = SvgDocument::new(
document.width * factor,
document.height * factor,
)
result.add(
SvgElement::SvgGroup(
document.elements,
transform=Matrix2D::scale(factor, factor),
),
)
result
}
///|
pub fn svg_document_crop(document : SvgDocument, bounds : Rect) -> SvgDocument {
let result = SvgDocument::new(bounds.width, bounds.height)
result.add(
SvgElement::SvgGroup(
document.elements,
transform=Matrix2D::translate(-bounds.x, -bounds.y),
),
)
result
}
///|
pub fn svg_document_optimize(
document : SvgDocument,
) -> (SvgDocument, SvgOptimizationReport) {
let source = document.to_svg_string()
let (paths, groups) = svg_document_element_counts(document)
let mut empty = 0
let elements = []
for element in document.elements {
match element {
SvgGroup(children, transform~) =>
if children.length() == 0 {
empty += 1
} else {
elements.push(SvgElement::SvgGroup(children, transform~))
}
SvgPath(path, style) => elements.push(SvgElement::SvgPath(path, style))
}
}
let result = { ..document, elements, }
let optimized = result.to_svg_string()
let ratio = if source.length() == 0 {
1.0
} else {
optimized.length().to_double() / source.length().to_double()
}
(
result,
{
source_bytes: source.length(),
optimized_bytes: optimized.length(),
path_count: paths,
group_count: groups,
removed_empty_groups: empty,
compression_ratio: ratio,
},
)
}
///|
pub fn svg_optimization_report_text(report : SvgOptimizationReport) -> String {
"source=" +
report.source_bytes.to_string() +
" optimized=" +
report.optimized_bytes.to_string() +
" paths=" +
report.path_count.to_string() +
" groups=" +
report.group_count.to_string() +
" empty=" +
report.removed_empty_groups.to_string() +
" ratio=" +
report.compression_ratio.to_string()
}
///|
pub fn svg_document_checksum(document : SvgDocument) -> UInt {
let text = document.to_svg_string()
let mut hash : UInt = 2166136261
for index in 0.. UInt {
svg_document_checksum(asset.document)
}
///|
pub fn svg_bundle_checksum(bundle : SvgAssetBundle) -> UInt {
let mut hash : UInt = 2166136261
for asset in bundle.assets {
hash = (hash ^ svg_asset_checksum(asset)) * 16777619
}
hash
}
///|
pub fn svg_document_with_metadata(
document : SvgDocument,
metadata : DocumentMetadata,
) -> String {
metadata.to_comment() + document.to_svg_string()
}
///|
pub fn svg_asset_index(bundle : SvgAssetBundle) -> Array[String] {
let result = []
for asset in bundle.assets {
result.push(asset.id.key())
}
result
}
///|
pub fn svg_bundle_validate(bundle : SvgAssetBundle) -> Array[String] {
let errors = []
if bundle.name.length() == 0 {
errors.push("bundle name is empty")
}
if bundle.version.length() == 0 {
errors.push("bundle version is empty")
}
for asset in bundle.assets {
if asset.id.name.length() == 0 {
errors.push("asset name is empty")
}
if asset.document.width <= 0.0 || asset.document.height <= 0.0 {
errors.push("asset dimensions must be positive")
}
}
for symbol in bundle.symbols {
if symbol.id.length() == 0 {
errors.push("symbol id is empty")
}
if symbol.view_box.width <= 0.0 || symbol.view_box.height <= 0.0 {
errors.push("symbol view box must be positive")
}
}
errors
}
///|
pub fn svg_bundle_report(bundle : SvgAssetBundle) -> String {
let builder = StringBuilder()
builder.write_string(
"bundle=" +
bundle.name +
" version=" +
bundle.version +
" assets=" +
bundle.assets.length().to_string() +
" symbols=" +
bundle.symbols.length().to_string() +
" styles=" +
bundle.styles.length().to_string() +
" checksum=" +
svg_bundle_checksum(bundle).to_string() +
"\n",
)
for asset in bundle.assets {
let (paths, groups) = svg_document_element_counts(asset.document)
builder.write_string(
"- " +
asset.id.key() +
" paths=" +
paths.to_string() +
" groups=" +
groups.to_string() +
" bytes=" +
asset.bytes().to_string() +
" tags=" +
asset.tags.join("|") +
"\n",
)
}
builder.to_string()
}
///|
pub fn svg_make_icon_asset(
name : String,
path : Path,
color : Color,
size : Double,
) -> SvgAsset {
let document = SvgDocument::new(size, size)
document.add(
SvgElement::SvgPath(path, svg_style_for_color(color, false, 0.0)),
)
svg_asset_from_document("icon", name, 1, document, "interface icon")
}
///|
pub fn svg_make_pattern_asset(
name : String,
composition : Composition,
usage : String,
) -> SvgAsset {
svg_asset_from_document("pattern", name, 1, composition.render(), usage)
}
///|
pub fn svg_make_palette_styles(
palette : Palette,
prefix : String,
) -> SvgCssRule {
let rule = SvgCssRule::new(":root")
let mut result = rule
for index, color in palette.colors {
result = result.set("--" + prefix + "-" + index.to_string(), color.to_hex())
}
result
}
///|
pub fn svg_bundle_from_documents(
name : String,
documents : Array[(String, SvgDocument)],
) -> SvgAssetBundle {
let bundle = SvgAssetBundle::new(name, "1.0.0")
for item in documents {
let asset = svg_asset_from_document(
"generated",
item.0,
1,
item.1,
"generated artwork",
)
let _ = bundle.add(asset)
}
bundle
}
///|
pub fn svg_bundle_scale_assets(
bundle : SvgAssetBundle,
scale : Double,
) -> SvgAssetBundle {
let result = SvgAssetBundle::new(bundle.name, bundle.version)
for asset in bundle.assets {
let scaled = {
..asset,
document: svg_document_scale(asset.document, scale),
view_box: Rect::new(
0.0,
0.0,
asset.view_box.width * scale.max(0.0),
asset.view_box.height * scale.max(0.0),
),
}
let _ = result.add(scaled)
}
for symbol in bundle.symbols {
let _ = result.add_symbol(
symbol.sized(symbol.width * scale, symbol.height * scale),
)
}
for rule in bundle.styles {
result.add_style(rule)
}
result
}
///|
pub fn svg_bundle_crop_assets(
bundle : SvgAssetBundle,
bounds : Rect,
) -> SvgAssetBundle {
let result = SvgAssetBundle::new(bundle.name, bundle.version)
for asset in bundle.assets {
let cropped = {
..asset,
document: svg_document_crop(asset.document, bounds),
view_box: Rect::new(0.0, 0.0, bounds.width, bounds.height),
}
let _ = result.add(cropped)
}
result
}
///|
pub fn svg_symbol_grid(
symbols : Array[SvgSpriteSymbol],
columns : Int,
gap : Double,
) -> SvgDocument {
let safe_columns = columns.max(1)
let cell_width = symbols.fold(init=0.0, fn(best, symbol) {
best.max(symbol.width)
})
let cell_height = symbols.fold(init=0.0, fn(best, symbol) {
best.max(symbol.height)
})
let rows = if symbols.length() == 0 {
0
} else {
(symbols.length() + safe_columns - 1) / safe_columns
}
let document = SvgDocument::new(
safe_columns.to_double() * (cell_width + gap.max(0.0)),
rows.to_double() * (cell_height + gap.max(0.0)),
)
for index, symbol in symbols {
let x = (index % safe_columns).to_double() * (cell_width + gap.max(0.0))
let y = (index / safe_columns).to_double() * (cell_height + gap.max(0.0))
let path = Path::rect(symbol.width, symbol.height).transform(
Matrix2D::translate(x, y),
)
document.add(
SvgElement::SvgPath(
path,
SvgStyle::new(fill="none", stroke="#cccccc", stroke_width=0.5),
),
)
}
document
}
///|
pub fn svg_document_add_border(
document : SvgDocument,
color : Color,
width : Double,
) -> SvgDocument {
let result = { ..document, elements: document.elements.copy() }
result.add(
SvgElement::SvgPath(
Path::rect(document.width, document.height),
svg_style_for_color(color, true, width),
),
)
result
}
///|
pub fn svg_document_background(
document : SvgDocument,
color : Color,
) -> SvgDocument {
let result = SvgDocument::new(document.width, document.height)
result.add(
SvgElement::SvgPath(
Path::rect(document.width, document.height),
svg_style_for_color(color, false, 0.0),
),
)
for element in document.elements {
result.add(element)
}
result
}
///|
pub fn svg_asset_with_tags(asset : SvgAsset, tags : Array[String]) -> SvgAsset {
let result = { ..asset, tags: [] }
for tag in tags {
let _ = result.tag(tag)
}
result
}
///|
pub fn svg_bundle_tag_index(bundle : SvgAssetBundle) -> String {
let builder = StringBuilder()
let tags = []
for asset in bundle.assets {
for tag in asset.tags {
if !tags.contains(tag) {
tags.push(tag)
}
}
}
for tag in tags {
builder.write_string(tag + ":")
for asset in bundle.assets {
if asset.tags.contains(tag) {
builder.write_string(asset.id.key() + ",")
}
}
builder.write_string("\n")
}
builder.to_string()
}
///|
pub fn svg_bundle_size_bytes(bundle : SvgAssetBundle) -> Int {
bundle.assets.fold(init=0, fn(total, asset) { total + asset.bytes() })
}
///|
pub fn svg_bundle_optimize(
bundle : SvgAssetBundle,
) -> (SvgAssetBundle, Array[SvgOptimizationReport]) {
let result = SvgAssetBundle::new(bundle.name, bundle.version)
let reports = []
for asset in bundle.assets {
let (optimized, report) = svg_document_optimize(asset.document)
let _ = result.add({ ..asset, document: optimized })
reports.push(report)
}
for symbol in bundle.symbols {
let _ = result.add_symbol(symbol)
}
for rule in bundle.styles {
result.add_style(rule)
}
(result, reports)
}
///|
pub fn svg_bundle_manifest_json_like(bundle : SvgAssetBundle) -> String {
let builder = StringBuilder()
builder.write_string("{")
builder.write_string(
"\"name\":\"" +
escape_svg(bundle.name) +
"\",\"version\":\"" +
escape_svg(bundle.version) +
"\",\"assets\":[",
)
for index, asset in bundle.assets {
if index > 0 {
builder.write_string(",")
}
builder.write_string("{")
builder.write_string(
"\"key\":\"" +
escape_svg(asset.id.key()) +
"\",\"file\":\"" +
escape_svg(asset.id.file_name()) +
"\",\"bytes\":" +
asset.bytes().to_string(),
)
builder.write_string("}")
}
builder.write_string("]}")
builder.to_string()
}
///|
pub fn svg_text_label(
text : String,
position : Vec2,
size : Double,
color : Color,
) -> String {
"" +
escape_svg(text) +
""
}
///|
pub fn svg_guides(
document : SvgDocument,
spacing : Double,
color : Color,
) -> SvgDocument {
let result = { ..document, elements: document.elements.copy() }
let step = spacing.max(1.0)
let style = svg_style_for_color(color, true, 0.25)
let mut x = 0.0
while x <= document.width {
result.add(
SvgElement::SvgPath(
Path::new().move_to(x, 0.0).line_to(x, document.height),
style,
),
)
x += step
}
let mut y = 0.0
while y <= document.height {
result.add(
SvgElement::SvgPath(
Path::new().move_to(0.0, y).line_to(document.width, y),
style,
),
)
y += step
}
result
}