package Prima::Bidi; use strict; use warnings; use base 'Exporter'; our $available; our $enabled = 0; our $failure_text; our $default_direction_rtl = 0; our @methods = qw( paragraph map revmap visual selection_map selection_diff selection_walk selection_chunks edit_insert edit_delete map_find ); our @EXPORT_OK = ( qw( is_bidi ), map { "bidi_$_" } @methods); { local $_; eval "sub bidi_$_ { shift; goto &$_ }" for @methods; } enabled(1); sub import { my $package = shift; my @other; for my $p ( @_ ) { if ( $p eq ':require' ) { my $error = enabled(1); die $error if $error; } elsif ( $p eq ':rtl') { $default_direction_rtl = 1; } elsif ( $p eq ':ltr') { $default_direction_rtl = 0; } elsif ( $p eq ':locale') { # http://stackoverflow.com/questions/18996183/identifyng-rtl-language-in-android $default_direction_rtl = ( $ENV{LANG} =~ /^( ar| # arabic dv| # divehi fa| # persian (farsi) ha| # hausa he| # hebrew iw| # hebrew (old code) ji| # yiddish (old code) ps| # pashto, pushto ur| # urdu yi # yiddish )/x ? 1 : 0) if defined $ENV{LANG}; } elsif ( $p eq ':methods') { $package->export_to_level(1, __PACKAGE__, qw(is_bidi), map { "bidi_$_" } @methods); } else { push @other, $p; } } if ( @other ) { @_ = ($package, @other); goto &Exporter::import; } } sub enabled { return $enabled unless @_; return $enabled = 0 unless $_[0]; unless ( defined $available ) { eval "use Text::Bidi::Paragraph; use Text::Bidi::Constants;"; if ( $@ ) { $failure_text = "Bi-directional text services not available: $@\n"; $available = $enabled = 0; return $failure_text; } $available = 1; } return ( $enabled = $available ) ? undef : $failure_text; } sub visual { scalar paragraph(@_) } sub paragraph { my ( $text, $rtl, $flags ) = @_; my $p = Text::Bidi::Paragraph->new( $text, defined($rtl) ? ( dir => $rtl ? $Text::Bidi::Par::RTL : $Text::Bidi::Par::LTR ) : () ); my $off = 0; my $width = $p->len; my @text; while ( $off < $width ) { my $v = $p->visual($off, $width, $flags); my $l = length($v); $off += $l; push @text, $v; } return ($p, join("\n", @text)); } sub map_find { my ($map, $index) = @_; for ( my $i = 0; $i < @$map; $i++) { return $i if $map->[$i] == $index; } return undef; } sub _par { my ( $text, @opt ) = @_; my $p = Text::Bidi::Paragraph->new( $text, @opt ); my $off = 0; my $width = $p->len; while ( $off < $width ) { my $v = $p->visual($off, $width); my $l = length($v); $off += $l; } return $p; } sub map { _par(@_)->map } sub revmap { my $map = shift; return $map unless ref $map; my @newmap = (0) x @$map; for (my $i = 0; $i < @$map; $i++) { $newmap[ $map->[$i] ] = $i } return \@newmap; } sub selection_map { my $text = shift; return is_bidi($text) ? _par($text)->map : length($text); } sub selection_chunks { my ( $map, $start, $end, $offset ) = @_; $offset //= 0; my @selection_map; return [0] if $start > $end || $offset > $end; unless ( ref $map ) { for ( my $i = $offset; $i < $map; $i++) { push @selection_map, ( $i >= $start && $i <= $end ) ? 1 : 0; } return _map2chunks( \@selection_map ); } return [0] if $offset > @$map; $start = 0 if $start < 0; $end = 0 if $end < 0; $start = $#$map if $start > $#$map; $end = $#$map if $end > $#$map; my ($text_start, $text_end) = @$map[$start, $end]; ($text_start, $text_end) = ($text_end, $text_start) if $text_start > $text_end; for ( my $i = $offset; $i < @$map; $i++) { push @selection_map, ($map->[$i] >= $text_start && $map->[$i] <= $text_end) ? 1 : 0; } # warn "$start:$end > $text_start:$text_end > @selection_map\n"; return _map2chunks( \@selection_map ); } sub _map2chunks { my $selection_map = shift; my @chunks; push @chunks, 0 if $selection_map->[0]; my $last_selected = -1; for my $selected ( @$selection_map ) { if ( $selected == $last_selected ) { $chunks[-1]++; } else { push @chunks, 1; $last_selected = $selected; } } return \@chunks; } sub _chunks2map { my @map; selection_walk( shift, 0, undef, sub { my ( $offset, $length, $selected ) = @_; push @map, ($selected) x $length; }); return \@map; } sub selection_diff { my ( $old, $new) = map { _chunks2map($_) } @_; my @diff; my $max = ( @$old > @$new ) ? @$old : @$new; for ( my $i = 0; $i < $max; $i++) { $diff[$i] = (($old->[$i] // 0) == ($new->[$i] // 0) ) ? 0 : 1 ; } return _map2chunks( \@diff ); } sub selection_walk { my ( $selection_chunks, $from, $to, $sub ) = @_; my ( $ptr, $selected ) = (0,1); for my $chunk ( @$selection_chunks ) { $selected = not $selected; my $offset = $ptr - $from; $ptr += $chunk; my $length = $chunk; if ( $offset < 0 ) { $length += $offset; $offset = 0; } if ( defined $to ) { last if $offset >= $to; $length = $from - $offset if $offset + $length > $to; } next if $length <= 0; $sub->( $offset, $length, $selected ); } } sub is_bidi { $enabled && $_[-1] =~ /[\p{bc=R}\p{bc=AL}\p{bc=AN}\p{bc=RLE}\p{bc=RLO}]/ } sub is_strong($) { $_[0] & $Text::Bidi::Mask::STRONG } sub is_weak($) { !($_[0] & $Text::Bidi::Mask::STRONG) } sub is_rtl($) { $_[0] & $Text::Bidi::Mask::RTL } sub is_ltr($) { !($_[0] & $Text::Bidi::Mask::RTL) } sub edit_insert { my ( $src_p, $visual_pos, $new_str ) = @_; return $visual_pos, 0 unless length $new_str; unless ($src_p) { # empty string or non-bidi my $rtl = $enabled && is_bidi($new_str); return $visual_pos, $rtl ? 0 : length($new_str); } my $new_p = _par($new_str); my $map = $src_p->map; my $t = $src_p->types; my $new_map = $new_p->map; my $new_types = $new_p->types; my $new_type = 0; my $limit = $#$map; my ($tl,$tr,$pl,$pr); if ( $visual_pos > 0 ) { $tl = $t->[$pl = $map->[$visual_pos - 1]]; } if ( $visual_pos <= $limit ) { $tr = $t->[$pr = $map->[$visual_pos]]; } # Cursor between two strongs if ( defined($tl) && defined($tr) && is_strong $tl && is_strong $tr ) { if ( is_ltr $tl ) { if ( is_ltr $tr ) { # normal LTR return $pl + 1, 1; } else { # Cursor between R and L - the rightmost wins return ( $pl > $pr ) ? ($pl + 1, 1) : ($pr + 1, 0); } } else { if ( is_rtl $tr) { # normal RTL return $pr + 1, 0; } else { # Cursor between L and R - the leftmost wins return ( $pl < $pr ) ? ($pl + 1, 1) : ($pr + 1, 0); } } } # Cursor next to a strong if ( defined($tl) && is_strong $tl) { return (is_ltr $tl) ? # LTR append ( $pl + 1, 1 ) : # RTL prepend ( $pl, 0 ); } if ( defined($tr) && is_strong $tr) { return (is_ltr $tr) ? # LTR prepend ( $pr, 0 ) : # RTL append ( $pr + 1, 0 ); } # find out dominant directions by scanning to the first strong at each direction, if any if ( defined $tl ) { my $vp = $visual_pos - 1; $vp-- while $vp >= 0 && is_weak($tl = $t->[$map->[$vp]]); # right to a weak, adjacent to a strong LTR further right return $pl + 1, 1 if is_strong $tl && is_ltr $tl; } if ( defined $tr ) { my $vp = $visual_pos; $vp++ while $vp < $limit && is_weak($tr = $t->[$map->[$vp]]); # left to a weak, adjacent to a strong LTR further left return $pr, 0 if is_strong $tr && is_ltr $tr; } # cursor at the end return is_ltr $tr ? (0, 0) : ($limit + 1, 0) unless defined $tl; return is_ltr $tl ? ($limit + 1, 1) : (0, 0) unless defined $tr; # XXX this is too complex, give up return $visual_pos, is_bidi($new_str) ? 0 : length($new_str); } sub edit_delete { my ( $p, $visual_pos, $backspace ) = @_; # non-bidi compatibility unless (ref $p) { if ( $backspace ) { return ($visual_pos > 0) ? (1, $visual_pos - 1, -1) : (0, 0, 0); } else { return ($visual_pos < $p) ? (1, $visual_pos, 0) : (0, 0, 0); } } my $map = $p->map; my $t = $p->types; my $limit = $#$map; my ($il,$ir,$l,$r,$pl,$pr) = (0,0,0,0,$limit,0); if ( $visual_pos > 0 ) { $il = $t->[$map->[$pl = $visual_pos - 1]]; $pl-- while is_weak($l = $t->[$map->[$pl]]) && $pl > 0; } if ( $visual_pos <= $limit ) { $ir = $t->[$map->[$pr = $visual_pos]]; $pr++ while is_weak($r = $t->[$map->[$pr]]) && $pr < $limit; } #warn "il: ", (is_strong($il) ? 'strong' : 'weak'), ' ', (is_rtl($il) ? 'rtl' : 'ltr'), " at ", $visual_pos - 1, "\n"; #warn "ir: ", (is_strong($ir) ? 'strong' : 'weak'), ' ', (is_rtl($ir) ? 'rtl' : 'ltr'), " at ", $visual_pos, "\n"; #warn "l: ", (is_strong($l) ? 'strong' : 'weak'), ' ', (is_rtl($l) ? 'rtl' : 'ltr'), " at $pl\n"; #warn "r: ", (is_strong($r) ? 'strong' : 'weak'), ' ', (is_rtl($r) ? 'rtl' : 'ltr'), " at $pr\n"; #warn "vp: $visual_pos, limit: $limit\n"; if ( $backspace ) { # strong ltr immediately left, kill immediately left return 1, $map->[$visual_pos - 1], -1 if $visual_pos > 0 && is_strong $il && is_ltr $il; # strong rtl immediately right, kill immediately right return 1, $map->[$visual_pos], 0 if $visual_pos <= $limit && is_strong $ir && is_rtl $ir; # any ltr immediately left, kill immediately left return 1, $map->[$visual_pos - 1], -1 if $visual_pos > 0 && is_ltr $il; # any rtl on right, kill immediately right return 1, $map->[$visual_pos], 0 if $visual_pos <= $limit && is_rtl $r; # any rtl on left, kill greedy leftmost if ($visual_pos > 0 && is_rtl $l) { my $L = $l; $pl-- while !(is_strong($L = $t->[$map->[$pl]]) && is_ltr $L) && $pl > 0; return 1, $map->[$pl], $pl - $visual_pos } } else { # strong ltr immediately right, kill immediately right return 1, $map->[$visual_pos], 0 if $visual_pos <= $limit && is_strong $il && is_ltr $il; # strong rtl immediately left, kill immediately left return 1, $map->[$visual_pos - 1], 0 if $visual_pos > 0 && is_strong $ir && is_rtl $ir; # any ltr immediately right, kill immediately right return 1, $map->[$visual_pos], 0 if $visual_pos <= $limit && is_ltr $ir; # any rtl on left, kill immediately left return 1, $map->[$visual_pos - 1], -1 if $visual_pos > 0 && is_rtl $l; # any ltr on right, kill greedy rightmost if ($visual_pos <= $limit && is_ltr $r) { my $R = $r; $pr-- while !(is_strong($R = $t->[$map->[$pr]]) && is_rtl $R) && $pr > 0; return 1, $map->[$pr], $pr - $visual_pos } } # nothing return 0, 0, 0; } sub debug_str { return unless $enabled; my $str = shift; my $p = _par($str); my $t = $p->types; my $b = $p->bd; for ( my $i = 0; $i < length($str); $i++) { my $chr = ord( substr( $str, $i, 1)); my $typ = $t->[$i]; my $tn = $b->get_bidi_type_name($typ); my @mas; no strict 'refs'; for my $name ( keys %Text::Bidi::Mask::) { my $value = ${"Text::Bidi::Mask::$name"}; next unless $typ & $value; push @mas, $name; } printf("$i: %03x: %06x / %s\n", $chr, $typ, join(',', @mas)); } } 1; =pod =head1 NAME Prima::Bidi - helper routines for bi-directional text input and output =head1 SYNOPSIS use Prima::Bidi qw(:enable is_bidi); say Prima::Bidi::visual( $bidi_text ) if is_bidi($bidi_text); or same, for classes use Prima::Bidi qw(:methods); say $self->bidi_visual( $bidi_text ) if $self-> is_bidi($bidi_text); =head1 API The API follows closely L api, with view to serve as a loose set of helper routines for input and output in Prima widgets. It also makes use of installations without C safe. Exports set of C names, available as method calls. =over =item is_bidi $TEXT Returns boolean flags whether the text contains any bidirectional characters. =item bidi_map $TEXT, ... Shortcut for C<< Text::Bidi::Paragraph->new($TEXT, ...)->map >>. Returns a set of integer indices, showing placement of where in original bidi text a corresponding character can be found, i.e. C<$map[0]> contains the index of the character to be displayed leftmost, etc. This function could be useful f.ex. for translating screen position to text position. See L for more. =item bidi_paragraph $TEXT, $RTL, $FLAGS Returns a C $RTL)> object together with result of call to C. =item bidi_revmap $MAP Returns an inverse array of result of C, i.e. showing where, if any, a bidi character is to be displayed in visual text, so that f.ex. C<$revmap[0]> contains visual position of character #0, etc. =item bidi_edit_delete $PARAGRAPH, $VISUAL_POSITION, $DELETE Handles bidirectional deletion, emulating user hitting a backspace (C<$DELETE = 0>) or delete (C<$DELETE = 1>) key at C<$VISUAL_POSITION> in text represented by a C object. Returns three integers, showing 1) how many characters are to be deleted, 2) at which text offset, and 3) how many characters to the right the cursor has to move. C<$PARAGRAPH> can be a non-object, in which case the text is considered to be non-bidi. =item bidi_edit_insert $PARAGRAPH, $VISUAL_POSITION, $NEW_STRING Handles typing of bidirectional text C<$NEW_STRING>, inside an existing C<$PARAGRAPH> represented by a C object, where cursor is a C<$VISUAL_POSITION>. C<$PARAGRAPH> can be a non-object, in which case the text is considered to be non-bidi. =item bidi_map_find $MAP, $INDEX Searches thround C<$MAP> (returned by C) for integer C<$INDEX>, returns its position if found. =item bidi_selection_chunks $MAP, $START, $END, $OFFSET = 0 Calculates a set of chunks of texts, that, given a text selection from positions C<$START> to C<$END>, represent each either a set of selected and non-selected visual characters. The text is represented by a result of C. Returns array of integers, RLE-encoding the chunks, where the first integer signifies number of non-selected characters to display, the second - number of selected characters, the third the non-selected again, etc. If the first character belongs to the selected chunk, the first integer in the result is set to 0. C<$MAP> can be also an integer length of text (i.e. shortcut for an identity array (0,1,2,3...) in which case the text is considered to be non-bidi, and selection result will contain max 3 chunks). C<$OFFSET> may be greater that 0, but less than C<$START>, if that information is not needed. Example: consider embedded number in a bidi text. For the sake of clarity I'll use latin characters here. For example, we have a text scalar containing these characters: ABC123 where I is right-to-left text, and which, when rendered on screen, should be displayed as 123CBA (and C<$MAP> will be (3,4,5,2,1,0) ). Next, the user clicks the mouse between A and B (in text offset 1), drags the mouse then to the left, and finally stops between characters 2 and 3 (text offset 4). The resulting selection then should not be, as one might naively expect, this: 123CBA __^^^_ but this instead: 123CBA ^^_^^_ because the next character after C is 1, and the I of the selected sub-text is from characters 1 to 4. In this case, the result of call to C will be C<0,2,1,2,1> . =item bidi_selection_diff $OLD, $NEW Given set of two chunk lists, in format as returned by C, calculates the list of chunks affected by the selection change. Can be used for efficient repaints when the user interactively changes text selection, to redraw only the changed regions. =item bidi_selection_map $TEXT Same as C, except when C<$TEXT> is not bidi, returns just the length of it. Such format can be used to pass the result further to C efficiently where operations are performed on a non-bidi text. =item bidi_selection_walk $CHUNKS, $FROM, $TO = length, $SUB Walks the selection chunks array, returned by C, between C<$FROM> and C<$TO> visual positions, and for each chunk calls the provided C<< $SUB->($offset, $length, $selected) >>, where each call contains 2 integers to chunk offset and length, and a boolean flag whether the chunk is selected or not. Can be also used on a result of C, in which case C<$selected> flag is irrelevant. =item bidi_visual $TEXT, $RTL, $FLAGS Same as C but returns only the rendered text, omitting the paragraph object. =back =head1 AUTHOR Dmitry Karasik, Edmitry@karasik.eu.orgE. =head1 SEE ALSO F, L =cut