Browse Source

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
dev
Seh Hui, Leong 9 years ago committed by Andy Meneely
parent
commit
25bf0d7a67
  1. 5
      CHANGELOG.md
  2. 9
      bin/squib
  3. 1
      lib/squib.rb
  4. 26
      lib/squib/api/save.rb
  5. 40
      lib/squib/args/output_file.rb
  6. 45
      lib/squib/args/template_file.rb
  7. 109
      lib/squib/commands/data/template_option.rb
  8. 275
      lib/squib/commands/make_template.rb
  9. 229
      lib/squib/graphics/save_templated_sheet.rb
  10. 42
      lib/squib/sheet_templates/a4_euro_card.yml
  11. 40
      lib/squib/sheet_templates/a4_poker_card_8up.yml
  12. 42
      lib/squib/sheet_templates/a4_poker_card_9up.yml
  13. 42
      lib/squib/sheet_templates/a4_usa_card.yml
  14. 300
      lib/squib/template.rb
  15. 10
      samples/templates/fold_sheet.rb
  16. 14
      samples/templates/hex_tiles.rb
  17. 57
      samples/templates/templates/fold_sheet.yml
  18. 24
      samples/templates/templates/hex_tiles.yml
  19. 8
      samples/templates/use_package_template.rb
  20. 7
      spec/data/templates/basic.yml
  21. 22
      spec/data/templates/card_center_coord.yml
  22. 23
      spec/data/templates/card_rotation.yml
  23. 28
      spec/data/templates/custom_crop_lines.yml
  24. 6
      spec/data/templates/fail_no_card_height.yml
  25. 6
      spec/data/templates/fail_no_card_width.yml
  26. 6
      spec/data/templates/fail_no_sheet_height.yml
  27. 6
      spec/data/templates/fail_no_sheet_width.yml
  28. 4
      spec/spec_helper.rb
  29. 181
      spec/template_spec.rb
  30. 2
      squib.gemspec

5
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

9
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

1
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'

26
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

40
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

45
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

109
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

275
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

229
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

42
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

40
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

42
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

42
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

300
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

10
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

14
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

57
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

24
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

8
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

7
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

22
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

23
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

28
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

6
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

6
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

6
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

6
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

4
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

181
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

2
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'

Loading…
Cancel
Save