From Code to Community: Sponsoring The Perl and Raku Conference 2025 Learn more

use Carp;
use strict;
use vars qw($VERSION);
$VERSION = '1.03';
=head1 NAME
Text::FormatTable - Format text tables
=head1 SYNOPSIS
my $table = Text::FormatTable->new('r|l');
$table->head('a', 'b');
$table->rule('=');
$table->row('c', 'd');
print $table->render(20);
=head1 DESCRIPTION
Text::FormatTable renders simple tables as text. You pass to the constructor
(I<new>) a table format specification similar to LaTeX (e.g. C<r|l|5l|R|20L>) and you
call methods to fill the table data and insert rules. After the data is filled,
you call the I<render> method and the table gets formatted as text.
Methods:
=over 4
=cut
# Remove ANSI color sequences when calculating length
sub _uncolorized_length($)
{
my $str = shift;
$str =~ s/\e \[ [^m]* m//xmsg;
return length $str;
}
# minimal width of $1 if word-wrapped
sub _min_width($)
{
my $str = shift;
my $min;
for my $s (split(/\s+/,$str)) {
my $l = _uncolorized_length $s;
$min = $l if not defined $min or $l > $min;
}
return $min ? $min : 1;
}
# width of $1 if not word-wrapped
sub _max_width($)
{
my $str = shift;
my $len = _uncolorized_length $str;
return $len ? $len : 1;
}
sub _max($$)
{
my ($a,$b) = @_;
return $a if defined $a and (not defined $b or $a >= $b);
return $b;
}
# word-wrap multi-line $2 with width $1
sub _wrap($$)
{
my ($width, $text) = @_;
my @lines = split(/\n/, $text);
my @w = ();
for my $l (@lines) {
push @w, @{_wrap_line($width, $l)};
}
return \@w;
}
sub _wrap_line($$)
{
my ($width, $text) = @_;
my $width_m1 = $width-1;
my @t = ($text);
while(1) {
my $t = pop @t;
my $l = _uncolorized_length $t;
if($l <= $width){
# last line is ok => done
push @t, $t;
return \@t;
}
elsif($t =~ /^(.{0,$width_m1}\S)\s+(\S.*?)$/) {
# farest space < width
push @t, $1;
push @t, $2;
}
elsif($t =~ /(.{$width,}?\S)\s+(\S.*?)$/) {
# nearest space > width
if ( _uncolorized_length $1 > $width_m1 )
{
# hard hyphanation
my $left = substr($1,0,$width);
my $right= substr($1,$width);
push @t, $left;
push @t, $right;
push @t, $2;
}
else
{
push @t, $1;
push @t, $2;
}
}
else {
# hard hyphanation
my $left = substr($t,0,$width);
my $right= substr($t,$width);
push @t, $left;
push @t, $right;
return \@t;
}
}
return \@t;
}
# render left-box $2 with width $1
sub _l_box($$)
{
my ($width, $text) = @_;
my $lines = _wrap($width, $text);
map { $_ .= ' 'x($width-_uncolorized_length($_)) } @$lines;
return $lines;
}
# render right-box $2 with width $1
sub _r_box($$)
{
my ($width, $text) = @_;
my $lines = _wrap($width, $text);
map { $_ = (' 'x($width-_uncolorized_length($_)).$_) } @$lines;
return $lines;
}
# Algorithm of:
# http://ei5nazha.yz.yamagata-u.ac.jp/~aito/w3m/eng/STORY.html
sub _distribution_f($)
{
my $max_width = shift;
return log($max_width);
}
sub _calculate_widths($$)
{
my ($self, $width) = @_;
my @widths = ();
# calculate min and max widths for each column
for my $r (@{$self->{data}})
{
$r->[0] eq 'data' or $r->[0] eq 'head' or next;
my $cn=0;
my ($max, $min) = (0,0);
for my $c (@{$r->[1]}) {
if ( $self->{fixed_widths}[$cn] )
{
# fixed width
$widths[$cn][0] = $self->{fixed_widths}[$cn];
$widths[$cn][1] = $self->{fixed_widths}[$cn];
}
else
{
$widths[$cn][0] = _max($widths[$cn][0], _min_width $c);
$widths[$cn][1] = _max($widths[$cn][1], _max_width $c);
}
$cn++;
}
}
# calculate total min and max width
my ($total_min, $total_max) = (0,0);
for my $c (@widths) {
$total_min += $c->[0];
$total_max += $c->[1];
}
# extra space
my $extra_width += scalar grep {$_->[0] eq '|' or $_->[0] eq ' '}
(@{$self->{format}});
$total_min += $extra_width;
$total_max += $extra_width;
# if total_max <= screen width => use max as width
if($total_max <= $width) {
my $cn = 0;
for my $c (@widths) {
$self->{widths}[$cn]=$c->[1];
$cn++;
}
$self->{total_width} = $total_max;
}
else {
my @dist_width;
ITERATION: while(1) {
my $total_f = 0.0;
my $fixed_width = 0;
my $remaining=0;
for my $c (@widths) {
if(defined $c->[2]) {
$fixed_width += $c->[2];
}
else {
$total_f += _distribution_f($c->[1]);
$remaining++;
}
}
my $available_width = $width-$extra_width-$fixed_width;
# enlarge width if it isn't enough
if($available_width < $remaining*5) {
$available_width = $remaining*5;
$width = $extra_width+$fixed_width+$available_width;
}
my $cn=-1;
COLUMN: for my $c (@widths) {
$cn++;
next COLUMN if defined $c->[2]; # skip fixed-widths
my $w = _distribution_f($c->[1]) * $available_width / $total_f;
if($c->[0] > $w) {
$c->[2] = $c->[0];
next ITERATION;
}
if($c->[1] < $w) {
$c->[2] = $c->[1];
next ITERATION;
}
$dist_width[$cn] = int($w);
}
last;
}
my $cn = 0;
for my $c (@widths) {
$self->{widths}[$cn]=defined $c->[2] ? $c->[2] : $dist_width[$cn];
$cn++;
}
}
}
sub _render_rule($$)
{
my ($self, $char) = @_;
my $out = '';
my ($col,$data_col) = (0,0);
for my $c (@{$self->{format}}) {
if($c->[0] eq '|') {
if ($char eq '-') { $out .= '+' }
elsif($char eq ' ') { $out .= '|' }
else { $out .= $char }
}
elsif($c->[0] eq ' ') {
$out .= $char;
}
elsif( $c->[0] eq 'l'
or $c->[0] eq 'L'
or $c->[0] eq 'r'
or $c->[0] eq 'R'
) {
$out .= ($char)x($self->{widths}[$data_col]);
$data_col++;
}
$col++;
}
return $out."\n";
}
sub _render_data($$)
{
my ($self,$data) = @_;
my @rdata; # rendered data
# render every column and find out number of lines
my ($col, $data_col) = (0,0);
my $lines=0;
my @rows_in_column;
for my $c (@{$self->{format}}) {
if( ($c->[0] eq 'l') or ($c->[0] eq 'L') ) {
my $lb = _l_box($self->{widths}[$data_col], $data->[$data_col]);
$rdata[$data_col] = $lb;
my $l = scalar @$lb ;
$lines = $l if $lines < $l;
$rows_in_column[$data_col] = $l;
$data_col++;
}
elsif( ($c->[0] eq 'r') or ($c->[0] eq 'R' ) ) {
my $rb = _r_box($self->{widths}[$data_col], $data->[$data_col]);
$rdata[$data_col] = $rb;
my $l = scalar @$rb ;
$lines = $l if $lines < $l;
$rows_in_column[$data_col] = $l ;
$data_col++;
}
$col++;
}
# render each line
my $out = '';
for my $l (0..($lines-1)) {
my ($col, $data_col) = (0,0);
for my $c (@{$self->{format}}) {
if($c->[0] eq '|') {
$out .= '|';
}
elsif($c->[0] eq ' ') {
$out .= ' ';
}
elsif( $c->[0] eq 'L' or $c->[0] eq 'R')
{
# bottom align
my $start_print = $lines - $rows_in_column[$data_col];
if ( defined $rdata[$data_col][$l-$start_print]
and $l >= $start_print
)
{
$out .= $rdata[$data_col][$l-$start_print];
}
else
{
$out .= ' 'x($self->{widths}[$data_col]);
}
$data_col++;
}
elsif($c->[0] eq 'l' or $c->[0] eq 'r') {
# top align
if(defined $rdata[$data_col][$l]) {
$out .= $rdata[$data_col][$l];
}
else {
$out .= ' 'x($self->{widths}[$data_col]);
}
$data_col++;
}
$col++;
}
$out .= "\n";
}
return $out;
}
sub _parse_format($$)
{
my ($self, $format) = @_;
my @f = split(//, $format);
my @format = ();
my @width = ();
my ($col,$data_col) = (0,0);
my $wid;
for my $f (@f) {
if ( $f =~ /(\d+)/)
{
$wid .= $f;
next;
}
if($f eq 'l' or $f eq 'L' or $f eq 'r' or $f eq 'R') {
$format[$col] = [$f, $data_col];
$width[$data_col] = $wid;
$wid = undef;
$data_col++;
}
elsif($f eq '|' or $f eq ' ') {
$format[$col] = [$f];
}
else {
croak "unknown column format: $f";
}
$col++;
}
$self->{format}=\@format;
$self->{fixed_widths}=\@width;
$self->{col}=$col;
$self->{data_col}=$data_col;
}
=item B<new>(I<$format>)
Create a Text::FormatTable object, the format of each column is specified as a
character of the $format string. The following formats are defined:
=over 4
=item l
Left-justified top aligned word-wrapped text.
=item L
Left-justified bottom aligned word-wrapped text.
=item r
Right-justified top aligned word-wrapped text.
=item R
Right-justified bottom aligned word-wrapped text.
=item 10R, 20r, 15L, 12l,
Number is fixed width of the column.
Justified and aligned word-wrapped text (see above).
=item ' '
A space.
=item |
Column separator.
=back
=cut
sub new($$)
{
my ($class, $format) = @_;
croak "new() requires one argument: format" unless defined $format;
my $self = { col => '0', row => '0', data => [] };
bless $self, $class;
$self->_parse_format($format);
return $self;
}
# remove head and trail space
sub _preprocess_row_data($$)
{
my ($self,$data) = @_;
my $cn = 0;
for my $c (0..($#$data)) {
$data->[$c] =~ s/^\s+//m;
$data->[$c] =~ s/\s+$//m;
}
}
=item B<head>(I<$col1>, I<$col2>, ...)
Add a header row using $col1, $col2, etc. as cell contents. Note that, at the
moment, header rows are treated like normal rows.
=cut
sub head($@)
{
my ($self, @data) = @_;
scalar @data == $self->{data_col} or
croak "number of columns must be $self->{data_col}";
$self->_preprocess_row_data(\@data);
$self->{data}[$self->{row}++] = ['head', \@data];
}
=item B<row>(I<$col1>, I<$col2>, ...)
Add a row with $col1, $col2, etc. as cell contents.
=cut
sub row($@)
{
my ($self, @data) = @_;
scalar @data == $self->{data_col} or
croak "number of columns must be $self->{data_col}";
$self->_preprocess_row_data(\@data);
$self->{data}[$self->{row}++] = ['data', \@data];
}
=item B<rule>([I<$char>])
Add an horizontal rule. If $char is specified it will be used as character to
draw the rule, otherwise '-' will be used.
=cut
sub rule($$)
{
my ($self, $char) = @_;
$char = '-' unless defined $char;
$self->{data}[$self->{row}++] = ['rule', $char];
}
=item B<render>([I<$screen_width>])
Return the rendered table formatted with $screen_width or 79 if it is not
specified.
=cut
sub render($$)
{
my ($self, $width) = @_;
$width = 79 unless defined $width;
$self->_calculate_widths($width);
my $out = '';
for my $r (@{$self->{data}}) {
if($r->[0] eq 'rule') {
$out .= $self->_render_rule($r->[1]);
}
elsif($r->[0] eq 'head') {
$out .= $self->_render_data($r->[1]);
}
elsif($r->[0] eq 'data') {
$out .= $self->_render_data($r->[1]);
}
}
return $out;
}
1;
=back
=head1 SEE ALSO
Text::ASCIITable
=head1 COPYRIGHT
Copyright (c) 2001-2004 Swiss Federal Institute of Technology, Zurich.
(c) 2009 Trey Harris
All Rights Reserved.
This module is free software; you can redistribute it and/or
modify it under the same terms as Perl itself.
=head1 CODE REPOSITORY
=head1 AUTHOR
S<David Schweikert <dws@ee.ethz.ch>>
Maintained by S<Trey Harris <treyharris@gmail.com>>
Fixed column width and bottom alignment written by
S<Veselin Slavov <vslavov@creditreform.bg>>
=cut
# vi: et sw=4