diff --git a/README.md b/README.md index 565900e..48a8bf3 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,44 @@ mtg_card_maker generate_sprite deck.yml sprite_sheet.svg \ *Shortcuts:* - `gs` or `gcs` for `generate_sprite` +### šŸ–¼ļø Experimental WebP Conversion + +**āš ļø Experimental Feature - Mac Only** + +The gem includes experimental support for converting SVG cards to WebP format using Chrome's headless mode. This feature is currently **Mac-only** and requires specific dependencies. + +**Why?:** +It seems a lot more user-friendly for use in things like this README.md file. The file size is bigger and it can't scale so I wouldn't use this option unless something in the SVG isn't working with whatever you are trying to do. You should also report that at https://github.com/joe-sharp/mtg_card_maker/issues . Thanks in advance! + +**Prerequisites:** +- **macOS**: Chrome must be installed at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` +- **cwebp**: Google's WebP encoder must be installed (install via Homebrew: `brew install webp`) + +**Usage:** +```bash +# Convert a single SVG card to WebP +bin/svg_to_webp output_card.svg + +# Convert any SVG file to WebP +bin/svg_to_webp path/to/your/card.svg +``` + +**Features:** +- **Lossless Conversion**: High-quality WebP output with transparency support +- **Automatic Cropping**: Removes Chrome's window chrome for clean card output +- **Optimized Dimensions**: Outputs at 630x880 pixels (standard MTG card ratio) +- **Transparent Background**: Preserves transparency for web and design use + +**Caveats:** +- **Platform Limitation**: Currently only works on macOS with Chrome installed +- **Dependency Required**: Requires `cwebp` command-line tool for final processing +- **Experimental Status**: May have issues with complex SVG content or Chrome updates +- **File Size**: WebP files may be larger than optimized SVGs for simple cards + +**Future Plans:** +- Cross-platform support (Windows, Linux) +- Integration with main CLI commands + ## šŸ”® Examples

