add json output driver
authorBen Pfaff <blp@cs.stanford.edu>
Thu, 14 Aug 2025 23:36:37 +0000 (16:36 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Thu, 14 Aug 2025 23:36:37 +0000 (16:36 -0700)
13 files changed:
rust/pspp/Cargo.toml
rust/pspp/src/dictionary.rs
rust/pspp/src/format/mod.rs
rust/pspp/src/main.rs
rust/pspp/src/message.rs
rust/pspp/src/output/csv.rs
rust/pspp/src/output/driver.rs
rust/pspp/src/output/json.rs [new file with mode: 0644]
rust/pspp/src/output/mod.rs
rust/pspp/src/output/pivot/mod.rs
rust/pspp/src/output/spv.rs
rust/pspp/src/settings.rs
rust/pspp/src/variable.rs

index a6c91db72858fe0e5080c5347fdc2fe0ea2f41ba..f9e7b4d0935a82ced392a5b839ce018d06a55ffa 100644 (file)
@@ -33,7 +33,7 @@ smallstr = "0.3.0"
 itertools = "0.14.0"
 unicode-linebreak = "0.1.5"
 quick-xml = { version = "0.37.2", features = ["serialize"] }
-serde = { version = "1.0.218", features = ["derive"] }
+serde = { version = "1.0.218", features = ["derive", "rc"] }
 color = { version = "0.2.3", features = ["serde"] }
 binrw = "0.14.1"
 ndarray = "0.16.1"
index a1f35907fb5b0616ef2cfbe412f1911658ddf011..923f289b3e57b1c190f264cb810201d03be016e8 100644 (file)
@@ -400,7 +400,7 @@ impl Dictionary {
                 } else if index < end {
                     None
                 } else {
-                    Some(index - end - start)
+                    Some(index - (end - start))
                 }
             })
         }
index b651ab743adea51915353cf3283c3460dc8fe1b1..2fe6e982ddf1309231de4c83782a513b160c1917 100644 (file)
@@ -896,7 +896,7 @@ impl Not for Decimal {
     }
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
 pub struct Epoch(pub i32);
 
 impl Epoch {
@@ -935,7 +935,7 @@ impl Display for Epoch {
     }
 }
 
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, Serialize)]
 pub struct Settings {
     pub epoch: Epoch,
 
@@ -1061,7 +1061,7 @@ impl Settings {
 
 /// A numeric output style.  This can express numeric formats in
 /// [Category::Basic] and [Category::Custom].
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct NumberStyle {
     pub neg_prefix: Affix,
     pub prefix: Affix,
@@ -1084,6 +1084,7 @@ pub struct NumberStyle {
     /// can be used to size memory allocations: for example, the formatted
     /// result of `CCA20.5` requires no more than `(20 + extra_bytes)` bytes in
     /// UTF-8.
+    #[serde(skip)]
     pub extra_bytes: usize,
 }
 
@@ -1138,11 +1139,12 @@ impl NumberStyle {
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct Affix {
     /// String contents of affix.
     pub s: String,
 
+    #[serde(skip)]
     /// Display width in columns (see [unicode_width])
     pub width: usize,
 }
index dacee9fcee9ecfc354d5081f632f621e208716ae..19907f17417abb3b2dd38a74243b9601563230da 100644 (file)
@@ -25,7 +25,9 @@ use pspp::{
     },
     sys::{
         self,
-        raw::{infer_encoding, records::Compression, Decoder, Magic, Reader, Record},
+        raw::{
+            infer_encoding, records::Compression, Decoder, EncodingReport, Magic, Reader, Record,
+        },
         ReadOptions, Records,
     },
 };
@@ -254,7 +256,7 @@ struct Show {
     mode: Mode,
 
     /// Output format.
-    #[arg(long)]
+    #[arg(long, short = 'f')]
     format: Option<ShowFormat>,
 
     /// The encoding to use.
@@ -271,6 +273,7 @@ enum Output {
         writer: Rc<RefCell<Box<dyn Write>>>,
         pretty: bool,
     },
+    Discard,
 }
 
 impl Output {
@@ -291,6 +294,7 @@ impl Output {
                 writeln!(writer)?;
                 Ok(())
             }
+            Self::Discard => Ok(()),
         }
     }
 
@@ -311,6 +315,7 @@ impl Output {
                 };
                 let _ = self.show_json(&warning);
             }
+            Self::Discard => (),
         }
     }
 }
