Project

svg_drawer

0.0
No commit activity in last 3 years
No release in over 3 years
A ruby gem for building table-based SVG layouts
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

~> 1.15
~> 3.0

Runtime

~> 0.7
 Project Readme

SvgDrawer

A ruby gem to build table-based SVG layouts

  • Installation
  • Usage
  • Params
  • Elements
    • Table
    • Row
    • Cell
    • TextBox
    • Line
    • Polyline
    • Multipolyline
    • Circle
    • Path
  • Nesting
  • Configuration
  • Useful shorthands
  • More examples
  • Contributing
  • License

Installation

In your Gemfile:

gem 'svg_drawer'

Usage

# The table with 2 columns
table = SvgDrawer::Table.new(columns: 2, width: 400)

# A row with two text cells
table.row(height: 50) do |row|
  row.cell { |cell| cell.text_box('foo') }
  row.cell { |cell| cell.text_box('bar') }
end

# Another row, with borders
table.row(height: 100, border: true, font_size: 20) do |row|
  row.cell { |cell| cell.text_box('baz') }
  row.cell { |cell| cell.text_box('qux') }
end

# Initialize the image object to draw the table in
img = Rasem::SVGImage.new(width: 600, height: 600)
table.draw(img)

img.to_s
# => "<?xml version=\"1.0\" ..."

Params

Different elements (tables, rows, text boxes, etc.) all accept a set of params when initialized. Usually, each element type accepts a different set of params, listed further below

