use strict;
use warnings;
use utf8;
use Irssi;
use Encode qw(decode encode);
use File::Spec;
use Cwd qw(abs_path);
our $VERSION = '1.12';
our %IRSSI = (
authors => 'Converted from tdf_send.py',
name => 'tdf_send',
description => 'Render and send wrapped TheDraw .TDF fonts',
license => 'Public Domain',
);
my $TDF_MARKER = "\x55\xaa\x00\xff";
my $FIRST_FONT_OFFSET = 20;
my $FONT_HEADER_SIZE = 213;
my $UNDEFINED_OFFSET = 0xFFFF;
my $MAX_IRC_BYTES = 350;
my @DOS_TO_IRC = (1, 2, 3, 10, 4, 6, 7, 15, 14, 12, 9, 11, 4, 13, 8, 0);
my %OUTLINE_MAP = (
A => 0xCD, B => 0xC4, C => 0xB3, D => 0xBA,
E => 0xD5, F => 0xBB, G => 0xD6, H => 0xBF,
I => 0xC8, J => 0xBE, K => 0xC0, L => 0xBD,
M => 0xB5, N => 0xC7,
);
my %PC_DISPLAY_GLYPHS = (
1=>'☺', 2=>'☻', 3=>'♥', 4=>'♦', 5=>'♣', 6=>'♠', 7=>'•', 8=>'◘',
9=>'○', 10=>'◙', 11=>'♂', 12=>'♀', 14=>'♫', 15=>'☼', 16=>'►',
17=>'◄', 18=>'↕', 19=>'‼', 20=>'¶', 21=>'§', 22=>'▬', 23=>'↨',
24=>'↑', 25=>'↓', 26=>'→', 27=>'←', 28=>'∟', 29=>'↔', 30=>'▲',
31=>'▼', 127=>'⌂',
);
my %TDF_BLOCK_GLYPHS = (
0xB0=>'░', 0xB1=>'▒', 0xB2=>'▓', 0xDB=>'█',
0xDC=>'▄', 0xDD=>'▌', 0xDE=>'▐', 0xDF=>'▀', 0xFE=>'█',
);
my %loaded_fonts;
my $loaded_file;
Irssi::settings_add_str('tdf', 'tdf_font_directory', "$ENV{HOME}/fonts");
Irssi::settings_add_str('tdf', 'tdf_last_font_file', '');
sub cp437_to_utf8 {
my ($value) = @_;
return $TDF_BLOCK_GLYPHS{$value} if exists $TDF_BLOCK_GLYPHS{$value};
return $PC_DISPLAY_GLYPHS{$value} if exists $PC_DISPLAY_GLYPHS{$value};
return decode('cp437', chr($value), Encode::FB_DEFAULT);
}
sub font_key {
my ($name) = @_;
$name =~ s/\x00//g;
$name =~ s/\x{A0}/ /g;
$name =~ s/^\s+|\s+$//g;
return lc $name;
}
sub display_width {
my ($text) = @_;
$text =~ s/\x03\d{2},\d{2}//g;
$text =~ s/\x0f//g;
return length($text);
}
sub compact_irc_colors {
my ($line) = @_;
my ($result, $last_color) = ('', undef);
while (length $line) {
if ($line =~ s/^(\x03\d{2},\d{2})//) {
my $color = $1;
if (!defined $last_color || $color ne $last_color) {
$result .= $color;
$last_color = $color;
}
next;
}
my $char = substr($line, 0, 1, '');
$last_color = undef if $char eq "\x0f";
$result .= $char;
}
return $result;
}
sub get_font_directory {
my $dir = Irssi::settings_get_str('tdf_font_directory');
$dir =~ s/^~/$ENV{HOME}/;
return $dir;
}
sub resolve_tdf_path {
my ($value) = @_;
$value =~ s/^~/$ENV{HOME}/;
my @candidates = (
File::Spec->file_name_is_absolute($value) || $value =~ m!/!
? $value
: File::Spec->catfile(get_font_directory(), $value)
);
for my $candidate (@candidates) {
return abs_path($candidate) if -f $candidate;
if ($candidate !~ /\.tdf$/i) {
my $with_extension = "$candidate.tdf";
return abs_path($with_extension) if -f $with_extension;
}
}
die qq{Could not find "$value" in } . get_font_directory();
}
sub character {
my ($font, $char) = @_;
return [' '] if $char eq ' ';
my $code = ord($char);
return [$char] if $code < 33 || $code > 126;
my $offset = $font->{offsets}->[$code - 33];
return [$char] if !defined($offset)
|| $offset == $UNDEFINED_OFFSET
|| $offset + 2 > length($font->{data});
my $width = ord(substr($font->{data}, $offset, 1));
my $height = ord(substr($font->{data}, $offset + 1, 1));
my $pos = $offset + 2;
return [' '] if $width == 0;
my @rows;
my $row = '';
my $columns = 0;
while ($pos < length($font->{data})) {
my $value = ord(substr($font->{data}, $pos++, 1));
if ($value == 0) {
if (length($row) || !@rows) {
my $padding = $width - $columns;
$padding = 0 if $padding < 0;
push @rows, $row . (' ' x $padding);
}
last;
}
if ($value == 13) {
my $padding = $width - $columns;
$padding = 0 if $padding < 0;
push @rows, $row . (' ' x $padding);
$row = '';
$columns = 0;
next;
}
my $color_value;
if ($font->{font_type} == 2) {
last if $pos >= length($font->{data});
$color_value = ord(substr($font->{data}, $pos++, 1));
}
my $glyph;
if ($font->{font_type} == 0) {
my $reference = chr($value);
if ($reference eq '@') {
$glyph = ' ';
} elsif ($reference eq 'O') {
$glyph = "\x{A0}";
} elsif ($reference eq '&') {
next;
} elsif (exists $OUTLINE_MAP{$reference}) {
$glyph = cp437_to_utf8($OUTLINE_MAP{$reference});
} else {
$glyph = cp437_to_utf8($value);
}
} else {
$glyph = cp437_to_utf8($value);
}
if (defined $color_value) {
my $fg = $DOS_TO_IRC[$color_value & 0x0F];
my $bg = $DOS_TO_IRC[($color_value >> 4) & 0x07];
$row .= sprintf("\x03%02d,%02d%s", $fg, $bg, $glyph);
} else {
$row .= $glyph;
}
$columns++;
}
push @rows, (' ' x $width) while @rows < $height;
return @rows ? \@rows : [(' ' x $width)];
}
sub render {
my ($font, $text) = @_;
my @rendered;
my @source_lines = split(/\n/, $text, -1);
@source_lines = ('') unless @source_lines;
for my $source_line (@source_lines) {
# Explicit blank paragraphs render as one blank IRC line.
if ($source_line eq '') {
push @rendered, "\x0f";
next;
}
my @glyphs = map { character($font, $_) } split(//, $source_line);
next unless @glyphs;
my @widths;
for my $glyph (@glyphs) {
my $max_width = 1;
for my $glyph_row (@$glyph) {
my $width = display_width($glyph_row);
$max_width = $width if $width > $max_width;
}
push @widths, $max_width;
}
my $height = 1;
for my $glyph (@glyphs) {
$height = scalar(@$glyph) if scalar(@$glyph) > $height;
}
my @output = ('') x $height;
for my $i (0 .. scalar(@glyphs) - 1) {
for my $row_index (0 .. $height - 1) {
if ($row_index < scalar(@{$glyphs[$i]})) {
$output[$row_index] .= $glyphs[$i]->[$row_index];
} else {
$output[$row_index] .= ' ' x $widths[$i];
}
}
if ($i < scalar(@glyphs) - 1) {
$_ .= ' ' x $font->{spacing} for @output;
}
}
push @rendered, map { $_ . "\x0f" } @output;
}
return @rendered;
}
sub rendered_lines_fit {
my ($font, $text, $limit) = @_;
for my $line (render($font, $text)) {
return 0 if length(encode('UTF-8', compact_irc_colors($line))) > $limit;
}
return 1;
}
sub render_wrapped {
my ($font, $text, $limit) = @_;
$limit //= $MAX_IRC_BYTES;
my @all_lines;
for my $paragraph (split(/\n/, $text, -1)) {
if ($paragraph eq '') {
push @all_lines, render($font, '');
next;
}
my @words = split(/\s+/, $paragraph);
my $current = '';
for my $word (@words) {
my $candidate = $current eq '' ? $word : "$current $word";
if (rendered_lines_fit($font, $candidate, $limit)) {
$current = $candidate;
next;
}
if ($current ne '') {
push @all_lines, render($font, $current);
$current = '';
}
if (rendered_lines_fit($font, $word, $limit)) {
$current = $word;
next;
}
my $partial = '';
for my $char (split(//, $word)) {
my $part_candidate = $partial . $char;
if (rendered_lines_fit($font, $part_candidate, $limit)) {
$partial = $part_candidate;
} else {
die 'One rendered character is wider than IRC allows.'
if $partial eq '';
push @all_lines, render($font, $partial);
$partial = $char;
}
}
$current = $partial;
}
push @all_lines, render($font, $current) if $current ne '';
}
return @all_lines;
}
sub load_tdf {
my ($path) = @_;
open my $fh, '<:raw', $path or die "Cannot open $path: $!";
local $/;
my $blob = <$fh>;
close $fh;
die 'Not a valid TheDraw .TDF file.'
unless substr($blob, 0, 20) eq "\x13TheDraw FONTS file\x1a";
my %fonts;
my $pos = $FIRST_FONT_OFFSET;
while ($pos + $FONT_HEADER_SIZE <= length($blob)) {
last unless substr($blob, $pos, 4) eq $TDF_MARKER;
my $name_length = ord(substr($blob, $pos + 4, 1));
$name_length = 12 if $name_length > 12;
my $name = decode('cp437', substr($blob, $pos + 5, $name_length));
$name =~ s/\x00.*$//s;
$name =~ s/^\s+|\s+$//g;
my $font_type = ord(substr($blob, $pos + 21, 1));
my $spacing = ord(substr($blob, $pos + 22, 1));
my $block_size = unpack('v', substr($blob, $pos + 23, 2));
my @offsets = unpack('v94', substr($blob, $pos + 25, 188));
my $data_start = $pos + $FONT_HEADER_SIZE;
my $data_end = $data_start + $block_size;
$data_end = length($blob) if $data_end > length($blob);
if ($name ne '') {
$fonts{font_key($name)} = {
name => $name,
font_type => $font_type,
spacing => $spacing > 0 ? $spacing - 1 : 0,
data => substr($blob, $data_start, $data_end - $data_start),
offsets => \@offsets,
};
}
$pos = $data_start + $block_size;
}
die 'No readable fonts were found in this file.' unless %fonts;
%loaded_fonts = %fonts;
$loaded_file = abs_path($path);
}
sub get_font {
my ($name) = @_;
my $font = $loaded_fonts{font_key($name)};
die qq{Font "$name" was not found. Use /TDFFONTS to list fonts.}
unless $font;
return $font;
}
sub send_lines {
my ($server, $target, @lines) = @_;
for my $line (@lines) {
$line =~ s/[\r\n]//g;
$line = compact_irc_colors($line);
die 'A rendered line is too wide for IRC.'
if length(encode('UTF-8', $line)) > $MAX_IRC_BYTES;
# Do not pre-encode: Irssi performs outgoing encoding itself.
$server->command("MSG $target $line");
}
}
sub active_target {
my $window = Irssi::active_win();
my $item = $window->{active};
die 'No active channel or query.'
unless $item && $item->{server};
return ($item->{server}, $item->{name});
}
sub parse_quoted {
my ($text) = @_;
my @args;
while ($text =~ /\G\s*(?:"([^"]*)"|'([^']*)'|(\S+))/gc) {
push @args, defined($1) ? $1 : defined($2) ? $2 : $3;
}
return @args;
}
sub cmd_tdfload {
my ($data) = @_;
eval {
die 'Usage: /TDFLOAD <filename-or-path>' unless $data =~ /\S/;
my $path = resolve_tdf_path($data);
load_tdf($path);
Irssi::settings_set_str('tdf_last_font_file', $loaded_file);
Irssi::print(
'Loaded ' . scalar(keys %loaded_fonts) . " font(s) from $loaded_file"
);
};
Irssi::print("TDF error: $@") if $@;
}
sub cmd_tdfdir {
my ($data) = @_;
eval {
if ($data !~ /\S/) {
Irssi::print('TDF directory: ' . get_font_directory());
return;
}
$data =~ s/^~/$ENV{HOME}/;
die "Directory does not exist: $data" unless -d $data;
my $path = abs_path($data);
Irssi::settings_set_str('tdf_font_directory', $path);
Irssi::print("TDF directory set to: $path");
};
Irssi::print("TDF error: $@") if $@;
}
sub cmd_tdffonts {
if (!%loaded_fonts) {
Irssi::print('No TDF file loaded. Use /TDFLOAD <file>.');
return;
}
Irssi::print(
'Loaded fonts: ' .
join(', ', sort map { $_->{name} } values %loaded_fonts)
);
}
sub cmd_tdf {
my ($data) = @_;
eval {
my @args = parse_quoted($data);
die 'Usage: /TDF "Font Name" text' unless @args >= 2;
my $font = get_font(shift @args);
my ($server, $target) = active_target();
send_lines($server, $target, render_wrapped($font, join(' ', @args)));
};
Irssi::print("TDF error: $@") if $@;
}
sub cmd_tdfto {
my ($data) = @_;
eval {
my @args = parse_quoted($data);
die 'Usage: /TDFTO <#channel-or-nick> "Font Name" text'
unless @args >= 3;
my $target = shift @args;
my $font = get_font(shift @args);
my ($server) = active_target();
send_lines($server, $target, render_wrapped($font, join(' ', @args)));
};
Irssi::print("TDF error: $@") if $@;
}
Irssi::command_bind('tdfload', \&cmd_tdfload);
Irssi::command_bind('tdfdir', \&cmd_tdfdir);
Irssi::command_bind('tdffonts', \&cmd_tdffonts);
Irssi::command_bind('tdf', \&cmd_tdf);
Irssi::command_bind('tdfto', \&cmd_tdfto);
Irssi::print("TDF Send $VERSION loaded");
my $saved_font = Irssi::settings_get_str('tdf_last_font_file');
if ($saved_font && -f $saved_font) {
eval {
load_tdf($saved_font);
Irssi::print("TDF Send restored $saved_font");
};
Irssi::print("TDF auto-load error: $@") if $@;
} else {
Irssi::print('TDF Send font directory: ' . get_font_directory());
}