All notable changes to this project are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[0.1.0] — 2026-08-21
Initial release.
Added
columns/3andcolumns!/3take a:propertiesoption. They already accepted an options list and ignored it, so a caller wanting three of a splat's sixty columns got all sixty and discarded the rest — most of a gigabyte of packed binary on a real capture. Unwanted properties are now walked for their offsets but never sliced. An unknown name is reported as:no_such_propertyrather than surfacing as a missing key further on.
Fixed — third review pass
- A float too small for
float32was accepted and flushed to zero. The exact mirror of the overflow case, which was already rejected with its own message — a nonzero value was written as0.0and the write reported success.finite?/1cannot detect this, because0.0is an ordinary float. Ordinary rounding (0.1losing digits) is still accepted; only total loss of the value is an error. - A comment containing a line break fabricated header directives. The
header is line-oriented, so
comments: ["hi\nelement ghost 99"]wrote a header declaring an element the caller never asked for — and the reader believed it. Comments now reject line breaks, and element and property names reject whitespace and emptiness, since header fields are whitespace-delimited. - A blank line before the
formatline is now tolerated, matching the existing behaviour after it.
Fixed — second review pass
The first fix round introduced two of these, which is the argument for reviewing again after fixing.
stream!/3leaked a file descriptor on every call. A file opened with:rawis a{:file_descriptor, :prim_file, _}tuple — neither a pid nor a port — so the close guard never matched and a long-running process would reach:emfile. Descriptors are now closed on the normal path and on the raising one, whichStream.resource/3does not cover.- An ASCII float beyond float32 range raised
MatchError. Narrowing fails on the left-hand match, not with theArgumentErrorthe rescue expected, so the rescue was dead code. Magnitude is checked before converting. columns/3on ASCII truncated out-of-range values, resurrecting the 300-becomes-44 defect on the read side. ASCII integers are now range-checked against their declared type at parse time, so a text file can no longer hold a value the binary format cannot express.Types.check/2raisedArithmeticErroron an integer too large to convert to a float, and had no float64 bound at all.- A failed write left its temporary file behind when the failure was an exception rather than an error tuple.
- A header with mixed terminators was off by one. The style was locked by the first line; it is now re-detected per line, which still resolves the ambiguous lone-CR case against what the previous line did.
- A header larger than the 1 MiB probe window mis-decoded silently. An
unterminated
end_headeris no longer accepted from a bounded probe. - Reading past the end of an in-memory source returned a short read instead of
raising
ArgumentError, matching:file.pread/3. :onlygiven a bare string returns an error instead of aFunctionClauseError.
Fixed — from external code review
stream!/3no longer reads the whole file. Binary elements with a computable offset and a fixed row size are now read incrementally viapread; the previous implementation read everything up front while the documentation promised the opposite.- Writes are validated before encoding. Out-of-range integers wrapped
silently (
300asucharbecame44), oversized floats became infinity, and a list longer than its count type wrote a length of zero — which then read back as an empty list, losing the data with no error reported. - Writes are atomic. A rejected write previously left a truncated file whose header lied, destroying whatever was already at that path.
- A header terminated by a lone CR whose body begins with
0x0Ais no longer off by one byte. The two are indistinguishable read in isolation, so the parser now tracks the terminator style the file has established. - ASCII
float32is narrowed to float32 precision, so a value reads the same from an ASCII file as from a binary one. - Non-finite and negative list counts return errors instead of raising
ArithmeticErroror consuming the rest of the file. - Extra values on an ASCII row, data past the last element, duplicate element
or property names, and text after
end_headerare all reported rather than silently absorbed. - Errors from a missing or unreadable file are
:io_error, not:not_ply. - Row numbers stay absolute across streaming chunks.
- Header strings no longer retain the entire source binary.
- An element positioned past the end of a short file reports
:truncatedrather than raisingArgumentError.
Changed
- A plain string source is now always a path; in-memory contents must be
passed as
{:binary, contents}. Sniffing for the magic number misrouted every path beginning withply. read/2returns a%Ply{header: _, elements: _}struct instead of a map with a magic:__header__key.write/4count mismatches report:count_mismatchrather than:truncated, which described the opposite condition.:onlyrejects unknown element names and genuinely skips unwanted ones.- Added
Ply.finite?/1. Term ordering places every number below every atom, so:nan > 0.5istrueand comparison-based filters silently keep the rows they meant to drop.
[0.1.0] - 2026-08-21
Initial release.
Added
Ply.info/1— parse only the header, reading at most 1 MiB regardless of file size. Exposesdata_offsetso callers can read the body themselves.Ply.read/2— decode a whole file into string-keyed maps, with:onlyto skip unwanted elements.Ply.stream!/3— lazily stream one element's rows, with a configurable chunk size.Ply.columns!/3— decode a fixed-width element into one packed binary per property, normalised to little-endian, for handoff toNxwithout a copy.Ply.write/4— write ASCII or binary files from any Enumerable of rows.- ASCII, binary little-endian, and binary big-endian bodies.
- Accepts both specification type names (
char,float, …) and the explicit-width aliases real tools emit (int8,float32, …). - Non-finite floats decode to
:nan/:infinity/:neg_infinityrather than raising, since BEAM floats cannot represent them and real Gaussian-splat exports contain them. - Header terminators are located byte-exactly across LF, CRLF, and lone CR, so binary bodies are not shifted by a stray carriage return.
Ply.Errorcarries byte offset, element, row, and property.