From 4498ed73eab6c942d42edeee813636caf29fe749 Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Fri, 27 Feb 2026 14:25:14 +0000 Subject: [PATCH] Bug 35104: Add context snippet with caret pointer to nonxml_stripped error messages Each per-character error message now includes a two-line snippet showing up to 30 characters of surrounding context, with the offending character replaced by a [U+XXXX] marker and a caret (^) aligned beneath it: 245$a: invalid char value 31 (U+001F) at position 10 Hello[U+001F]World ^ Adds _context_snippet() helper and refactors _find_nonxml_chars() to use index-based iteration so the char array can be passed directly to the snippet helper without re-splitting. --- Koha/Biblio/Metadata.pm | 71 ++++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/Koha/Biblio/Metadata.pm b/Koha/Biblio/Metadata.pm index a7a202fb9cc..7207eec2f08 100644 --- a/Koha/Biblio/Metadata.pm +++ b/Koha/Biblio/Metadata.pm @@ -132,8 +132,9 @@ sub store { metadata_id => $self->id, error_type => 'nonxml_stripped', message => sprintf( - "%s: invalid char value %d (U+%04X) at position %d", - $occ->{field}, $occ->{char_ord}, $occ->{char_ord}, $occ->{position} + "%s: invalid char value %d (U+%04X) at position %d\n%s", + $occ->{field}, $occ->{char_ord}, $occ->{char_ord}, $occ->{position}, + $occ->{snippet} ), } )->store; @@ -383,6 +384,53 @@ sub _embed_items { return $record; } +=head3 _context_snippet + + my $snippet = _context_snippet( \@chars, $pos_0, $char_ord ); + +Given an array-ref of characters C<\@chars>, a 0-based position C<$pos_0> of +a bad character, and its ordinal C<$char_ord>, returns a two-line string: + +=over 4 + +=item * Line 1 – up to 30 characters of context either side of the bad +character, with the bad character replaced by a C<[U+XXXX]> marker and +ellipses when the window is truncated. + +=item * Line 2 – a caret (C<^>) aligned beneath the start of the marker. + +=back + +Example output (4-space indent): + + ...arr \ [U+0008]$btxt V $2rdac ontent + ^ + +=cut + +sub _context_snippet { + my ( $chars_ref, $pos_0, $char_ord ) = @_; + + my $marker = sprintf '[U+%04X]', $char_ord; + my $last = $#{$chars_ref}; + my $win = 30; + + my $pre_start = ( $pos_0 > $win ) ? $pos_0 - $win : 0; + my $post_end = ( $pos_0 + $win < $last ) ? $pos_0 + $win : $last; + + my $pre = $pos_0 > 0 ? join( '', @{$chars_ref}[ $pre_start .. $pos_0 - 1 ] ) : ''; + my $post = $pos_0 < $last ? join( '', @{$chars_ref}[ $pos_0 + 1 .. $post_end ] ) : ''; + + my $prefix = ( $pos_0 > $win ) ? '...' : ''; + my $suffix = ( $pos_0 + $win < $last ) ? '...' : ''; + + my $indent = ' '; + my $line = "$indent$prefix$pre$marker$post$suffix"; + my $caret_col = length($indent) + length($prefix) + length($pre); + + return "$line\n" . ( ' ' x $caret_col ) . '^'; +} + =head3 _find_nonxml_chars my @occurrences = _find_nonxml_chars( $marcxml_string ); @@ -399,6 +447,8 @@ the following keys: =item * C - 1-based character offset within the field value +=item * C - two-line string with context window and caret pointer (see C<_context_snippet>) + =back The same character class is used as L, so @@ -416,17 +466,16 @@ sub _find_nonxml_chars { my $scan = sub { my ( $field_ref, $val ) = @_; - my $pos = 1; - for my $char ( split //, $val ) { - if ( $char =~ $non_xml_re ) { - push @occurrences, - { + my @chars = split //, $val; + for my $i ( 0 .. $#chars ) { + if ( $chars[$i] =~ $non_xml_re ) { + push @occurrences, { field => $field_ref, - char_ord => ord($char), - position => $pos, - }; + char_ord => ord( $chars[$i] ), + position => $i + 1, + snippet => _context_snippet( \@chars, $i, ord( $chars[$i] ) ), + }; } - $pos++; } }; -- 2.53.0