File Coverage

File:blib/lib/Text/Names/Abbreviate.pm
Coverage:96.2%

linestmtbrancondsubtimecode
1package 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
15our @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
27our $VERSION = '0.04';
28
29# ---------------------------------------------------------------------------
30# Named constants -- eliminate magic strings throughout the logic
31# ---------------------------------------------------------------------------
32Readonly my $FMT_DEFAULT   => 'default';
33Readonly my $FMT_INITIALS  => 'initials';
34Readonly my $FMT_COMPACT   => 'compact';
35Readonly my $FMT_SHORTLAST => 'shortlast';
36Readonly my $STY_FIRST     => 'first_last';
37Readonly my $STY_LAST      => 'last_first';
38Readonly 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.
43Readonly 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.
54Readonly 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.
271sub _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.
313sub _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
354sub 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
4081;
409