@@ -330,6 +335,8 @@ impl Show {
                 "ndjson" => ShowFormat::Ndjson,
                 _ => ShowFormat::Output,
             }
+        } else if self.mode == Mode::Encodings {
+            ShowFormat::Output
         } else {
             ShowFormat::Json
         };
@@ -354,13 +361,14 @@ impl Show {
                 }
 
                 let table: toml::Table = toml::from_str(&config)?;
-                if !table.contains_key("driver")
-                    && let Some(file) = &self.output_file
-                {
-                    let driver =
+                if !table.contains_key("driver") {
+                    let driver = if let Some(file) = &self.output_file {
                         <dyn Driver>::driver_type_from_filename(file).ok_or_else(|| {
                             anyhow!("{}: no default output format for file name", file.display())
-                        })?;
+                        })?
+                    } else {
+                        "text"
+                    };
 
                     #[derive(Serialize)]
                     struct DriverConfig {
@@ -386,6 +394,7 @@ impl Show {
                     Rc::new(RefCell::new(Box::new(stdout())))
                 },
             },
+            ShowFormat::Discard => Output::Discard,
         };
 
         let reader = File::open(&self.input_file)?;
@@ -459,6 +468,30 @@ impl Show {
                             output.show_json(&case?)?;
                         }
                     }
+                    Output::Discard => (),
+                }
+            }
+            Mode::Encodings => {
+                let mut record_strings = reader.header().get_strings();
+                for record in reader.records() {
+                    record_strings.append(&mut record?.get_strings());
+                }
+                let Some(encoding_report) = EncodingReport::new(&record_strings) else {
+                    output.warn(&"No valid encodings found.");
+                    return Ok(());
+                };
+                match &output {
+                    Output::Driver { driver, .. } => {
+                        driver
+                            .borrow_mut()
+                            .write(&Arc::new(Item::new(encoding_report.valid_encodings)));
+                        if let Some(interpretations) = encoding_report.interpretations {
+                            driver
+                                .borrow_mut()
+                                .write(&Arc::new(Item::new(interpretations)));
+                        }
+                    }
+                    _ => todo!(),
                 }
             }
         }
@@ -495,13 +528,14 @@ fn parse_encoding(arg: &str) -> Result<&'static Encoding, UnknownEncodingError>
     }
 }
 
-#[derive(Clone, Copy, Debug, Default, ValueEnum)]
+#[derive(Clone, Copy, Debug, Default, PartialEq, ValueEnum)]
 enum Mode {
     Identify,
     Raw,
     Decoded,
     #[default]
     Parsed,
+    Encodings,
 }
 
 impl Mode {
@@ -511,6 +545,7 @@ impl Mode {
             Mode::Raw => "raw",
             Mode::Decoded => "decoded",
             Mode::Parsed => "parsed",
+            Mode::Encodings => "encodings",
         }
     }
 }
