#!/usr/bin/perl # formless - converts tsv to html forms # # License: public domain # Author: Sam Watkins # Web: http://sam.nipl.net/ # # Version: 0.2~9 # Date: 2012-04-11 # # Version: 0.1 # Date: 2010-05-14 use strict; use warnings; use Cwd qw(realpath); use File::Basename; our ($column_width, $column_input_size, $cell_padding, $cell_padding_input_size, $error_messages_tt, $error_messages_star, $data_tt, $data_span, $data_default, $options_tt, $metadata_tt, $wrap_with_html_body, $stylesheet, @form_attr, $checkbox_value, $file_input_size_sub); unshift @INC, '.', dirname(realpath($0)); require 'formless.cnf'; sub id { return @_; } sub set_form_attr { my ($k, $v) = @_; return if !defined $v; for (my $i=0; $i < @form_attr; $i+=2) { if ($form_attr[$i] eq $k) { $form_attr[$i+1] = $v; } } } sub slurp { my ($file) = @_; my $fh; if (ref $file) { $fh = $file; } else { open $fh, '<', $file or die "can't open file to read: $file: $!"; } local $/; return <$fh>; } sub enen { local ($_) = @_; if (defined $_) { s/&/&/g; s//>/g; s/"/"/g; } else { $_ = 'NULL'; } return $_; } sub tag { my ($tag, @attrs) = @_; my $single = $tag =~ s{/$}{} ? '/' : ''; my $attr = ''; while (my ($name, $value) = splice @attrs, 0, 2) { next if !defined $value; my $venc = enen($value); $attr .= qq{ $name="$venc"}; } return "<$tag$attr$single>"; } sub wrap { my ($tag, @elements) = @_; my $attrs = ref $elements[0] ? shift @elements : []; my $content = join('', grep {defined $_} @elements); return tag($tag, @$attrs) .$content .tag("/$tag"); } our (@grid, @size, @span, @card, @border, @boxes, %need_hidden, @need_hidden, %metadata, @metadata); our $td_col; our ($n_rows, $n_cols); our %bracket_pair = ( '['=>']', '<'=>'>', '('=>')', '{'=>'}' ); our %card_class = (''=>'single required', '?'=>'optional', '*'=>'multi optional', '+'=>'multi required'); sub td { my ($r, $c) = @_; my $text = $grid[$r][$c]; return undef if !defined $text; my $colspan = $span[$r][$c] || 1; my $class = join ' ', sort keys %{$border[$r][$c]}; my $tag = $text =~ /:$/ ? 'th' : 'td'; my $align; $text = " " if $text eq ""; if ($tag eq "th") { my $t = $grid[$r][$c+$colspan]; if (!defined($t) || $t =~ /:$/) { $align = "left"; } else { $align = "right"; } } if ($class) { $class =~ s/^ //; } else { $class = undef; } return wrap($tag, [colspan=>$colspan == 1 ? undef : $colspan, class => $class, align => $align], $text); } sub search_for_boxes { for my $r (0..$n_rows-1) { for my $c (0..$n_cols-1) { if ($grid[$r][$c] =~ s{^/}{}) { add_box($r, $c); } } } return; } sub add_box { my ($r0, $c0) = @_; my ($r1, $c1); # find top-right for ($c1=$c0; $c1<$n_cols; ++$c1) { last if $grid[$r0][$c1] =~ s{\\$}{}; } die "could not find top-right \ of box" if $c1 == $n_cols; # find bottom for ($r1=$r0; $r1<$n_rows; ++$r1) { my $a = $grid[$r1][$c0] =~ s{^\\}{}; my $b = $grid[$r1][$c1] =~ s{/$}{}; last if $a && $b; die "misaligned bottom of box" if $a || $b; } die "could not find bottom of box" if $r1 == $n_rows; # create borders for this box for my $r ($r0..$r1) { $border[$r][$c0]{left} = 1; $border[$r][$c1]{right} = 1; } for my $c ($c0..$c1) { $border[$r0][$c]{top} = 1; $border[$r1][$c]{bottom} = 1; } push @boxes, [$r0, $c0, $r1, $c1]; # TODO remove? return; } sub widget_size { my ($r, $c, $open) = @_; my $expect = $bracket_pair{$open}; for my $c1 ($c..$n_cols-1) { my ($close, $card); if ($grid[$r][$c1] =~ s/([]>}])([?*+]|)$//) { ($close, $card) = ($1, $2); } undef $grid[$r][$c1] if $c1 > $c; if ($close) { return ($c1 - $c + 1, $card) if $close eq $expect; die "mismatched widget brackets"; } } die "did not find widget's closing bracket"; } sub colspan { my ($r, $c) = @_; my $span = 1; for my $c1 ($c+1..$n_cols-1) { last if (defined $grid[$r][$c1] && $grid[$r][$c1] ne "") || $border[$r][$c1-1]{right} || $border[$r][$c1]{left}; undef $grid[$r][$c1]; ++$span; } return $span; } sub need_hidden { my ($id) = @_; $need_hidden{$id} ||= do { push @need_hidden, $id; 1; }; } sub data_value { my ($id, $stash, $opt) = @_; my $enc = \&enen; my $tt_enc = ' | html'; if (($opt||'') eq 'html') { $enc = \&id; $tt_enc = ''; } my $ret; if (defined $stash->{$id}) { $ret = $enc->($stash->{$id}); } elsif ($data_tt) { $ret = "[% $id$tt_enc %]"; } else { $ret = $enc->($data_default); } if ($data_span) { $ret = wrap('span', [id=>"text_".$id], $ret); } return $ret; } sub text2form { my ($tmpl, $stash) = @_; @grid = @size = @span = @card = @border = @boxes = %need_hidden = @need_hidden = %metadata = @metadata = (); $n_rows = $n_cols = 0; # read TSV lines into table cells my @lines = split /\r?\n/, $tmpl; for (@lines) { s/\r//g; chomp; s/\t+$//; my @cells = split /\t/, $_, -1; push @grid, \@cells; if (@cells > $n_cols) { $n_cols = @cells; } } # get any metadata while (@{$grid[0]} == 2 && $grid[0][0] =~ /:$/) { my ($k, $v) = @{shift @grid}; $k =~ s/:$//; push @metadata, $k; $metadata{$k} = $v; } die "missing well-formed metadata section\n" if @{shift @grid} != 0; my $metadata = ""; my $hidden = ""; my $meta_hidden = delete $metadata{hidden}; my $meta_method = delete $metadata{method}; my $meta_action = delete $metadata{action}; my $meta_form = delete $metadata{form}; if (!defined $meta_form) { $meta_form = 1; } my $meta_form_id = delete $metadata{form_id}; my $meta_expand = delete $metadata{expand}; my $meta_min_width = delete $metadata{min_width}; my $meta_width = delete $metadata{width}; my $meta_class = delete $metadata{class}; if ($meta_width) { $column_input_size *= $meta_width / $column_width; $column_width = $meta_width; } $n_rows = @grid; # pad each row to be the same length for my $row (@grid) { while (@$row < $n_cols) { push @$row, ''; } } # search for boxes search_for_boxes(); my ($r, $c); # calculate colspan for each cell, and undef unused cells: # [inputs], and {buttons} can have tabs inside to indicate colspan # labels, data, links and checkbox+label followed by blank cells are extended into those cells for $r (0..$n_rows-1) { for $c (0..$n_cols-1) { local $_ = $grid[$r][$c]; next if !defined $_; my ($size, $colspan, $card); if (my ($open) = /^([[<{])/) { ($size, $card) = widget_size($r, $c, $open); $card[$r][$c] = $card; } ($colspan) = colspan($r, $c); if ($colspan > 1) { $border[$r][$c] = {map {$_=>1} keys %{$border[$r][$c]}, keys %{$border[$r][$c + $colspan-1]}}; } $span[$r][$c] = $colspan; $size[$r][$c] = $size || $colspan; } } # generate html for each cell for $r (0..$n_rows-1) { for $c (0..$n_cols-1) { local $_ = $grid[$r][$c]; next if !defined $_; my $colspan = $span[$r][$c] || 1; my $size = $size[$r][$c] || 1; my $card = $card[$r][$c] || ''; my $width = $column_width * $size - $cell_padding; my @style_width; # if ($c + $colspan < $n_cols) { # @style_width = (style => "width: ${width}px;"); # } # @style_width = (style => "min-width: ${width}px;"); @style_width = (style => "width: ${width}px;"); my $input_size = int($column_input_size * $size - $cell_padding_input_size + 1e-9); my ($id, $label, $maxlength); # entry if (s/^([[<{?])//) { my ($type) = ($1); if ($type eq '[') { ($id, $maxlength) = /^(\S+)\s*(\d+)?/; my $input_type = 'text'; if ($id =~ s/^(file)://) { $input_type = $1; $input_size -= $file_input_size_sub; } $_ = tag('input/', name=>$id, id=>$id, type=>$input_type, value=>$stash->{$id}, maxlength=>$maxlength, class => $card_class{$card}, size => $input_size, @style_width); # TODO textarea # select } elsif ($type eq '<') { $id = $_; my @options; if ($options_tt) { push @options, qq{[% FOREACH x IN options.$id %][% END %]}; } $_ = wrap('select', [name=>$id, id=>$id, value=>$stash->{$id}, class => $card_class{$card}, @style_width], @options) # button } elsif ($type eq '{') { $label = $_; my @default = $label =~ s/^!// ? (class => 'default') : (); ($id = $label) =~ s/\W/_/g; $_ = tag('input/', type=>'submit', name=>$id, id=>$id, value=>$stash->{$id}||$label, @style_width, @default) # checkbox with label } elsif ($type eq '?') { ($id, $card, $label) = /^(\S+?)([?*+]|\b)(.*)/; die "bad checkbox widget: $_\n" if !defined $id; $_ = tag('input/', type => 'checkbox', name=>$id, id=>$id, value=>$checkbox_value||1, checked => $stash->{$id} ? 1 : undef, class => $card_class{$card}); $_ .= wrap('label', [for=>$id], enen($label)) if $label ne ''; } else { die "invalid syntax for input item"; } # error messages per field, via template toolkit at the moment. not for buttons! if ($error_messages_tt && $type ne '{') { $_ .= " [% errors.$id %]"; } elsif ($error_messages_star) { $_ .= " *"; } # text (maybe with $data and links, etc) # The following is not ideal, it would be better to step through it # than to do multiple substitution passes like this. } else { # remove a lone . (placeholder) s{^\.$}{}; # entities $_ = enen($_); # data / variables s{\$(\$|\d|<\S+>|\S+)}{ my ($id) = ($1); if ($id eq '$') { '$' } elsif ($id =~ /^\d/) { "\$$id" } elsif ($id =~ /^<(.*)>$/) { $id = $1; need_hidden($id); data_value($id, $stash, 'html'); } else { need_hidden($id); data_value($id, $stash); } }ge; # spacing s/ /  /g; # links s{\b_(.+?)_\b}{ my $label = $1; (my $id = lc $label) =~ s/\s/_/g; my $href = $stash->{$id}||'#'; wrap('a', [href=>$href], enen($label)); }ge; # code - javascript links s{(^|\s)!(.+?)!($|\s)}{ my ($pre, $label, $post) = ($1, $2, $3); (my $id = lc $label) =~ s/\s/_/g; my $href = $stash->{$id}||"javascript:$id()"; $pre . wrap('a', [href=>$href], enen($label)) . $post; }ge; } $grid[$r][$c] = $_; } } # build html table my $body; for $r (0..$n_rows-1) { for $c (0..$n_cols-1) { $grid[$r][$c] = td($r, $c); } $body .= wrap('tr', @{$grid[$r]}) . "\n"; } set_form_attr('method', $meta_method); set_form_attr('action', $meta_action); set_form_attr('id', $meta_form_id); set_form_attr('name', $meta_form_id); # add metadata if ($metadata_tt) { for my $k (@metadata) { my $v = $metadata{$k}; $metadata .= "[% META $k = '$v' -%]\n" if defined $v; } } # add hidden form elements for each $data displayed - for use by javascript unshift @need_hidden, split /\s+/, $meta_hidden if $meta_hidden; for my $id (@need_hidden) { $hidden .= tag('input/', type=>'hidden', name=>$id, id=>$id, value=>$stash->{$id})."\n"; } # add one blank row with all the cells, with widths, to ensure sensible layout # hopefully min-width would work on IE6, but I doubt it. my $column_width_minus_padding = $column_width - $cell_padding; if ($meta_min_width) { $column_width_minus_padding = $meta_min_width; } # $body .= wrap('tr', map {wrap('td', [style=>"min-width: ${column_width_minus_padding}px;"])} 1..$n_cols); # $body .= wrap('tr', (map {wrap('td', [style=>"width: ${column_width_minus_padding}px;"])} 1..($n_cols-1)), wrap('td')); # I.e. limits the width MORE if use min-width than width!!! wtf my @expand; @expand = (style=>"width: 100%;") if $meta_expand; $body .= wrap('tr', (map {wrap('td', [style=>"width: ${column_width_minus_padding}px; min-width: ${column_width_minus_padding}px; max-width: ${column_width_minus_padding}px;"])} 1..($n_cols-1)), wrap('td', [@expand])); # wrap with form and table tags my $form_body = $hidden . wrap('table', [class=>($meta_class||'layout')], $body); if ($meta_form) { return $metadata . wrap('form', [@form_attr], $form_body); } else { return $metadata . $form_body; } } sub formless { my $tmpl = slurp(\*STDIN); my $stash = {}; my $html = text2form($tmpl, $stash); # wrap with html (with head) and body tags if ($wrap_with_html_body) { $html = wrap('html', wrap('head', tag('link/', rel=>'stylesheet', type=>'text/css', href=>$stylesheet)), wrap('body', $html)); } $html .= "\n"; # output html print $html; } formless();