Skip to main content

fits_well/writer/
mod.rs

1//! Header and data-unit serialization.
2//!
3//! The high-level writers — [`FitsWriter::write_image`], `write_table`,
4//! `write_ascii_table`, and the compressed forms — synthesize the mandatory header
5//! and emit the data unit through `write_hdu` (which pads to the block grid and
6//! embeds `CHECKSUM`/`DATASUM` when enabled), assembling each unit in the writer's
7//! reused `scratch`. [`FitsWriter::write_header`] / [`FitsWriter::write_data_unit`]
8//! are the low-level escape hatches for callers driving the layout themselves.
9
10use std::io::Write;
11
12use num_complex::Complex;
13
14use crate::block::BLOCK_SIZE;
15use crate::block::CARD_SIZE;
16use crate::block::SPACE_FILL;
17use crate::block::ZERO_FILL;
18use crate::checksum;
19#[cfg(feature = "compression")]
20use crate::compress::{CompressOptions, compress_image, compress_table};
21use crate::data::Image;
22use crate::data::shape_product;
23use crate::endian::extend_be;
24use crate::endian::push_pq_descriptor;
25use crate::error::FitsError;
26use crate::error::Result;
27use crate::header::Header;
28use crate::keyword::key;
29#[cfg(feature = "compression")]
30use crate::table::BinTable;
31use crate::table::ColumnData;
32
33/// 16-zero `CHECKSUM` value written before the real checksum is solved and
34/// patched in (Appendix J.1).
35const PLACEHOLDER_CHECKSUM: &str = "0000000000000000";
36
37/// Serialize a header unit: every card rendered to 80 bytes, the `END` record,
38/// then space padding to the next 2880-byte boundary.
39pub(crate) fn render_header(header: &Header) -> Vec<u8> {
40    let mut buf = Vec::with_capacity((header.cards.len() + 1) * CARD_SIZE);
41    for card in &header.cards {
42        for record in card.render_records() {
43            buf.extend_from_slice(&record);
44        }
45    }
46    let mut end = [SPACE_FILL; CARD_SIZE];
47    end[..3].copy_from_slice(b"END");
48    buf.extend_from_slice(&end);
49    pad_to_block(&mut buf, SPACE_FILL);
50    buf
51}
52
53/// Round `buf` up to a whole number of 2880-byte blocks using `fill`.
54fn pad_to_block(buf: &mut Vec<u8>, fill: u8) {
55    let rem = buf.len() % BLOCK_SIZE;
56    if rem != 0 {
57        buf.resize(buf.len() + (BLOCK_SIZE - rem), fill);
58    }
59}
60
61/// One column to write into a binary table: its name, optional unit, data, and
62/// the number of elements per row (`repeat`). For [`ColumnData::Text`], `repeat`
63/// is the fixed character width of the field.
64///
65/// When `vla` is `Some`, the column is written as a variable-length (`P`) array:
66/// each entry is one row's array and `data`/`repeat` are ignored (the element
67/// type comes from the first row, or from `data` if there are no rows).
68#[derive(Debug, Clone)]
69pub struct WriteColumn {
70    pub name: String,
71    pub unit: Option<String>,
72    pub data: ColumnData,
73    pub repeat: usize,
74    pub vla: Option<Vec<ColumnData>>,
75    /// `TDIMn` array shape (fastest axis first) for a multidimensional column.
76    pub tdim: Option<Vec<usize>>,
77    /// Use 64-bit `Q` descriptors instead of 32-bit `P` for a VLA column.
78    pub wide: bool,
79    /// Bit count for an `X` (bit-array) column; `data` is the packed bytes.
80    pub bits: Option<usize>,
81    /// `TSCALn`/`TZEROn` to emit: `data` holds the stored values, and a reader's
82    /// `ColumnReader::physical` recovers `TZEROn + TSCALn × stored`.
83    pub tscale: Option<f64>,
84    pub tzero: Option<f64>,
85    /// `TNULLn`: the stored integer marking an undefined element.
86    pub tnull: Option<i64>,
87}
88
89impl WriteColumn {
90    /// A fixed-width column of `repeat` elements per row.
91    pub fn fixed(name: impl Into<String>, data: ColumnData, repeat: usize) -> WriteColumn {
92        WriteColumn {
93            name: name.into(),
94            unit: None,
95            data,
96            repeat,
97            vla: None,
98            tdim: None,
99            wide: false,
100            bits: None,
101            tscale: None,
102            tzero: None,
103            tnull: None,
104        }
105    }
106
107    /// A variable-length (`P`, or `Q` via [`WriteColumn::wide`]) column: `rows[r]`
108    /// is row `r`'s array.
109    pub fn vla(name: impl Into<String>, rows: Vec<ColumnData>) -> WriteColumn {
110        // The element type tag for `data` is the first row's kind, or empty bytes.
111        let tag = rows
112            .first()
113            .cloned()
114            .unwrap_or(ColumnData::Bytes(Vec::new()));
115        // Every cell must share that type — `TFORMn` advertises the row-0 kind, so a
116        // mismatched cell would serialize bytes the reader decodes as the wrong type.
117        // Building columns is caller code, so a mixed-type VLA is a logic error.
118        assert!(
119            rows.iter()
120                .all(|r| std::mem::discriminant(r) == std::mem::discriminant(&tag)),
121            "VLA column cells must all be the same ColumnData variant"
122        );
123        WriteColumn {
124            data: tag,
125            repeat: 0,
126            vla: Some(rows),
127            ..WriteColumn::fixed(name, ColumnData::Bytes(Vec::new()), 0)
128        }
129    }
130
131    /// An `X` (bit-array) column of `nbits` bits per row, `data` the packed bytes
132    /// (`ceil(nbits/8)` per row). `repeat` is the byte width so the bytes pack
133    /// directly; `TFORMn` is rendered as `<nbits>X`.
134    pub fn bits(name: impl Into<String>, data: ColumnData, nbits: usize) -> WriteColumn {
135        WriteColumn {
136            bits: Some(nbits),
137            ..WriteColumn::fixed(name, data, nbits.div_ceil(8))
138        }
139    }
140
141    /// Attach a unit (`TUNITn`).
142    pub fn with_unit(mut self, unit: impl Into<String>) -> WriteColumn {
143        self.unit = Some(unit.into());
144        self
145    }
146
147    /// Attach a `TDIMn` array shape (fastest axis first).
148    pub fn with_tdim(mut self, shape: Vec<usize>) -> WriteColumn {
149        self.tdim = Some(shape);
150        self
151    }
152
153    /// Use 64-bit `Q` descriptors for this VLA column.
154    pub fn wide(mut self) -> WriteColumn {
155        self.wide = true;
156        self
157    }
158
159    /// Emit `TSCALn`/`TZEROn` so the stored `data` reads back as
160    /// `TZEROn + TSCALn × stored` physically.
161    pub fn scaled(mut self, tscale: f64, tzero: f64) -> WriteColumn {
162        self.tscale = Some(tscale);
163        self.tzero = Some(tzero);
164        self
165    }
166
167    /// Emit `TNULLn`, the stored integer denoting an undefined element.
168    pub fn with_null(mut self, tnull: i64) -> WriteColumn {
169        self.tnull = Some(tnull);
170        self
171    }
172}
173
174/// One column to write into an ASCII table: data (`Text`/`I64`/`F64` only), the
175/// fixed field width in characters, and the decimal count for floats.
176#[derive(Debug, Clone)]
177pub struct AsciiWriteColumn {
178    pub name: String,
179    pub unit: Option<String>,
180    pub data: ColumnData,
181    pub width: usize,
182    pub decimals: usize,
183    /// Emit `TSCALn`/`TZEROn` (§7.2.2): `data` holds the stored field values and a
184    /// reader recovers `TZEROn + TSCALn × field` physically.
185    pub tscale: Option<f64>,
186    pub tzero: Option<f64>,
187    /// Emit `TNULLn`, the field text marking an undefined value (§7.2.4). A
188    /// non-finite `F64` cell is written as this marker (or a blank field — which
189    /// reads back as 0 per §7.2.5 — when no marker is set).
190    pub tnull: Option<String>,
191}
192
193/// Writes FITS HDUs to a byte sink. The first HDU written becomes the primary
194/// array; subsequent images/tables are written as extensions.
195#[derive(Debug)]
196pub struct FitsWriter<W> {
197    sink: W,
198    has_primary: bool,
199    checksum: bool,
200    /// Reused buffer the data unit is assembled into before padding + writing, so
201    /// writing many HDUs allocates no per-call staging. Each high-level write
202    /// `clear`s it, builds the unit, and hands it to [`FitsWriter::write_hdu`].
203    scratch: Vec<u8>,
204}
205
206impl<W: Write> FitsWriter<W> {
207    pub fn new(sink: W) -> Self {
208        FitsWriter {
209            sink,
210            has_primary: false,
211            checksum: false,
212            scratch: Vec::new(),
213        }
214    }
215
216    /// Enable `DATASUM`/`CHECKSUM` integrity keywords on every HDU written through
217    /// the high-level [`FitsWriter::write_image`] / `write_table` / `write_ascii_table`
218    /// methods (§J).
219    pub fn with_checksums(mut self) -> Self {
220        self.checksum = true;
221        self
222    }
223
224    /// Write a header unit (cards + `END` + block padding).
225    pub fn write_header(&mut self, header: &Header) -> Result<()> {
226        self.sink.write_all(&render_header(header))?;
227        Ok(())
228    }
229
230    /// Write a pre-encoded data unit, padding to a block with `fill` — NUL for
231    /// most data, ASCII space for ASCII-table data (§3.1).
232    pub fn write_data_unit(&mut self, raw: &[u8], fill: u8) -> Result<()> {
233        self.sink.write_all(raw)?;
234        let rem = raw.len() % BLOCK_SIZE;
235        if rem != 0 {
236            self.sink.write_all(&vec![fill; BLOCK_SIZE - rem])?;
237        }
238        Ok(())
239    }
240
241    /// Write `image` as the primary HDU (first call) or an `IMAGE` extension
242    /// (later calls). The mandatory header is synthesized (`SIMPLE`/`XTENSION`,
243    /// `BITPIX`, `NAXISn`, plus `BSCALE`/`BZERO`/`BLANK` when scaling is
244    /// non-trivial), followed by the big-endian data unit.
245    pub fn write_image(&mut self, image: &Image) -> Result<()> {
246        let expected = shape_product(&image.shape);
247        assert_eq!(
248            image.samples.len(),
249            expected,
250            "image sample count must match the shape product"
251        );
252        let header = image_header(image, !self.has_primary);
253        self.has_primary = true;
254        self.scratch.clear();
255        image.samples.encode_into(&mut self.scratch);
256        self.write_hdu(header, ZERO_FILL)
257    }
258
259    /// Write a binary table as a `BINTABLE` extension. A dataless primary HDU is
260    /// written automatically first if nothing has been written yet (a table can
261    /// never be the primary HDU). Fixed-width and variable-length (`P`) columns
262    /// are both supported — VLA columns write a heap after the main table.
263    pub fn write_table(&mut self, nrows: usize, columns: &[WriteColumn]) -> Result<()> {
264        self.ensure_primary()?;
265        let mut row_len = 0;
266        for col in columns {
267            row_len += check_column(col, nrows)?;
268        }
269        // Build the heap (row-major) and the VLA descriptors first, so the main table
270        // can carry the `P`/`Q` (count, offset) pairs. Descriptors are recorded in the
271        // same row-major, column order the main-table pass emits them, so a single flat
272        // queue (drained below) stays aligned without per-column bookkeeping.
273        let mut heap: Vec<u8> = Vec::new();
274        let mut descs: Vec<(u64, u64)> = Vec::new();
275        for r in 0..nrows {
276            for col in columns {
277                if let Some(rows) = &col.vla {
278                    let cell = &rows[r];
279                    let (n, o) = (cell.element_count() as u64, heap.len() as u64);
280                    // A `P` (32-bit) descriptor can't address a count/offset past
281                    // u32::MAX; refuse rather than silently truncate into an
282                    // unreadable file. `WriteColumn::wide()` (a `Q` descriptor) is the
283                    // 64-bit path for >4 GiB heaps or huge cells.
284                    if !col.wide && (n > u32::MAX as u64 || o > u32::MAX as u64) {
285                        return Err(FitsError::DataUnitOverflow);
286                    }
287                    descs.push((n, o));
288                    append_be(&mut heap, cell);
289                }
290            }
291        }
292        // Main table: fixed cells inline, VLA columns as `P`/`Q` descriptors drained
293        // in the same row-major order they were built. Built into the reused scratch,
294        // with the heap appended after.
295        self.scratch.clear();
296        self.scratch.reserve(nrows * row_len + heap.len());
297        let mut descs = descs.into_iter();
298        for r in 0..nrows {
299            for col in columns {
300                if col.vla.is_some() {
301                    let (n, o) = descs.next().expect("one descriptor per VLA cell");
302                    push_pq_descriptor(&mut self.scratch, col.wide, n, o);
303                } else {
304                    pack_cell(&mut self.scratch, col, r);
305                }
306            }
307        }
308        self.scratch.extend_from_slice(&heap);
309        let header = bintable_header(nrows, row_len, columns, heap.len());
310        self.write_hdu(header, ZERO_FILL)
311    }
312
313    /// Write an ASCII table as a `TABLE` extension (a dataless primary is written
314    /// first if needed). Columns are packed left-to-right with no gaps; data is
315    /// space-padded per §7.2.3.
316    pub fn write_ascii_table(&mut self, nrows: usize, columns: &[AsciiWriteColumn]) -> Result<()> {
317        self.ensure_primary()?;
318        let mut tbcols = Vec::with_capacity(columns.len());
319        let mut row_len = 0;
320        for col in columns {
321            let count = ascii_count(&col.data)?;
322            if count != nrows {
323                return Err(FitsError::RowWidthMismatch {
324                    computed: count,
325                    declared: nrows,
326                });
327            }
328            tbcols.push(row_len + 1); // 1-based start column
329            row_len += col.width;
330        }
331        let header = ascii_table_header(nrows, row_len, columns, &tbcols);
332        self.scratch.clear();
333        self.scratch.reserve(nrows * row_len);
334        for r in 0..nrows {
335            for col in columns {
336                format_ascii_field(&mut self.scratch, col, r);
337            }
338        }
339        self.write_hdu(header, SPACE_FILL)
340    }
341
342    /// Write `image` as a tiled-compressed `BINTABLE` extension (§10.1), using the
343    /// `ZCMPTYPE` codec and the given [`CompressOptions`] (tile shape, gzip level,
344    /// HCOMPRESS scale, float quantization level — each used only by the codecs it
345    /// applies to). Requires the `compression` feature. Integer images support
346    /// `GZIP_1`/`GZIP_2`/`RICE_1`/`PLIO_1`/`HCOMPRESS_1`; float images are quantized
347    /// (`SUBTRACTIVE_DITHER_1`) and compressed with `GZIP_1`/`GZIP_2`/`RICE_1`.
348    /// `HCOMPRESS_1` needs a 2-D tile shape, and `PLIO_1` a non-negative (mask) image.
349    #[cfg(feature = "compression")]
350    pub fn write_compressed_image(
351        &mut self,
352        image: &Image,
353        cmptype: &str,
354        options: &CompressOptions,
355    ) -> Result<()> {
356        self.ensure_primary()?;
357        // The codec assembles the compressed data unit directly into the reused
358        // scratch and hands back just the header.
359        let header = compress_image(image, cmptype, options, &mut self.scratch)?;
360        self.write_hdu(header, ZERO_FILL)
361    }
362
363    /// Write a fixed-width `BINTABLE` as a tiled-compressed table (§10.3). `header`
364    /// is the original table's header (column metadata is copied from it), `table`
365    /// its parsed data, `rows_per_tile` the tile height, and `algo` the per-column
366    /// codec (`GZIP_1`/`GZIP_2`/`RICE_1`). Requires the `compression` feature.
367    #[cfg(feature = "compression")]
368    pub fn write_compressed_table(
369        &mut self,
370        header: &Header,
371        table: &BinTable,
372        rows_per_tile: usize,
373        algo: &str,
374    ) -> Result<()> {
375        self.ensure_primary()?;
376        let zheader = compress_table(header, table, rows_per_tile, algo, &mut self.scratch)?;
377        self.write_hdu(zheader, ZERO_FILL)
378    }
379
380    /// Write a dataless primary HDU if none has been written yet, so subsequent
381    /// extensions are well-formed.
382    fn ensure_primary(&mut self) -> Result<()> {
383        if !self.has_primary {
384            self.scratch.clear();
385            self.write_hdu(empty_primary_header(), ZERO_FILL)?;
386            self.has_primary = true;
387        }
388        Ok(())
389    }
390
391    /// Render and write one HDU: the unpadded data unit the caller has assembled in
392    /// `self.scratch`, padded to a block and framed by the header (with
393    /// `DATASUM`/`CHECKSUM` embedded when checksums are enabled).
394    ///
395    /// Takes the data via the reused `scratch` field rather than an owned argument,
396    /// so the high-level writers build into one buffer that survives across HDUs.
397    fn write_hdu(&mut self, mut header: Header, fill: u8) -> Result<()> {
398        pad_to_block(&mut self.scratch, fill);
399        if self.checksum {
400            header.set(
401                "DATASUM",
402                checksum::accumulate(&self.scratch, 0).to_string(),
403            );
404            header.set("CHECKSUM", PLACEHOLDER_CHECKSUM);
405        }
406        let mut header_bytes = render_header(&header);
407        if self.checksum {
408            // Re-sum with the zero placeholder, then encode the value that forces
409            // the whole-HDU checksum to negative zero, and patch it in place.
410            let hdu_sum =
411                checksum::accumulate(&self.scratch, checksum::accumulate(&header_bytes, 0));
412            patch_checksum(&mut header_bytes, &checksum::encode(hdu_sum, true));
413        }
414        self.sink.write_all(&header_bytes)?;
415        self.sink.write_all(&self.scratch)?;
416        Ok(())
417    }
418
419    /// Consume the writer and return the underlying sink. HDUs are written eagerly,
420    /// so an unbuffered sink (e.g. a `File`) holds the complete file. This does **not**
421    /// flush: if the sink is a `BufWriter`, flush it (or rely on its `Drop`) before
422    /// trusting the bytes, and check the flush result if you need write errors surfaced.
423    pub fn into_inner(self) -> W {
424        self.sink
425    }
426}
427
428/// A dataless primary HDU (`NAXIS = 0`), written before extensions when the
429/// caller's first HDU is itself an extension.
430fn empty_primary_header() -> Header {
431    let mut header = Header::new();
432    header
433        .set("SIMPLE", true)
434        .comment("SIMPLE", "file conforms to FITS standard");
435    header.set("BITPIX", 8).set("NAXIS", 0);
436    header
437        .set("EXTEND", true)
438        .comment("EXTEND", "extensions follow");
439    header
440}
441
442/// Image header: the primary array (§4.4.1) when `primary`, else an `IMAGE`
443/// extension (§7.1). The two differ only in the prologue (`SIMPLE`+`EXTEND` vs
444/// `XTENSION`+`PCOUNT`/`GCOUNT`); the axes and scaling keywords are identical.
445fn image_header(image: &Image, primary: bool) -> Header {
446    let mut header = Header::new();
447    if primary {
448        header
449            .set("SIMPLE", true)
450            .comment("SIMPLE", "file conforms to FITS standard");
451        add_image_axes(&mut header, image);
452        header
453            .set("EXTEND", true)
454            .comment("EXTEND", "extensions may follow");
455    } else {
456        header
457            .set("XTENSION", "IMAGE")
458            .comment("XTENSION", "image extension");
459        add_image_axes(&mut header, image);
460        header.set("PCOUNT", 0).set("GCOUNT", 1);
461    }
462    add_scaling(&mut header, image);
463    header
464}
465
466/// `BITPIX`, `NAXIS`, `NAXISn` — the mandatory array-shape keywords, in order.
467fn add_image_axes(header: &mut Header, image: &Image) {
468    header
469        .set("BITPIX", image.samples.bitpix().code())
470        .comment("BITPIX", "number of bits per data pixel");
471    header
472        .set("NAXIS", image.shape.len() as i64)
473        .comment("NAXIS", "number of data axes");
474    for (i, &n) in image.shape.iter().enumerate() {
475        header.set(key!("NAXIS{}", i + 1).as_str(), n as i64);
476    }
477}
478
479/// Emit `BZERO`/`BSCALE`/`BLANK` only when scaling carries information beyond the
480/// identity map.
481fn add_scaling(header: &mut Header, image: &Image) {
482    if !image.scaling.is_identity() {
483        header.set("BZERO", image.scaling.bzero);
484        header.set("BSCALE", image.scaling.bscale);
485    }
486    // §4.4.2.5: BLANK applies only to integer images (positive BITPIX).
487    if let Some(blank) = image.scaling.blank
488        && image.samples.bitpix().is_integer()
489    {
490        header.set("BLANK", blank);
491    }
492}
493
494/// `BINTABLE` extension header (§7.3.1) for the given columns.
495fn bintable_header(
496    nrows: usize,
497    row_len: usize,
498    columns: &[WriteColumn],
499    heap_len: usize,
500) -> Header {
501    let mut header = Header::new();
502    header
503        .set("XTENSION", "BINTABLE")
504        .comment("XTENSION", "binary table extension");
505    header.set("BITPIX", 8).set("NAXIS", 2);
506    header
507        .set("NAXIS1", row_len as i64)
508        .comment("NAXIS1", "width of table in bytes");
509    header
510        .set("NAXIS2", nrows as i64)
511        .comment("NAXIS2", "number of rows");
512    header.set("PCOUNT", heap_len as i64).set("GCOUNT", 1);
513    header
514        .set("TFIELDS", columns.len() as i64)
515        .comment("TFIELDS", "number of columns");
516    for (i, col) in columns.iter().enumerate() {
517        let n = i + 1;
518        header.set(key!("TFORM{n}").as_str(), tform_of(col));
519        header.set(key!("TTYPE{n}").as_str(), col.name.as_str());
520        if let Some(unit) = &col.unit {
521            header.set(key!("TUNIT{n}").as_str(), unit.as_str());
522        }
523        if let Some(shape) = &col.tdim {
524            let dims: Vec<String> = shape.iter().map(|d| d.to_string()).collect();
525            header.set(key!("TDIM{n}").as_str(), format!("({})", dims.join(",")));
526        }
527        if let Some(tscale) = col.tscale {
528            header.set(key!("TSCAL{n}").as_str(), tscale);
529        }
530        if let Some(tzero) = col.tzero {
531            header.set(key!("TZERO{n}").as_str(), tzero);
532        }
533        if let Some(tnull) = col.tnull {
534            header.set(key!("TNULL{n}").as_str(), tnull);
535        }
536    }
537    header
538}
539
540/// The `TFORMn` letter and element byte size for a column's data kind.
541#[derive(Debug, Clone, Copy)]
542struct ColumnCode {
543    letter: char,
544    elem_size: usize,
545}
546
547fn column_code(data: &ColumnData) -> ColumnCode {
548    let (letter, elem_size) = match data {
549        ColumnData::Logical(_) => ('L', 1),
550        ColumnData::Bytes(_) => ('B', 1),
551        ColumnData::I16(_) => ('I', 2),
552        ColumnData::I32(_) => ('J', 4),
553        ColumnData::I64(_) => ('K', 8),
554        ColumnData::F32(_) => ('E', 4),
555        ColumnData::F64(_) => ('D', 8),
556        ColumnData::ComplexF32(_) => ('C', 8),
557        ColumnData::ComplexF64(_) => ('M', 16),
558        ColumnData::Text(_) => ('A', 1),
559    };
560    ColumnCode { letter, elem_size }
561}
562
563fn tform_of(col: &WriteColumn) -> String {
564    let code = column_code(&col.data).letter;
565    if let Some(nbits) = col.bits {
566        return format!("{nbits}X");
567    }
568    match &col.vla {
569        // `1P<code>(maxnelem)`, or `1Q…` for 64-bit descriptors.
570        Some(rows) => {
571            let max = rows
572                .iter()
573                .map(ColumnData::element_count)
574                .max()
575                .unwrap_or(0);
576            let p = if col.wide { 'Q' } else { 'P' };
577            format!("1{p}{code}({max})")
578        }
579        None => format!("{}{}", col.repeat, code),
580    }
581}
582
583/// Validate a column against `nrows` and return its per-row byte width.
584fn check_column(col: &WriteColumn, nrows: usize) -> Result<usize> {
585    let elem = column_code(&col.data).elem_size;
586    if let Some(rows) = &col.vla {
587        if rows.len() != nrows {
588            return Err(FitsError::RowWidthMismatch {
589                computed: rows.len(),
590                declared: nrows,
591            });
592        }
593        // `P` descriptor = two 32-bit ints; `Q` = two 64-bit.
594        return Ok(if col.wide { 16 } else { 8 });
595    }
596    let mismatch = || FitsError::RowWidthMismatch {
597        computed: col.data.element_count(),
598        declared: nrows * col.repeat,
599    };
600    match &col.data {
601        ColumnData::Text(v) => {
602            if v.len() != nrows {
603                return Err(FitsError::RowWidthMismatch {
604                    computed: v.len(),
605                    declared: nrows,
606                });
607            }
608            Ok(col.repeat) // field width in bytes
609        }
610        _ => {
611            if col.data.element_count() != nrows * col.repeat {
612                return Err(mismatch());
613            }
614            Ok(col.repeat * elem)
615        }
616    }
617}
618
619/// Append a whole column cell (a VLA row's array) to the heap, big-endian.
620fn append_be(out: &mut Vec<u8>, cell: &ColumnData) {
621    match cell {
622        ColumnData::Logical(v) => out.extend(v.iter().map(|&b| match b {
623            Some(true) => b'T',
624            Some(false) => b'F',
625            None => 0, // §7.3.3 null
626        })),
627        ColumnData::Bytes(v) => out.extend_from_slice(v),
628        ColumnData::I16(v) => extend_be(out, v, i16::to_be_bytes),
629        ColumnData::I32(v) => extend_be(out, v, i32::to_be_bytes),
630        ColumnData::I64(v) => extend_be(out, v, i64::to_be_bytes),
631        ColumnData::F32(v) => extend_be(out, v, f32::to_be_bytes),
632        ColumnData::F64(v) => extend_be(out, v, f64::to_be_bytes),
633        ColumnData::ComplexF32(v) => {
634            for &Complex { re, im } in v {
635                out.extend_from_slice(&re.to_be_bytes());
636                out.extend_from_slice(&im.to_be_bytes());
637            }
638        }
639        ColumnData::ComplexF64(v) => {
640            for &Complex { re, im } in v {
641                out.extend_from_slice(&re.to_be_bytes());
642                out.extend_from_slice(&im.to_be_bytes());
643            }
644        }
645        // Character VLAs (`PA`) concatenate the strings' bytes.
646        ColumnData::Text(v) => {
647            for s in v {
648                out.extend_from_slice(s.as_bytes());
649            }
650        }
651    }
652}
653
654fn pack_cell(out: &mut Vec<u8>, col: &WriteColumn, r: usize) {
655    let rep = col.repeat;
656    let base = r * rep;
657    match &col.data {
658        ColumnData::Logical(v) => {
659            for k in 0..rep {
660                out.push(match v[base + k] {
661                    Some(true) => b'T',
662                    Some(false) => b'F',
663                    None => 0, // §7.3.3 null
664                });
665            }
666        }
667        ColumnData::Bytes(v) => out.extend_from_slice(&v[base..base + rep]),
668        ColumnData::I16(v) => extend_be(out, &v[base..base + rep], i16::to_be_bytes),
669        ColumnData::I32(v) => extend_be(out, &v[base..base + rep], i32::to_be_bytes),
670        ColumnData::I64(v) => extend_be(out, &v[base..base + rep], i64::to_be_bytes),
671        ColumnData::F32(v) => extend_be(out, &v[base..base + rep], f32::to_be_bytes),
672        ColumnData::F64(v) => extend_be(out, &v[base..base + rep], f64::to_be_bytes),
673        ColumnData::ComplexF32(v) => {
674            for &Complex { re, im } in &v[base..base + rep] {
675                out.extend_from_slice(&re.to_be_bytes());
676                out.extend_from_slice(&im.to_be_bytes());
677            }
678        }
679        ColumnData::ComplexF64(v) => {
680            for &Complex { re, im } in &v[base..base + rep] {
681                out.extend_from_slice(&re.to_be_bytes());
682                out.extend_from_slice(&im.to_be_bytes());
683            }
684        }
685        // `A`: the row's string, space-padded or truncated to the field width.
686        ColumnData::Text(v) => {
687            let bytes = v[r].as_bytes();
688            let n = bytes.len().min(rep);
689            out.extend_from_slice(&bytes[..n]);
690            out.extend(std::iter::repeat_n(b' ', rep - n));
691        }
692    }
693}
694
695/// Replace the 16 placeholder bytes of the rendered `CHECKSUM` card's value with
696/// the solved value. The value occupies bytes 12–27 (0-based 11–26) of its card.
697fn patch_checksum(header_bytes: &mut [u8], encoded: &[u8; 16]) {
698    for card in header_bytes.chunks_exact_mut(CARD_SIZE) {
699        if &card[..8] == b"CHECKSUM" {
700            card[11..27].copy_from_slice(encoded);
701            return;
702        }
703    }
704}
705
706/// Number of rows implied by an ASCII column (`Text`/`I64`/`F64` only).
707fn ascii_count(data: &ColumnData) -> Result<usize> {
708    match data {
709        ColumnData::Text(v) => Ok(v.len()),
710        ColumnData::I64(v) => Ok(v.len()),
711        ColumnData::F64(v) => Ok(v.len()),
712        _ => Err(FitsError::InvalidValue {
713            card: "ASCII table column must be Text, I64, or F64".to_string(),
714        }),
715    }
716}
717
718/// `TABLE` extension header (§7.2) for the given columns and computed `TBCOLn`s.
719fn ascii_table_header(
720    nrows: usize,
721    row_len: usize,
722    columns: &[AsciiWriteColumn],
723    tbcols: &[usize],
724) -> Header {
725    let mut header = Header::new();
726    header
727        .set("XTENSION", "TABLE")
728        .comment("XTENSION", "ASCII table extension");
729    header.set("BITPIX", 8).set("NAXIS", 2);
730    header
731        .set("NAXIS1", row_len as i64)
732        .comment("NAXIS1", "width of table in characters");
733    header
734        .set("NAXIS2", nrows as i64)
735        .comment("NAXIS2", "number of rows");
736    header.set("PCOUNT", 0).set("GCOUNT", 1);
737    header
738        .set("TFIELDS", columns.len() as i64)
739        .comment("TFIELDS", "number of columns");
740    for (i, col) in columns.iter().enumerate() {
741        let n = i + 1;
742        header.set(key!("TBCOL{n}").as_str(), tbcols[i] as i64);
743        header.set(key!("TFORM{n}").as_str(), ascii_tform(col));
744        header.set(key!("TTYPE{n}").as_str(), col.name.as_str());
745        if let Some(unit) = &col.unit {
746            header.set(key!("TUNIT{n}").as_str(), unit.as_str());
747        }
748        if let Some(tscale) = col.tscale {
749            header.set(key!("TSCAL{n}").as_str(), tscale);
750        }
751        if let Some(tzero) = col.tzero {
752            header.set(key!("TZERO{n}").as_str(), tzero);
753        }
754        if let Some(tnull) = &col.tnull {
755            header.set(key!("TNULL{n}").as_str(), tnull.as_str());
756        }
757    }
758    header
759}
760
761fn ascii_tform(col: &AsciiWriteColumn) -> String {
762    match col.data {
763        ColumnData::Text(_) => format!("A{}", col.width),
764        ColumnData::I64(_) => format!("I{}", col.width),
765        ColumnData::F64(_) => format!("F{}.{}", col.width, col.decimals),
766        _ => format!("A{}", col.width), // unreachable: validated in ascii_count
767    }
768}
769
770/// Format row `r` of an ASCII column into exactly `width` bytes (space-padded;
771/// overflow becomes `*` fill per §7.2.5).
772fn format_ascii_field(out: &mut Vec<u8>, col: &AsciiWriteColumn, r: usize) {
773    let (text, left) = match &col.data {
774        ColumnData::Text(v) => (v[r].clone(), true),
775        ColumnData::I64(v) => (v[r].to_string(), false),
776        // A non-finite cell has no §7.2.5 real representation: write the TNULLn
777        // marker if set, else a blank field (which a reader takes as 0).
778        ColumnData::F64(v) if !v[r].is_finite() => (col.tnull.clone().unwrap_or_default(), false),
779        ColumnData::F64(v) => (format!("{:.*}", col.decimals, v[r]), false),
780        _ => (String::new(), true),
781    };
782    let bytes = text.as_bytes();
783    if bytes.len() > col.width {
784        out.extend(std::iter::repeat_n(b'*', col.width));
785        return;
786    }
787    let pad = col.width - bytes.len();
788    if left {
789        out.extend_from_slice(bytes);
790        out.extend(std::iter::repeat_n(b' ', pad));
791    } else {
792        out.extend(std::iter::repeat_n(b' ', pad));
793        out.extend_from_slice(bytes);
794    }
795}
796
797#[cfg(test)]
798mod tests;