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.002.4'; 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.002.4 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: no autodie 'open'; # autodie would swallow the open failure before or-croak fires 221: open my $fh, '<:raw', $filename 222: or croak _fmt_msg('error_not_a_file', "$filename: $!"); 223: local $/; 224: $data = <$fh>; 225: close $fh; 226: } 227: 228: # Delegate to decode so tests can bypass file I/O entirely. 229: my $self = ref($proto) ? $proto : $proto->new; 230: return $self->decode($data, $filename);
Mutants (Total: 2, Killed: 2, Survived: 0)
231: } 232: 233: =head2 decode 234: 235: Decode a binary NWC payload (already loaded into a scalar) and return its 236: NWCTXT representation. 237: 238: Can be called as a class method or instance method. 239: 240: Separates decompression logic from file I/O; enables unit testing with 241: in-memory test vectors. 242: 243: =head3 Arguments 244: 245: =over 4 246: 247: =item C<$data> -- binary scalar containing the full file content (required). 248: 249: =item C<$filename> -- source filename for diagnostic messages (optional, default C<< <buffer> >>). 250: 251: =back 252: 253: =head3 Returns 254: 255: Scalar string containing the NWCTXT representation (UTF-8). 256: 257: =head3 Side Effects 258: 259: None (no I/O). Croaks on any format or decompression error. 260: 261: =head3 Usage Example 262: 263: my $nwctxt = Music::NWC2MusicXML::NWC->decode($binary_blob); 264: 265: =head3 API SPECIFICATION 266: 267: =head4 Input 268: 269: $data : SCALAR (binary, required) 270: -- Valid domain: length >= MIN_FILE_BYTES (9 bytes) 271: -- Minimum: 5-byte magic '[NWZ]' + 4 bytes = 9 bytes (MIN_FILE_BYTES) 272: -- Below min (length 0..8): croaks error_truncated 273: -- At min (length 9) with wrong magic: croaks error_not_nwc 274: -- MAX decompressed size: 268,435,456 bytes (MAX_DECOMP_BYTES = 256 MB) 275: $filename : SCALAR (optional, default '<buffer>') 276: -- Any string; used only in diagnostic messages 277: 278: =head4 Output 279: 280: SCALAR (UTF-8 string) 281: 282: =head3 MESSAGES 283: 284: | Code | Meaning | Resolution | 285: |------------------------|-----------------------------------------|-----------------------------------| 286: | error_not_nwc | Magic signature absent | Confirm file is an NWC 2.x binary | 287: | error_truncated | Data too short | File may be truncated | 288: | error_no_zlib_stream | zlib stream not found in binary | File may be corrupt | 289: | error_decompress_fail | zlib inflation failed | Payload is corrupt | 290: | error_decomp_too_large | Decompressed size exceeds safety limit | Reject; may be a zip bomb | 291: | error_no_nwctxt_marker | NWCTXT marker absent after decompression| File structure unexpected | 292: | error_bad_utf8 | Decompressed text is not valid UTF-8 | NWC file may use a legacy encoding| 293: 294: =cut 295: 296: sub decode { ●297 → 334 → 341 297: my ($proto, $data, $filename) = @_; 298: $filename //= '<buffer>'; 299: 300: my $self = ref($proto) ? $proto : $proto->new; 301: 302: # Guard: minimum viable length 303: croak _fmt_msg('error_truncated', $filename) 304: if !defined $data || length($data) < $MIN_FILE_BYTES;
Mutants (Total: 3, Killed: 3, Survived: 0)
305: 306: # Verify NWC magic signature at offset 0 307: croak _fmt_msg('error_not_nwc', $filename) 308: unless substr($data, 0, $NWC_MAGIC_LEN) eq $NWC_MAGIC; 309: 310: $self->_debug("magic verified: $filename"); 311: 312: # Locate the zlib compressed stream within the binary payload. 313: # We scan rather than relying on a fixed offset so that different 314: # NWC versions and padding sizes are handled uniformly. 315: my $zlib_offset = $self->_find_zlib_offset(\$data, $filename); 316: 317: $self->_debug("zlib stream at offset $zlib_offset in $filename"); 318: 319: # Decompress 320: my $raw = $self->_decompress(\$data, $zlib_offset, $filename); 321: 322: $self->_debug('decompressed ' . length($raw) . ' bytes from ' . $filename); 323: 324: # Locate the NWCTXT marker within the decompressed bytes 325: my $marker_pos = index($raw, $NWCTXT_MARKER); 326: croak _fmt_msg('error_no_nwctxt_marker', $filename) 327: if $marker_pos < 0;
Mutants (Total: 3, Killed: 3, Survived: 0)
328: 329: my $nwctxt = substr($raw, $marker_pos); 330: 331: # Validate UTF-8: attempt decode; Perl's utf8 flag approach 332: # We do not blindly assume the file is UTF-8; we verify it. 333: # If it fails, treat as Latin-1 (a common NWC legacy encoding) and warn. 334: if (!utf8::decode($nwctxt)) {
Mutants (Total: 1, Killed: 1, Survived: 0)
335: # Latin-1 fallback: re-encode as UTF-8 336: utf8::upgrade($nwctxt); 337: carp _fmt_msg('error_bad_utf8', $filename) 338: . ' -- assuming Latin-1'; 339: } 340: 341: return $nwctxt;
Mutants (Total: 2, Killed: 2, Survived: 0)
342: } 343: 344: # --------------------------------------------------------------------------- 345: # Private: _find_zlib_offset 346: # --------------------------------------------------------------------------- 347: 348: sub _find_zlib_offset { ●349 → 362 → 372 349: my ($self, $data_ref, $filename) = @_; 350: 351: # Strategy: scan the binary for a byte pair (CMF, FLG) that satisfies 352: # the zlib header checksum rule: (CMF*256 + FLG) % 31 == 0. 353: # We look only for CMF=0x78 (deflate, 32K window) which covers all 354: # compression levels produced by NoteWorthy Composer. 355: # 356: # We start at offset NWC_MAGIC_LEN + 2 because at minimum there must 357: # be a two-byte version/header field after the magic. 358: 359: my $len = length $$data_ref; 360: my $start = $NWC_MAGIC_LEN; # zlib stream follows immediately after magic 361: 362: for my $i ($start .. $len - 2) { 363: my $cmf = ord(substr($$data_ref, $i, 1)); 364: my $flg = ord(substr($$data_ref, $i + 1, 1)); 365: 366: next unless $cmf == $ZLIB_CMF;
Mutants (Total: 1, Killed: 1, Survived: 0)
367: next unless ($cmf * 256 + $flg) % 31 == 0;
Mutants (Total: 1, Killed: 1, Survived: 0)
368: 369: return $i;
Mutants (Total: 2, Killed: 2, Survived: 0)
370: } 371: 372: croak _fmt_msg('error_no_zlib_stream', $filename); 373: } 374: 375: # --------------------------------------------------------------------------- 376: # Private: _decompress 377: # --------------------------------------------------------------------------- 378: 379: sub _decompress { 380: my ($self, $data_ref, $offset, $filename) = @_; 381: 382: my $compressed = substr($$data_ref, $offset); 383: 384: # Compress::Zlib::uncompress handles a complete zlib stream. 385: # It returns undef on failure. 386: my $raw = Compress::Zlib::uncompress(\$compressed); 387: 388: croak _fmt_msg('error_decompress_fail', "zlib error in $filename") 389: unless defined $raw; 390: 391: croak _fmt_msg('error_decomp_too_large', $MAX_DECOMP_BYTES, $filename) 392: if length($raw) > $MAX_DECOMP_BYTES;
Mutants (Total: 3, Killed: 3, Survived: 0)
393: 394: return $raw;
Mutants (Total: 2, Killed: 2, Survived: 0)
395: } 396: 397: # --------------------------------------------------------------------------- 398: # Private: _debug 399: # --------------------------------------------------------------------------- 400: 401: sub _debug { 402: my ($self, $msg) = @_; 403: return unless defined $self->{_diagnostics}; 404: $self->{_diagnostics}->debug($msg); 405: } 406: 407: sub _fmt_msg { 408: my ($key, @args) = @_; 409: croak "Unknown message key: $key" unless exists $MESSAGES{$key}; 410: return sprintf $MESSAGES{$key}, @args;
Mutants (Total: 2, Killed: 2, Survived: 0)
411: } 412: 413: 1; 414: 415: __END__ 416: 417: =head1 DIAGNOSTICS 418: 419: =head3 MESSAGES 420: 421: | Code | Meaning | Resolution | 422: |------------------------|----------------------------------------|-----------------------------------| 423: | error_not_a_file | Cannot open/read input file | Check path and permissions | 424: | error_not_nwc | Magic signature absent | Confirm file is NWC 2.x | 425: | error_truncated | File too short | File may be corrupt or incomplete | 426: | error_no_zlib_stream | No valid zlib header found | Binary may be from unknown version| 427: | error_decompress_fail | zlib inflate failed | Payload corrupt | 428: | error_decomp_too_large | Decompressed size exceeds 256 MB | Possible zip-bomb; reject | 429: | error_no_nwctxt_marker | NWCTXT marker absent | Unexpected binary structure | 430: | error_bad_utf8 | Text not valid UTF-8 | Latin-1 fallback applied | 431: 432: =head1 LIMITATIONS 433: 434: =over 4 435: 436: =item * Only NWC 2.x binary format (C<[NWZ]> magic) is supported. NWC 1.x 437: files use a different structure and will be rejected. 438: 439: =item * Files larger than 256 MB when decompressed will be rejected for 440: safety. Legitimate scores should not approach this limit. 441: 442: =item * Latin-1 fallback for non-UTF-8 NWCTXT is a best-effort heuristic. 443: 444: =back 445: 446: =head1 FORMAL SPECIFICATION 447: 448: =head2 new 449: 450: [NWCDecoderInit] 451: diagnostics : Diagnostics 452: 453: (placeholder -- populate with Z calculus as implementation matures) 454: 455: =head2 read 456: 457: [ReadFile] 458: filename? : FileName 459: ---------- 460: result! : NWCTXT 461: 462: (placeholder) 463: 464: =head2 decode 465: 466: [Decode] 467: data? : BinaryData 468: filename? : FileName 469: ---------- 470: nwctxt! : NWCTXT 471: 472: (placeholder) 473: 474: =head1 AUTHOR 475: 476: Nigel Horne C<< <njh@nigelhorne.com> >> 477: 478: =head1 LICENSE 479: 480: This library is free software; you can redistribute it and/or modify it 481: under the same terms as Perl itself. 482: 483: =cut