TER1 (Statement): 100.00%
TER2 (Branch): 84.38%
TER3 (LCSAJ): 100.0% (2/2)
Approximate LCSAJ segments: 33
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package Music::NWC2MusicXML::NWC; 2: 3: use strict; 4: use warnings; 5: use autodie qw(open close); 6: 7: our $VERSION = '0.001.1'; 8: 9: use Carp qw(croak carp); 10: use Readonly; 11: use Params::Validate::Strict qw(validate_strict); 12: use Params::Get; 13: use Compress::Zlib (); 14: 15: # --------------------------------------------------------------------------- 16: # Binary format constants 17: # --------------------------------------------------------------------------- 18: 19: # The [NWZ] signature bytes at the start of a binary NWC 2.x file. 20: # Stored as a Readonly so every comparison in the module uses the same value. 21: Readonly::Scalar my $NWC_MAGIC => '[NWZ]'; 22: Readonly::Scalar my $NWC_MAGIC_LEN => length $NWC_MAGIC; 23: 24: # The text marker that opens the NWCTXT content inside the decompressed stream. 25: Readonly::Scalar my $NWCTXT_MARKER => '!NoteWorthyComposer('; 26: 27: # Safety limit: reject decompressed payloads larger than this. 28: # A 50 MB score would be extraordinary; 256 MB signals something is wrong. 29: Readonly::Scalar my $MAX_DECOMP_BYTES => 256 * 1024 * 1024; 30: 31: # Minimum length of a plausible NWC binary file. 32: Readonly::Scalar my $MIN_FILE_BYTES => $NWC_MAGIC_LEN + 4; 33: 34: # zlib CMF byte for deflate streams (window size bits 7..4 = 7, CM 3..0 = 8) 35: Readonly::Scalar my $ZLIB_CMF => 0x78; 36: 37: # Valid FLG bytes that satisfy (CMF*256 + FLG) % 31 == 0 for CMF = 0x78 38: Readonly::Array my @ZLIB_VALID_FLAGS => (0x01, 0x5E, 0x9C, 0xDA); 39: 40: Readonly::Hash my %MESSAGES => ( 41: error_not_a_file => 'Cannot read file: %s', 42: error_not_nwc => 'Not a valid NWC file (magic not found): %s', 43: error_truncated => 'File appears truncated: %s', 44: error_no_zlib_stream => 'No compressed data stream found in: %s', 45: error_decompress_fail => 'Decompression failed: %s', 46: error_decomp_too_large => 'Decompressed data exceeds safety limit (%d bytes): %s', 47: error_no_nwctxt_marker => 'NWCTXT marker not found in decompressed data: %s', 48: error_bad_utf8 => 'NWCTXT text is not valid UTF-8: %s', 49: error_internal => 'Internal error: %s', 50: warn_unknown_version => 'Unrecognised NWC version %s -- attempting conversion anyway', 51: ); 52: 53: =head1 NAME 54: 55: Music::NWC2MusicXML::NWC - Binary NWC container decoder. 56: 57: =head1 VERSION 58: 59: 0.001.1 60: 61: =head1 SYNOPSIS 62: 63: use Music::NWC2MusicXML::NWC; 64: 65: # From a file 66: my $nwctxt = Music::NWC2MusicXML::NWC->read('Pilgrim.nwc'); 67: 68: # From an in-memory buffer (e.g. read from a database blob) 69: my $nwctxt = Music::NWC2MusicXML::NWC->decode($binary_data); 70: 71: print $nwctxt; # prints the NWCTXT representation 72: 73: =head1 DESCRIPTION 74: 75: Converts a NoteWorthy Composer 2 binary C<.nwc> file into its NWCTXT text 76: representation. The NWCTXT string is then passed to 77: C<Music::NWC2MusicXML::Parser>. 78: 79: The conversion pipeline implemented here is: 80: 81: NWC binary container 82: | 83: v (verify magic + locate zlib stream) 84: compressed score data 85: | 86: v (Compress::Zlib inflate) 87: raw decompressed bytes 88: | 89: v (locate !NoteWorthyComposer( marker) 90: NWCTXT representation (UTF-8 string) 91: 92: No NoteWorthy Composer installation is required. 93: 94: =cut 95: 96: # --------------------------------------------------------------------------- 97: # Constructor 98: # --------------------------------------------------------------------------- 99: 100: =head2 new 101: 102: Construct a decoder object. Optionally binds diagnostic output to a 103: C<Music::NWC2MusicXML::Diagnostics> instance. 104: 105: =head3 Arguments 106: 107: Named parameters: 108: 109: =over 4 110: 111: =item C<diagnostics> -- a C<Music::NWC2MusicXML::Diagnostics> instance (optional). 112: 113: =back 114: 115: =head3 Returns 116: 117: Blessed C<Music::NWC2MusicXML::NWC> object. 118: 119: =head3 API SPECIFICATION 120: 121: =head4 Input 122: 123: diagnostics : Music::NWC2MusicXML::Diagnostics (optional) 124: 125: =head4 Output 126: 127: Music::NWC2MusicXML::NWC object 128: 129: =cut 130: 131: sub new { 132: my ($class, %input) = @_; 133: my $args = validate_strict( 134: schema => { 135: diagnostics => { type => 'object', optional => 1 }, 136: }, 137: input => \%input, 138: ); 139: croak $@ unless defined $args; 140: 141: my $self = bless { 142: _diagnostics => $args->{diagnostics}, 143: }, $class; 144: 145: return $self;Mutants (Total: 2, Killed: 2, Survived: 0)
146: } 147: 148: # --------------------------------------------------------------------------- 149: # Public: read 150: # --------------------------------------------------------------------------- 151: 152: =head2 read 153: 154: Read a C<.nwc> binary file from disk and return its NWCTXT representation. 155: 156: Can be called as a class method (C<< Music::NWC2MusicXML::NWC->read($file) >>) 157: or as an instance method. 158: 159: =head3 Purpose 160: 161: Encapsulates file-I/O so that C<decode> can be tested independently with 162: in-memory data. 163: 164: =head3 Arguments 165: 166: =over 4 167: 168: =item C<$filename> -- path to the C<.nwc> file (required). 169: 170: =back 171: 172: =head3 Returns 173: 174: Scalar string containing the NWCTXT representation (UTF-8). 175: 176: =head3 Side Effects 177: 178: Reads from disk. Croaks on any I/O or format error. 179: 180: =head3 Usage Example 181: 182: my $nwctxt = Music::NWC2MusicXML::NWC->read('Pilgrim.nwc'); 183: 184: =head3 API SPECIFICATION 185: 186: =head4 Input 187: 188: $filename : SCALAR path (required) 189: -- Valid domain: defined, non-empty string naming a regular, 190: -- readable file in NWC 2.x binary format 191: -- Invalid partitions: undef, '' (empty), directory, non-existent, 192: -- unreadable, or wrong format -> all croak error_not_a_file 193: -- or error_not_nwc / error_truncated depending on failure point 194: 195: =head4 Output 196: 197: SCALAR (UTF-8 string) 198: 199: =head3 MESSAGES 200: 201: | Code | Meaning | Resolution | 202: |---------------------|-------------------------------------|--------------------------------| 203: | error_not_a_file | File cannot be opened | Check path and permissions | 204: | error_not_nwc | File does not begin with NWC magic | Verify file is a real .nwc | 205: | error_truncated | File too short to be valid | File may be corrupt | 206: 207: =cut 208: 209: sub read { 210: my ($proto, $filename) = @_; 211: 212: croak _fmt_msg('error_not_a_file', $filename // '(undef)') 213: unless defined $filename && length $filename; 214: 215: croak _fmt_msg('error_not_a_file', $filename) 216: unless -f $filename && -r $filename; 217: 218: my $data; 219: { 220: open my $fh, '<:raw', $filename 221: or croak _fmt_msg('error_not_a_file', "$filename: $!"); 222: local $/; 223: $data = <$fh>; 224: close $fh; 225: } 226: 227: # Delegate to decode so tests can bypass file I/O entirely. 228: my $self = ref($proto) ? $proto : $proto->new; 229: return $self->decode($data, $filename);
Mutants (Total: 2, Killed: 2, Survived: 0)
230: } 231: 232: =head2 decode 233: 234: Decode a binary NWC payload (already loaded into a scalar) and return its 235: NWCTXT representation. 236: 237: Can be called as a class method or instance method. 238: 239: Separates decompression logic from file I/O; enables unit testing with 240: in-memory test vectors. 241: 242: =head3 Arguments 243: 244: =over 4 245: 246: =item C<$data> -- binary scalar containing the full file content (required). 247: 248: =item C<$filename> -- source filename for diagnostic messages (optional, default C<< <buffer> >>). 249: 250: =back 251: 252: =head3 Returns 253: 254: Scalar string containing the NWCTXT representation (UTF-8). 255: 256: =head3 Side Effects 257: 258: None (no I/O). Croaks on any format or decompression error. 259: 260: =head3 Usage Example 261: 262: my $nwctxt = Music::NWC2MusicXML::NWC->decode($binary_blob); 263: 264: =head3 API SPECIFICATION 265: 266: =head4 Input 267: 268: $data : SCALAR (binary, required) 269: -- Valid domain: length >= MIN_FILE_BYTES (9 bytes) 270: -- Minimum: 5-byte magic '[NWZ]' + 4 bytes = 9 bytes (MIN_FILE_BYTES) 271: -- Below min (length 0..8): croaks error_truncated 272: -- At min (length 9) with wrong magic: croaks error_not_nwc 273: -- MAX decompressed size: 268,435,456 bytes (MAX_DECOMP_BYTES = 256 MB) 274: $filename : SCALAR (optional, default '<buffer>') 275: -- Any string; used only in diagnostic messages 276: 277: =head4 Output 278: 279: SCALAR (UTF-8 string) 280: 281: =head3 MESSAGES 282: 283: | Code | Meaning | Resolution | 284: |------------------------|-----------------------------------------|-----------------------------------| 285: | error_not_nwc | Magic signature absent | Confirm file is an NWC 2.x binary | 286: | error_truncated | Data too short | File may be truncated | 287: | error_no_zlib_stream | zlib stream not found in binary | File may be corrupt | 288: | error_decompress_fail | zlib inflation failed | Payload is corrupt | 289: | error_decomp_too_large | Decompressed size exceeds safety limit | Reject; may be a zip bomb | 290: | error_no_nwctxt_marker | NWCTXT marker absent after decompression| File structure unexpected | 291: | error_bad_utf8 | Decompressed text is not valid UTF-8 | NWC file may use a legacy encoding| 292: 293: =cut 294: 295: sub decode { ●296 → 333 → 340 296: my ($proto, $data, $filename) = @_; 297: $filename //= '<buffer>'; 298: 299: my $self = ref($proto) ? $proto : $proto->new; 300: 301: # Guard: minimum viable length 302: croak _fmt_msg('error_truncated', $filename) 303: if !defined $data || length($data) < $MIN_FILE_BYTES;
Mutants (Total: 3, Killed: 3, Survived: 0)
304: 305: # Verify NWC magic signature at offset 0 306: croak _fmt_msg('error_not_nwc', $filename) 307: unless substr($data, 0, $NWC_MAGIC_LEN) eq $NWC_MAGIC; 308: 309: $self->_debug("magic verified: $filename"); 310: 311: # Locate the zlib compressed stream within the binary payload. 312: # We scan rather than relying on a fixed offset so that different 313: # NWC versions and padding sizes are handled uniformly. 314: my $zlib_offset = $self->_find_zlib_offset(\$data, $filename); 315: 316: $self->_debug("zlib stream at offset $zlib_offset in $filename"); 317: 318: # Decompress 319: my $raw = $self->_decompress(\$data, $zlib_offset, $filename); 320: 321: $self->_debug('decompressed ' . length($raw) . ' bytes from ' . $filename); 322: 323: # Locate the NWCTXT marker within the decompressed bytes 324: my $marker_pos = index($raw, $NWCTXT_MARKER); 325: croak _fmt_msg('error_no_nwctxt_marker', $filename) 326: if $marker_pos < 0;
Mutants (Total: 3, Killed: 3, Survived: 0)
327: 328: my $nwctxt = substr($raw, $marker_pos); 329: 330: # Validate UTF-8: attempt decode; Perl's utf8 flag approach 331: # We do not blindly assume the file is UTF-8; we verify it. 332: # If it fails, treat as Latin-1 (a common NWC legacy encoding) and warn. 333: if (!utf8::decode($nwctxt)) {
Mutants (Total: 1, Killed: 1, Survived: 0)
334: # Latin-1 fallback: re-encode as UTF-8 335: utf8::upgrade($nwctxt); 336: carp _fmt_msg('error_bad_utf8', $filename) 337: . ' -- assuming Latin-1'; 338: } 339: 340: return $nwctxt;
Mutants (Total: 2, Killed: 2, Survived: 0)
341: } 342: 343: # --------------------------------------------------------------------------- 344: # Private: _find_zlib_offset 345: # --------------------------------------------------------------------------- 346: 347: sub _find_zlib_offset { ●348 → 361 → 371 348: my ($self, $data_ref, $filename) = @_; 349: 350: # Strategy: scan the binary for a byte pair (CMF, FLG) that satisfies 351: # the zlib header checksum rule: (CMF*256 + FLG) % 31 == 0. 352: # We look only for CMF=0x78 (deflate, 32K window) which covers all 353: # compression levels produced by NoteWorthy Composer. 354: # 355: # We start at offset NWC_MAGIC_LEN + 2 because at minimum there must 356: # be a two-byte version/header field after the magic. 357: 358: my $len = length $$data_ref; 359: my $start = $NWC_MAGIC_LEN; # zlib stream follows immediately after magic 360: 361: for my $i ($start .. $len - 2) { 362: my $cmf = ord(substr($$data_ref, $i, 1)); 363: my $flg = ord(substr($$data_ref, $i + 1, 1)); 364: 365: next unless $cmf == $ZLIB_CMF;
Mutants (Total: 1, Killed: 1, Survived: 0)
366: next unless ($cmf * 256 + $flg) % 31 == 0;
Mutants (Total: 1, Killed: 1, Survived: 0)
367: 368: return $i;
Mutants (Total: 2, Killed: 2, Survived: 0)
369: } 370: 371: croak _fmt_msg('error_no_zlib_stream', $filename); 372: } 373: 374: # --------------------------------------------------------------------------- 375: # Private: _decompress 376: # --------------------------------------------------------------------------- 377: 378: sub _decompress { 379: my ($self, $data_ref, $offset, $filename) = @_; 380: 381: my $compressed = substr($$data_ref, $offset); 382: 383: # Compress::Zlib::uncompress handles a complete zlib stream. 384: # It returns undef on failure. 385: my $raw = Compress::Zlib::uncompress(\$compressed); 386: 387: croak _fmt_msg('error_decompress_fail', "zlib error in $filename") 388: unless defined $raw; 389: 390: croak _fmt_msg('error_decomp_too_large', $MAX_DECOMP_BYTES, $filename) 391: if length($raw) > $MAX_DECOMP_BYTES;
Mutants (Total: 3, Killed: 3, Survived: 0)
392: 393: return $raw;
Mutants (Total: 2, Killed: 2, Survived: 0)
394: } 395: 396: # --------------------------------------------------------------------------- 397: # Private: _debug 398: # --------------------------------------------------------------------------- 399: 400: sub _debug { 401: my ($self, $msg) = @_; 402: return unless defined $self->{_diagnostics}; 403: $self->{_diagnostics}->debug($msg); 404: } 405: 406: sub _fmt_msg { 407: my ($key, @args) = @_; 408: croak "Unknown message key: $key" unless exists $MESSAGES{$key}; 409: return sprintf $MESSAGES{$key}, @args;
Mutants (Total: 2, Killed: 2, Survived: 0)
410: } 411: 412: 1; 413: 414: __END__ 415: 416: =head1 DIAGNOSTICS 417: 418: =head3 MESSAGES 419: 420: | Code | Meaning | Resolution | 421: |------------------------|----------------------------------------|-----------------------------------| 422: | error_not_a_file | Cannot open/read input file | Check path and permissions | 423: | error_not_nwc | Magic signature absent | Confirm file is NWC 2.x | 424: | error_truncated | File too short | File may be corrupt or incomplete | 425: | error_no_zlib_stream | No valid zlib header found | Binary may be from unknown version| 426: | error_decompress_fail | zlib inflate failed | Payload corrupt | 427: | error_decomp_too_large | Decompressed size exceeds 256 MB | Possible zip-bomb; reject | 428: | error_no_nwctxt_marker | NWCTXT marker absent | Unexpected binary structure | 429: | error_bad_utf8 | Text not valid UTF-8 | Latin-1 fallback applied | 430: 431: =head1 LIMITATIONS 432: 433: =over 4 434: 435: =item * Only NWC 2.x binary format (C<[NWZ]> magic) is supported. NWC 1.x 436: files use a different structure and will be rejected. 437: 438: =item * Files larger than 256 MB when decompressed will be rejected for 439: safety. Legitimate scores should not approach this limit. 440: 441: =item * Latin-1 fallback for non-UTF-8 NWCTXT is a best-effort heuristic. 442: 443: =back 444: 445: =head1 FORMAL SPECIFICATION 446: 447: =head2 new 448: 449: [NWCDecoderInit] 450: diagnostics : Diagnostics 451: 452: (placeholder -- populate with Z calculus as implementation matures) 453: 454: =head2 read 455: 456: [ReadFile] 457: filename? : FileName 458: ---------- 459: result! : NWCTXT 460: 461: (placeholder) 462: 463: =head2 decode 464: 465: [Decode] 466: data? : BinaryData 467: filename? : FileName 468: ---------- 469: nwctxt! : NWCTXT 470: 471: (placeholder) 472: 473: =head1 AUTHOR 474: 475: Nigel Horne C<< <njh@nigelhorne.com> >> 476: 477: =head1 LICENSE 478: 479: This library is free software; you can redistribute it and/or modify it 480: under the same terms as Perl itself. 481: 482: =cut