@@ -286,9 +324,12 @@ mtg_card_maker generate_card \ --power=3 \ --toughness=3 \ --border-color=gold \ - --color=blue + --color=blue \ + --art=images/joe.webp ``` + + **Add multiple cards to a YAML file:** ```bash mtg_card_maker add_card deck.yml --name="Lightning Bolt" --mana-cost="R" --type-line="Instant" --rules-text="Deal 3 damage to any target." --color="red" diff --git a/bin/svg_to_webp b/bin/svg_to_webp new file mode 100755 index 0000000..d211518 --- /dev/null +++ b/bin/svg_to_webp @@ -0,0 +1,90 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'fileutils' +require 'English' + +# Script to convert SVG files to WebP format +# Uses Chrome headless mode for high-quality SVG to WebP conversion +class OutputCardConverter + def initialize(input_svg) + @input_svg = input_svg + @output_webp = @input_svg.sub('.svg', '.webp') + @screenshot_file = 'screenshot.webp' + end + + def convert + puts "šŸŖ„ Converting #{@input_svg} to WebP..." + + unless File.exist?(@input_svg) + puts "āŒ Error: #{@input_svg} not found!" + exit 1 + end + + convert_svg_to_webp + crop_and_cleanup + display_result + end + + private + + def convert_svg_to_webp + puts "Converting #{@input_svg} to WebP using Chrome..." + + chrome_path = find_chrome_path + chrome_args = build_chrome_args + + puts "Running Chrome command: #{chrome_path} #{chrome_args.join(' ')}" + + execute_chrome_conversion(chrome_path, chrome_args) + end + + def find_chrome_path + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + end + + def build_chrome_args + [ + '--headless', + '--hide-scrollbars', + "--screenshot=#{@screenshot_file}", + '--screenshot-format=webp', + '--window-size=630,1200', + '--default-background-color=00000000', + "file://#{File.expand_path(@input_svg)}" + ] + end + + def execute_chrome_conversion(chrome_path, chrome_args) + puts "Current working directory: #{Dir.pwd}" + puts "SVG file exists: #{File.exist?(@input_svg)}" + + result = system(chrome_path, *chrome_args) + puts "Chrome command result: #{result}" + puts "Exit status: #{$CHILD_STATUS.exitstatus}" if $CHILD_STATUS + end + + def crop_and_cleanup + if File.exist?(@screenshot_file) + # Crop the image to the size of the card, make white background transparent + system("cwebp -lossless -crop 0 0 630 880 #{@screenshot_file} -o #{@output_webp}") + puts "āœ… Successfully created #{@output_webp} (WebP)" + FileUtils.rm_f(@screenshot_file) + else + puts 'āš ļø Chrome conversion failed - no screenshot file found' + puts "Current directory contents: #{Dir.entries('.')}" + exit 1 + end + end + + def display_result + puts "\n✨ Conversion complete!" + puts "šŸ“ Generated file: #{@output_webp}" + puts 'šŸ“ Dimensions: 630x880 pixels' + puts 'šŸŽØ Format: WebP (lossless)' + end +end + +# Run the converter +input_file = ARGV.first || 'output_card.svg' +OutputCardConverter.new(input_file).convert diff --git a/color_cards_sprite.svg b/color_cards_sprite.svg index f59a021..61989ef 100644 --- a/color_cards_sprite.svg +++ b/color_cards_sprite.svg @@ -17,6 +17,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/docs/images/joe-sharp_card.webp b/docs/images/joe-sharp_card.webp index 2c54550..df273f6 100644 Binary files a/docs/images/joe-sharp_card.webp and b/docs/images/joe-sharp_card.webp differ diff --git a/docs/images/mtgcm_card.webp b/docs/images/mtgcm_card.webp index a5ae1af..5dd0e09 100644 Binary files a/docs/images/mtgcm_card.webp and b/docs/images/mtgcm_card.webp differ diff --git a/images/joe-sharp_card.svg b/images/joe-sharp_card.svg index 32d3b50..5e765ca 100644 --- a/images/joe-sharp_card.svg +++ b/images/joe-sharp_card.svg @@ -17,6 +17,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { @@ -153,9 +154,9 @@ Whenever you cast a Red spell, draw a card.