Many of the params are inherited from the parent element (e.g. if a Table is initialized with a font param, it won't use it directly, but child elements (rows) will inherit it, their children (cells) will inherit it, their children (text box) will inherit it).

Regardless of their type, all elements share some common params, which are never inherited:

name type default strict comment
width numeric varies Total element width
height numeric varies Total element height
class string no Sets the class SVG attribute
id string no Sets the id SVG attribute

The strict column specifies whether the given param value imposes a hard or a soft requirement when drawing.

For the width and height params, the value is strict only for TextBox, Line, Polyline, Circle, Multipolyline and Path. Basically any element that contains another element treats its width and height as a soft requirement.

For example:

A table of 1 row and 2 columns can be given a width of 100. In the case of a table, this is a soft requirement, it will only be drawn with that width if the cells themselves can fit within that value.

If each of the cells is also given a width (say, 80), then the total table width of 100 above will be exceeded (to a total of 160). In the case of a cell, this is also a soft requirement, as each cell has some content (text box or polyline etc.) that may have its own, different width.

If the two cells each contain polyline with a width of 200 and 300, respectively, then the cells' width of 80 will be ignored (and the total table width becomes 500). In the case of a polyline, the width is a hard requirement and, when given, the elements up in the hierarchy must respect it, i.e. the cell becomes wider (and eventually the table too)

If a width is not specified in any of the elements, it will be calculated based on the elements at the bottom of the hierarchy: TextBox, Line, Polyline, Circle, etc. -- their width and height, if not explicitly given, can always be calculated, so the table itself will take as little space as possible, keeping its tabular form.

The below params are shared only by Table, Row and Cell elements and are never inherited:

name type default comment
borders array[string] [] Valid element values: 'top', 'bottom', 'left', 'right'
border boolean false Equivalent to passing all possible values in borders
border_style hash { stroke: 'black', size: 1 } Directly mapped to CSS attributes (except for size, which is an alias to stroke-width)

Elements

A high-level overview of the elements:

Table                          # Table#rows returns the array of Row objects
└── Row                        # Row#cells returns the array of Cell objects
    └── Cell                   # Cell#content returns the contained object
        ├── TextBox
        ├── Line
        ├── Polyline
        ├── Multipolyline
        ├── Circle
        ├── Path
        └── Table              # an entire new table!

Elements are created with a list of params, some of which are mandatory.

Elements become complete when certain criteria are met, usually different for each element type.

Complete elements can be drawn by invoking #draw with a single argument -- a Rasem SVG object. Usually, one needs to call #draw only a the top-level element, which will recursively draw all its child elements too.

Table

  • Required params:
name type strict comment
columns integer yes An error is raised unless all rows have exactly this number of cells when draw is called
  • Optional params:
name type default strict comment
row_height numeric no default height for rows. Ignored if individual rows specify their height or if their contents can't fit in it
col_widths array[numeric] width/columns no default width for columns. Ignored if individual row cells specify their width or if their contents can't fit in it
  • Completeness: Contains at least one row.
Examples
  • Create a table with two columns. Let the child elements determine width and height:

    table = SvgDrawer::Table.new(columns: 2)
  • Create a table with 2 columns and a default row height of 100. Each row will still be able to specify its own, or contain a cell with different height, though:

    table = SvgDrawer::Table.new(columns: 2, row_height: 100)

Row

  • Required params:
name type strict comment
columns integer yes See the Table section. Usually inherited from the parent (table)
  • Optional params:
name type default strict comment
col_widths array[numeric] (width/columns) no See the Table section. Usually inherited from the parent (table)
  • Completeness: Contains exactly columns number of cells.
Examples
  • Create a row with two columns. Let the child elements determine width and height:

    row = SvgDrawer::Table.new(columns: 2)
    table.add_row(row)
  • Do the same via the convenient Table#row method, inheriting the columns param from it:

      table.row do |row|
        # ...
      end

Cell

  • Required params: (none)
  • Optional params: (none)
  • Completeness: Has a content.
Examples
  • Create a cell with a minimum width of 100 (the contained element, whatever it is, can still expand it):

    cell = SvgDrawer::Cell.new(width: 100)
    row.add_cell(cell)
  • Do the same via the convenient Row#cell method:

      row.cell do |cell|
        # ...
      end

TextBox

In contrast to the Table, Row and Cell elements, this element expects an argument to #initialize: the string to draw. Second come the params.

Also, the strict column below is omitted, as all params are considered strict (a text box is at the bottom of the element hierarchy and has no children). Exceptions to that rule are explicitly stated otherwise.

It is also always complete, as the only requirement is to have a text to draw, which is a mandatory argument to initialize anyway.

The text itself will be wrapped on multiple lines if the width is restricted (by having a fixed width, either inherited or set explicitly). The height is not strict, because a text can't be vertically restricted -- it either wraps on as many lines as needed, or is always drawn on a single line (there is no width restriction, or overflow and/or truncate params are given)

  • Required params: (none)
  • Optional params:
name type default comment
font string 'Courier New' Font name
font_style array[string] [] Valid element values: 'bold', 'italic'
font_weight numeric 400 More means bolder. Ignored if font_style is also given and contains 'bold'
font_size varies 12 If given value is numeric, then size is in px. Alternatively, string values such as '1em' or '8pt' are also valid
font_color string 'black'
text_align string 'left' Valid values: 'left', 'right', 'center'
text_valign string 'bottom' Valid values: 'top', 'bottom', 'middle'
line_height numeric 1 Determines vertical spacing when text is wrapped into multiple lines
wrap_policy string 'normal' Valid values: 'weak', 'normal', 'aggressive', 'max'. More information in the Configuration section
word_pattern regexp (see below) Pattern that will be used to extract words from the text (whatever matches will never be wrapped unless it alone exceeds the total line length)
overflow boolean false When true, wrapping will not occur and all text will be drawn on one line, possibly exceeding the element width, but without affecting it. Ignored if there is no width restrictions
truncate boolean false Same as overflow, but the text will be truncated at the element width. Ignored if overflow is also true
truncate_with string '...' The string to append to the truncated text
text_padding hash { top: 0, bottom: 0, left: 0, right: 0 } Extra spacing at the sides of the text box

The word pattern is a regexp that tries to match either a word, or a word followed by a single delimiter -- this helps word wrap to avoid moving a . at the end of a sentence to a new line. This pattern can surely be improved, but it looks sufficient for now:

/[[:word:]]+[^[:word:]]\s?(?![^[:word:]])|[[:word:]]+|[^[:word:]]/

Contains exactly columns number of cells.

  • Completeness: Always.
Examples
  • Create text box with a fixed width. If the text does not fit on one line with that width, let it overflow without chaning the tabular structure (i.e. pretend that width is 100):

    text_box = SvgDrawer::TextBox.new("Lorem ipsum", width: 100, overflow: true)
    cell.content(text_box)
  • Do the same via the convenient Cell#text_box method:

      cell.text_box('foo bar', width: 100, overflow: true)

Line

Similar to the TextBox, this element expects an argument to #initialize: an array of exactly 4 numeric values ([start_x, start_y, end_x, end_y]). Second come the params.

  • Required params: (none)
  • Optional params:
name type default comment
stroke string 'black' Line color
linecap string 'butt' Line ending style (see the SVG stroke-linecap attribute)
size numeric 1 Line size (or line width)
x_reposition string 'none' Transpose the line (i.e. if points are [40,20,30,40], a left align will change them to [10,20,0,40])
y_reposition string 'none' Same as x_reposition, but for vertical alignment
expand boolean false Scale the element up to a fixed width and/or height, maintaining aspect ratio. Ignored if neither width nor height are also given
shrink boolean false Same as expand, but scale the element down to ensure it completely fits
dotspace numeric 0 When greater than 0, draw a dotted line instead, the value being the space between the dots
overflow boolean false Ignore any width and/or height restrictions and draw freely. Ignored if expand and/or shrink are given
scale numeric 1 Manually scale the element. If shrink and/or expand are also given, apply scale after them
scale_size boolean true When true, scaling/expanding/shrinking will also change the line size proportionally
  • Completeness: Initialized with exactly two coordinates (star and end point)
Examples
  • Create a line that is scaled down to fit a 100x100 box:

    line = SvgDrawer::Line.new([0, 0, 300, 50], width: 100, height: 100, shrink: true)
    cell.content(line)
  • Do the same via the convenient Cell#line method. It will be shrinked until it fits the cell's boundaries, if any, otherwise it will be drawn full-size:

      cell.line([0, 0, 300, 50], shrink: true)

Polyline

A polyline behaves just like the line, but can be initialized with more than 4 elements in the array.

  • Required params: (none)
  • Optional params: Same as for Line, with two more:
name type default comment
linejoin string 'miter' Line joint style (see the SVG stroke-linejoin attribute)
fill string 'none' Coloring of the inner area of the polyline
  • Completeness: Initialized with two or more coordinates
Examples
  • Create a polyline using the Cell#polyline shorthand:
      cell.polyline([0, 0, 300, 50, 20, 74, 244, 124])

Multipolyline

Same as polyline, but initialized with a 2-dimension array (each array representing a polyline)

This is useful mostly when a combination of polylines needs to be drawn, which all scale proportionally, as one, to fit a given container.

Note: one can always use Multipolyline instead of a Polyline, as it builds on top of it -- in the end, it is a matter of personal taste.

  • Required params: (none)
  • Optional params: Same as for Polyline
  • Completeness: Any of the nested polylines is not complete
Examples
  • Create a multipolylines using the Cell#multipolyline shorthand:
      cell.multipolyline([0, 0, 300, 50, 20, 74, 244, 124])

Circle

This element expects two arguments to #initialize: an array of exactly 2 numeric values ([x, y]) and a radius. Third come the params.

  • Optional params:
name type default comment
fill string 'none' Coloring of the inner area of the circle
stroke string 'black' Circle outline color
size numeric 1 Circle outline size
x_reposition string 'none' see Line
y_reposition string 'none' see Line
expand boolean false see Line
shrink boolean false see Line
overflow boolean false see Line
scale numeric 1 see Line
scale_size boolean true see Line
  • Completeness: Always
Examples
  • Create a circle that is scaled down to fit a 100x100 box:

    line = SvgDrawer::Circle.new([0, 50], 300, width: 100, height: 100, shrink: true)
    cell.content(line)
  • Do the same via the convenient Cell#circle method. It will be shrinked until it fits the cell's boundaries, if any, otherwise it will be drawn full-size. Also re-position the circle in the container's center:

      cell.line([0, 0, 300, 50], shrink: true, x_reposition: 'center', y_reposition: 'middle')

Path

The Path element expects an array of strings, each representing a list of path commands.

This is the most restricted element in SvgDrawer -- it does not support repositioning, expanding and/or shrinking, it is basically always drawn as-is. This is because it is very hard to compute the element's boundary coordinates and, without those, no useful transformations can be applied here.

For that reason, width and height are required params, as they can't be calculated based on the contents.

Examples
  • Create a path (width and height are needed by the parent element to know how to draw its contents):
    cell.path(['M859.6,53.59a20.3,20.3,0,1,0,20.29,20.3'], width: 8, height: 8)

Nesting

Nesting is supported -- a cell's content can be an entirely new table:

table = SvgDrawer::Table.new(columns: 2)

table.row do |row|
  row.cell do |cell|
    sub_table = SvgDrawer::Table.new(columns: 4)
    # add rows, cells to sub_table
    # ...
    # then set the sub_table as the content of the parent table's cell
    cell.content(sub_table)
  end
end

Configuration

Calling SvgDrawer.configuration will return the current fonts configuration in use.

It can be updated with

config = YAML.load_file('svg_drawer.yml')
SvgDrawer.configuration.update(config)

The configuration file has the following structure:

Arial:
  width: 0.63         # (average) width in px of a font letter at font size 1
  height: 1.3         # (average) height in px of a font letter at font size 1
  y_offset: 0.23      # how much to "lift" the text
  wrap_policies:
    weak: 0.9             # english text with very occasional capitals
    normal: 1             # randomly mixed small/capital chars
    aggressive: 1.17      # capital chars
    max: 1.85             # capital "W" (widest english char in Arial)

Configuring a non-monospace font is extremely tricky, provided that every client renders fonts differently, so it is recommended to use monospace fonts only.

y_offset is also calculated experimentally with chrome inspector. It is needed because, as opposed to HTML, in SVG, when a text is drawn, it is transposed down by (1.2 * [font_height]) px. This causes problems when drawing a text box with a border, as letters like g or p will intersect with the bottom border (and _ will even be drawn entirely below the border). This is compensated with the y_offset, which seems to be ~0.22 and usually does OK for all fonts I have tested so far.

Any value that is not found in the font's config is taken from the default font configuration, which seems good for most monospaced fonts:

default:
  width: 0.63
  height: 1.2
  y_offset: 0.23
  wrap_policies:
    weak: 1
    normal: 1
    aggressive: 1
    max: 1

The default configuration file can be found here

Useful shorthands

As already pointed out, there are methods are not required, but that make it slightly easier to work with SvgDrawer.

They are:

  • Table#row
  • Table#blank_row (useful for as a "spacer" row in a table with a fixed height)
  • Table#text_row (can accept an array of strings, one for each cell)
  • Table#path_row
  • Table#line_row
  • Table#polyline_row
  • Table#multipolyline_row
  • Table#circle_row
  • Row#cell
  • Row#text_cell
  • Row#path_cell
  • Row#line_cell
  • Row#polyline_cell
  • Row#multipolyline_cell
  • Row#circle_cell
  • Cell#text_box
  • Cell#path
  • Cell#line
  • Cell#polyline
  • Cell#multipolyline
  • Cell#circle

For example, to create a simple table with only text boxes, all formated the same way, one can combine the inheritance mechanism and these shorthands and do this:

t = Table.new(columns: 3, width: 300, row_height: 50, text_align: 'center', text_valign: 'middle', font_size: 16)
t.text_row(['foo', 'bar', 'baz'])
t.text_row(['qux', 'fred', 'thud'])
t.text_row(['wibble', 'wobble', 'wubble'])

Result here

More examples

See the examples directory for moar

A real-world example: at SumUp, we generate SVG receipts which we then convert to PNG before printing receipt for a transaction made with SumUp card reader.

Known limitations

  • Text is rendered with different size on different viewers, which causes problems with text wrapping. As opposed to HTML, automatic text wrapping is not possible with SVG, so the text is wrapped manually at generation time, by calculating its width based on the config values for that font. However, different viewers render text differently, and you might end up with your text looking fine on one viewer and completely wrong on another viewer. To be on the safe side, you can work this around by setting an aggressive wrap policy.
  • support for path elements is quite limited -- you can't scale them up, down, or reposition them.
  • if two cells in a row have different heights, their borders will be drawn at different heights, making the table layout look weird. The table layout is still Ok, though, if you don't draw cell borders at all. It is questionable if that is actually a feature, or a limitation, anyway, maybe in some future version it will can be made configurable.
  • some SVG attributes are not supported (there is no corresponding param for them). This can usually be easily resolved if there is a need for that. One exception is the transpose transformation, which is heavily used behind the curtains and can't really be made configurable at this point.

Contributing

  1. Fork it
  2. Create your feature branch
  3. Comply with the ruby style guide
  4. Try to add tests for your new feature/bugfix. Given I haven't written any thus far :) I can't put a hard requirement on this.
  5. Submit a pull request

License

The gem is available as open source under the terms of the MIT License.