├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitattributes ├── lib ├── victor │ ├── version.rb │ ├── script.rb │ ├── templates │ │ ├── minimal.svg │ │ └── default.svg │ ├── svg.rb │ ├── dsl.rb │ ├── marshaling.rb │ ├── attributes.rb │ ├── css.rb │ ├── component.rb │ └── svg_base.rb └── victor.rb ├── spec ├── approvals │ ├── css │ │ ├── css3 │ │ ├── css1 │ │ ├── render │ │ └── css2 │ ├── svg │ │ ├── minimal │ │ ├── glue │ │ ├── full │ │ └── css │ └── component │ │ └── set1 │ │ └── render ├── fixtures │ ├── custom_template.svg │ ├── dsl_script.rb │ └── components │ │ └── component_set1.rb ├── README.md ├── victor │ ├── svg_base_spec.rb │ ├── script_spec.rb │ ├── marshaling_spec.rb │ ├── component_subclass_spec.rb │ ├── svg_marshaling_spec.rb │ ├── dsl_spec.rb │ ├── css_spec.rb │ ├── attributes_spec.rb │ ├── component_spec.rb │ └── svg_spec.rb └── spec_helper.rb ├── .rspec ├── .codespellrc ├── .gitignore ├── .envrc.example ├── Runfile ├── Gemfile ├── config.reek ├── assets ├── ghost.svg └── logo.svg ├── examples └── README.md ├── victor.gemspec ├── .rubocop.yml ├── LICENSE ├── README.md └── CHANGELOG.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: DannyBen 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | Runfile linguist-language=Ruby 2 | -------------------------------------------------------------------------------- /lib/victor/version.rb: -------------------------------------------------------------------------------- 1 | module Victor 2 | VERSION = '0.5.0' 3 | end 4 | -------------------------------------------------------------------------------- /spec/approvals/css/css3: -------------------------------------------------------------------------------- 1 | @import some url; 2 | @import another url; -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | --format documentation 4 | --fail-fast -------------------------------------------------------------------------------- /lib/victor/script.rb: -------------------------------------------------------------------------------- 1 | require 'victor' 2 | include Victor 3 | include Victor::DSL 4 | -------------------------------------------------------------------------------- /spec/approvals/css/css1: -------------------------------------------------------------------------------- 1 | .main { 2 | color: black; 3 | background: white; 4 | } -------------------------------------------------------------------------------- /lib/victor/templates/minimal.svg: -------------------------------------------------------------------------------- 1 | 2 | %{style} 3 | %{content} 4 | 5 | -------------------------------------------------------------------------------- /spec/approvals/css/render: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = coverage,tmp,Gemfile.lock,sublime* 3 | ; ignore-words-list = rouge 4 | -------------------------------------------------------------------------------- /spec/fixtures/custom_template.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | %{content} 4 | -------------------------------------------------------------------------------- /spec/fixtures/dsl_script.rb: -------------------------------------------------------------------------------- 1 | require 'victor/script' 2 | 3 | p SVG 4 | p svg.class 5 | p respond_to? :build 6 | -------------------------------------------------------------------------------- /lib/victor/templates/default.svg: -------------------------------------------------------------------------------- 1 | 2 | %{style} 3 | %{content} 4 | 5 | -------------------------------------------------------------------------------- /spec/approvals/svg/minimal: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /spec/README.md: -------------------------------------------------------------------------------- 1 | Testing 2 | ================================================== 3 | 4 | Run tests with: 5 | 6 | $ bundle exec run spec 7 | 8 | -------------------------------------------------------------------------------- /spec/approvals/css/css2: -------------------------------------------------------------------------------- 1 | @keyframes animation { 2 | 0% { 3 | font-size: 10px; 4 | } 5 | 30% { 6 | font-size: 15px; 7 | } 8 | } -------------------------------------------------------------------------------- /spec/approvals/svg/glue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /spec/approvals/svg/full: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | /.yardoc 3 | /coverage 4 | /debug.runfile 5 | /dev 6 | /doc 7 | /Gemfile.lock 8 | /gems 9 | /spec/coverage 10 | /spec/status.txt 11 | /test.svg 12 | /tmp 13 | -------------------------------------------------------------------------------- /spec/victor/svg_base_spec.rb: -------------------------------------------------------------------------------- 1 | describe Victor::SVGBase do 2 | it 'does not define #method_missing' do 3 | expect { subject.polygon }.to raise_error NoMethodError 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.envrc.example: -------------------------------------------------------------------------------- 1 | # for git-changelog (https://github.com/dannyben/git-changelog) 2 | export CHANGELOG_COMMIT_URL=https://github.com/DannyBen/victor/commit/%h 3 | export CHANGELOG_COMPARE_URL=https://github.com/dannyben/victor/compare/%s 4 | -------------------------------------------------------------------------------- /lib/victor/svg.rb: -------------------------------------------------------------------------------- 1 | module Victor 2 | class SVG < SVGBase 3 | def method_missing(method_sym, ...) 4 | tag(method_sym, ...) 5 | end 6 | 7 | def respond_to_missing?(*) 8 | true 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Runfile: -------------------------------------------------------------------------------- 1 | require 'filewatcher' 2 | require 'debug' 3 | require 'victor' 4 | 5 | title "Victor Developer Toolbelt" 6 | summary "Runfile tasks for building the Victor gem" 7 | version Victor::VERSION 8 | 9 | import_gem 'runfile-tasks/gem' 10 | import 'debug' 11 | -------------------------------------------------------------------------------- /spec/approvals/svg/css: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /spec/victor/script_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'victor/script' do 2 | subject { File.read 'spec/fixtures/dsl_script.rb' } 3 | 4 | it 'includes Victor and Victor::DSL' do 5 | expect { eval subject }.to output("Victor::SVG\nVictor::SVG\ntrue\n").to_stdout 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'debug' 4 | gem 'filewatcher', require: false 5 | gem 'rentacop', require: false 6 | gem 'rspec' 7 | gem 'rspec_approvals' 8 | gem 'runfile', require: false 9 | gem 'runfile-tasks', require: false 10 | gem 'simplecov' 11 | 12 | gemspec 13 | -------------------------------------------------------------------------------- /lib/victor/dsl.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module Victor 4 | module DSL 5 | extend Forwardable 6 | def_delegators :svg, :setup, :build, :save, :render, :append, :element, :tag, :css 7 | 8 | def svg 9 | @svg ||= Victor::SVG.new 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /config.reek: -------------------------------------------------------------------------------- 1 | --- 2 | exclude_paths: 3 | - dev 4 | 5 | Attribute: 6 | enabled: false 7 | 8 | FeatureEnvy: 9 | exclude: 10 | - Victor::Attributes#to_s 11 | - Victor::CSS#convert_hash 12 | - Victor::CSS#css_block 13 | 14 | TooManyStatements: 15 | max_statements: 7 16 | exclude: 17 | - Victor::CSS#css_block 18 | - Victor::SVG#element 19 | -------------------------------------------------------------------------------- /spec/victor/marshaling_spec.rb: -------------------------------------------------------------------------------- 1 | describe Victor::Marshaling do 2 | subject do 3 | Class.new do 4 | include Victor::Marshaling 5 | end.new 6 | end 7 | 8 | describe '#marshaling' do 9 | it 'raises a NotImplementedError' do 10 | expect { subject.marshaling }.to raise_error(NotImplementedError) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/victor.rb: -------------------------------------------------------------------------------- 1 | require 'victor/version' 2 | 3 | module Victor 4 | autoload :Attributes, 'victor/attributes' 5 | autoload :Component, 'victor/component' 6 | autoload :CSS, 'victor/css' 7 | autoload :DSL, 'victor/dsl' 8 | autoload :Marshaling, 'victor/marshaling' 9 | autoload :SVG, 'victor/svg' 10 | autoload :SVGBase, 'victor/svg_base' 11 | end 12 | -------------------------------------------------------------------------------- /spec/victor/component_subclass_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../fixtures/components/component_set1' 2 | 3 | describe 'Component subclassing' do 4 | subject { ComponentSet1::Main.new } 5 | 6 | describe '#render' do 7 | it 'returns the expected SVG' do 8 | expect(subject.render).to match_approval 'component/set1/render' 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | unless ENV['NOCOV'] 3 | SimpleCov.start do 4 | enable_coverage :branch if ENV['BRANCH_COV'] 5 | coverage_dir 'spec/coverage' 6 | # track_files 'lib/**/*.rb' 7 | end 8 | end 9 | 10 | require 'bundler' 11 | Bundler.require :default, :development 12 | 13 | RSpec.configure do |config| 14 | config.example_status_persistence_file_path = 'spec/status.txt' 15 | end 16 | -------------------------------------------------------------------------------- /spec/approvals/component/set1/render: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | Two 17 | 18 | 19 | Tada 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /assets/ghost.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | push: { branches: master } 5 | 6 | jobs: 7 | test: 8 | name: Ruby ${{ matrix.ruby }} 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: { ruby: ['3.1', '3.2', '3.3', '3.4'] } 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Install OS dependencies 18 | run: sudo apt-get -y install libyaml-dev 19 | 20 | - name: Setup Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: '${{ matrix.ruby }}' 24 | bundler-cache: true 25 | 26 | - name: Run tests 27 | run: bundle exec rspec 28 | -------------------------------------------------------------------------------- /spec/fixtures/components/component_set1.rb: -------------------------------------------------------------------------------- 1 | module ComponentSet1 2 | class Base < Victor::Component 3 | def width = 100 4 | def height = 100 5 | end 6 | 7 | class Main < Base 8 | def body 9 | add.g transform: 'translate(10, 10)' do 10 | append Two.new 11 | end 12 | end 13 | 14 | def style = { '.one': { stroke: :magenta } } 15 | end 16 | 17 | class Two < Base 18 | def body 19 | add.text 'Two' 20 | append Three.new 21 | end 22 | 23 | def style = { '.two': { stroke: :magenta } } 24 | end 25 | 26 | class Three < Base 27 | def body 28 | add.text 'Tada' 29 | end 30 | 31 | def style = { '.three': { stroke: :magenta } } 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Victor offers a wide range of possibilities for creating SVG images. To help you 4 | get started or explore more advanced techniques, we've provided a variety of 5 | examples. 6 | 7 | --- 8 | 9 | ### ➜ [Victor Documentation][1] ❯ [Ways to Use Victor][2] 10 | 11 | For comprehensive explanations and step-by-step guides: 12 | 13 | This section is designed to guide you through different usage patterns, from basic usage to 14 | more complex implementations, allowing you to fully leverage Victor's 15 | capabilities. 16 | 17 | --- 18 | 19 | ### ➜ [Victor Documentation Source Code / examples folder][3] 20 | 21 | If you prefer browsing the example source code. 22 | 23 | [1]: https://victor.dannyb.co/ 24 | [2]: https://victor.dannyb.co/usage-patterns/ 25 | [3]: https://github.com/DannyBen/victor-book/tree/master/src/examples 26 | -------------------------------------------------------------------------------- /lib/victor/marshaling.rb: -------------------------------------------------------------------------------- 1 | module Victor 2 | module Marshaling 3 | def marshaling 4 | raise NotImplementedError, "#{self.class.name} must implement `marshaling'" 5 | end 6 | 7 | # YAML serialization methods 8 | def encode_with(coder) 9 | marshaling.each do |attr| 10 | coder[attr.to_s] = send(attr) 11 | end 12 | end 13 | 14 | def init_with(coder) 15 | marshaling.each do |attr| 16 | instance_variable_set(:"@#{attr}", coder[attr.to_s]) 17 | end 18 | end 19 | 20 | # Marshal serialization methods 21 | def marshal_dump 22 | marshaling.to_h do |attr| 23 | [attr, send(attr)] 24 | end 25 | end 26 | 27 | def marshal_load(data) 28 | marshaling.each do |attr| 29 | instance_variable_set(:"@#{attr}", data[attr]) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /victor.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'victor/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'victor' 7 | s.version = Victor::VERSION 8 | s.summary = 'SVG Builder' 9 | s.description = 'Build SVG images with ease' 10 | s.authors = ['Danny Ben Shitrit'] 11 | s.email = 'db@dannyben.com' 12 | s.files = Dir['README.md', 'lib/**/*.*'] 13 | s.homepage = 'https://github.com/DannyBen/victor' 14 | s.license = 'MIT' 15 | 16 | s.required_ruby_version = '>= 3.1' 17 | 18 | s.metadata = { 19 | 'bug_tracker_uri' => 'https://github.com/DannyBen/victor/issues', 20 | 'changelog_uri' => 'https://github.com/DannyBen/victor/blob/master/CHANGELOG.md', 21 | 'homepage_uri' => 'https://victor.dannyb.co/', 22 | 'source_code_uri' => 'https://github.com/DannyBen/victor', 23 | 'rubygems_mfa_required' => 'true', 24 | } 25 | end 26 | -------------------------------------------------------------------------------- /spec/victor/svg_marshaling_spec.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | describe Victor::SVG do 4 | subject do 5 | described_class.new viewBox: '0 0 10 100' do 6 | text 'Hello world' 7 | rect x: 0, y: 0, width: 10, height: 100 8 | css['*'] = { font_family: 'Assistant' } 9 | end 10 | end 11 | 12 | describe 'YAML marshaling' do 13 | it 'serializes and deserializes correctly using YAML' do 14 | yaml_data = YAML.dump subject 15 | restored_object = YAML.unsafe_load yaml_data 16 | 17 | expect(restored_object).to be_a described_class 18 | expect(restored_object.render).to eq subject.render 19 | end 20 | end 21 | 22 | describe 'Ruby marshaling' do 23 | it 'serializes and deserializes correctly using Marshal' do 24 | marshaled_data = Marshal.dump subject 25 | restored_object = Marshal.load marshaled_data 26 | 27 | expect(restored_object).to be_a described_class 28 | expect(restored_object.render).to eq subject.render 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | - rubocop-performance 4 | 5 | inherit_gem: 6 | rentacop: 7 | - rentacop.yml 8 | - rspec.yml 9 | 10 | # Merge `Exclude` arrays 11 | inherit_mode: 12 | merge: 13 | - Exclude 14 | 15 | AllCops: 16 | TargetRubyVersion: 3.1 17 | SuggestExtensions: false 18 | Exclude: 19 | - 'dev/**/*' 20 | 21 | # There is a special use case that needs this 22 | Lint/LiteralAsCondition: 23 | Exclude: 24 | - 'spec/**/*' 25 | 26 | # `SVGBase#element` is a bit complex 27 | Metrics/PerceivedComplexity: 28 | Max: 11 29 | 30 | # This test is allowed to use `eval` 31 | Security/Eval: 32 | Exclude: 33 | - spec/victor/script_spec.rb 34 | 35 | # Allow `include Victor` in some places 36 | Style/MixinUsage: 37 | Exclude: 38 | - lib/victor/script.rb 39 | 40 | # We use Marshal.load to test that it *can* be done. Allow it. 41 | Security/MarshalLoad: 42 | Exclude: 43 | - spec/victor/svg_marshaling_spec.rb 44 | 45 | RSpec/ExampleLength: 46 | Exclude: 47 | - spec/victor/svg_spec.rb 48 | -------------------------------------------------------------------------------- /lib/victor/attributes.rb: -------------------------------------------------------------------------------- 1 | module Victor 2 | # Handles conversion from a Hash of attributes, to an XML string or 3 | # a CSS string. 4 | class Attributes 5 | attr_reader :attributes 6 | 7 | def initialize(attributes = {}) 8 | @attributes = attributes 9 | end 10 | 11 | def to_s 12 | mapped = attributes.map do |key, value| 13 | key = key.to_s.tr '_', '-' 14 | 15 | case value 16 | when Hash 17 | style = Attributes.new(value).to_style 18 | "#{key}=\"#{style}\"" 19 | when Array 20 | "#{key}=\"#{value.join ' '}\"" 21 | else 22 | "#{key}=#{value.to_s.encode(xml: :attr)}" 23 | end 24 | end 25 | 26 | mapped.join ' ' 27 | end 28 | 29 | def to_style 30 | mapped = attributes.map do |key, value| 31 | key = key.to_s.tr '_', '-' 32 | "#{key}:#{value}" 33 | end 34 | 35 | mapped.join '; ' 36 | end 37 | 38 | def [](key) 39 | attributes[key] 40 | end 41 | 42 | def []=(key, value) 43 | attributes[key] = value 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Danny Ben Shitrit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /spec/victor/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | describe Victor::DSL do 2 | subject { Class.new { include Victor::DSL }.new } 3 | 4 | describe '#svg' do 5 | it 'returns a Victor::SVG.instance' do 6 | expect(subject.svg).to be_a Victor::SVG 7 | end 8 | end 9 | 10 | describe '#setup' do 11 | it 'forwards the call to the svg object' do 12 | expect(subject.svg).to receive(:setup).with(attrib: :utes) 13 | subject.setup attrib: :utes 14 | end 15 | end 16 | 17 | describe '#build' do 18 | it 'forwards the call to the svg object' do 19 | expect(subject.svg).to receive(:build) 20 | subject.build 21 | end 22 | end 23 | 24 | describe '#save' do 25 | it 'forwards the call to the svg object' do 26 | expect(subject.svg).to receive(:save).with('filename') 27 | subject.save 'filename' 28 | end 29 | end 30 | 31 | describe '#render' do 32 | it 'forwards the call to the svg object' do 33 | expect(subject.svg).to receive(:render) 34 | subject.render 35 | end 36 | end 37 | 38 | describe '#css' do 39 | it 'forwards the call to the svg object' do 40 | expect(subject.svg).to receive(:css) 41 | subject.css 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/victor/css.rb: -------------------------------------------------------------------------------- 1 | module Victor 2 | class CSS 3 | attr_reader :attributes 4 | 5 | def initialize(attributes = nil) 6 | @attributes = attributes || {} 7 | end 8 | 9 | def to_s 10 | convert_hash attributes 11 | end 12 | 13 | def render 14 | return '' if attributes.empty? 15 | 16 | %[\n] 17 | end 18 | 19 | protected 20 | 21 | def convert_hash(hash, indent = 2) 22 | return hash unless hash.is_a? Hash 23 | 24 | result = [] 25 | hash.each do |key, value| 26 | key = key.to_s.tr '_', '-' 27 | result += css_block(key, value, indent) 28 | end 29 | 30 | result.join "\n" 31 | end 32 | 33 | def css_block(key, value, indent) 34 | result = [] 35 | 36 | my_indent = ' ' * indent 37 | 38 | case value 39 | when Hash 40 | result.push "#{my_indent}#{key} {" 41 | result.push convert_hash(value, indent + 2) 42 | result.push "#{my_indent}}" 43 | when Array 44 | value.each do |row| 45 | result.push "#{my_indent}#{key} #{row};" 46 | end 47 | else 48 | result.push "#{my_indent}#{key}: #{value};" 49 | end 50 | 51 | result 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | # Victor - Ruby SVG Image Builder 5 | 6 | ## [victor.dannyb.co](https://victor.dannyb.co) 7 | 8 |
9 | 10 | --- 11 | 12 | **Victor** is a lightweight, zero-dependencies Ruby library that lets you build 13 | SVG images using Ruby code. 14 | 15 | --- 16 | 17 | ## Install 18 | 19 | ``` 20 | $ gem install victor 21 | ``` 22 | 23 | ## Example 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | ```ruby 32 | setup viewBox: '0 0 100 100' 33 | 34 | build do 35 | rect x: 0, y: 0, width: 100, height: 100, fill: :white 36 | circle cx: 50, cy: 50, r: 40, fill: 'yellow' 37 | rect x: 10, y: 50, width: 80, height: 50, fill: :yellow 38 | 39 | [25, 50].each do |x| 40 | circle cx: x, cy: 40, r: 8, fill: :white 41 | end 42 | 43 | path fill: 'white', d: %w[ 44 | M11 100 l13 -15 l13 15 l13 -15 45 | l13 15 l13 -15 l13 15 Z 46 | ] 47 | end 48 | ``` 49 | 50 |
51 | 52 | 53 | ## Documentation 54 | 55 | - [Victor Homepage][docs] 56 | 57 | ## Contributing / Support 58 | 59 | If you experience any issue, have a question or a suggestion, or if you wish 60 | to contribute, feel free to [open an issue][issues] or 61 | [start a discussion][discussions]. 62 | 63 | [issues]: https://github.com/DannyBen/victor/issues 64 | [discussions]: https://github.com/DannyBen/victor/discussions 65 | [docs]: https://victor.dannyb.co/ 66 | -------------------------------------------------------------------------------- /spec/victor/css_spec.rb: -------------------------------------------------------------------------------- 1 | describe Victor::CSS do 2 | subject { described_class.new css } 3 | 4 | let(:css) { {} } 5 | 6 | describe '#to_s' do 7 | it 'converts css one level deep' do 8 | css['.main'] = { 9 | color: 'black', 10 | background: 'white', 11 | } 12 | 13 | expect(subject.to_s).to match_approval 'css/css1' 14 | end 15 | 16 | it 'converts css several levels deep' do 17 | css['@keyframes animation'] = { 18 | '0%' => { font_size: '10px' }, 19 | '30%' => { font_size: '15px' }, 20 | } 21 | 22 | expect(subject.to_s).to match_approval 'css/css2' 23 | end 24 | 25 | it 'converts array values to single lines' do 26 | css['@import'] = [ 27 | 'some url', 28 | 'another url', 29 | ] 30 | 31 | expect(subject.to_s).to match_approval 'css/css3' 32 | end 33 | 34 | context 'when attributes are not a hash' do 35 | let(:css) { '.class { color: blue }' } 36 | 37 | it 'returns the attributes as is' do 38 | expect(subject.to_s).to eq css 39 | end 40 | end 41 | end 42 | 43 | describe '#render' do 44 | let(:css) { { '.main' => { color: 'black' } } } 45 | 46 | it 'returns the css string wrapped inside style tags' do 47 | expect(subject.render).to match_approval 'css/render' 48 | end 49 | 50 | context 'when the css is empty' do 51 | let(:css) { {} } 52 | 53 | it 'returns an empty string' do 54 | expect(subject.render).to be_empty 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/victor/component.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | module Victor 4 | class Component 5 | extend Forwardable 6 | include Marshaling 7 | 8 | def_delegators :svg, :save, :render, :content, :element, :tag, :css, :to_s 9 | 10 | # Marshaling data 11 | def marshaling = %i[width height x y svg merged_css] 12 | 13 | # Subclasses MUST implement this 14 | def body 15 | raise(NotImplementedError, "#{self.class.name} must implement `body'") 16 | end 17 | 18 | # Subclasses MUST override these methods, OR assign instance vars 19 | def height 20 | @height || raise(NotImplementedError, 21 | "#{self.class.name} must implement `height' or `@height'") 22 | end 23 | 24 | def width 25 | @width || raise(NotImplementedError, 26 | "#{self.class.name} must implement `width' or `@width'") 27 | end 28 | 29 | # Subclasses MAY override these methods, OR assign instance vars 30 | def style = @style ||= {} 31 | def x = @x ||= 0 32 | def y = @y ||= 0 33 | 34 | # Appending/Embedding - DSL for the `#body` implementation 35 | def append(component) 36 | svg_instance.append component.svg 37 | merged_css.merge! component.merged_css 38 | end 39 | alias embed append 40 | 41 | # SVG / CSS 42 | def svg 43 | @svg ||= begin 44 | body 45 | svg_instance.css = merged_css 46 | svg_instance 47 | end 48 | end 49 | 50 | protected 51 | 52 | # Start with an ordinary SVG instance 53 | def svg_instance = @svg_instance ||= SVG.new(viewBox: "#{x} #{y} #{width} #{height}") 54 | 55 | # Internal DSL to enable `add.anything` in the `#body` implementation 56 | alias add svg_instance 57 | 58 | # Start with a copy of our own style 59 | def merged_css = @merged_css ||= style.dup 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/victor/attributes_spec.rb: -------------------------------------------------------------------------------- 1 | describe Victor::Attributes do 2 | attrs = nil 3 | subject { described_class.new attrs } 4 | 5 | it 'makes attributes accessible hash-style' do 6 | attrs = { hello: :world } 7 | expect(subject[:hello]).to eq :world 8 | end 9 | 10 | describe '#to_s' do 11 | it 'converts to xml attributes' do 12 | attrs = { duke: :nukem, vanilla: :ice } 13 | expect(subject.to_s).to eq 'duke="nukem" vanilla="ice"' 14 | end 15 | 16 | it 'escapes XML' do 17 | attrs = { href: '/speaker?needs=an&lifier', encode_me: '<>' } 18 | expect(subject.to_s).to eq 'href="/speaker?needs=an&lifier" encode-me="<>"' 19 | end 20 | 21 | it 'converts nested attributes to style' do 22 | attrs = { dudes: { duke: :nukem, vanilla: :ice } } 23 | expect(subject.to_s).to eq 'dudes="duke:nukem; vanilla:ice"' 24 | end 25 | 26 | it 'converts array to space delimited string' do 27 | attrs = { points: [1, 2, 3, 4] } 28 | expect(subject.to_s).to eq 'points="1 2 3 4"' 29 | end 30 | end 31 | 32 | describe '#to_style' do 33 | it 'converts to style compatible string' do 34 | attrs = { duke: :nukem, vanilla: :ice } 35 | expect(subject.to_style).to eq 'duke:nukem; vanilla:ice' 36 | end 37 | 38 | it 'converts underscores to dashes' do 39 | attrs = { heroes_of_the_storm: 10 } 40 | expect(subject.to_style).to eq 'heroes-of-the-storm:10' 41 | end 42 | end 43 | 44 | describe '#[]' do 45 | it 'returns an attribute value' do 46 | attrs = { hello: :world } 47 | expect(subject[:hello]).to eq :world 48 | end 49 | end 50 | 51 | describe '#[]=' do 52 | it 'sets an attribute value' do 53 | attrs = { hello: :world } 54 | subject[:hello] = :overridden 55 | expect(subject[:hello]).to eq :overridden 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/victor/svg_base.rb: -------------------------------------------------------------------------------- 1 | module Victor 2 | class SVGBase 3 | include Marshaling 4 | 5 | attr_accessor :template, :glue 6 | attr_reader :content, :svg_attributes 7 | attr_writer :css 8 | 9 | def initialize(attributes = nil, &block) 10 | setup attributes 11 | @content = [] 12 | build(&block) if block 13 | end 14 | 15 | def marshaling 16 | %i[template glue svg_attributes css content] 17 | end 18 | 19 | def <<(additional_content) 20 | content.push additional_content.to_s 21 | end 22 | alias append << 23 | alias embed << 24 | 25 | def setup(attributes = nil) 26 | attributes ||= {} 27 | attributes[:width] ||= '100%' 28 | attributes[:height] ||= '100%' 29 | 30 | @template = attributes[:template] || @template || :default 31 | @glue = attributes[:glue] || @glue || "\n" 32 | 33 | attributes.delete :template 34 | attributes.delete :glue 35 | 36 | @svg_attributes = Attributes.new attributes 37 | end 38 | 39 | def build(&) 40 | instance_eval(&) 41 | end 42 | 43 | def tag(name, value = nil, attributes = {}) 44 | if value.is_a? Hash 45 | attributes = value 46 | value = nil 47 | end 48 | 49 | escape = true 50 | 51 | if name.to_s.end_with? '!' 52 | escape = false 53 | name = name[0..-2] 54 | end 55 | 56 | attributes = Attributes.new attributes 57 | empty_tag = name.to_s == '_' 58 | 59 | if block_given? || value 60 | content.push "#{"<#{name} #{attributes}".strip}>" unless empty_tag 61 | if value 62 | content.push(escape ? value.to_s.encode(xml: :text) : value) 63 | else 64 | yield 65 | end 66 | content.push "" unless empty_tag 67 | else 68 | content.push "<#{name} #{attributes}/>" 69 | end 70 | end 71 | alias element tag 72 | 73 | def css(defs = nil) 74 | @css ||= {} 75 | @css = defs if defs 76 | @css 77 | end 78 | 79 | def render(template: nil, glue: nil) 80 | @template = template if template 81 | @glue = glue if glue 82 | css_handler = CSS.new css 83 | 84 | svg_template % { 85 | css: css_handler, 86 | style: css_handler.render, 87 | attributes: svg_attributes, 88 | content: to_s, 89 | } 90 | end 91 | 92 | def to_s 93 | content.join glue 94 | end 95 | 96 | def save(filename, template: nil, glue: nil) 97 | filename = "#{filename}.svg" unless /\..{2,4}$/.match?(filename) 98 | File.write filename, render(template: template, glue: glue) 99 | end 100 | 101 | protected 102 | 103 | def svg_template 104 | File.read template_path 105 | end 106 | 107 | def template_path 108 | if template.is_a? Symbol 109 | File.join File.dirname(__FILE__), 'templates', "#{template}.svg" 110 | else 111 | template 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/victor/component_spec.rb: -------------------------------------------------------------------------------- 1 | describe Victor::Component do 2 | describe '#body' do 3 | it 'raises a NotImplementedError' do 4 | expect { subject.body }.to raise_error(NotImplementedError) 5 | end 6 | end 7 | 8 | describe '#height' do 9 | it 'raises a NotImplementedError' do 10 | expect { subject.height }.to raise_error(NotImplementedError) 11 | end 12 | end 13 | 14 | describe '#width' do 15 | it 'raises a NotImplementedError' do 16 | expect { subject.width }.to raise_error(NotImplementedError) 17 | end 18 | end 19 | 20 | describe '#style' do 21 | it 'returns an empty hash' do 22 | expect(subject.style).to eq({}) 23 | end 24 | end 25 | 26 | describe '#x' do 27 | it 'returns 0' do 28 | expect(subject.x).to eq 0 29 | end 30 | end 31 | 32 | describe '#y' do 33 | it 'returns 0' do 34 | expect(subject.y).to eq 0 35 | end 36 | end 37 | 38 | context 'when all required methods are implemented' do 39 | let(:svg) do 40 | double save: true, render: true, content: true, element: true, tag: true, to_s: true 41 | end 42 | 43 | before do 44 | allow(subject).to receive_messages(body: nil, width: 100, height: 100) 45 | allow(subject).to receive(:svg).and_return(svg) 46 | end 47 | 48 | describe '#save' do 49 | it 'delegates to SVG' do 50 | expect(svg).to receive(:save).with('filename') 51 | subject.save 'filename' 52 | end 53 | end 54 | 55 | describe '#render' do 56 | it 'delegates to SVG' do 57 | expect(svg).to receive(:render).with(template: :minimal) 58 | subject.render template: :minimal 59 | end 60 | end 61 | 62 | describe '#content' do 63 | it 'delegates to SVG' do 64 | expect(svg).to receive(:content) 65 | subject.content 66 | end 67 | end 68 | 69 | describe '#tag' do 70 | it 'delegates to SVG' do 71 | expect(svg).to receive(:tag).with(:rect) 72 | subject.tag :rect 73 | end 74 | end 75 | 76 | describe '#element' do 77 | it 'delegates to SVG' do 78 | expect(svg).to receive(:element).with(:rect) 79 | subject.element :rect 80 | end 81 | end 82 | 83 | describe '#to_s' do 84 | it 'delegates to SVG' do 85 | expect(svg).to receive(:to_s) 86 | subject.to_s 87 | end 88 | end 89 | 90 | describe '#append' do 91 | let(:component) { double svg: 'mocked_svg', merged_css: { color: 'red' } } 92 | let(:svg_instance) { double append: true } 93 | let(:merged_css) { double merge!: true } 94 | 95 | it 'appends another component and merges its css' do 96 | allow(subject).to receive_messages(svg_instance: svg_instance, merged_css: merged_css) 97 | expect(svg_instance).to receive(:append).with('mocked_svg') 98 | expect(merged_css).to receive(:merge!).with({ color: 'red' }) 99 | 100 | subject.append component 101 | end 102 | end 103 | 104 | describe '#embed' do 105 | it 'is an alias to #append' do 106 | expect(subject.method(:embed)).to eq subject.method(:append) 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ======================================== 3 | 4 | v0.5.0 - 2024-08-29 5 | ---------------------------------------- 6 | 7 | - Add Victor::Component for component-driven SVG composition [`2786ae2`](https://github.com/DannyBen/victor/commit/2786ae2) 8 | - Remove xlink from svg tag [`440ff61`](https://github.com/DannyBen/victor/commit/440ff61) 9 | - Add `SVG#embed` as an alias to `SVG#append` [`79940d6`](https://github.com/DannyBen/victor/commit/79940d6) 10 | - Add `SVG#tag` as the preferred alias to `SVG#element` [`dff5399`](https://github.com/DannyBen/victor/commit/dff5399) 11 | - Update logo and documentation [`ae17bd5`](https://github.com/DannyBen/victor/commit/ae17bd5) 12 | - Compare [`v0.4.0..v0.5.0`](https://github.com/dannyben/victor/compare/v0.4.0..v0.5.0) 13 | 14 | 15 | v0.4.0 - 2024-08-25 16 | ---------------------------------------- 17 | 18 | - Drop support for Ruby 2.x [`245c8cc`](https://github.com/DannyBen/victor/commit/245c8cc) 19 | - Remove XML doctype, CDATA, and `type=text/css` from `