| File: | blib/lib/Text/Names/Abbreviate.pm |
| Coverage: | 96.2% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Text::Names::Abbreviate; | |||||
| 2 | ||||||
| 3 | 9 9 9 | 832557 7 119 | use strict; | |||
| 4 | 9 9 9 | 13 9 174 | use warnings; | |||
| 5 | 9 9 9 | 394 11938 21 | use autodie qw(:all); | |||
| 6 | 9 9 9 | 29638 416 24 | use utf8; | |||
| 7 | ||||||
| 8 | 9 9 9 | 112 7 229 | use Carp; | |||
| 9 | 9 9 9 | 16 8 105 | use Exporter 'import'; | |||
| 10 | 9 9 9 | 390 9941 170 | use Params::Get 0.13; | |||
| 11 | 9 9 9 | 632 28965 146 | use Params::Validate::Strict 0.13; | |||
| 12 | 9 9 9 | 19 9 154 | use Readonly; | |||
| 13 | 9 9 9 | 1690 8380 6001 | use Unicode::Normalize (); | |||
| 14 | ||||||
| 15 | our @EXPORT_OK = qw(abbreviate); | |||||
| 16 | ||||||
| 17 - 25 | =head1 NAME Text::Names::Abbreviate - Create abbreviated name formats from full names =head2 VERSION Version 0.04 =cut | |||||
| 26 | ||||||
| 27 | our $VERSION = '0.04'; | |||||
| 28 | ||||||
| 29 | # --------------------------------------------------------------------------- | |||||
| 30 | # Named constants -- eliminate magic strings throughout the logic | |||||
| 31 | # --------------------------------------------------------------------------- | |||||
| 32 | Readonly my $FMT_DEFAULT => 'default'; | |||||
| 33 | Readonly my $FMT_INITIALS => 'initials'; | |||||
| 34 | Readonly my $FMT_COMPACT => 'compact'; | |||||
| 35 | Readonly my $FMT_SHORTLAST => 'shortlast'; | |||||
| 36 | Readonly my $STY_FIRST => 'first_last'; | |||||
| 37 | Readonly my $STY_LAST => 'last_first'; | |||||
| 38 | Readonly my $DEFAULT_SEP => '.'; | |||||
| 39 | ||||||
| 40 | # Common surname particles across Dutch, German, French, Italian, Spanish, | |||||
| 41 | # Portuguese, Arabic, and Scandinavian naming traditions. Matching is | |||||
| 42 | # case-sensitive: only lowercase tokens are eligible. | |||||
| 43 | Readonly my @DEFAULT_PARTICLES => qw( | |||||
| 44 | van de di da von der den des du | |||||
| 45 | la le las los el al | |||||
| 46 | te ten ter | |||||
| 47 | af av | |||||
| 48 | bin bint ibn | |||||
| 49 | y do dos das del | |||||
| 50 | zu zum zur | |||||
| 51 | ); | |||||
| 52 | ||||||
| 53 | # Single source of truth for parameter validation; also reflected in POD below. | |||||
| 54 | Readonly my %PARAM_SCHEMA => ( | |||||
| 55 | name => { | |||||
| 56 | type => 'string', | |||||
| 57 | min => 1, | |||||
| 58 | optional => 0, | |||||
| 59 | }, | |||||
| 60 | format => { | |||||
| 61 | type => 'string', | |||||
| 62 | memberof => [ $FMT_DEFAULT, $FMT_INITIALS, $FMT_COMPACT, $FMT_SHORTLAST ], | |||||
| 63 | optional => 1, | |||||
| 64 | }, | |||||
| 65 | style => { | |||||
| 66 | type => 'string', | |||||
| 67 | memberof => [ $STY_FIRST, $STY_LAST ], | |||||
| 68 | optional => 1, | |||||
| 69 | }, | |||||
| 70 | separator => { | |||||
| 71 | type => 'string', | |||||
| 72 | optional => 1, | |||||
| 73 | }, | |||||
| 74 | particles => { | |||||
| 75 | type => ['boolean', 'arrayref'], | |||||
| 76 | optional => 1, | |||||
| 77 | }, | |||||
| 78 | ); | |||||
| 79 | ||||||
| 80 - 258 | =head1 SYNOPSIS
use Text::Names::Abbreviate qw(abbreviate);
say abbreviate('John Quincy Adams'); # J. Q. Adams
say abbreviate('Adams, John Quincy'); # J. Q. Adams
say abbreviate('George R R Martin', { format => 'initials' }); # G.R.R.M.
say abbreviate('Ludwig van Beethoven'); # L. van Beethoven
say abbreviate("R\x{e9}mi Dupr\x{e9}"); # R. Dupr\x{e9}
=head1 DESCRIPTION
This module provides simple abbreviation logic for full personal names with
multiple formatting options and styles. Input is expected to be a personal
name consisting of one or more whitespace-separated components interpreted as:
First [Middle ...] Last
Names consisting of a single component are returned unchanged.
=head1 SUBROUTINES/METHODS
=head2 abbreviate
Produce an abbreviated form of a personal name.
=head3 Purpose
Accept a full name in either C<First Middle Last> or C<Last, First Middle>
form and return a formatted abbreviated string according to the requested
C<format>, C<style>, C<separator>, and C<particles> options. Input is
NFC-normalised before processing, so strings differing only in Unicode
normalisation form produce identical output. Surname particles (C<van>,
C<de>, C<von>, etc.) are absorbed into the last-name component by default.
=head3 Args
=over 4
=item name (required)
Non-empty string. Accepted in two forms:
=over 4
=item C<First [Middle ...] Last>
=item C<Last, First [Middle ...]>
=back
A leading comma (C<", John">) signals that no last name is present; only
initials are produced.
=item format (optional, default C<default>)
One of C<default>, C<initials>, C<compact>, C<shortlast>.
=over 4
=item C<default> -- C<J. Q. Adams>
=item C<initials> -- C<J.Q.A.>
=item C<compact> -- C<JQA>
=item C<shortlast> -- initials then full last name; honours C<last_first> style
(e.g. C<Adams, J. Q.>).
=back
=item style (optional, default C<first_last>)
One of C<first_last>, C<last_first>. All formats honour this option.
=item separator (optional, default C<.>)
String appended after each initial. Empty string removes all punctuation.
=item particles (optional, default enabled)
Controls detection of surname particles (C<van>, C<de>, C<von>, etc.) that
prefix the last name. Tokens immediately before the last name that appear in
the particle list are absorbed into the last-name component. Matching is
case-sensitive: only lowercase tokens are eligible.
=over 4
=item omitted or C<1> - use the built-in particle list
=item C<0> - disable particle detection entirely
=item arrayref of strings - use that list instead of the built-in one
=back
abbreviate('Ludwig van Beethoven'); # L. van Beethoven
abbreviate('Ludwig van Beethoven', { particles => 0 }); # L. v. Beethoven
abbreviate('Felipe de la Cruz', { particles => ['de','la'] }); # F. de la Cruz
=back
=head3 Returns
A plain string. Returns C<''> for inputs that normalise to nothing (e.g. a
bare comma).
=head3 Side Effects
None. The function is purely functional with no persistent state.
=head3 Usage
# Positional
my $abbrev = abbreviate('John Quincy Adams');
# Options hashref
my $abbrev = abbreviate('John Quincy Adams', {
format => 'initials',
style => 'last_first',
separator => '-',
});
=head3 API SPECIFICATION
INPUT
{
name => { type => 'string', min => 1, optional => 0 },
format => { type => 'string',
memberof => [qw(default initials compact shortlast)],
optional => 1 },
style => { type => 'string',
memberof => [qw(first_last last_first)],
optional => 1 },
separator => { type => 'string', optional => 1 },
particles => { type => ['boolean', 'arrayref'], optional => 1 },
}
OUTPUT
{ type => 'string' } # croaks on argument error
=head3 MESSAGES
Error Meaning / Resolution
--------------------------------------- -----------------------------------------------
name parameter missing or undefined Called without a name argument; supply one.
name must be a non-empty string Passed '' or undef; supply a non-empty string.
format must be one of: ... Invalid format constant; see API SPECIFICATION.
style must be one of: ... Invalid style constant; see API SPECIFICATION.
particles: must be one of boolean, Passed a string or hashref; pass 0/1 or an
arrayref arrayref of particle strings instead.
=head3 PSEUDOCODE
FUNCTION abbreviate(name, options):
Validate parameters via %PARAM_SCHEMA (croak on violation)
Assign defaults: format=default, style=first_last, sep=".", particles=built-in list
_normalize_name(name):
- NFC-normalize to precomposed Unicode form
- collapse consecutive commas
- detect and reorder "Last, First" form
- track $had_leading_comma (input had no last-name component)
- collapse internal whitespace; trim
Return '' if normalized name is empty
_extract_parts(name, had_leading_comma, format, style, particles):
- tokenize on whitespace
- pop last token as $last_name (unless leading-comma form)
- if particles enabled: while last remaining token is a particle,
pop it and prepend to $last_name
- build @initials from remaining tokens (first char each)
- if style=last_first and format!=default: unshift last initial, clear $last_name
- filter empty initials
Format result:
compact -> join('', @initials, first($last_name))
initials -> join($sep, @all_letters) . $sep
shortlast -> join(' ', map {"$_$sep"} @initials) . " $last_name"
default -> joined initials; prepend/append $last_name per $style
=cut | |||||
| 259 | ||||||
| 260 | # --------------------------------------------------------------------------- | |||||
| 261 | # Private helpers | |||||
| 262 | # --------------------------------------------------------------------------- | |||||
| 263 | ||||||
| 264 | # Purpose: Resolve "Last, First" and leading-comma forms into a canonical | |||||
| 265 | # "First ... Last" string, collapsing all internal whitespace. | |||||
| 266 | # Entry Criteria: $raw is a defined, non-empty string (validated by the caller). | |||||
| 267 | # Exit Status: Returns ($normalized, $had_leading_comma). $normalized is | |||||
| 268 | # whitespace-collapsed and trimmed. $had_leading_comma is 1 when | |||||
| 269 | # the original input began with a comma (no last-name component). | |||||
| 270 | # Side Effects: None. | |||||
| 271 | sub _normalize_name { | |||||
| 272 | 2271 | 96598 | my ($raw) = @_; | |||
| 273 | ||||||
| 274 | 2271 | 11909 | $raw = Unicode::Normalize::NFC($raw); | |||
| 275 | 2271 | 2775 | $raw =~ s/,+/,/g; # collapse any run of commas to one before splitting | |||
| 276 | ||||||
| 277 | 2271 | 1612 | my $had_leading_comma = 0; | |||
| 278 | ||||||
| 279 | 2271 | 2328 | if ($raw =~ /,/) { | |||
| 280 | 154 308 | 360 502 | my ($last, $rest) = map { s/^\s+|\s+$//gr } split /\s*,\s*/, $raw, 2; | |||
| 281 | 154 | 172 | $rest //= q{}; | |||
| 282 | 154 | 145 | $last //= q{}; | |||
| 283 | ||||||
| 284 | 154 | 240 | $had_leading_comma = 1 if !length($last) && length($rest); | |||
| 285 | ||||||
| 286 | 154 | 249 | if (length($last) && length($rest)) { | |||
| 287 | 65 | 67 | $raw = "$rest $last"; | |||
| 288 | } elsif (length $rest) { | |||||
| 289 | 74 | 73 | $raw = $rest; | |||
| 290 | } elsif (length $last) { | |||||
| 291 | 4 | 4 | $raw = $last; | |||
| 292 | } else { | |||||
| 293 | 11 | 20 | return (q{}, 0); | |||
| 294 | } | |||||
| 295 | } | |||||
| 296 | ||||||
| 297 | 2260 | 11658 | $raw =~ s/^\s+|\s+$//g; | |||
| 298 | 2260 | 3594 | $raw =~ s/\s+/ /g; | |||
| 299 | ||||||
| 300 | 2260 | 2794 | return ($raw, $had_leading_comma); | |||
| 301 | } | |||||
| 302 | ||||||
| 303 | # Purpose: Derive the ordered list of initials and the preserved last name | |||||
| 304 | # from a normalized name string, honouring format, style, and particles. | |||||
| 305 | # Entry Criteria: $name is output of _normalize_name (trimmed, single-spaced). | |||||
| 306 | # $had_leading_comma is the boolean from _normalize_name. | |||||
| 307 | # $format and $style are validated constants (FMT_*/STY_*). | |||||
| 308 | # $particles is an arrayref of particle strings, or undef to disable. | |||||
| 309 | # Exit Status: Returns ($initials_ref, $last_name). $initials_ref is an | |||||
| 310 | # arrayref of single-character strings with empty entries removed. | |||||
| 311 | # $last_name is '' when consumed by style/format reordering. | |||||
| 312 | # Side Effects: None. | |||||
| 313 | sub _extract_parts { | |||||
| 314 | 2017 | 34438 | my ($name, $had_leading_comma, $format, $style, $particles) = @_; | |||
| 315 | ||||||
| 316 | 2017 | 2756 | my @parts = split /\s+/, $name; | |||
| 317 | 2017 | 1856 | return ([], q{}) unless @parts; | |||
| 318 | ||||||
| 319 | 2016 | 1369 | my ($last_name, @initials); | |||
| 320 | ||||||
| 321 | 2016 | 1766 | if ($had_leading_comma) { | |||
| 322 | 74 | 47 | $last_name = q{}; | |||
| 323 | 74 113 | 61 144 | @initials = map { substr $_, 0, 1 } @parts; | |||
| 324 | } else { | |||||
| 325 | 1942 | 1528 | $last_name = pop @parts; | |||
| 326 | ||||||
| 327 | # Absorb surname particles immediately preceding the last name. | |||||
| 328 | 1942 | 2627 | if ($particles && @parts) { | |||
| 329 | 634 19300 19301 634 | 474 17411 44974 1049 | my %is_particle = map { $_ => 1 } grep { defined } @{$particles}; | |||
| 330 | 634 | 3563 | while (@parts && $is_particle{ $parts[-1] }) { | |||
| 331 | 37 | 132 | $last_name = (pop @parts) . q{ } . $last_name; | |||
| 332 | } | |||||
| 333 | } | |||||
| 334 | ||||||
| 335 | 1942 2024 | 1646 2313 | @initials = map { substr $_, 0, 1 } @parts; | |||
| 336 | ||||||
| 337 | # last_first on non-default formats (except shortlast, which keeps the full last name): | |||||
| 338 | # move the last-name initial to the front and discard the full last name | |||||
| 339 | 1942 | 2174 | if ($style eq $STY_LAST && $format ne $FMT_DEFAULT && $format ne $FMT_SHORTLAST && length $last_name) { | |||
| 340 | 61 | 439 | unshift @initials, substr $last_name, 0, 1; | |||
| 341 | 61 | 55 | $last_name = q{}; | |||
| 342 | } | |||||
| 343 | } | |||||
| 344 | ||||||
| 345 | 2016 2198 | 5285 1855 | @initials = grep { length $_ } @initials; | |||
| 346 | ||||||
| 347 | 2016 | 3028 | return (\@initials, $last_name); | |||
| 348 | } | |||||
| 349 | ||||||
| 350 | # --------------------------------------------------------------------------- | |||||
| 351 | # Public API | |||||
| 352 | # --------------------------------------------------------------------------- | |||||
| 353 | ||||||
| 354 | sub abbreviate { | |||||
| 355 | 3127 | 857360 | my $params = Params::Validate::Strict::validate_strict({ | |||
| 356 | args => Params::Get::get_params('name', @_), | |||||
| 357 | schema => \%PARAM_SCHEMA, | |||||
| 358 | }); | |||||
| 359 | ||||||
| 360 | Carp::croak(__PACKAGE__ . '::abbreviate: name parameter is required and must be defined') | |||||
| 361 | 2282 | 693346 | unless defined $params->{name}; | |||
| 362 | ||||||
| 363 | 2263 | 4074 | my $format = $params->{format} // $FMT_DEFAULT; | |||
| 364 | 2263 | 8615 | my $style = $params->{style} // $STY_FIRST; | |||
| 365 | 2263 | 7404 | my $sep = $params->{separator} // $DEFAULT_SEP; | |||
| 366 | ||||||
| 367 | 2263 | 5328 | my $raw_particles = $params->{particles}; | |||
| 368 | 2263 | 2431 | my $particles_ref = | |||
| 369 | !defined $raw_particles ? \@DEFAULT_PARTICLES | |||||
| 370 | : ref $raw_particles eq 'ARRAY' ? $raw_particles | |||||
| 371 | : $raw_particles ? \@DEFAULT_PARTICLES | |||||
| 372 | : undef; | |||||
| 373 | ||||||
| 374 | 2263 | 2471 | my ($name, $had_leading_comma) = _normalize_name($params->{name}); | |||
| 375 | 2263 | 2985 | return q{} unless length $name; | |||
| 376 | ||||||
| 377 | 2000 | 2161 | my ($initials, $last_name) = _extract_parts($name, $had_leading_comma, $format, $style, $particles_ref); | |||
| 378 | ||||||
| 379 | 2000 | 2220 | if ($format eq $FMT_COMPACT) { | |||
| 380 | 84 84 | 171 270 | return join q{}, @{$initials}, | |||
| 381 | (length $last_name ? (substr $last_name, 0, 1) : ()); | |||||
| 382 | } | |||||
| 383 | ||||||
| 384 | 1916 | 4324 | if ($format eq $FMT_INITIALS) { | |||
| 385 | 100 100 | 198 104 | my @letters = @{$initials}; | |||
| 386 | 99 | 125 | push @letters, substr($last_name, 0, 1) if length $last_name; | |||
| 387 | 99 | 293 | return join($sep, @letters) . $sep; | |||
| 388 | } | |||||
| 389 | ||||||
| 390 | 1816 | 3902 | if ($format eq $FMT_SHORTLAST) { | |||
| 391 | 159 159 178 113 | 295 157 186 87 | my $joined = @{$initials} ? join(' ', map { $_ . $sep } @{$initials}) : q{}; | |||
| 392 | 159 | 153 | if ($style eq $STY_LAST && length $last_name) { | |||
| 393 | 64 | 284 | return length($joined) ? "$last_name, $joined" : $last_name; | |||
| 394 | } | |||||
| 395 | 95 | 382 | return length($joined) | |||
| 396 | ? (length($last_name) ? "$joined $last_name" : $joined) | |||||
| 397 | : $last_name; | |||||
| 398 | } | |||||
| 399 | ||||||
| 400 | # default format | |||||
| 401 | 1657 1657 | 3108 3981 | return $last_name unless @{$initials}; | |||
| 402 | 429 1611 429 | 362 1496 389 | my $joined = join ' ', map { $_ . $sep } @{$initials}; | |||
| 403 | 429 | 525 | return ($style eq $STY_LAST && length $last_name) | |||
| 404 | ? "$last_name, $joined" | |||||
| 405 | : (length $last_name ? "$joined $last_name" : $joined); | |||||
| 406 | } | |||||
| 407 | ||||||
| 408 | 1; | |||||
| 409 | ||||||