From 25bf0d7a676ff8138f48ac3bb63a013437293996 Mon Sep 17 00:00:00 2001 From: "Seh Hui, Leong" Date: Mon, 17 Jul 2017 11:59:00 -0400 Subject: [PATCH] Squash @felixleong's pdf-template feature. I will be making revisions based on this, but this is all of his hard work. Thank you!!! Conflicts: squib.gemspec --- CHANGELOG.md | 5 +- bin/squib | 9 + lib/squib.rb | 1 + lib/squib/api/save.rb | 26 +- lib/squib/args/output_file.rb | 40 +++ lib/squib/args/template_file.rb | 45 +++ lib/squib/commands/data/template_option.rb | 109 +++++++ lib/squib/commands/make_template.rb | 275 ++++++++++++++++ lib/squib/graphics/save_templated_sheet.rb | 229 +++++++++++++ lib/squib/sheet_templates/a4_euro_card.yml | 42 +++ .../sheet_templates/a4_poker_card_8up.yml | 40 +++ .../sheet_templates/a4_poker_card_9up.yml | 42 +++ lib/squib/sheet_templates/a4_usa_card.yml | 42 +++ lib/squib/template.rb | 300 ++++++++++++++++++ samples/templates/fold_sheet.rb | 10 + samples/templates/hex_tiles.rb | 14 + samples/templates/templates/fold_sheet.yml | 57 ++++ samples/templates/templates/hex_tiles.yml | 24 ++ samples/templates/use_package_template.rb | 8 + spec/data/templates/basic.yml | 7 + spec/data/templates/card_center_coord.yml | 22 ++ spec/data/templates/card_rotation.yml | 23 ++ spec/data/templates/custom_crop_lines.yml | 28 ++ spec/data/templates/fail_no_card_height.yml | 6 + spec/data/templates/fail_no_card_width.yml | 6 + spec/data/templates/fail_no_sheet_height.yml | 6 + spec/data/templates/fail_no_sheet_width.yml | 6 + spec/spec_helper.rb | 4 + spec/template_spec.rb | 181 +++++++++++ squib.gemspec | 2 + 30 files changed, 1606 insertions(+), 3 deletions(-) create mode 100644 lib/squib/args/output_file.rb create mode 100644 lib/squib/args/template_file.rb create mode 100644 lib/squib/commands/data/template_option.rb create mode 100644 lib/squib/commands/make_template.rb create mode 100644 lib/squib/graphics/save_templated_sheet.rb create mode 100644 lib/squib/sheet_templates/a4_euro_card.yml create mode 100644 lib/squib/sheet_templates/a4_poker_card_8up.yml create mode 100644 lib/squib/sheet_templates/a4_poker_card_9up.yml create mode 100644 lib/squib/sheet_templates/a4_usa_card.yml create mode 100644 lib/squib/template.rb create mode 100644 samples/templates/fold_sheet.rb create mode 100644 samples/templates/hex_tiles.rb create mode 100644 samples/templates/templates/fold_sheet.yml create mode 100644 samples/templates/templates/hex_tiles.yml create mode 100644 samples/templates/use_package_template.rb create mode 100644 spec/data/templates/basic.yml create mode 100644 spec/data/templates/card_center_coord.yml create mode 100644 spec/data/templates/card_rotation.yml create mode 100644 spec/data/templates/custom_crop_lines.yml create mode 100644 spec/data/templates/fail_no_card_height.yml create mode 100644 spec/data/templates/fail_no_card_width.yml create mode 100644 spec/data/templates/fail_no_sheet_height.yml create mode 100644 spec/data/templates/fail_no_sheet_width.yml create mode 100644 spec/template_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d468c8..75dfca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,12 @@ Squib follows [semantic versioning](http://semver.org). Features: * `circle` method now supports various `arc` options, so you can draw partial circles (#211) by @sparr * `save_sheet` method now supports `rtl` or "right-to-left", for easier duplex printing of backs (#204, #208) by @sparr +<<<<<<< HEAD * `yaml` method for reading in data, much like `csv` and `xlsx` by @blinks +* `save_pdf/save_sheet` method now supports `template_file`, which allows you to define +template layouts and position your cards freely (#217) by @felixleong -Special thanks to @sparr and @blinks for all of their work!! +Special thanks to @sparr, @blinks and @felixleong for all of their work!! ## v0.13.4 / 2017-07-17 diff --git a/bin/squib b/bin/squib index 6ff031c..c91c5ea 100755 --- a/bin/squib +++ b/bin/squib @@ -16,4 +16,13 @@ Mercenary.program(:squib) do |p| end end + p.command(:make_template) do |c| + c.syntax 'make_template' + c.description 'Creates a template definition file to generate a templated PDF.' + + c.action do |args, options| + Squib::Commands::MakeTemplate.new.process(args) + end + end + end diff --git a/lib/squib.rb b/lib/squib.rb index 73dc7d4..298ad47 100644 --- a/lib/squib.rb +++ b/lib/squib.rb @@ -4,6 +4,7 @@ require 'pango' require 'rsvg2' require_relative 'squib/version' require_relative 'squib/commands/new' +require_relative 'squib/commands/make_template' require_relative 'squib/deck' require_relative 'squib/card' diff --git a/lib/squib/api/save.rb b/lib/squib/api/save.rb index 2818683..18eb0a3 100644 --- a/lib/squib/api/save.rb +++ b/lib/squib/api/save.rb @@ -1,9 +1,13 @@ +require_relative '../template' require_relative '../args/card_range' require_relative '../args/hand_special' require_relative '../args/save_batch' require_relative '../args/sheet' +require_relative '../args/template_file' +require_relative '../args/output_file' require_relative '../args/showcase_special' require_relative '../graphics/save_pdf' +require_relative '../graphics/save_templated_sheet' module Squib class Deck @@ -19,7 +23,16 @@ module Squib def save_pdf(opts = {}) range = Args::CardRange.new(opts[:range], deck_size: size) sheet = Args::Sheet.new(custom_colors, { file: 'output.pdf' }).load!(opts, expand_by: size, layout: layout, dpi: dpi) - Graphics::SavePDF.new(self).render_pdf(range, sheet) + tmpl_file = Args::TemplateFile.new.load!(opts, expand_by: size) + + if tmpl_file.template_file.nil? + Graphics::SavePDF.new(self).render_pdf(range, sheet) + else + tmpl = Template.load tmpl_file.template_file, dpi + Graphics::SaveTemplatedSheetPDF.new(self, tmpl, sheet).render_sheet( + range + ) + end end # DSL method. See http://squib.readthedocs.io @@ -39,7 +52,16 @@ module Squib range = Args::CardRange.new(opts[:range], deck_size: size) batch = Args::SaveBatch.new.load!(opts, expand_by: size, layout: layout, dpi: dpi) sheet = Args::Sheet.new(custom_colors, { margin: 0 }, size).load!(opts, expand_by: size, layout: layout, dpi: dpi) - render_sheet(range, batch, sheet) + tmpl_file = Args::TemplateFile.new.load!(opts, expand_by: size) + + if tmpl_file.template_file.nil? + render_sheet(range, batch, sheet) + else + tmpl = Template.load tmpl_file.template_file, dpi + Graphics::SaveTemplatedSheetPNG.new(self, tmpl, batch).render_sheet( + range + ) + end end # DSL method. See http://squib.readthedocs.io diff --git a/lib/squib/args/output_file.rb b/lib/squib/args/output_file.rb new file mode 100644 index 0000000..254929e --- /dev/null +++ b/lib/squib/args/output_file.rb @@ -0,0 +1,40 @@ +require_relative 'arg_loader' +require_relative 'dir_validator' + +module Squib + # @api private + module Args + class OutputFile + include ArgLoader + include DirValidator + + def initialize(dpi = 300) + @dpi = 300 + end + + def self.parameters + { + dir: '_output', + file: 'output.pdf' + } + end + + def self.expanding_parameters + [] # none of them + end + + def self.params_with_units + [] + end + + def validate_dir(arg) + ensure_dir_created(arg) + end + + def full_filename + "#{dir}/#{file}" + end + + end + end +end diff --git a/lib/squib/args/template_file.rb b/lib/squib/args/template_file.rb new file mode 100644 index 0000000..4633689 --- /dev/null +++ b/lib/squib/args/template_file.rb @@ -0,0 +1,45 @@ +require_relative 'arg_loader' + +module Squib + # @api private + module Args + # Template file argument loader + class TemplateFile + include ArgLoader + + def initialize(dsl_method_default = {}) + @dsl_method_default = dsl_method_default + end + + def self.parameters + { + template_file: nil + } + end + + def self.expanding_parameters + [] + end + + def self.params_with_units + [] # none of them + end + + def validate_template_file(arg) + return nil if arg.nil? + + thefile = File.exist?(arg) ? arg : builtin(arg) + raise "File #{File.expand_path(arg)} does not exist!" unless + File.exist? thefile + + File.expand_path(thefile) + end + + private + + def builtin(file) + "#{File.dirname(__FILE__)}/../sheet_templates/#{file}" + end + end + end +end diff --git a/lib/squib/commands/data/template_option.rb b/lib/squib/commands/data/template_option.rb new file mode 100644 index 0000000..6d461da --- /dev/null +++ b/lib/squib/commands/data/template_option.rb @@ -0,0 +1,109 @@ +module Squib + class Margin + attr_reader :top + attr_reader :right + attr_reader :bottom + attr_reader :left + + ## + # Create a new margin definition. + # + # Takes +definition+ which can either be a space-separated +String+ or an + # +Array+ of +Float+ and will translate it to the top, right, bottom and + # left members. + # + # The syntax follows how CSS parses margin shorthand strings. + def initialize(definition) + if definition.instance_of? String + @top, @right, @bottom, @left = expand_shorthand( + definition.split(/\s+/).map!(&:to_f)) + elsif definition.is_a? Numeric + @top, @right, @bottom, @left = expand_shorthand [definition] + elsif definition.instance_of? Array + @top, @right, @bottom, @left = expand_shorthand definition + else + raise ArgumentError, 'Invalid value, must be either string or array' + end + end + + ## + # Map out the margin array. + # + # Takes +margin_arr+ and attempt to expand it to a strict [top, right, + # bottom, left] array. + private def expand_shorthand(margin_arr) + if margin_arr.size == 1 + all = margin_arr[0] + [all, all, all, all] + elsif margin_arr.size == 2 + margin_arr + margin_arr + elsif margin_arr.size == 3 + margin_arr + [margin_arr[1]] + elsif margin_arr.size >= 4 + margin_arr[0..3] + else + raise ArgumentError, 'Invalid array' + end + end + end + + + class Gap + attr_reader :horizontal + attr_reader :vertical + + def initialize(definition) + if definition.instance_of? String + @horizontal, @vertical = expand_shorthand( + definition.split(/\s+/).map!(&:to_f)) + elsif definition.instance_of? Array + @horizontal, @vertical = expand_shorthand definition + elsif definition.is_a? Numeric + @horizontal, @vertical = definition, definition + else + raise ArgumentError, 'Invalid value, must be either string or array' + end + end + + private def expand_shorthand(gap_arr) + if gap_arr.size >= 2 + gap_arr[0..1] + elsif gap_arr.size == 1 + gap_arr + gap_arr + else + raise ArgumentError, 'Invalid array' + end + end + end + + + class TemplateOption + attr_accessor :unit + attr_accessor :sheet_width + attr_accessor :sheet_height + attr_writer :sheet_margin + attr_accessor :sheet_align + attr_accessor :card_width + attr_accessor :card_height + attr_writer :card_gap + attr_accessor :card_ordering + attr_accessor :output_file + attr_accessor :crop_lines + + def sheet_margin + if not @sheet_margin.instance_of? Margin + @sheet_margin = Margin.new @sheet_margin + else + @sheet_margin + end + end + + def card_gap + if not @card_gap.instance_of? Gap + @card_gap = Gap.new @card_gap + else + @card_gap + end + end + end +end diff --git a/lib/squib/commands/make_template.rb b/lib/squib/commands/make_template.rb new file mode 100644 index 0000000..2a168c8 --- /dev/null +++ b/lib/squib/commands/make_template.rb @@ -0,0 +1,275 @@ +require 'fileutils' +require 'pathname' +require 'highline' +require 'bigdecimal' +require 'yaml' +require_relative 'data/template_option' + +module Squib + # Squib's command-line option + module Commands + # Generate a template definition file that can be used for + # +save_templated_sheet+ + # + # @api public + class MakeTemplate + # :nodoc: + # @api private + def process(args) + # Get definitions from the user + @option = prompt + + @printable_edge_right = ( + @option.sheet_width - @option.sheet_margin.right) + @printable_edge_bottom = ( + @option.sheet_height - @option.sheet_margin.bottom) + @card_iter_x = @option.card_width + @option.card_gap.horizontal + @card_iter_y = @option.card_height + @option.card_gap.vertical + + # Recalculate the sheet margin if the sheet alignment is in the center + if @option.sheet_align == :center + @option.sheet_margin = recalculate_center_align_sheet + end + + # We would now have to output the file + YAML.dump generate_template, File.new(@option.output_file, 'w') + end + + private + + # Accept user input that defines the template. + def prompt + option = TemplateOption.new + cli = HighLine.new + + option.unit = cli.choose do |menu| + menu.prompt = 'What measure unit should we use? ' + menu.choice(:in) + menu.choice(:cm) + menu.choice(:mm) + end + + cli.choose do |menu| + menu.prompt = 'What paper size you are using? ' + menu.choice('A4, portrait') do + option.sheet_width = convert_measurement_value( + 210, :mm, option.unit + ) + option.sheet_height = convert_measurement_value( + 297, :mm, option.unit + ) + end + menu.choice('A4, landscape') do + option.sheet_width = convert_measurement_value( + 297, :mm, option.unit + ) + option.sheet_height = convert_measurement_value( + 210, :mm, option.unit + ) + end + menu.choice('US letter, portrait') do + option.sheet_width = convert_measurement_value( + 8.5, :in, option.unit + ) + option.sheet_height = convert_measurement_value( + 11, :in, option.unit + ) + end + menu.choice('US letter, landscape') do + option.sheet_width = convert_measurement_value( + 11, :in, option.unit + ) + option.sheet_height = convert_measurement_value( + 8.5, :in, option.unit + ) + end + menu.choice('Custom size') do + option.sheet_width = cli.ask( + "Custom paper width? (#{option.unit}) ", Float + ) + option.sheet_height = cli.ask( + "Custom paper height? (#{option.unit}) ", Float + ) + end + end + + option.sheet_margin = cli.ask( + "Sheet margins? (#{option.unit}) " + ) do |q| + q.validate = /^((\d+\.\d+|\d+) ){0,3}(\d+\.\d+|\d+)/ + end + option.sheet_align = cli.choose do |menu| + menu.prompt = 'How to align cards on sheet? [left] ' + menu.choice(:left) + menu.choice(:right) + menu.choice(:center) + menu.default = :left + end + + option.card_width = cli.ask( + "Card width? (#{option.unit}) ", Float + ) { |q| q.above = 0 } + option.card_height = cli.ask( + "Card height? (#{option.unit}) ", Float + ) { |q| q.above = 0 } + option.card_gap = cli.ask( + "Gap between cards? (#{option.unit}) " + ) { |q| q.validate = /^((\d+\.\d+|\d+))(\s+(\d+\.\d+|\d+))?/ } + + option.card_ordering = cli.choose do |menu| + menu.prompt = 'How to layout your cards? [rows]' + menu.choice(:rows, text: 'In rows') + menu.choice(:columns, text: 'In columns') + menu.default = :rows + end + + option.crop_lines = cli.choose do |menu| + menu.prompt = 'Generate crop lines? [true]' + menu.choice(:true) + menu.choice(:false) + menu.default = :true + end + + option.output_file = cli.ask('Output to? ') do |q| + q.validate = lambda do |path_str| + path = Pathname.new path_str + if path.exist? + path.writable? && !path.directory? + else + path.dirname.writable? + end + end + + q.responses[:not_valid] = ( + 'The filename specified is not a writable file or is a directory.' + ) + q.default = 'template.yml' + end + + option + end + + def convert_measurement_value(val, from_unit, to_unit) + return val if from_unit == to_unit + + if from_unit == :in + val_mm = val * 25.4 + elsif from_unit == :cm + val_mm = val * 10.0 + end + + if to_unit == :in + val_mm / 25.4 + elsif to_unit == :cm + val_mm / 10.0 + else + val_mm + end + end + + def generate_template + x = @option.sheet_margin.left + y = @option.sheet_margin.top + cards = [] + horizontal_crop_lines = Set.new + vertical_crop_lines = Set.new + + while ( + x + @card_iter_x < @printable_edge_right && + y + @card_iter_y < @printable_edge_bottom) + xpos = x + @option.card_gap.horizontal + ypos = y + @option.card_gap.vertical + cards.push( + 'x' => "#{xpos}#{@option.unit}", + 'y' => "#{ypos}#{@option.unit}" + ) + + # Append the crop lines + vertical_crop_lines.add xpos + vertical_crop_lines.add xpos + @option.card_width + horizontal_crop_lines.add ypos + horizontal_crop_lines.add ypos + @option.card_height + + # Calculate the next iterator + if @option.card_ordering == :rows + x, y = next_card_pos_row(x, y) + elsif @option.card_ordering == :columns + x, y = next_card_pos_col(x, y) + else + raise RunTimeException, 'Invalid card ordering value received' + end + end + + output = { + 'sheet_width' => "#{@option.sheet_width}#{@option.unit}", + 'sheet_height' => "#{@option.sheet_height}#{@option.unit}", + 'card_width' => "#{@option.card_width}#{@option.unit}", + 'card_height' => "#{@option.card_height}#{@option.unit}", + 'cards' => cards + } + + if @option.crop_lines == :true + lines = [] + vertical_crop_lines.each do |val| + lines.push( + 'type' => :vertical, 'position' => "#{val}#{@option.unit}" + ) + end + horizontal_crop_lines.each do |val| + lines.push( + 'type' => :horizontal, 'position' => "#{val}#{@option.unit}" + ) + end + output['crop_line'] = { 'lines' => lines } + end + + # Return the output data + output + end + + def recalculate_center_align_sheet + # We will still respect the user specified margins + printable_width = ( + @option.sheet_width - @option.sheet_margin.left - + @option.sheet_margin.right) + num_of_cols, remainder = printable_width.divmod(@card_iter_x) + if ( + @option.card_gap.horizontal > 0 && + remainder < @option.card_gap.horizontal) + num_of_cols -= 1 + end + + new_hor_margin = ( + (@option.sheet_width - num_of_cols * @card_iter_x - + @option.card_gap.horizontal) / 2) + Margin.new [ + @option.sheet_margin.top, + new_hor_margin, + @option.sheet_margin.bottom + ] + end + + def next_card_pos_row(x, y) + x += @card_iter_x + + if (x + @card_iter_x) > @printable_edge_right + x = @option.sheet_margin.left + y += @card_iter_y + end + + [x, y] + end + + def next_card_pos_col(x, y) + y += @card_iter_y + + if (y + @card_iter_y) > @printable_edge_bottom + x += @card_iter_x + y = @option.sheet_margin.top + end + + [x, y] + end + end + end +end diff --git a/lib/squib/graphics/save_templated_sheet.rb b/lib/squib/graphics/save_templated_sheet.rb new file mode 100644 index 0000000..eec30d6 --- /dev/null +++ b/lib/squib/graphics/save_templated_sheet.rb @@ -0,0 +1,229 @@ +module Squib + module Graphics + # Helper class to generate templated sheet. + class SaveTemplatedSheet + def initialize(deck, tmpl, outfile) + @deck = deck + @tmpl = tmpl + @page_number = 1 + @outfile = outfile + @rotated_delta = (@tmpl.card_width - @deck.width).abs / 2 + @overlay_lines = @tmpl.crop_lines.select do |line| + line['overlay_on_cards'] + end + end + + def render_sheet(range) + cc = init_cc + card_set = @tmpl.cards + per_sheet = card_set.size + default_angle = @tmpl.card_default_rotation + if default_angle.zero? + default_angle = check_card_orientation + end + + draw_overlay_below_cards cc if range.size + + track_progress(range) do |bar| + range.each do |i| + next_page_if_needed(cc, i, per_sheet) + + card = @deck.cards[i] + slot = card_set[i % per_sheet] + x = slot['x'] + y = slot['y'] + angle = slot['rotate'] != 0 ? slot['rotate'] : default_angle + + if angle != 0 + draw_rotated_card cc, card, x, y, angle + else + cc.set_source card.cairo_surface, x, y + end + cc.paint + + bar.increment + end + + draw_overlay_above_cards cc + draw_page cc + cc.target.finish + end + end + + protected + + # Initialize the Cairo Context + def init_cc + raise NotImplementedError + end + + def draw_page(cc) + raise NotImplementedError + end + + def full_filename + raise NotImplementedError + end + + private + + def next_page_if_needed(cc, i, per_sheet) + return unless (i != 0) && (i % per_sheet).zero? + + draw_overlay_above_cards cc + cc = draw_page cc + draw_overlay_below_cards cc + @page_number += 1 + end + + def track_progress(range) + msg = "Saving templated sheet to #{full_filename}" + @deck.progress_bar.start(msg, range.size) { |bar| yield(bar) } + end + + def draw_overlay_below_cards(cc) + if @tmpl.crop_line_overlay == :on_margin + add_margin_overlay_clip_mask cc + cc.clip + draw_crop_line cc, @tmpl.crop_lines + cc.reset_clip + elsif @tmpl.crop_line_overlay == :beneath_cards + draw_crop_line cc, @tmpl.crop_lines + end + end + + def draw_overlay_above_cards(cc) + if @tmpl.crop_line_overlay == :overlay_on_cards + draw_crop_line cc, @tmpl.crop_lines + else + draw_crop_line cc, @overlay_lines + end + end + + def add_margin_overlay_clip_mask(cc) + margin = @tmpl.margin + cc.new_path + cc.rectangle( + margin[:left], margin[:top], + margin[:right] - margin[:left], + margin[:bottom] - margin[:top] + ) + cc.new_sub_path + cc.move_to @tmpl.sheet_width, 0 + cc.line_to 0, 0 + cc.line_to 0, @tmpl.sheet_height + cc.line_to @tmpl.sheet_width, @tmpl.sheet_height + cc.close_path + end + + def draw_crop_line(cc, crop_lines) + crop_lines.each do |line| + cc.move_to line['line'].x1, line['line'].y1 + cc.line_to line['line'].x2, line['line'].y2 + cc.set_source_color line['color'] + cc.set_line_width line['width'] + cc.set_dash(line['style'].pattern) if line['style'].pattern + cc.stroke + end + end + + def check_card_orientation + clockwise = 1.5 * Math::PI + # Simple detection + if @deck.width == @tmpl.card_width && @deck.height == @tmpl.card_height + return 0 + elsif ( + @deck.width == @tmpl.card_height && + @deck.height == @tmpl.card_width) + Squib.logger.warn { + 'Rotating cards to match card orientation in template.' + } + return clockwise + end + + # If the card dimensions doesn't match, warns the user... + Squib.logger.warn { + 'Card size does not match the template\'s expected card size. '\ + 'Cards may overlap.' + } + + # ... but still try to auto-orient the cards anyway + is_tmpl_card_landscape = @tmpl.card_width > @tmpl.card_height + is_deck_card_landscape = @deck.width > @deck.height + if is_tmpl_card_landscape == is_deck_card_landscape + clockwise + else + 0 + end + end + + def draw_rotated_card(cc, card, x, y, angle) + # Normalize the angles first + angle = angle % (2 * Math::PI) + angle = 2 * Math::PI - angle if angle < 0 + + # Determine what's the delta we need to translate our cards + delta_shift = @deck.width < @deck.height ? 1 : -1 + if angle.zero? || angle == Math::PI + delta = 0 + elsif angle < Math::PI + delta = -delta_shift * @rotated_delta + else + delta = delta_shift * @rotated_delta + end + + # Perform the actual rotation and drawing + mat = cc.matrix # Save the transformation matrix to revert later + cc.translate x, y + cc.translate @deck.width / 2, @deck.height / 2 + cc.rotate angle + cc.translate(-@deck.width / 2 + delta, -@deck.height / 2 + delta) + cc.set_source card.cairo_surface, 0, 0 + cc.matrix = mat + end + end + + # Templated sheet renderer in PDF format. + class SaveTemplatedSheetPDF < SaveTemplatedSheet + def init_cc + ratio = 72.0 / @deck.dpi + + surface = Cairo::PDFSurface.new( + full_filename, + @tmpl.sheet_width * ratio, + @tmpl.sheet_height * ratio + ) + + cc = Cairo::Context.new(surface) + cc.scale(72.0 / @deck.dpi, 72.0 / @deck.dpi) # make it like pixels + cc + end + + def draw_page(cc) + cc.show_page + cc + end + + def full_filename + @outfile.full_filename + end + end + + # Templated sheet renderer in PDF format. + class SaveTemplatedSheetPNG < SaveTemplatedSheet + def init_cc + surface = Cairo::ImageSurface.new @tmpl.sheet_width, @tmpl.sheet_height + Cairo::Context.new(surface) + end + + def draw_page(cc) + cc.target.write_to_png(full_filename) + init_cc + end + + def full_filename + @outfile.full_filename @page_number + end + end + end +end diff --git a/lib/squib/sheet_templates/a4_euro_card.yml b/lib/squib/sheet_templates/a4_euro_card.yml new file mode 100644 index 0000000..c3ee8ff --- /dev/null +++ b/lib/squib/sheet_templates/a4_euro_card.yml @@ -0,0 +1,42 @@ +--- +sheet_width: 210mm +sheet_height: 297mm +card_width: 59.0mm +card_height: 92.0mm +cards: +- x: 16.5mm + y: 10.0mm +- x: 75.5mm + y: 10.0mm +- x: 134.5mm + y: 10.0mm +- x: 16.5mm + y: 102.0mm +- x: 75.5mm + y: 102.0mm +- x: 134.5mm + y: 102.0mm +- x: 16.5mm + y: 194.0mm +- x: 75.5mm + y: 194.0mm +- x: 134.5mm + y: 194.0mm +crop_line: + lines: + - type: :vertical + position: 16.5mm + - type: :vertical + position: 75.5mm + - type: :vertical + position: 134.5mm + - type: :vertical + position: 193.5mm + - type: :horizontal + position: 10.0mm + - type: :horizontal + position: 102.0mm + - type: :horizontal + position: 194.0mm + - type: :horizontal + position: 286.0mm diff --git a/lib/squib/sheet_templates/a4_poker_card_8up.yml b/lib/squib/sheet_templates/a4_poker_card_8up.yml new file mode 100644 index 0000000..1b7f58b --- /dev/null +++ b/lib/squib/sheet_templates/a4_poker_card_8up.yml @@ -0,0 +1,40 @@ +--- +sheet_width: 297mm +sheet_height: 210mm +card_width: 63.0mm +card_height: 88.0mm +cards: +- x: 22.5mm + y: 10.0mm +- x: 85.5mm + y: 10.0mm +- x: 148.5mm + y: 10.0mm +- x: 211.5mm + y: 10.0mm +- x: 22.5mm + y: 98.0mm +- x: 85.5mm + y: 98.0mm +- x: 148.5mm + y: 98.0mm +- x: 211.5mm + y: 98.0mm +crop_line: + lines: + - type: :vertical + position: 22.5mm + - type: :vertical + position: 85.5mm + - type: :vertical + position: 148.5mm + - type: :vertical + position: 211.5mm + - type: :vertical + position: 274.5mm + - type: :horizontal + position: 10.0mm + - type: :horizontal + position: 98.0mm + - type: :horizontal + position: 186.0mm diff --git a/lib/squib/sheet_templates/a4_poker_card_9up.yml b/lib/squib/sheet_templates/a4_poker_card_9up.yml new file mode 100644 index 0000000..eb1a410 --- /dev/null +++ b/lib/squib/sheet_templates/a4_poker_card_9up.yml @@ -0,0 +1,42 @@ +--- +sheet_width: 210mm +sheet_height: 297mm +card_width: 63.0mm +card_height: 88.0mm +cards: +- x: 10.5mm + y: 10.0mm +- x: 73.5mm + y: 10.0mm +- x: 136.5mm + y: 10.0mm +- x: 10.5mm + y: 98.0mm +- x: 73.5mm + y: 98.0mm +- x: 136.5mm + y: 98.0mm +- x: 10.5mm + y: 186.0mm +- x: 73.5mm + y: 186.0mm +- x: 136.5mm + y: 186.0mm +crop_line: + lines: + - type: :vertical + position: 10.5mm + - type: :vertical + position: 73.5mm + - type: :vertical + position: 136.5mm + - type: :vertical + position: 199.5mm + - type: :horizontal + position: 10.0mm + - type: :horizontal + position: 98.0mm + - type: :horizontal + position: 186.0mm + - type: :horizontal + position: 274.0mm diff --git a/lib/squib/sheet_templates/a4_usa_card.yml b/lib/squib/sheet_templates/a4_usa_card.yml new file mode 100644 index 0000000..889f791 --- /dev/null +++ b/lib/squib/sheet_templates/a4_usa_card.yml @@ -0,0 +1,42 @@ +--- +sheet_width: 210mm +sheet_height: 297mm +card_width: 56.0mm +card_height: 87.0mm +cards: +- x: 21.0mm + y: 10.0mm +- x: 77.0mm + y: 10.0mm +- x: 133.0mm + y: 10.0mm +- x: 21.0mm + y: 97.0mm +- x: 77.0mm + y: 97.0mm +- x: 133.0mm + y: 97.0mm +- x: 21.0mm + y: 184.0mm +- x: 77.0mm + y: 184.0mm +- x: 133.0mm + y: 184.0mm +crop_line: + lines: + - type: :vertical + position: 21.0mm + - type: :vertical + position: 77.0mm + - type: :vertical + position: 133.0mm + - type: :vertical + position: 189.0mm + - type: :horizontal + position: 10.0mm + - type: :horizontal + position: 97.0mm + - type: :horizontal + position: 184.0mm + - type: :horizontal + position: 271.0mm diff --git a/lib/squib/template.rb b/lib/squib/template.rb new file mode 100644 index 0000000..4136c92 --- /dev/null +++ b/lib/squib/template.rb @@ -0,0 +1,300 @@ +require 'yaml' +require 'classy_hash' +require_relative 'args/color_validator' +require_relative 'args/unit_conversion' + +module Squib + # Crop line dash definition + class CropLineDash + VALIDATION_REGEX = /%r{ + ^(\d*[.])?\d+(in|cm|mm) + \s+ + (\d*[.])?\d+(in|cm|mm)$ + }x/ + + attr_reader :pattern + + def initialize(value, dpi) + if value == :solid + @pattern = nil + elsif value == :dotted + @pattern = [ + Args::UnitConversion.parse('0.2mm', dpi), + Args::UnitConversion.parse('0.5mm', dpi) + ] + elsif value == :dashed + @pattern = [ + Args::UnitConversion.parse('2mm', dpi), + Args::UnitConversion.parse('2mm', dpi) + ] + elsif value.is_a? String + @pattern = value.split(' ').map do |val| + Args::UnitConversion.parse val, dpi + end + else + raise ArgumentError, 'Unsupported dash style' + end + end + end + + # Template file + class Template + include Args::ColorValidator + + # Defaults are set for poker sized deck on a A4 sheet, with no cards + DEFAULTS = { + 'sheet_width' => nil, + 'sheet_height' => nil, + 'card_width' => nil, + 'card_height' => nil, + 'dpi' => 300, + 'position_reference' => :topleft, + 'rotate' => 0.0, + 'crop_line' => { + 'style' => :solid, + 'width' => '0.02mm', + 'color' => :black, + 'overlay' => :on_margin, + 'lines' => [] + }, + 'cards' => [] + }.freeze + + attr_reader :dpi + + def initialize(template_hash, dpi) + ClassyHash.validate(template_hash, SCHEMA) + @template_hash = template_hash + @dpi = dpi + @crop_line_default = @template_hash['crop_line'].select do |k, _| + %w[style width color].include? k + end + end + + # Load the template definition file + def self.load(file, dpi) + yaml = {} + thefile = File.exist?(file) ? file : builtin(file) + yaml = YAML.load_file(thefile) || {} if File.exist? thefile + + # Bake the default values into our template + new_hash = DEFAULTS.merge(yaml) + new_hash['crop_line'] = DEFAULTS['crop_line'].merge( + new_hash['crop_line'] + ) + + # Create a new template file + warn_unrecognized(yaml) + Template.new new_hash, dpi + end + + def sheet_width + Args::UnitConversion.parse @template_hash['sheet_width'], @dpi + end + + def sheet_height + Args::UnitConversion.parse @template_hash['sheet_height'], @dpi + end + + def card_width + Args::UnitConversion.parse @template_hash['card_width'], @dpi + end + + def card_height + Args::UnitConversion.parse @template_hash['card_height'], @dpi + end + + def card_default_rotation + parse_rotate_param @template_hash['rotate'] + end + + def crop_line_overlay + @template_hash['crop_line']['overlay'] + end + + def crop_lines + lines = @template_hash['crop_line']['lines'].map( + &method(:parse_crop_line) + ) + if block_given? + lines.each { |v| yield v } + else + lines + end + end + + def cards + parsed_cards = @template_hash['cards'].map(&method(:parse_card)) + if block_given? + parsed_cards.each { |v| yield v } + else + parsed_cards + end + end + + def margin + # NOTE: There's a baseline of 0.25mm that we can 100% make sure that we + # can overlap really thin lines on the PDF + crop_line_width = [ + Args::UnitConversion.parse(@template_hash['crop_line']['width'], @dpi), + Args::UnitConversion.parse('0.25mm', @dpi) + ].max + + parsed_cards = cards + left, right = parsed_cards.minmax { |a, b| a['x'] <=> b['x'] } + top, bottom = parsed_cards.minmax { |a, b| a['y'] <=> b['y'] } + + { + left: left['x'] - crop_line_width, + right: right['x'] + card_width + crop_line_width, + top: top['y'] - crop_line_width, + bottom: bottom['y'] + card_height + crop_line_width + } + end + + # Warn unrecognized options in the template sheet + def self.warn_unrecognized(yaml) + unrec = yaml.keys - DEFAULTS.keys + return unless unrec.any? + + Squib.logger.warn( + "Unrecognized configuration option(s): #{unrec.join(',')}" + ) + end + + private + + # Template file schema + UNIT_REGEX = /^(\d*[.])?\d+(in|cm|mm)$/ + ROTATE_REGEX = /^(\d*[.])?\d+(deg|rad)?$/ + SCHEMA = { + 'sheet_width' => UNIT_REGEX, + 'sheet_height' => UNIT_REGEX, + 'card_width' => UNIT_REGEX, + 'card_height' => UNIT_REGEX, + 'position_reference' => ClassyHash::G.enum(:topleft, :center), + 'rotate' => [ + :optional, Numeric, + ClassyHash::G.enum(:clockwise, :counterclockwise, :turnaround), + ROTATE_REGEX + ], + 'crop_line' => { + 'style' => [ + ClassyHash::G.enum(:solid, :dotted, :dashed), + CropLineDash::VALIDATION_REGEX + ], + 'width' => UNIT_REGEX, + 'color' => [String, Symbol], + 'overlay' => ClassyHash::G.enum( + :on_margin, :overlay_on_cards, :beneath_cards + ), + 'lines' => [[{ + 'type' => ClassyHash::G.enum(:horizontal, :vertical), + 'position' => UNIT_REGEX, + 'style' => [ + :optional, ClassyHash::G.enum(:solid, :dotted, :dashed) + ], + 'width' => [:optional, UNIT_REGEX], + 'color' => [:optional, String, Symbol], + 'overlay_on_cards' => [:optional, TrueClass] + }]] + }, + 'cards' => [[{ + 'x' => UNIT_REGEX, + 'y' => UNIT_REGEX, + # NOTE: Don't think that we should specify rotation on a per card + # basis, but just included here for now + 'rotate' => [ + :optional, Numeric, + ClassyHash::G.enum(:clockwise, :counterclockwise, :turnaround), + ROTATE_REGEX + ] + }]] + }.freeze + + # Return path for built-in sheet templates + def self.builtin(file) + "#{File.dirname(__FILE__)}/sheet_templates/#{file}" + end + + # Parse crop line definitions from template. + def parse_crop_line(line) + new_line = @crop_line_default.merge line + new_line['width'] = Args::UnitConversion.parse(new_line['width'], @dpi) + new_line['color'] = colorify new_line['color'] + new_line['style_desc'] = new_line['style'] + new_line['style'] = CropLineDash.new(new_line['style'], @dpi) + new_line['line'] = CropLine.new( + new_line['type'], new_line['position'], sheet_width, sheet_height, @dpi + ) + new_line + end + + # Parse card definitions from template. + def parse_card(card) + new_card = card.clone + + x = Args::UnitConversion.parse(card['x'], @dpi) + y = Args::UnitConversion.parse(card['y'], @dpi) + if @template_hash['position_reference'] == :center + # Normalize it to a top-left positional reference + x -= card_width / 2 + y -= card_height / 2 + end + + new_card['x'] = x + new_card['y'] = y + new_card['rotate'] = parse_rotate_param( + card['rotate'] ? card['rotate'] : @template_hash['rotate']) + new_card + end + + def parse_rotate_param(val) + if val == :clockwise + 0.5 * Math::PI + elsif val == :counterclockwise + 1.5 * Math::PI + elsif val == :turnaround + Math::PI + elsif val.is_a? String + if val.end_with? 'deg' + val.gsub(/deg$/, '').to_f / 180 * Math::PI + elsif val.end_with? 'rad' + val.gsub(/rad$/, '').to_f + else + val.to_f + end + elsif val.nil? + 0.0 + else + val.to_f + end + end + end + + # Crop line definition + class CropLine + attr_reader :x1, :y1, :x2, :y2 + + def initialize(type, position, sheet_width, sheet_height, dpi) + method = "parse_#{type}" + send method, position, sheet_width, sheet_height, dpi + end + + def parse_horizontal(position, sheet_width, _, dpi) + position = Args::UnitConversion.parse(position, dpi) + @x1 = 0 + @y1 = position + @x2 = sheet_width + @y2 = position + end + + def parse_vertical(position, _, sheet_height, dpi) + position = Args::UnitConversion.parse(position, dpi) + @x1 = position + @y1 = 0 + @x2 = position + @y2 = sheet_height + end + end +end diff --git a/samples/templates/fold_sheet.rb b/samples/templates/fold_sheet.rb new file mode 100644 index 0000000..48ccf58 --- /dev/null +++ b/samples/templates/fold_sheet.rb @@ -0,0 +1,10 @@ +require 'squib' + +Squib::Deck.new(width: '63mm', height: '88mm', cards: 8) do + rect fill_color: :gray + text( + str: %w[Front_1 Front_2 Front_3 Front_4 Back_1 Back_2 Back_3 Back_4], + x: '3mm', y: '3mm' + ) + save_pdf file: 'fold_sheet.pdf', template_file: 'templates/fold_sheet.yml' +end diff --git a/samples/templates/hex_tiles.rb b/samples/templates/hex_tiles.rb new file mode 100644 index 0000000..86c047a --- /dev/null +++ b/samples/templates/hex_tiles.rb @@ -0,0 +1,14 @@ +require 'squib' + +Squib::Deck.new(width: '65.8mm', height: '76mm', cards: 9) do + polygon( + x: '32.9mm', y: '38mm', n: 6, radius: '38mm', angle: 1.571, + stroke_color: :black, stroke_width: '0.014in', fill_color: :pink + ) + text( + str: %w[One Two Three Four Five Six Seven Eight Nine], + x: '27mm', y: '35mm', width: '11.8mm', height: '6mm', + align: :center, valign: :middle + ) + save_pdf file: 'hex_tiles.pdf', template_file: 'templates/hex_tiles.yml' +end diff --git a/samples/templates/templates/fold_sheet.yml b/samples/templates/templates/fold_sheet.yml new file mode 100644 index 0000000..8dbfb9b --- /dev/null +++ b/samples/templates/templates/fold_sheet.yml @@ -0,0 +1,57 @@ +--- +sheet_width: 297mm +sheet_height: 210mm +card_width: 63.0mm +card_height: 88.0mm +cards: +- x: 16.5mm + y: 13.0mm +- x: 83.5mm + y: 13.0mm +- x: 150.5mm + y: 13.0mm +- x: 217.5mm + y: 13.0mm +- x: 16.5mm + y: 109.0mm + rotate: :turnaround +- x: 83.5mm + y: 109.0mm + rotate: :turnaround +- x: 150.5mm + y: 109.0mm + rotate: :turnaround +- x: 217.5mm + y: 109.0mm + rotate: :turnaround +crop_line: + lines: + - type: :vertical + position: 16.5mm + - type: :vertical + position: 79.5mm + - type: :vertical + position: 83.5mm + - type: :vertical + position: 146.5mm + - type: :vertical + position: 150.5mm + - type: :vertical + position: 213.5mm + - type: :vertical + position: 217.5mm + - type: :vertical + position: 280.5mm + - type: :horizontal + position: 13.0mm + - type: :horizontal + position: 101.0mm + - type: :horizontal + position: 109.0mm + - type: :horizontal + position: 197.0mm + - type: :horizontal + position: 105.0mm + style: :dashed + color: :red + overlay_on_cards: true diff --git a/samples/templates/templates/hex_tiles.yml b/samples/templates/templates/hex_tiles.yml new file mode 100644 index 0000000..b0d7f50 --- /dev/null +++ b/samples/templates/templates/hex_tiles.yml @@ -0,0 +1,24 @@ +--- +sheet_width: 210mm +sheet_height: 297.000000mm +card_width: 65.800000mm +card_height: 76.000000mm +cards: +- x: 6.300000mm + y: 5.000000mm +- x: 6.300000mm + y: 81.000000mm +- x: 6.300000mm + y: 157.000000mm +- x: 72.100000mm + y: 43.000000mm +- x: 72.100000mm + y: 119.000000mm +- x: 72.100000mm + y: 195.000000mm +- x: 137.900000mm + y: 5.000000mm +- x: 137.900000mm + y: 81.000000mm +- x: 137.900000mm + y: 157.000000mm diff --git a/samples/templates/use_package_template.rb b/samples/templates/use_package_template.rb new file mode 100644 index 0000000..5be8e82 --- /dev/null +++ b/samples/templates/use_package_template.rb @@ -0,0 +1,8 @@ +require 'squib' + +Squib::Deck.new(width: '63mm', height: '88mm', cards: 9) do + text( + str: %w[One Two Three Four Five Six Seven Eight Nine], x: '3mm', y: '3mm' + ) + save_pdf file: 'use_package_tmpl.pdf', template_file: 'a4_poker_card_9up.yml' +end diff --git a/spec/data/templates/basic.yml b/spec/data/templates/basic.yml new file mode 100644 index 0000000..04e9817 --- /dev/null +++ b/spec/data/templates/basic.yml @@ -0,0 +1,7 @@ +sheet_width: 8.5in +sheet_height: 11in +card_width: 2.5in +card_height: 3.5in +cards: +- x: 0.5in + y: 1in diff --git a/spec/data/templates/card_center_coord.yml b/spec/data/templates/card_center_coord.yml new file mode 100644 index 0000000..d81dcd8 --- /dev/null +++ b/spec/data/templates/card_center_coord.yml @@ -0,0 +1,22 @@ +sheet_width: 8.5in +sheet_height: 11in +card_width: 2in +card_height: 3in +position_reference: :center +cards: +- x: 1.25in + y: 2.5in +- x: 3.25in + y: 2.5in +- x: 5.25in + y: 2.5in +- x: 7.25in + y: 2.5in +- x: 1.25in + y: 5.5in +- x: 3.25in + y: 5.5in +- x: 5.25in + y: 5.5in +- x: 7.25in + y: 5.5in diff --git a/spec/data/templates/card_rotation.yml b/spec/data/templates/card_rotation.yml new file mode 100644 index 0000000..ed0cfa2 --- /dev/null +++ b/spec/data/templates/card_rotation.yml @@ -0,0 +1,23 @@ +sheet_width: 8.5in +sheet_height: 11in +card_width: 2.5in +card_height: 3.5in +rotate: :clockwise +cards: +- x: 0.5in + y: 1in +- x: 3.0in + y: 1in + rotate: :counterclockwise +- x: 3.5in + y: 1in + rotate: :turnaround +- x: 0.5in + y: 4.5in + rotate: 1 +- x: 3.0in + y: 4.5in + rotate: 60deg +- x: 3.5in + y: 4.5in + rotate: 1.25rad diff --git a/spec/data/templates/custom_crop_lines.yml b/spec/data/templates/custom_crop_lines.yml new file mode 100644 index 0000000..de4f83f --- /dev/null +++ b/spec/data/templates/custom_crop_lines.yml @@ -0,0 +1,28 @@ +sheet_width: 8.5in +sheet_height: 11in +card_width: 2in +card_height: 3in +crop_line: + style: :dashed + width: 0.1in + color: :pink + overlay: :overlay_on_cards + lines: + - type: :horizontal + position: 1in + - type: :horizontal + position: 2in + width: 0.2in + - type: :horizontal + position: 3in + style: :dotted + - type: :horizontal + position: 4in + color: "#ff0000" + - type: :horizontal + position: 5in + width: 0.3in + style: :solid + color: :blue + - type: :vertical + position: 6in diff --git a/spec/data/templates/fail_no_card_height.yml b/spec/data/templates/fail_no_card_height.yml new file mode 100644 index 0000000..f860536 --- /dev/null +++ b/spec/data/templates/fail_no_card_height.yml @@ -0,0 +1,6 @@ +sheet_width: 8.5in +sheet_height: 11in +card_width: 2.5in +cards: +- x: 0.5in + y: 1in diff --git a/spec/data/templates/fail_no_card_width.yml b/spec/data/templates/fail_no_card_width.yml new file mode 100644 index 0000000..7e01a89 --- /dev/null +++ b/spec/data/templates/fail_no_card_width.yml @@ -0,0 +1,6 @@ +sheet_width: 8.5in +sheet_height: 11in +card_height: 3.5in +cards: +- x: 0.5in + y: 1in diff --git a/spec/data/templates/fail_no_sheet_height.yml b/spec/data/templates/fail_no_sheet_height.yml new file mode 100644 index 0000000..e5e5056 --- /dev/null +++ b/spec/data/templates/fail_no_sheet_height.yml @@ -0,0 +1,6 @@ +sheet_width: 8.5in +card_width: 2.5in +card_height: 3.5in +cards: +- x: 0.5in + y: 1in diff --git a/spec/data/templates/fail_no_sheet_width.yml b/spec/data/templates/fail_no_sheet_width.yml new file mode 100644 index 0000000..c915b5d --- /dev/null +++ b/spec/data/templates/fail_no_sheet_width.yml @@ -0,0 +1,6 @@ +sheet_height: 11in +card_width: 2.5in +card_height: 3.5in +cards: +- x: 0.5in + y: 1in diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 33d3679..6e70927 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -50,6 +50,10 @@ def yaml_file(file) "#{File.expand_path(File.dirname(__FILE__))}/data/yaml/#{file}" end +def template_file(file) + "#{File.expand_path(File.dirname(__FILE__))}/data/templates/#{file}" +end + def project_template(file) "#{File.expand_path(File.dirname(__FILE__))}/../lib/squib/project_template/#{file}" end diff --git a/spec/template_spec.rb b/spec/template_spec.rb new file mode 100644 index 0000000..6b9d41e --- /dev/null +++ b/spec/template_spec.rb @@ -0,0 +1,181 @@ +require 'spec_helper' + +describe Squib::Template do + it 'loads a template' do + tmpl = Squib::Template.load(template_file('basic.yml'), 100) + expect(tmpl.sheet_width).to eq(850) + expect(tmpl.sheet_height).to eq(1100) + expect(tmpl.card_width).to eq(250) + expect(tmpl.card_height).to eq(350) + expect(tmpl.dpi).to eq(100) + expect(tmpl.crop_line_overlay).to eq( + Squib::Template::DEFAULTS['crop_line']['overlay'] + ) + expect(tmpl.crop_lines).to eq([]) + expect(tmpl.cards).to eq([{ 'x' => 50, 'y' => 100, 'rotate' => 0 }]) + end + + it 'loads from the default templates if none exists' do + tmpl = Squib::Template.load('a4_poker_card_9up.yml', 100) + expect(tmpl.sheet_width).to eq(826.7716527) + expect(tmpl.sheet_height).to eq(1169.2913373899999) + expect(tmpl.card_width).to eq(248.03149580999997) + expect(tmpl.card_height).to eq(346.45669256) + expect(tmpl.dpi).to eq(100) + + expect(tmpl.crop_lines.length).to eq(8) + expect(tmpl.crop_lines.map { |line| line['type'] }).to eq( + %i[ + vertical vertical vertical vertical + horizontal horizontal horizontal horizontal + ] + ) + expect(tmpl.crop_lines.map { |line| line['line'].x1 }).to eq( + [ + 41.338582635, 289.370078445, 537.401574255, 785.433070065, + 0, 0, 0, 0 + ] + ) + expect(tmpl.crop_lines.map { |line| line['line'].x2 }).to eq( + [ + 41.338582635, 289.370078445, 537.401574255, 785.433070065, + 826.7716527, 826.7716527, 826.7716527, 826.7716527 + ] + ) + expect(tmpl.crop_lines.map { |line| line['line'].y1 }).to eq( + [ + 0, 0, 0, 0, + 39.3700787, 385.82677126, 732.28346382, 1078.74015638 + ] + ) + expect(tmpl.crop_lines.map { |line| line['line'].y2 }).to eq( + [ + 1169.2913373899999, 1169.2913373899999, 1169.2913373899999, + 1169.2913373899999, + 39.3700787, 385.82677126, 732.28346382, 1078.74015638 + ] + ) + + expect(tmpl.cards.length).to eq(9) + expect(tmpl.cards.map { |card| card['x'] }).to eq( + [41.338582635, 289.370078445, 537.401574255] * 3 + ) + expect(tmpl.cards.map { |card| card['y'] }).to eq( + [ + 39.3700787, 39.3700787, 39.3700787, + 385.82677126, 385.82677126, 385.82677126, + 732.28346382, 732.28346382, 732.28346382 + ] + ) + + expect(tmpl.margin).to eq( + left: 40.3543306675, + right: 786.4173220325, + top: 38.3858267325, + bottom: 1079.7244083475 + ) + end + + it 'loads a template with the coordinates specifying the middle of cards' do + tmpl = Squib::Template.load(template_file('card_center_coord.yml'), 100) + expect(tmpl.sheet_width).to eq(850) + expect(tmpl.sheet_height).to eq(1100) + expect(tmpl.card_width).to eq(200) + expect(tmpl.card_height).to eq(300) + expect(tmpl.dpi).to eq(100) + + expect(tmpl.cards.length).to eq(8) + expect(tmpl.cards.map { |card| card['x'] }).to eq( + [25.0, 225.0, 425.0, 625.0] * 2 + ) + expect(tmpl.cards.map { |card| card['y'] }).to eq( + [100.0, 100.0, 100.0, 100.0, 400.0, 400.0, 400.0, 400.0] + ) + end + + it 'loads a template with customized crop lines' do + tmpl = Squib::Template.load(template_file('custom_crop_lines.yml'), 100) + expect(tmpl.sheet_width).to eq(850) + expect(tmpl.sheet_height).to eq(1100) + expect(tmpl.card_width).to eq(200) + expect(tmpl.card_height).to eq(300) + expect(tmpl.dpi).to eq(100) + expect(tmpl.crop_line_overlay).to eq(:overlay_on_cards) + + expect(tmpl.crop_lines.length).to eq(6) + expect(tmpl.crop_lines.map { |line| line['type'] }).to eq( + %i[horizontal horizontal horizontal horizontal horizontal vertical] + ) + expect(tmpl.crop_lines.map { |line| line['line'].x1 }).to eq( + [0, 0, 0, 0, 0, 600] + ) + expect(tmpl.crop_lines.map { |line| line['line'].x2 }).to eq( + [850, 850, 850, 850, 850, 600] + ) + expect(tmpl.crop_lines.map { |line| line['line'].y1 }).to eq( + [100, 200, 300, 400, 500, 0] + ) + expect(tmpl.crop_lines.map { |line| line['line'].y2 }).to eq( + [100, 200, 300, 400, 500, 1100] + ) + expect(tmpl.crop_lines.map { |line| line['style_desc'] }).to eq( + %i[dashed dashed dotted dashed solid dashed] + ) + expect(tmpl.crop_lines.map { |line| line['width'] }).to eq( + [10, 20, 10, 10, 30, 10] + ) + expect(tmpl.crop_lines.map { |line| line['color'] }).to eq( + ['pink', 'pink', 'pink', '#ff0000', 'blue', 'pink'] + ) + end + + it 'loads a template with rotated cards' do + tmpl = Squib::Template.load(template_file('card_rotation.yml'), 100) + expect(tmpl.sheet_width).to eq(850) + expect(tmpl.sheet_height).to eq(1100) + expect(tmpl.card_width).to eq(250) + expect(tmpl.card_height).to eq(350) + expect(tmpl.dpi).to eq(100) + + expect(tmpl.cards.length).to eq(6) + expect(tmpl.cards.map { |card| card['rotate'] }).to eq( + [0.5 * Math::PI, 1.5 * Math::PI, Math::PI, 1, Math::PI / 3, 1.25] + ) + end + + it 'fails when sheet_width is not defined' do + expect do + Squib::Template.load(template_file('fail_no_sheet_width.yml'), 100) + end.to raise_error( + RuntimeError, + '"sheet_width" is not a String matching /^(\d*[.])?\d+(in|cm|mm)$/' + ) + end + + it 'fails when sheet_height is not defined' do + expect do + Squib::Template.load(template_file('fail_no_sheet_height.yml'), 100) + end.to raise_error( + RuntimeError, + '"sheet_height" is not a String matching /^(\d*[.])?\d+(in|cm|mm)$/' + ) + end + + it 'fails when card_width is not defined' do + expect do + Squib::Template.load(template_file('fail_no_card_width.yml'), 100) + end.to raise_error( + RuntimeError, + '"card_width" is not a String matching /^(\d*[.])?\d+(in|cm|mm)$/' + ) + end + + it 'fails when card_height is not defined' do + expect do + Squib::Template.load(template_file('fail_no_card_height.yml'), 100) + end.to raise_error( + RuntimeError, + '"card_height" is not a String matching /^(\d*[.])?\d+(in|cm|mm)$/' + ) + end +end diff --git a/squib.gemspec b/squib.gemspec index b184d76..5b1fae4 100644 --- a/squib.gemspec +++ b/squib.gemspec @@ -38,6 +38,8 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'rsvg2', '3.1.8' spec.add_runtime_dependency 'roo', '2.7.1' spec.add_runtime_dependency 'ruby-progressbar', '1.8.1' + spec.add_runtime_dependency 'highline', '1.7.8' + spec.add_runtime_dependency 'classy_hash', '0.1.5' spec.add_development_dependency 'bundler' spec.add_development_dependency 'rake'