- +
- + : Create a custom Magic: The Gathering card.
diff --git a/images/mtgcm_card.webp b/images/mtgcm_card.webp index a5ae1af..5dd0e09 100644 Binary files a/images/mtgcm_card.webp and b/images/mtgcm_card.webp differ diff --git a/lib/mtg_card_maker/css_service.rb b/lib/mtg_card_maker/css_service.rb index aea5d0f..afa5491 100644 --- a/lib/mtg_card_maker/css_service.rb +++ b/lib/mtg_card_maker/css_service.rb @@ -38,6 +38,7 @@ def css_classes(embed: false) .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/output_card.svg b/output_card.svg index 4a3ff9b..51231e5 100644 --- a/output_card.svg +++ b/output_card.svg @@ -17,6 +17,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/fixtures/art_layer.svg b/spec/fixtures/art_layer.svg index 5ed1216..92ea4ba 100644 --- a/spec/fixtures/art_layer.svg +++ b/spec/fixtures/art_layer.svg @@ -13,6 +13,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/fixtures/border_layer.svg b/spec/fixtures/border_layer.svg index 9ba2040..0398e73 100644 --- a/spec/fixtures/border_layer.svg +++ b/spec/fixtures/border_layer.svg @@ -13,6 +13,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/fixtures/complete_card.svg b/spec/fixtures/complete_card.svg index 4a3ff9b..51231e5 100644 --- a/spec/fixtures/complete_card.svg +++ b/spec/fixtures/complete_card.svg @@ -17,6 +17,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/fixtures/frame_layer.svg b/spec/fixtures/frame_layer.svg index 9ec7af0..e62736b 100644 --- a/spec/fixtures/frame_layer.svg +++ b/spec/fixtures/frame_layer.svg @@ -13,6 +13,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/fixtures/name_layer.svg b/spec/fixtures/name_layer.svg index 1573215..737ffaf 100644 --- a/spec/fixtures/name_layer.svg +++ b/spec/fixtures/name_layer.svg @@ -13,6 +13,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/fixtures/power_layer.svg b/spec/fixtures/power_layer.svg index c41e02e..929d616 100644 --- a/spec/fixtures/power_layer.svg +++ b/spec/fixtures/power_layer.svg @@ -13,6 +13,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/fixtures/text_box_layer.svg b/spec/fixtures/text_box_layer.svg index cb08fe8..6f98547 100644 --- a/spec/fixtures/text_box_layer.svg +++ b/spec/fixtures/text_box_layer.svg @@ -13,6 +13,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/fixtures/type_line_layer.svg b/spec/fixtures/type_line_layer.svg index 8ee535b..2d79054 100644 --- a/spec/fixtures/type_line_layer.svg +++ b/spec/fixtures/type_line_layer.svg @@ -13,6 +13,7 @@ .card-flavor-text { font-family: serif; + font-style: italic; } .card-power-toughness { diff --git a/spec/mtg_card_maker/css_service_spec.rb b/spec/mtg_card_maker/css_service_spec.rb new file mode 100644 index 0000000..e08882a --- /dev/null +++ b/spec/mtg_card_maker/css_service_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MtgCardMaker::CssService do + describe '.css_classes' do + it 'generates CSS classes for all card elements', :aggregate_failures do + css = described_class.css_classes + + expect(css).to include('.card-name') + expect(css).to include('.card-type') + expect(css).to include('.card-rules-text') + expect(css).to include('.card-flavor-text') + expect(css).to include('.card-power-toughness') + expect(css).to include('.card-copyright') + expect(css).to include('.mana-cost-text') + end + + it 'applies correct font styling to flavor text', :aggregate_failures do + css = described_class.css_classes + + # Extract the flavor text CSS block + flavor_css_match = css.match(/\.card-flavor-text\s*\{[^}]*\}/m) + expect(flavor_css_match).not_to be_nil + + flavor_css = flavor_css_match[0] + expect(flavor_css).to include('font-family: serif') + expect(flavor_css).to include('font-style: italic') + end + + it 'applies correct font styling to name and type elements', :aggregate_failures do + css = described_class.css_classes + + # Extract the name/type CSS block + name_type_css_match = css.match(/\.card-name,\s*\.card-type\s*\{[^}]*\}/m) + expect(name_type_css_match).not_to be_nil + + name_type_css = name_type_css_match[0] + expect(name_type_css).to include('font-family: serif') + expect(name_type_css).to include('font-weight: bold') # Default when embed is false + end + + it 'applies correct font styling to power/toughness', :aggregate_failures do + css = described_class.css_classes + + # Extract the power/toughness CSS block + power_css_match = css.match(/\.card-power-toughness\s*\{[^}]*\}/m) + expect(power_css_match).not_to be_nil + + power_css = power_css_match[0] + expect(power_css).to include('font-family: serif') + expect(power_css).to include('font-weight: bold') + end + + it 'applies correct font styling to rules text', :aggregate_failures do + css = described_class.css_classes + + # Extract the rules text CSS block + rules_css_match = css.match(/\.card-rules-text,\s*\.mana-cost-text\s*\{[^}]*\}/m) + expect(rules_css_match).not_to be_nil + + rules_css = rules_css_match[0] + expect(rules_css).to include('font-family: serif') + end + + it 'applies correct font styling to copyright', :aggregate_failures do + css = described_class.css_classes + + # Extract the copyright CSS block + copyright_css_match = css.match(/\.card-copyright\s*\{[^}]*\}/m) + expect(copyright_css_match).not_to be_nil + + copyright_css = copyright_css_match[0] + expect(copyright_css).to include('font-family: sans-serif') + end + + context 'with embedded fonts' do + it 'uses embedded font family when embed is true', :aggregate_failures do + css = described_class.css_classes(embed: true) + + name_type_css_match = css.match(/\.card-name,\s*\.card-type\s*\{[^}]*\}/m) + expect(name_type_css_match).not_to be_nil + + name_type_css = name_type_css_match[0] + expect(name_type_css).to include("font-family: 'Goudy Mediaeval DemiBold', serif") + expect(name_type_css).to include('font-weight: normal') + end + + it 'uses fallback font family when embed is false', :aggregate_failures do + css = described_class.css_classes(embed: false) + + name_type_css_match = css.match(/\.card-name,\s*\.card-type\s*\{[^}]*\}/m) + expect(name_type_css_match).not_to be_nil + + name_type_css = name_type_css_match[0] + expect(name_type_css).to include('font-family: serif') + expect(name_type_css).to include('font-weight: bold') + end + end + end + + describe '.font_face' do + it 'returns empty string when embed is false' do + result = described_class.font_face(embed: false) + expect(result).to eq('') + end + + it 'generates font face declaration when embed is true', :aggregate_failures do + result = described_class.font_face(embed: true) + + expect(result).to include('@font-face') + expect(result).to include("font-family: 'Goudy Mediaeval DemiBold'") + expect(result).to include('src: url(data:font/truetype') + expect(result).to include('format(\'truetype\')') + end + + it 'reads font data from correct file' do + font_path = File.join(File.dirname(__FILE__), '../../lib/mtg_card_maker/fonts/goudy_base64.txt') + expect(File.exist?(font_path)).to be true + end + end + + describe '.complete_styles' do + it 'combines font face and CSS classes when embed is true', :aggregate_failures do + result = described_class.complete_styles(embed: true) + + expect(result).to include('@font-face') + expect(result).to include('.card-name') + expect(result).to include('.card-flavor-text') + end + + it 'includes only CSS classes when embed is false', :aggregate_failures do + result = described_class.complete_styles(embed: false) + + expect(result).not_to include('@font-face') + expect(result).to include('.card-name') + expect(result).to include('.card-flavor-text') + end + end + + describe '.font_family' do + it 'returns embedded font family when embed is true' do + result = described_class.font_family(true) + expect(result).to eq("'Goudy Mediaeval DemiBold', serif") + end + + it 'returns fallback font family when embed is false' do + result = described_class.font_family(false) + expect(result).to eq('serif') + end + end + + describe '.font_weight' do + it 'returns normal weight when embed is true' do + result = described_class.font_weight(true) + expect(result).to eq('normal') + end + + it 'returns bold weight when embed is false' do + result = described_class.font_weight(false) + expect(result).to eq('bold') + end + end +end diff --git a/spec/mtg_card_maker/text_box_layer_spec.rb b/spec/mtg_card_maker/text_box_layer_spec.rb index 4ca5c94..3ce4067 100644 --- a/spec/mtg_card_maker/text_box_layer_spec.rb +++ b/spec/mtg_card_maker/text_box_layer_spec.rb @@ -31,6 +31,22 @@ expect_svg_to_match_fixture(fixture_layer, 'text_box_layer') end + it 'applies correct CSS class to flavor text elements', :aggregate_failures do + layer = described_class.new( + dimensions: { x: 10, y: 10, width: 200, height: 100 }, + rules_text: 'Short rules text.', + flavor_text: 'Short flavor text.' + ) + + svg_content = generate_svg_for_layer(layer, canvas_width: 300, canvas_height: 200) + + # Should have flavor text elements with correct CSS class + expect(svg_content).to include('class="card-flavor-text"') + + # Should also have rules text elements with correct CSS class + expect(svg_content).to include('class="card-rules-text"') + end + context 'with text wrapping behavior' do it 'breaks long text into multiple lines' do long_text = 'This is a very long piece of text that should be broken into multiple lines'