@@ -529,7 +564,10 @@ enum ShowFormat {
     Json,
     /// Newline-delimited JSON.
     Ndjson,
+    /// Pivot tables.
     Output,
+    /// No output.
+    Discard,
 }
 
 fn main() -> Result<()> {
index 3d0f667b57ee8d74b619331e7c4b85241fd709f4..97bcc90b847ed19989e66c398f0b6e1dae95bddb 100644 (file)
@@ -22,10 +22,11 @@ use std::{
 };
 
 use enum_map::Enum;
+use serde::Serialize;
 use unicode_width::UnicodeWidthStr;
 
 /// A line number and optional column number within a source file.
-#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
 pub struct Point {
     /// 1-based line number.
     pub line: i32,
@@ -65,7 +66,7 @@ impl Point {
 }
 
 /// Location relevant to an diagnostic message.
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, Serialize)]
 pub struct Location {
     /// File name, if any.
     pub file_name: Option<Arc<String>>,
@@ -76,6 +77,7 @@ pub struct Location {
     /// Normally, if `span` contains column information, then displaying the
     /// message will underline the location.  Setting this to true disables
     /// displaying underlines.
+    #[serde(skip)]
     pub omit_underlines: bool,
 }
 
@@ -136,7 +138,8 @@ impl Location {
     }
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum Severity {
     Error,
     Warning,
@@ -167,13 +170,15 @@ impl Display for Severity {
     }
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum Category {
     General,
     Syntax,
     Data,
 }
 
+#[derive(Serialize)]
 pub struct Stack {
     location: Location,
     description: String,
@@ -188,6 +193,7 @@ impl From<Diagnostic> for Diagnostics {
     }
 }
 
+#[derive(Serialize)]
 pub struct Diagnostic {
     pub severity: Severity,
     pub category: Category,
index b7768566800a56302bf641420a707872f67d4837..5e65b0b75e6ddcbe3dce60a8b74e7a9db7a0526b 100644 (file)
@@ -18,7 +18,7 @@ use std::{
     borrow::Cow,
     fmt::Display,
     fs::File,
-    io::{Error, Write},
+    io::{BufWriter, Error, Write},
     path::PathBuf,
     sync::Arc,
 };
@@ -40,7 +40,7 @@ pub struct CsvConfig {
 }
 
 pub struct CsvDriver {
-    file: File,
+    file: BufWriter<File>,
     options: CsvOptions,
 
     /// Number of items written so far.
@@ -130,7 +130,7 @@ impl Display for CsvField<'_> {
 impl CsvDriver {
     pub fn new(config: &CsvConfig) -> std::io::Result<Self> {
         Ok(Self {
-            file: File::create(&config.file)?,
+            file: BufWriter::new(File::create(&config.file)?),
             options: config.options.clone(),
             n_items: 0,
         })
@@ -201,7 +201,7 @@ impl Driver for CsvDriver {
             Details::Message(diagnostic) => {
                 self.start_item();
                 let text = diagnostic.to_string();
-                writeln!(&self.file, "{}", CsvField::new(&text, self.options)).unwrap();
+                writeln!(&mut self.file, "{}", CsvField::new(&text, self.options)).unwrap();
             }
             Details::Table(pivot_table) => {
                 for layer in pivot_table.layers(true) {
@@ -217,7 +217,7 @@ impl Driver for CsvDriver {
                 TextType::Title | TextType::Log => {
                     self.start_item();
                     for line in text.content.display(()).to_string().lines() {
-                        writeln!(&self.file, "{}", CsvField::new(line, self.options)).unwrap();
+                        writeln!(&mut self.file, "{}", CsvField::new(line, self.options)).unwrap();
                     }
                 }
             },
index 0d722e1f630882bb4acf4e71764cbacd647c918f..963661146e0002279a0c94316b685a63fd901153 100644 (file)
@@ -23,6 +23,7 @@ use crate::output::{
     cairo::{CairoConfig, CairoDriver},
     csv::{CsvConfig, CsvDriver},
     html::{HtmlConfig, HtmlDriver},
+    json::{JsonConfig, JsonDriver},
     spv::{SpvConfig, SpvDriver},
     text::{TextConfig, TextDriver},
 };
@@ -96,6 +97,7 @@ pub enum Config {
     Text(TextConfig),
     Pdf(CairoConfig),
     Html(HtmlConfig),
+    Json(JsonConfig),
     Csv(CsvConfig),
     Spv(SpvConfig),
 }
@@ -107,6 +109,7 @@ pub enum DriverType {
     Pdf,
     Html,
     Csv,
+    Json,
     Spv,
 }
 
@@ -117,6 +120,7 @@ impl dyn Driver {
             Config::Pdf(cairo_config) => Ok(Box::new(CairoDriver::new(cairo_config)?)),
             Config::Html(html_config) => Ok(Box::new(HtmlDriver::new(html_config)?)),
             Config::Csv(csv_config) => Ok(Box::new(CsvDriver::new(csv_config)?)),
+            Config::Json(json_config) => Ok(Box::new(JsonDriver::new(json_config)?)),
             Config::Spv(spv_config) => Ok(Box::new(SpvDriver::new(spv_config)?)),
         }
     }
@@ -127,6 +131,7 @@ impl dyn Driver {
             "pdf" => Some("pdf"),
             "htm" | "html" => Some("html"),
             "csv" => Some("csv"),
+            "json" => Some("json"),
             "spv" => Some("spv"),
             _ => None,
         }
diff --git a/rust/pspp/src/output/json.rs b/rust/pspp/src/output/json.rs
new file mode 100644 (file)
index 0000000..af6923d
--- /dev/null
@@ -0,0 +1,58 @@
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <http://www.gnu.org/licenses/>.
+
+use std::{
+    borrow::Cow,
+    fs::File,
+    io::{BufWriter, Write},
+    path::PathBuf,
+    sync::Arc,
+};
+
+use serde::{Deserialize, Serialize};
+
+use super::{driver::Driver, Item};
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct JsonConfig {
+    file: PathBuf,
+}
+
+pub struct JsonDriver {
+    file: BufWriter<File>,
+}
+
+impl JsonDriver {
+    pub fn new(config: &JsonConfig) -> std::io::Result<Self> {
+        Ok(Self {
+            file: BufWriter::new(File::create(&config.file)?),
+        })
+    }
+}
+
+impl Driver for JsonDriver {
+    fn name(&self) -> Cow<'static, str> {
+        Cow::from("json")
+    }
+
+    fn write(&mut self, item: &Arc<Item>) {
+        serde_json::to_writer_pretty(&mut self.file, item).unwrap(); // XXX handle errors
+    }
+
+    fn flush(&mut self) {
+        let _ = self.file.flush();
+    }
+}
index 5417de68b7fe8b22ce09b8b0bef7e9ca4520553d..0779129f8e5a41c51a53e2193330bc3bb5431f57 100644 (file)
@@ -22,6 +22,7 @@ use std::{
 
 use enum_map::EnumMap;
 use pivot::PivotTable;
+use serde::Serialize;
 
 use crate::{
     message::Diagnostic,
@@ -34,6 +35,7 @@ pub mod cairo;
 pub mod csv;
 pub mod driver;
 pub mod html;
+pub mod json;
 pub mod page;
 pub mod pivot;
 pub mod render;
@@ -43,6 +45,7 @@ pub mod text;
 pub mod text_line;
 
 /// A single output item.
+#[derive(Serialize)]
 pub struct Item {
     /// The localized label for the item that appears in the outline pane in the
     /// output viewer and in PDF outlines.  This is `None` if no label has been
@@ -94,6 +97,7 @@ where
     }
 }
 
+#[derive(Serialize)]
 pub enum Details {
     Chart,
     Image,
@@ -180,7 +184,7 @@ impl From<Box<Text>> for Details {
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct Text {
     type_: TextType,
 
@@ -228,7 +232,8 @@ impl From<&Diagnostic> for Text {
     }
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum TextType {
     /// `TITLE` and `SUBTITLE` commands.
     PageTitle,
index 99d727107058d6c5f37822a50a1b03952d8bc164..39645987bc1463daa46bfa2f65bc2cc171023447 100644 (file)
@@ -60,7 +60,7 @@ use enum_iterator::Sequence;
 use enum_map::{enum_map, Enum, EnumMap};
 use look_xml::TableProperties;
 use quick_xml::{de::from_str, DeError};
-use serde::{de::Visitor, Deserialize, Serialize};
+use serde::{de::Visitor, ser::SerializeStruct, Deserialize, Serialize};
 use smallstr::SmallString;
 use smallvec::SmallVec;
 use thiserror::Error as ThisError;
@@ -102,6 +102,31 @@ pub enum Area {
     Layers,
 }
 
+impl Display for Area {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Area::Title => write!(f, "title"),
+            Area::Caption => write!(f, "caption"),
+            Area::Footer => write!(f, "footer"),
+            Area::Corner => write!(f, "corner"),
+            Area::Labels(axis2) => write!(f, "labels({axis2})"),
+            Area::Data => write!(f, "data"),
+            Area::Layers => write!(f, "layers"),
+        }
+    }
+}
+
+impl Serialize for Area {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut s = SmallString::<[u8; 16]>::new();
+        write!(&mut s, "{}", self).unwrap();
+        serializer.serialize_str(&s)
+    }
+}
+
 impl Area {
     fn default_cell_style(self) -> CellStyle {
         use HorzAlign::*;
@@ -187,8 +212,34 @@ impl Border {
     }
 }
 
+impl Display for Border {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Border::Title => write!(f, "title"),
+            Border::OuterFrame(box_border) => write!(f, "outer_frame({box_border})"),
+            Border::InnerFrame(box_border) => write!(f, "inner_frame({box_border})"),
+            Border::Dimension(row_col_border) => write!(f, "dimension({row_col_border})"),
+            Border::Category(row_col_border) => write!(f, "category({row_col_border})"),
+            Border::DataLeft => write!(f, "data(left)"),
+            Border::DataTop => write!(f, "data(top)"),
+        }
+    }
+}
+
+impl Serialize for Border {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut s = SmallString::<[u8; 32]>::new();
+        write!(&mut s, "{}", self).unwrap();
+        serializer.serialize_str(&s)
+    }
+}
+
 /// The borders on a box.
-#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum BoxBorder {
     Left,
     Top,
@@ -196,8 +247,26 @@ pub enum BoxBorder {
     Bottom,
 }
 
+impl BoxBorder {
+    fn as_str(&self) -> &'static str {
+        match self {
+            BoxBorder::Left => "left",
+            BoxBorder::Top => "top",
+            BoxBorder::Right => "right",
+            BoxBorder::Bottom => "bottom",
+        }
+    }
+}
+
+impl Display for BoxBorder {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
 /// Borders between rows and columns.
-#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub struct RowColBorder(
     /// Row or column headings.
     pub HeadingRegion,
@@ -205,11 +274,17 @@ pub struct RowColBorder(
     pub Axis2,
 );
 
+impl Display for RowColBorder {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}:{}", self.0, self.1)
+    }
+}
+
 /// Sizing for rows or columns of a rendered table.
 ///
 /// The comments below talk about columns and their widths but they apply
 /// equally to rows and their heights.
-#[derive(Default, Clone, Debug)]
+#[derive(Default, Clone, Debug, Serialize)]
 pub struct Sizing {
     /// Specific column widths, in 1/96" units.
     widths: Vec<i32>,
@@ -222,7 +297,8 @@ pub struct Sizing {
     keeps: Vec<Range<usize>>,
 }
 
-#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Sequence)]
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Sequence, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum Axis3 {
     X,
     Y,
@@ -249,7 +325,7 @@ impl From<Axis2> for Axis3 {
 }
 
 /// An axis within a pivot table.
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, Serialize)]
 pub struct Axis {
     /// `dimensions[0]` is the innermost dimension.
     pub dimensions: Vec<usize>,
@@ -338,7 +414,7 @@ impl PivotTable {
 /// (A dimension or a group can contain zero categories, but this is unusual.
 /// If a dimension contains no categories, then its table cannot contain any
 /// data.)
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct Dimension {
     /// Hierarchy of categories within the dimension.  The groups and categories
     /// are sorted in the order that should be used for display.  This might be
@@ -399,8 +475,9 @@ impl Dimension {
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct Group {
+    #[serde(skip)]
     len: usize,
     pub name: Box<Value>,
 
@@ -505,7 +582,7 @@ where
     }
 }
 
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, Serialize)]
 pub struct Footnotes(pub Vec<Arc<Footnote>>);
 
 impl Footnotes {
@@ -540,6 +617,15 @@ impl Leaf {
     }
 }
 
+impl Serialize for Leaf {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        self.name.serialize(serializer)
+    }
+}
+
 /// Pivot result classes.
 ///
 /// These are used to mark [Leaf] categories as having particular types of data,
@@ -556,7 +642,7 @@ pub enum Class {
 }
 
 /// A pivot_category is a leaf (a category) or a group.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub enum Category {
     Group(Group),
     Leaf(Leaf),
@@ -658,7 +744,7 @@ impl From<&String> for Category {
 /// The division between this and the style information in [PivotTable] seems
 /// fairly arbitrary.  The ultimate reason for the division is simply because
 /// that's how SPSS documentation and file formats do it.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct Look {
     pub name: Option<String>,
 
@@ -789,7 +875,7 @@ impl Look {
 }
 
 /// Position for group labels.
-#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
 pub enum LabelPosition {
     /// Hierarachically enclosing the categories.
     ///
@@ -840,12 +926,28 @@ pub enum LabelPosition {
 /// │                  │                                                 │
 /// └──────────────────┴─────────────────────────────────────────────────┘
 /// ```
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum HeadingRegion {
     Rows,
     Columns,
 }
 
+impl HeadingRegion {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            HeadingRegion::Rows => "rows",
+            HeadingRegion::Columns => "columns",
+        }
+    }
+}
+
+impl Display for HeadingRegion {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.as_str())
+    }
+}
+
 impl From<Axis2> for HeadingRegion {
     fn from(axis: Axis2) -> Self {
         match axis {
@@ -855,13 +957,13 @@ impl From<Axis2> for HeadingRegion {
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct AreaStyle {
     pub cell_style: CellStyle,
     pub font_style: FontStyle,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct CellStyle {
     /// `None` means "mixed" alignment: align strings to the left, numbers to
     /// the right.
@@ -908,7 +1010,8 @@ impl HorzAlign {
     }
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum VertAlign {
     /// Top alignment.
     Top,
@@ -920,7 +1023,7 @@ pub enum VertAlign {
     Bottom,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct FontStyle {
     pub bold: bool,
     pub italic: bool,
@@ -1014,6 +1117,17 @@ impl FromStr for Color {
     }
 }
 
+impl Serialize for Color {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut s = SmallString::<[u8; 32]>::new();
+        write!(&mut s, "{}", self.display_css()).unwrap();
+        serializer.serialize_str(&s)
+    }
+}
+
 impl<'de> Deserialize<'de> for Color {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
@@ -1061,6 +1175,18 @@ pub struct BorderStyle {
     pub color: Color,
 }
 
+impl Serialize for BorderStyle {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut s = serializer.serialize_struct("BorderStyle", 2)?;
+        s.serialize_field("stroke", &self.stroke)?;
+        s.serialize_field("color", &self.color)?;
+        s.end()
+    }
+}
+
 impl BorderStyle {
     pub const fn none() -> Self {
         Self {
@@ -1084,7 +1210,7 @@ impl BorderStyle {
     }
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum, Deserialize)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum, Deserialize, Serialize)]
 #[serde(rename_all = "camelCase")]
 pub enum Stroke {
     None,
@@ -1120,6 +1246,19 @@ impl Axis2 {
     pub fn new_enum<T>(x: T, y: T) -> EnumMap<Axis2, T> {
         EnumMap::from_array([x, y])
     }
+
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            Axis2::X => "x",
+            Axis2::Y => "y",
+        }
+    }
+}
+
+impl Display for Axis2 {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.as_str())
+    }
 }
 
 impl Not for Axis2 {
@@ -1251,7 +1390,7 @@ impl IndexMut<Axis2> for Rect2 {
     }
 }
 
-#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 pub enum FootnoteMarkerType {
     /// a, b, c, ...
@@ -1262,7 +1401,7 @@ pub enum FootnoteMarkerType {
     Numeric,
 }
 
-#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 pub enum FootnoteMarkerPosition {
     /// Subscripts.
@@ -1324,7 +1463,7 @@ impl IntoValueOptions for ValueOptions {
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct PivotTable {
     pub look: Arc<Look>,
 
@@ -1679,8 +1818,9 @@ where
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct Footnote {
+    #[serde(skip)]
     index: usize,
     pub content: Box<Value>,
     pub marker: Option<Box<Value>>,
@@ -1850,6 +1990,15 @@ pub struct Value {
     pub styling: Option<Box<ValueStyle>>,
 }
 
+impl Serialize for Value {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        self.inner.serialize(serializer)
+    }
+}
+
 impl Value {
     fn new(inner: ValueInner) -> Self {
         Self {
@@ -1932,9 +2081,9 @@ impl Value {
         } else {
             Self::new(ValueInner::Text(TextValue {
                 user_provided: true,
-                local: s.clone(),
-                c: s.clone(),
-                id: s.clone(),
+                localized: s.clone(),
+                c: None,
+                id: None,
             }))
         }
     }
@@ -2253,7 +2402,9 @@ impl Display for DisplayValue<'_> {
                 }
             }
 
-            ValueInner::Text(TextValue { local, .. }) => {
+            ValueInner::Text(TextValue {
+                localized: local, ..
+            }) => {
                 /*
                 if self
                     .inner
@@ -2304,7 +2455,7 @@ impl Debug for Value {
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct NumberValue {
     pub show: Option<Show>,
     pub format: Format,
@@ -2314,7 +2465,7 @@ pub struct NumberValue {
     pub value_label: Option<String>,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct StringValue {
     pub show: Option<Show>,
     pub hex: bool,
@@ -2326,7 +2477,7 @@ pub struct StringValue {
     pub value_label: Option<String>,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
 pub struct VariableValue {
     pub show: Option<Show>,
     pub var_name: String,
@@ -2337,21 +2488,59 @@ pub struct VariableValue {
 pub struct TextValue {
     pub user_provided: bool,
     /// Localized.
-    pub local: String,
+    pub localized: String,
     /// English.
-    pub c: String,
+    pub c: Option<String>,
     /// Identifier.
-    pub id: String,
+    pub id: Option<String>,
 }
 
-#[derive(Clone, Debug)]
+impl Serialize for TextValue {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        if self.user_provided && self.c.is_none() && self.id.is_none() {
+            serializer.serialize_str(&self.localized)
+        } else {
+            let mut s = serializer.serialize_struct(
+                "TextValue",
+                2 + self.c.is_some() as usize + self.id.is_some() as usize,
+            )?;
+            s.serialize_field("user_provided", &self.user_provided)?;
+            s.serialize_field("localized", &self.localized)?;
+            if let Some(c) = &self.c {
+                s.serialize_field("c", &c)?;
+            }
+            if let Some(id) = &self.id {
+                s.serialize_field("id", &id)?;
+            }
+            s.end()
+        }
+    }
+}
+
+impl TextValue {
+    pub fn localized(&self) -> &str {
+        self.localized.as_str()
+    }
+    pub fn c(&self) -> &str {
+        self.c.as_ref().unwrap_or(&self.localized).as_str()
+    }
+    pub fn id(&self) -> &str {
+        self.id.as_ref().unwrap_or(&self.localized).as_str()
+    }
+}
+
+#[derive(Clone, Debug, Serialize)]
 pub struct TemplateValue {
     pub args: Vec<Vec<Value>>,
     pub local: String,
     pub id: String,
 }
 
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum ValueInner {
     Number(NumberValue),
     String(StringValue),
index 325595345303d305a1dfd5aa952f6e286815956d..21854df5ef39364fc2f4a8fbc037a4235538d1fe 100644 (file)
@@ -1331,10 +1331,10 @@ impl BinWrite for Value {
             ValueInner::Text(text) => {
                 (
                     3u8,
-                    SpvString(&text.local),
+                    SpvString(&text.localized),
                     ValueMod::new(self),
-                    SpvString(&text.id),
-                    SpvString(&text.c),
+                    SpvString(text.id()),
+                    SpvString(text.c()),
                     SpvBool(true),
                 )
                     .write_options(writer, endian, args)?;
index 7c7678c7439154aa3c2ea2a93c85f4bc0335f4ab..aac4a4c8a83953db1dbff2ed9e0d9d9f27fcd923 100644 (file)
@@ -18,6 +18,7 @@ use std::sync::{Arc, OnceLock};
 
 use binrw::Endian;
 use enum_map::EnumMap;
+use serde::Serialize;
 
 use crate::{
     format::{Format, Settings as FormatSettings},
@@ -27,7 +28,8 @@ use crate::{
 
 /// Whether to show variable or value labels or the underlying value or variable
 /// name.
-#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
 pub enum Show {
     /// Value (or variable name) only.
     Value,
index 01913dfd0fccc57975be71081aea97b4486d340b..738732dff13c2ae7c1cd5c5b5f16b84a1d9ca8cb 100644 (file)
@@ -26,7 +26,7 @@ use std::{
 
 use encoding_rs::{Encoding, UTF_8};
 use num::integer::div_ceil;
-use serde::Serialize;
+use serde::{ser::SerializeSeq, Serialize};
 use thiserror::Error as ThisError;
 use unicase::UniCase;
 
@@ -580,7 +580,7 @@ impl HasIdentifier for Variable {
 ///
 /// For example, 1 => strongly disagree, 2 => disagree, 3 => neither agree nor
 /// disagree, ...
-#[derive(Clone, Default, PartialEq, Eq, Serialize)]
+#[derive(Clone, Default, PartialEq, Eq)]
 pub struct ValueLabels(pub HashMap<Datum<ByteString>, String>);
 
 impl ValueLabels {
@@ -625,6 +625,19 @@ impl ValueLabels {
     }
 }
 
+impl Serialize for ValueLabels {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut map = serializer.serialize_seq(Some(self.0.len()))?;
+        for tuple in &self.0 {
+            map.serialize_element(&tuple)?;
+        }
+        map.end()
+    }
+}
+
 impl Debug for ValueLabels {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         self.0.fmt(f)