mtg_card_maker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/CODE_OF_CONDUCT.md +131 -0
  4. data/LICENSE.txt +20 -0
  5. data/README.md +305 -0
  6. data/bin/mtg_card_maker +7 -0
  7. data/lib/mtg_card_maker/base_card.rb +210 -0
  8. data/lib/mtg_card_maker/cli.rb +221 -0
  9. data/lib/mtg_card_maker/color_palette.rb +135 -0
  10. data/lib/mtg_card_maker/color_scheme.rb +305 -0
  11. data/lib/mtg_card_maker/core_ext/deep_merge.rb +21 -0
  12. data/lib/mtg_card_maker/fonts/Goudy Mediaeval DemiBold.ttf +0 -0
  13. data/lib/mtg_card_maker/fonts/goudy_base64.txt +1 -0
  14. data/lib/mtg_card_maker/icon_service.rb +95 -0
  15. data/lib/mtg_card_maker/icons/black.svg +1 -0
  16. data/lib/mtg_card_maker/icons/blue.svg +1 -0
  17. data/lib/mtg_card_maker/icons/colorless.svg +1 -0
  18. data/lib/mtg_card_maker/icons/green.svg +1 -0
  19. data/lib/mtg_card_maker/icons/jsharp.svg +1 -0
  20. data/lib/mtg_card_maker/icons/qrcode.svg +1 -0
  21. data/lib/mtg_card_maker/icons/red.svg +1 -0
  22. data/lib/mtg_card_maker/icons/white.svg +1 -0
  23. data/lib/mtg_card_maker/layer_config.rb +289 -0
  24. data/lib/mtg_card_maker/layer_factory.rb +122 -0
  25. data/lib/mtg_card_maker/layer_initializer.rb +12 -0
  26. data/lib/mtg_card_maker/layers/art_layer.rb +63 -0
  27. data/lib/mtg_card_maker/layers/border_layer.rb +166 -0
  28. data/lib/mtg_card_maker/layers/frame_layer.rb +62 -0
  29. data/lib/mtg_card_maker/layers/name_layer.rb +82 -0
  30. data/lib/mtg_card_maker/layers/power_layer.rb +69 -0
  31. data/lib/mtg_card_maker/layers/text_box_layer.rb +107 -0
  32. data/lib/mtg_card_maker/layers/type_line_layer.rb +86 -0
  33. data/lib/mtg_card_maker/mana_cost.rb +220 -0
  34. data/lib/mtg_card_maker/metallic_renderer.rb +174 -0
  35. data/lib/mtg_card_maker/sprite_sheet_assets.rb +158 -0
  36. data/lib/mtg_card_maker/sprite_sheet_builder.rb +90 -0
  37. data/lib/mtg_card_maker/sprite_sheet_service.rb +126 -0
  38. data/lib/mtg_card_maker/svg_gradient_service.rb +159 -0
  39. data/lib/mtg_card_maker/text_rendering_service.rb +160 -0
  40. data/lib/mtg_card_maker/version.rb +8 -0
  41. data/lib/mtg_card_maker.rb +268 -0
  42. data/sig/mtg_card_maker.rbs +4 -0
  43. metadata +149 -0
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MtgCardMaker
4
+ # Mixin for metallic rendering logic shared by multiple layers
5
+ module MetallicRenderer
6
+ # Parameter validation and sharing class for metallic rendering
7
+ class MetallicRenderParams
8
+ attr_reader :mask, :corners, :bottom_margin, :opacity, :geometry_params
9
+
10
+ def initialize(config_params = {})
11
+ @mask = config_params[:mask]
12
+ @corners = config_params[:corners] || LayerConfig.default.corner_radius(:inner)
13
+ @bottom_margin = config_params[:bottom_margin] || 0
14
+ @opacity = config_params[:opacity] || {}
15
+ @geometry_params = config_params[:geometry] || {}
16
+ end
17
+
18
+ def texture_opacity
19
+ opacity[:texture] || 0.05
20
+ end
21
+
22
+ def shadow_opacity
23
+ opacity[:shadow] || 0.08
24
+ end
25
+
26
+ def shadow_offset
27
+ 2
28
+ end
29
+
30
+ def shadow_corners
31
+ {
32
+ x: [corners[:x] - shadow_offset, 0].max,
33
+ y: [corners[:y] - shadow_offset, 0].max
34
+ }
35
+ end
36
+ end
37
+
38
+ # Entrypoint for metallic rendering
39
+ def render_metallic_elements(config_params = {})
40
+ params = MetallicRenderParams.new(config_params)
41
+
42
+ render_base_frame(params)
43
+ render_texture_overlay(params)
44
+ render_shadow(params)
45
+ end
46
+
47
+ private
48
+
49
+ def render_base_frame(params)
50
+ create_rect(
51
+ params.geometry_params,
52
+ params.bottom_margin,
53
+ params.mask,
54
+ params.corners,
55
+ fill: "url(##{SvgGradientService.metallic_highlight_gradient_id(color_scheme)})",
56
+ stroke: color_scheme.primary_color,
57
+ stroke_width: LayerConfig.default.stroke_width
58
+ )
59
+ end
60
+
61
+ def render_texture_overlay(params)
62
+ create_rect(
63
+ params.geometry_params,
64
+ params.bottom_margin,
65
+ params.mask,
66
+ params.corners,
67
+ fill: "url(##{SvgGradientService.metallic_pattern_id(color_scheme)})",
68
+ opacity: params.texture_opacity
69
+ )
70
+ end
71
+
72
+ def render_shadow(params)
73
+ shadow_attrs = shadow_attributes(params)
74
+ create_rect(
75
+ params.geometry_params,
76
+ params.bottom_margin,
77
+ params.mask,
78
+ params.corners,
79
+ **shadow_attrs
80
+ )
81
+ end
82
+
83
+ def shadow_attributes(params)
84
+ {
85
+ fill: "url(##{SvgGradientService.metallic_shadow_gradient_id(color_scheme)})",
86
+ opacity: params.shadow_opacity,
87
+ x_offset: params.shadow_offset,
88
+ y_offset: params.shadow_offset,
89
+ width_offset: params.shadow_offset * 2,
90
+ height_offset: params.shadow_offset * 2,
91
+ rx: params.shadow_corners[:x],
92
+ ry: params.shadow_corners[:y]
93
+ }
94
+ end
95
+
96
+ def create_rect(geometry_params, bottom_margin, mask, corners, **attributes)
97
+ rect_attributes = build_rect_attributes(geometry_params, bottom_margin, mask, corners, attributes)
98
+ svg.rect(**rect_attributes)
99
+ end
100
+
101
+ def build_rect_attributes(geometry_params, bottom_margin, mask, corners, attributes)
102
+ geometry = extract_geometry(geometry_params)
103
+ offsets = extract_offsets(attributes)
104
+ base_attrs = {
105
+ x: rect_x(geometry, offsets),
106
+ y: rect_y(geometry, offsets),
107
+ width: rect_width(geometry, offsets),
108
+ height: rect_height(geometry, bottom_margin, offsets),
109
+ mask: mask ? "url(##{mask})" : nil,
110
+ rx: attributes[:rx] || corners[:x],
111
+ ry: attributes[:ry] || corners[:y]
112
+ }.compact
113
+ # Merge in all other SVG attributes (fill, opacity, stroke, etc.)
114
+ base_attrs.merge!(attributes)
115
+ base_attrs
116
+ end
117
+
118
+ def rect_x(geometry, offsets)
119
+ geometry[:x] + geometry[:padding] + offsets[:x]
120
+ end
121
+
122
+ def rect_y(geometry, offsets)
123
+ geometry[:y] + geometry[:padding] + offsets[:y]
124
+ end
125
+
126
+ def rect_width(geometry, offsets)
127
+ geometry[:width] - (geometry[:padding] * 2) - offsets[:width]
128
+ end
129
+
130
+ def rect_height(geometry, bottom_margin, offsets)
131
+ geometry[:height] - bottom_margin - offsets[:height]
132
+ end
133
+
134
+ def extract_geometry(geometry_params)
135
+ layer_config = LayerConfig.default
136
+ {
137
+ x: geometry_x(geometry_params),
138
+ y: geometry_y(geometry_params),
139
+ width: geometry_width(geometry_params),
140
+ height: geometry_height(geometry_params),
141
+ padding: geometry_padding(geometry_params, layer_config)
142
+ }
143
+ end
144
+
145
+ def geometry_x(geometry_params)
146
+ geometry_params[:x] || (respond_to?(:x) ? x : 0)
147
+ end
148
+
149
+ def geometry_y(geometry_params)
150
+ geometry_params[:y] || (respond_to?(:y) ? y : 0)
151
+ end
152
+
153
+ def geometry_width(geometry_params)
154
+ geometry_params[:width] || (respond_to?(:width) ? width : 0)
155
+ end
156
+
157
+ def geometry_height(geometry_params)
158
+ geometry_params[:height] || (respond_to?(:height) ? height : 0)
159
+ end
160
+
161
+ def geometry_padding(geometry_params, layer_config)
162
+ geometry_params[:padding] || layer_config.horizontal_padding
163
+ end
164
+
165
+ def extract_offsets(attributes)
166
+ {
167
+ x: attributes.delete(:x_offset) || 0,
168
+ y: attributes.delete(:y_offset) || 0,
169
+ width: attributes.delete(:width_offset) || 0,
170
+ height: attributes.delete(:height_offset) || 0
171
+ }
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MtgCardMaker
4
+ # Handles the creation of shared assets (fonts, gradients, masks) for sprite sheets
5
+ class SpriteSheetAssets
6
+ def initialize
7
+ @color_schemes = build_color_schemes
8
+ end
9
+
10
+ # Add all shared assets to the sprite sheet XML
11
+ def add_assets_to_sprite(xml)
12
+ add_sprite_styles(xml)
13
+ add_sprite_masks(xml)
14
+ add_sprite_gradients(xml)
15
+ end
16
+
17
+ private
18
+
19
+ def add_sprite_styles(xml)
20
+ xml.defs do
21
+ xml.style(type: 'text/css') do
22
+ xml.cdata <<~CSS
23
+ @font-face {
24
+ font-family: 'Goudy Mediaeval DemiBold';
25
+ src: url('fonts/Goudy Mediaeval DemiBold.ttf') format('truetype');
26
+ font-weight: normal;
27
+ font-style: normal;
28
+ }
29
+
30
+ .card-name {
31
+ font-family: 'Goudy Mediaeval DemiBold', serif;
32
+ font-weight: normal;
33
+ font-style: normal;
34
+ }
35
+
36
+ .card-type {
37
+ font-family: 'Goudy Mediaeval DemiBold', serif;
38
+ font-weight: normal;
39
+ font-style: normal;
40
+ }
41
+
42
+ .card-description {
43
+ font-family: serif;
44
+ font-weight: normal;
45
+ font-style: normal;
46
+ }
47
+
48
+ .card-flavor-text {
49
+ font-family: serif;
50
+ font-weight: normal;
51
+ font-style: italic;
52
+ }
53
+
54
+ .card-power-toughness {
55
+ font-family: serif;
56
+ font-weight: bold;
57
+ font-style: normal;
58
+ }
59
+
60
+ .mana-cost-text {
61
+ font-family: serif;
62
+ font-weight: normal;
63
+ font-style: normal;
64
+ }
65
+
66
+ .mana-cost-text-large {
67
+ font-family: serif;
68
+ font-weight: semibold;
69
+ font-style: normal;
70
+ }
71
+ CSS
72
+ end
73
+ end
74
+ end
75
+
76
+ def add_sprite_masks(xml)
77
+ xml.defs do
78
+ # Define the art window mask once for the entire sprite sheet
79
+ xml.mask(id: 'artWindowMask') do
80
+ # White rectangle covers the entire card (opaque)
81
+ xml.rect(x: 0, y: 0, width: '100%', height: '100%', fill: '#FFF')
82
+ # Black rectangle creates the transparent window at art position
83
+ art_config = BaseCard::DEFAULTS[:layers][:art_layer]
84
+ xml.rect(x: art_config[:x], y: art_config[:y],
85
+ width: art_config[:width], height: art_config[:height],
86
+ fill: '#000', rx: art_config[:corner_radius][:x], ry: art_config[:corner_radius][:y])
87
+ end
88
+ end
89
+ end
90
+
91
+ def add_sprite_gradients(xml)
92
+ # Define gradients for all color schemes using SvgGradientService
93
+ @color_schemes.each do |color_scheme|
94
+ SvgGradientService.define_standard_gradients(xml, color_scheme)
95
+ if SvgGradientService.metallic_properties?(color_scheme)
96
+ SvgGradientService.define_metallic_gradients(xml,
97
+ color_scheme)
98
+ end
99
+ end
100
+ end
101
+
102
+ # Test compatibility methods - delegate to SvgGradientService
103
+ def define_gradient(xml, color_scheme, scheme_name, gradient_type, _color_methods)
104
+ case gradient_type
105
+ when 'card'
106
+ SvgGradientService.define_card_gradient(xml, color_scheme, scheme_name)
107
+ when 'frame'
108
+ SvgGradientService.define_frame_gradient(xml, color_scheme, scheme_name)
109
+ when 'name'
110
+ SvgGradientService.define_name_gradient(xml, color_scheme, scheme_name)
111
+ when 'description'
112
+ SvgGradientService.define_description_gradient(xml, color_scheme, scheme_name)
113
+ end
114
+ end
115
+
116
+ def define_metallic_gradients(xml, color_scheme, _scheme_name)
117
+ SvgGradientService.define_metallic_gradients(xml, color_scheme)
118
+ end
119
+
120
+ def define_metallic_highlight_gradient(xml, color_scheme, scheme_name)
121
+ SvgGradientService.define_metallic_highlight_gradient(xml, color_scheme, scheme_name)
122
+ end
123
+
124
+ def define_metallic_shadow_gradient(xml, color_scheme, scheme_name)
125
+ SvgGradientService.define_metallic_shadow_gradient(xml, color_scheme, scheme_name)
126
+ end
127
+
128
+ def define_metallic_pattern(xml, color_scheme, scheme_name)
129
+ SvgGradientService.define_metallic_pattern(xml, color_scheme, scheme_name)
130
+ end
131
+
132
+ def add_metallic_pattern_lines(xml, color_scheme)
133
+ SvgGradientService.add_metallic_pattern_lines(xml, color_scheme)
134
+ end
135
+
136
+ def add_metallic_pattern_circles(xml, color_scheme)
137
+ SvgGradientService.add_metallic_pattern_circles(xml, color_scheme)
138
+ end
139
+
140
+ def metallic_properties?(color_scheme)
141
+ SvgGradientService.metallic_properties?(color_scheme)
142
+ end
143
+
144
+ def build_color_schemes
145
+ # Define all possible color schemes to ensure coverage
146
+ [
147
+ ColorScheme.new(:colorless),
148
+ ColorScheme.new(:white),
149
+ ColorScheme.new(:blue),
150
+ ColorScheme.new(:black),
151
+ ColorScheme.new(:red),
152
+ ColorScheme.new(:green),
153
+ ColorScheme.new(:gold),
154
+ ColorScheme.new(:artifact)
155
+ ]
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module MtgCardMaker
6
+ # Handles the core construction of sprite sheets from individual card files
7
+ class SpriteSheetBuilder
8
+ attr_reader :cards_per_row, :spacing
9
+
10
+ def initialize(cards_per_row: 5, spacing: 30)
11
+ @cards_per_row = cards_per_row
12
+ @spacing = spacing
13
+ end
14
+
15
+ # Calculate sprite sheet dimensions
16
+ def sprite_dimensions(card_count)
17
+ return [0, 0] if card_count.zero?
18
+
19
+ cols = [card_count, @cards_per_row].min
20
+ rows = (card_count.to_f / @cards_per_row).ceil
21
+
22
+ width = (cols * CARD_WIDTH) + ((cols - 1) * @spacing)
23
+ height = (rows * CARD_HEIGHT) + ((rows - 1) * @spacing)
24
+
25
+ [width, height]
26
+ end
27
+
28
+ # Create the sprite sheet XML builder with assets and cards
29
+ def create_sprite_builder(width, height, card_files, assets)
30
+ Nokogiri::XML::Builder.new do |xml|
31
+ xml.svg(xmlns: 'http://www.w3.org/2000/svg',
32
+ viewBox: "0 0 #{width} #{height}",
33
+ width: width,
34
+ height: height) do
35
+ assets.add_assets_to_sprite(xml)
36
+ add_cards_to_sprite(xml, card_files)
37
+ end
38
+ end
39
+ end
40
+
41
+ # Write the sprite sheet to file
42
+ def write_sprite_file(output_file, builder)
43
+ File.write(output_file, builder.to_xml)
44
+ end
45
+
46
+ private
47
+
48
+ def add_cards_to_sprite(xml, card_files)
49
+ card_files.each_with_index do |file, index|
50
+ add_card_to_sprite(xml, file, index)
51
+ end
52
+ end
53
+
54
+ def add_card_to_sprite(xml, card_file, index)
55
+ position = calculate_card_position(index)
56
+ doc = load_card_document(card_file)
57
+ svg_element = doc.at_css('svg')
58
+
59
+ return unless svg_element
60
+
61
+ xml.g(transform: "translate(#{position[:x]}, #{position[:y]})") do
62
+ add_card_children(xml, svg_element)
63
+ end
64
+ end
65
+
66
+ def calculate_card_position(index)
67
+ row = index / @cards_per_row
68
+ col = index % @cards_per_row
69
+
70
+ {
71
+ x: col * (CARD_WIDTH + @spacing),
72
+ y: row * (CARD_HEIGHT + @spacing)
73
+ }
74
+ end
75
+
76
+ def load_card_document(card_file)
77
+ Nokogiri::XML(File.read(card_file.path))
78
+ end
79
+
80
+ def add_card_children(xml, svg_element)
81
+ excluded_elements = ['defs', 'style']
82
+ svg_element.children.each do |child|
83
+ next if excluded_elements.include?(child.name)
84
+ next if child.name&.start_with?('linearGradient', 'radialGradient', 'pattern')
85
+
86
+ xml.parent.add_child(child.dup)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+
5
+ module MtgCardMaker
6
+ # Service for creating sprite sheets by generating individual cards
7
+ # and stitching them together using SpriteSheetBuilder and SpriteSheetAssets.
8
+ # This service handles the complete workflow from card configurations to
9
+ # final sprite sheet output, including temporary file management.
10
+ #
11
+ # @example
12
+ # service = MtgCardMaker::SpriteSheetService.new(cards_per_row: 4, spacing: 30)
13
+ # success = service.create_sprite_sheet(card_configs, 'output.svg')
14
+ # width, height = service.sprite_dimensions(card_configs.length)
15
+ #
16
+ # @since 0.1.0
17
+ class SpriteSheetService
18
+ # @return [Integer] the number of cards per row in the sprite sheet
19
+ attr_reader :cards_per_row
20
+
21
+ # @return [Integer] the spacing between cards in pixels
22
+ attr_reader :spacing
23
+
24
+ # Initialize a new sprite sheet service
25
+ #
26
+ # @param cards_per_row [Integer] the number of cards per row (default: 5)
27
+ # @param spacing [Integer] the spacing between cards in pixels (default: 30)
28
+ def initialize(cards_per_row: 5, spacing: 30)
29
+ @cards_per_row = cards_per_row
30
+ @spacing = spacing
31
+ @builder = SpriteSheetBuilder.new(cards_per_row: cards_per_row, spacing: spacing)
32
+ @assets = SpriteSheetAssets.new
33
+ end
34
+
35
+ # Create a sprite sheet from an array of card configurations
36
+ #
37
+ # @param card_configs [Hash] the card configurations
38
+ # @param output_file [String] the output file path
39
+ # @return [Boolean] true if successful, false if no cards provided
40
+ # @raise [StandardError] if card generation or sprite creation fails
41
+ def create_sprite_sheet(card_configs, output_file)
42
+ return false if card_configs.empty?
43
+
44
+ begin
45
+ # Generate individual cards first
46
+ card_files = generate_individual_cards(card_configs)
47
+
48
+ # Stitch them together into a sprite sheet
49
+ stitch_cards_into_sprite(card_files, output_file)
50
+ ensure
51
+ # Clean up temporary files
52
+ cleanup_temp_files(card_files)
53
+ end
54
+ end
55
+
56
+ # Calculate sprite sheet dimensions for a given number of cards
57
+ #
58
+ # @param card_count [Integer] the number of cards in the sprite sheet
59
+ # @return [Array<Integer>] the width and height of the sprite sheet
60
+ def sprite_dimensions(card_count)
61
+ @builder.sprite_dimensions(card_count)
62
+ end
63
+
64
+ private
65
+
66
+ def generate_individual_cards(card_configs)
67
+ card_files = []
68
+
69
+ card_configs.values.each_with_index do |config, index|
70
+ card = card_from_config(config)
71
+ temp_file = save_card_to_temp_file(card, index)
72
+ card_files << temp_file
73
+ rescue StandardError => e
74
+ handle_card_generation_error(e, index, card_files)
75
+ end
76
+
77
+ card_files
78
+ end
79
+
80
+ def handle_card_generation_error(error, index, card_files)
81
+ cleanup_temp_files(card_files)
82
+ raise "❌ Error generating card #{index}: #{error.message}"
83
+ end
84
+
85
+ def card_from_config(config)
86
+ BaseCard.new(
87
+ name: config['name'],
88
+ mana_cost: config['mana_cost'],
89
+ type_line: config['type_line'],
90
+ rules_text: config['rules_text'],
91
+ flavor_text: config['flavor_text'],
92
+ power: config['power'],
93
+ toughness: config['toughness'],
94
+ color: config['color'],
95
+ border_color: config['border_color'],
96
+ art: config['art']
97
+ )
98
+ end
99
+
100
+ def save_card_to_temp_file(card, index)
101
+ temp_file = Tempfile.new(["card_#{index}", '.svg'])
102
+ card.save(temp_file.path)
103
+ temp_file
104
+ end
105
+
106
+ def stitch_cards_into_sprite(card_files, output_file)
107
+ width, height = sprite_dimensions(card_files.length)
108
+ builder = @builder.create_sprite_builder(width, height, card_files, @assets)
109
+ @builder.write_sprite_file(output_file, builder)
110
+ true
111
+ rescue StandardError => e
112
+ raise "❌ Error creating sprite sheet: #{e.message}"
113
+ end
114
+
115
+ def cleanup_temp_files(card_files)
116
+ return if card_files.nil?
117
+
118
+ card_files.each do |file|
119
+ file.close
120
+ file.unlink
121
+ rescue StandardError => e
122
+ warn "Warning: Could not clean up temp file #{file.path}: #{e.message}"
123
+ end
124
+ end
125
+ end
126
+ end