| File: | blib/lib/Music/NWC2MusicXML/NWC.pm |
| Coverage: | 96.5% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Music::NWC2MusicXML::NWC; | |||||
| 2 | ||||||
| 3 | 14 14 14 | 393887 11 75 | use strict; | |||
| 4 | 14 14 14 | 11 4 214 | use warnings; | |||
| 5 | 14 14 14 | 342 14916 30 | use autodie qw(open close); | |||
| 6 | ||||||
| 7 | our $VERSION = '0.002.4'; | |||||
| 8 | ||||||
| 9 | 14 14 14 | 2062 9 188 | use Carp qw(croak carp); | |||
| 10 | 14 14 14 | 345 3169 156 | use Readonly; | |||
| 11 | 14 14 14 | 748 22994 157 | use Params::Validate::Strict qw(validate_strict); | |||
| 12 | 14 14 14 | 416 20898 111 | use Params::Get; | |||
| 13 | 14 13 13 | 1550 121457 1098 | 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 - 94 | =head1 NAME
Music::NWC2MusicXML::NWC - Binary NWC container decoder.
=head1 VERSION
0.002.4
=head1 SYNOPSIS
use Music::NWC2MusicXML::NWC;
# From a file
my $nwctxt = Music::NWC2MusicXML::NWC->read('Pilgrim.nwc');
# From an in-memory buffer (e.g. read from a database blob)
my $nwctxt = Music::NWC2MusicXML::NWC->decode($binary_data);
print $nwctxt; # prints the NWCTXT representation
=head1 DESCRIPTION
Converts a NoteWorthy Composer 2 binary C<.nwc> file into its NWCTXT text
representation. The NWCTXT string is then passed to
C<Music::NWC2MusicXML::Parser>.
The conversion pipeline implemented here is:
NWC binary container
|
v (verify magic + locate zlib stream)
compressed score data
|
v (Compress::Zlib inflate)
raw decompressed bytes
|
v (locate !NoteWorthyComposer( marker)
NWCTXT representation (UTF-8 string)
No NoteWorthy Composer installation is required.
=cut | |||||
| 95 | ||||||
| 96 | # --------------------------------------------------------------------------- | |||||
| 97 | # Constructor | |||||
| 98 | # --------------------------------------------------------------------------- | |||||
| 99 | ||||||
| 100 - 129 | =head2 new Construct a decoder object. Optionally binds diagnostic output to a C<Music::NWC2MusicXML::Diagnostics> instance. =head3 Arguments Named parameters: =over 4 =item C<diagnostics> -- a C<Music::NWC2MusicXML::Diagnostics> instance (optional). =back =head3 Returns Blessed C<Music::NWC2MusicXML::NWC> object. =head3 API SPECIFICATION =head4 Input diagnostics : Music::NWC2MusicXML::Diagnostics (optional) =head4 Output Music::NWC2MusicXML::NWC object =cut | |||||
| 130 | ||||||
| 131 | sub new { | |||||
| 132 | 119 | 144342 | my ($class, %input) = @_; | |||
| 133 | 119 | 231 | my $args = validate_strict( | |||
| 134 | schema => { | |||||
| 135 | diagnostics => { type => 'object', optional => 1 }, | |||||
| 136 | }, | |||||
| 137 | input => \%input, | |||||
| 138 | ); | |||||
| 139 | 119 | 4780 | croak $@ unless defined $args; | |||
| 140 | ||||||
| 141 | my $self = bless { | |||||
| 142 | _diagnostics => $args->{diagnostics}, | |||||
| 143 | 119 | 143 | }, $class; | |||
| 144 | ||||||
| 145 | 119 | 192 | return $self; | |||
| 146 | } | |||||
| 147 | ||||||
| 148 | # --------------------------------------------------------------------------- | |||||
| 149 | # Public: read | |||||
| 150 | # --------------------------------------------------------------------------- | |||||
| 151 | ||||||
| 152 - 207 | =head2 read
Read a C<.nwc> binary file from disk and return its NWCTXT representation.
Can be called as a class method (C<< Music::NWC2MusicXML::NWC->read($file) >>)
or as an instance method.
=head3 Purpose
Encapsulates file-I/O so that C<decode> can be tested independently with
in-memory data.
=head3 Arguments
=over 4
=item C<$filename> -- path to the C<.nwc> file (required).
=back
=head3 Returns
Scalar string containing the NWCTXT representation (UTF-8).
=head3 Side Effects
Reads from disk. Croaks on any I/O or format error.
=head3 Usage Example
my $nwctxt = Music::NWC2MusicXML::NWC->read('Pilgrim.nwc');
=head3 API SPECIFICATION
=head4 Input
$filename : SCALAR path (required)
-- Valid domain: defined, non-empty string naming a regular,
-- readable file in NWC 2.x binary format
-- Invalid partitions: undef, '' (empty), directory, non-existent,
-- unreadable, or wrong format -> all croak error_not_a_file
-- or error_not_nwc / error_truncated depending on failure point
=head4 Output
SCALAR (UTF-8 string)
=head3 MESSAGES
| Code | Meaning | Resolution |
|---------------------|-------------------------------------|--------------------------------|
| error_not_a_file | File cannot be opened | Check path and permissions |
| error_not_nwc | File does not begin with NWC magic | Verify file is a real .nwc |
| error_truncated | File too short to be valid | File may be corrupt |
=cut | |||||
| 208 | ||||||
| 209 | sub read { | |||||
| 210 | 87 | 140818 | my ($proto, $filename) = @_; | |||
| 211 | ||||||
| 212 | 87 | 214 | croak _fmt_msg('error_not_a_file', $filename // '(undef)') | |||
| 213 | unless defined $filename && length $filename; | |||||
| 214 | ||||||
| 215 | 81 | 546 | croak _fmt_msg('error_not_a_file', $filename) | |||
| 216 | unless -f $filename && -r $filename; | |||||
| 217 | ||||||
| 218 | 65 | 50 | my $data; | |||
| 219 | { | |||||
| 220 | 13 13 13 65 | 23 9 40 51 | no autodie 'open'; # autodie would swallow the open failure before or-croak fires | |||
| 221 | 65 | 1088 | open my $fh, '<:raw', $filename | |||
| 222 | or croak _fmt_msg('error_not_a_file', "$filename: $!"); | |||||
| 223 | 65 | 96 | local $/; | |||
| 224 | 65 | 1261 | $data = <$fh>; | |||
| 225 | 65 | 131 | close $fh; | |||
| 226 | } | |||||
| 227 | ||||||
| 228 | # Delegate to decode so tests can bypass file I/O entirely. | |||||
| 229 | 65 | 4569 | my $self = ref($proto) ? $proto : $proto->new; | |||
| 230 | 65 | 133 | return $self->decode($data, $filename); | |||
| 231 | } | |||||
| 232 | ||||||
| 233 - 294 | =head2 decode Decode a binary NWC payload (already loaded into a scalar) and return its NWCTXT representation. Can be called as a class method or instance method. Separates decompression logic from file I/O; enables unit testing with in-memory test vectors. =head3 Arguments =over 4 =item C<$data> -- binary scalar containing the full file content (required). =item C<$filename> -- source filename for diagnostic messages (optional, default C<< <buffer> >>). =back =head3 Returns Scalar string containing the NWCTXT representation (UTF-8). =head3 Side Effects None (no I/O). Croaks on any format or decompression error. =head3 Usage Example my $nwctxt = Music::NWC2MusicXML::NWC->decode($binary_blob); =head3 API SPECIFICATION =head4 Input $data : SCALAR (binary, required) -- Valid domain: length >= MIN_FILE_BYTES (9 bytes) -- Minimum: 5-byte magic '[NWZ]' + 4 bytes = 9 bytes (MIN_FILE_BYTES) -- Below min (length 0..8): croaks error_truncated -- At min (length 9) with wrong magic: croaks error_not_nwc -- MAX decompressed size: 268,435,456 bytes (MAX_DECOMP_BYTES = 256 MB) $filename : SCALAR (optional, default '<buffer>') -- Any string; used only in diagnostic messages =head4 Output SCALAR (UTF-8 string) =head3 MESSAGES | Code | Meaning | Resolution | |------------------------|-----------------------------------------|-----------------------------------| | error_not_nwc | Magic signature absent | Confirm file is an NWC 2.x binary | | error_truncated | Data too short | File may be truncated | | error_no_zlib_stream | zlib stream not found in binary | File may be corrupt | | error_decompress_fail | zlib inflation failed | Payload is corrupt | | error_decomp_too_large | Decompressed size exceeds safety limit | Reject; may be a zip bomb | | error_no_nwctxt_marker | NWCTXT marker absent after decompression| File structure unexpected | | error_bad_utf8 | Decompressed text is not valid UTF-8 | NWC file may use a legacy encoding| =cut | |||||
| 295 | ||||||
| 296 | sub decode { | |||||
| 297 | 100 | 70825 | my ($proto, $data, $filename) = @_; | |||
| 298 | 100 | 124 | $filename //= '<buffer>'; | |||
| 299 | ||||||
| 300 | 100 | 114 | my $self = ref($proto) ? $proto : $proto->new; | |||
| 301 | ||||||
| 302 | # Guard: minimum viable length | |||||
| 303 | 100 | 206 | croak _fmt_msg('error_truncated', $filename) | |||
| 304 | if !defined $data || length($data) < $MIN_FILE_BYTES; | |||||
| 305 | ||||||
| 306 | # Verify NWC magic signature at offset 0 | |||||
| 307 | 87 | 141 | croak _fmt_msg('error_not_nwc', $filename) | |||
| 308 | unless substr($data, 0, $NWC_MAGIC_LEN) eq $NWC_MAGIC; | |||||
| 309 | ||||||
| 310 | 77 | 142 | $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 | 77 | 113 | my $zlib_offset = $self->_find_zlib_offset(\$data, $filename); | |||
| 316 | ||||||
| 317 | 70 | 107 | $self->_debug("zlib stream at offset $zlib_offset in $filename"); | |||
| 318 | ||||||
| 319 | # Decompress | |||||
| 320 | 70 | 94 | my $raw = $self->_decompress(\$data, $zlib_offset, $filename); | |||
| 321 | ||||||
| 322 | 66 | 111 | $self->_debug('decompressed ' . length($raw) . ' bytes from ' . $filename); | |||
| 323 | ||||||
| 324 | # Locate the NWCTXT marker within the decompressed bytes | |||||
| 325 | 66 | 78 | my $marker_pos = index($raw, $NWCTXT_MARKER); | |||
| 326 | 66 | 94 | croak _fmt_msg('error_no_nwctxt_marker', $filename) | |||
| 327 | if $marker_pos < 0; | |||||
| 328 | ||||||
| 329 | 65 | 1114 | 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 | 65 | 7522 | if (!utf8::decode($nwctxt)) { | |||
| 335 | # Latin-1 fallback: re-encode as UTF-8 | |||||
| 336 | 1 | 1 | utf8::upgrade($nwctxt); | |||
| 337 | 1 | 2 | carp _fmt_msg('error_bad_utf8', $filename) | |||
| 338 | . ' -- assuming Latin-1'; | |||||
| 339 | } | |||||
| 340 | ||||||
| 341 | 65 | 228 | return $nwctxt; | |||
| 342 | } | |||||
| 343 | ||||||
| 344 | # --------------------------------------------------------------------------- | |||||
| 345 | # Private: _find_zlib_offset | |||||
| 346 | # --------------------------------------------------------------------------- | |||||
| 347 | ||||||
| 348 | sub _find_zlib_offset { | |||||
| 349 | 77 | 80 | 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 | 77 | 44 | my $len = length $$data_ref; | |||
| 360 | 77 | 53 | my $start = $NWC_MAGIC_LEN; # zlib stream follows immediately after magic | |||
| 361 | ||||||
| 362 | 77 | 100 | for my $i ($start .. $len - 2) { | |||
| 363 | 583 | 202 | my $cmf = ord(substr($$data_ref, $i, 1)); | |||
| 364 | 583 | 188 | my $flg = ord(substr($$data_ref, $i + 1, 1)); | |||
| 365 | ||||||
| 366 | 583 | 244 | next unless $cmf == $ZLIB_CMF; | |||
| 367 | 70 | 96 | next unless ($cmf * 256 + $flg) % 31 == 0; | |||
| 368 | ||||||
| 369 | 70 | 64 | return $i; | |||
| 370 | } | |||||
| 371 | ||||||
| 372 | 7 | 7 | croak _fmt_msg('error_no_zlib_stream', $filename); | |||
| 373 | } | |||||
| 374 | ||||||
| 375 | # --------------------------------------------------------------------------- | |||||
| 376 | # Private: _decompress | |||||
| 377 | # --------------------------------------------------------------------------- | |||||
| 378 | ||||||
| 379 | sub _decompress { | |||||
| 380 | 70 | 67 | my ($self, $data_ref, $offset, $filename) = @_; | |||
| 381 | ||||||
| 382 | 70 | 149 | my $compressed = substr($$data_ref, $offset); | |||
| 383 | ||||||
| 384 | # Compress::Zlib::uncompress handles a complete zlib stream. | |||||
| 385 | # It returns undef on failure. | |||||
| 386 | 70 | 132 | my $raw = Compress::Zlib::uncompress(\$compressed); | |||
| 387 | ||||||
| 388 | 70 | 13079 | croak _fmt_msg('error_decompress_fail', "zlib error in $filename") | |||
| 389 | unless defined $raw; | |||||
| 390 | ||||||
| 391 | 66 | 79 | croak _fmt_msg('error_decomp_too_large', $MAX_DECOMP_BYTES, $filename) | |||
| 392 | if length($raw) > $MAX_DECOMP_BYTES; | |||||
| 393 | ||||||
| 394 | 66 | 275 | return $raw; | |||
| 395 | } | |||||
| 396 | ||||||
| 397 | # --------------------------------------------------------------------------- | |||||
| 398 | # Private: _debug | |||||
| 399 | # --------------------------------------------------------------------------- | |||||
| 400 | ||||||
| 401 | sub _debug { | |||||
| 402 | 213 | 119 | my ($self, $msg) = @_; | |||
| 403 | 213 | 173 | return unless defined $self->{_diagnostics}; | |||
| 404 | 103 | 112 | $self->{_diagnostics}->debug($msg); | |||
| 405 | } | |||||
| 406 | ||||||
| 407 | sub _fmt_msg { | |||||
| 408 | 58 | 40 | my ($key, @args) = @_; | |||
| 409 | 58 | 106 | croak "Unknown message key: $key" unless exists $MESSAGES{$key}; | |||
| 410 | 58 | 172 | return sprintf $MESSAGES{$key}, @args; | |||
| 411 | } | |||||
| 412 | ||||||
| 413 | 1; | |||||
| 414 | ||||||