"memchr",
]
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.101",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.101",
+]
+
[[package]]
name = "dashmap"
version = "5.5.3"
"syn 2.0.101",
]
+[[package]]
+name = "enumset"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634"
+dependencies = [
+ "enumset_derive",
+]
+
+[[package]]
+name = "enumset_derive"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.101",
+]
+
[[package]]
name = "env_filter"
version = "0.1.3"
"miniz_oxide",
]
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
[[package]]
name = "foldhash"
version = "0.1.5"
"zerovec",
]
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
[[package]]
name = "idna"
version = "1.0.3"
"encoding_rs",
"enum-iterator",
"enum-map",
+ "enumset",
"flagset",
"flate2",
"hashbrown 0.15.5",
- [Inspecting System Files](invoking/pspp-show.md)
- [Inspecting Portable Files](invoking/pspp-show-por.md)
- [Inspecting SPSS/PC+ Files](invoking/pspp-show-pc.md)
+ - [Inspecting SPSS Viewer Files](invoking/pspp-show-spv.md)
- [Decrypting Files](invoking/pspp-decrypt.md)
- [Output Driver Configuration](invoking/output.md)
--- /dev/null
+# Inspecting SPSS Viewer Files
+
+The `pspp show-spv` command reads SPSS Viewer (SPV) files, which
+usually have `.sav` extension, and produces a report. The basic
+syntax is:
+
+```
+pspp show-spv <MODE> <INPUT> [OUTPUT]
+```
+
+where `<MODE>` is a mode of operation (see below) and `<INPUT>` is the
+SPV file to read, and `[OUTPUT]` is the output file name. If
+`[OUTPUT]` is omitted, output is written to the terminal.
+
+The following `<MODE>`s are accepted:
+
+* `dir`: Outputs a table of contents for the SPV file, listing every
+ selected object, which by default is every object except for hidden
+ ones.
+
+ The following additional option for `dir` is intended mainly for use
+ by PSPP developers:
+
+ - `--member-names`: Also show the names of the ZIP file members
+ associated with each object.
+
+* `get-table-look`: Extracts the TableLook from the first table in the
+ selected objects and outputs it in TableLook XML format. The output
+ file should have an `.stt` extension.
+
+ Use `-` for `<INPUT>` to instead write the default TableLook.
+
+* `convert-table-look`: Reads an `.stt` or `.tlo` TableLook file as
+ `<INPUT>` and outputs it in TableLook XML format. The output file
+ should have an `.stt` extension.
+
+ This is useful for converting a TableLook `.tlo` file from SPSS 15
+ or earlier into the newer `.stt` format.
+
+## Input Selection Options
+
+The `dir` and `convert` commands, by default, operate on all of the
+objects in the source SPV file, except for objects that are not visible
+in the output viewer window. The user may specify these options to
+select a subset of the input objects. When multiple options are used,
+only objects that satisfy all of them are selected:
+
+* `--select=[^]CLASS...`
+ Include only objects of the given `CLASS`; with leading `^`, include
+ only objects not in the class. Use commas to separate multiple
+ classes. The supported classes are:
+
+ * `charts`
+ * `headings`
+ * `logs`
+ * `models`
+ * `tables`
+ * `texts`
+ * `trees`
+ * `warnings`
+ * `outlineheaders`
+ * `pagetitle`
+ * `notes`
+ * `unknown`
+ * `other`
+
+* `--commands=[^]COMMAND...`
+ `--subtypes=[^]SUBTYPE...`
+ `--labels=[^]LABEL...`
+ Include only objects with the specified `COMMAND`, `SUBTYPE`, or
+ `LABEL`. With a leading `^`, include only the objects that do not
+ match. Multiple values may be specified separated by commas. An
+ asterisk at the end of a value acts as a wildcard.
+
+ The `--command` option matches command identifiers, case
+ insensitively. All of the objects produced by a single command use
+ the same, unique command identifier. Command identifiers are
+ always in English regardless of the language used for output. They
+ often differ from the command name in PSPP syntax. Use the
+ `pspp-output` program's `dir` command to print command identifiers
+ in particular output.
+
+ The `--subtypes` option matches particular tables within a command,
+ case insensitively. Subtypes are not necessarily unique: two
+ commands that produce similar output tables may use the same
+ subtype. Subtypes are always in English and `dir` will print them.
+
+ The `--labels` option matches the labels in table output (that is,
+ the table titles). Labels are affected by the output language,
+ variable names and labels, split file settings, and other factors.
+
+* `--nth-commands=N...`
+ Include only objects from the `N`th command that matches `--command`
+ (or the `N`th command overall if `--command` is not specified),
+ where `N` is 1 for the first command, 2 for the second, and so on.
+
+* `--instances=INSTANCE...`
+ Include the specified `INSTANCE` of an object that matches the other
+ criteria within a single command. `INSTANCE` may be a number (1 for
+ the first instance, 2 for the second, and so on) or `last` for the
+ last instance.
+
+* `--show-hidden`
+ Include hidden output objects in the output. By default, they are
+ excluded.
+
+* `--or`
+ Separates two sets of selection options. Objects selected by
+ either set of options are included in the output.
+
+The following additional input selection options are intended mainly
+for use by PSPP developers:
+
+* `--errors`
+ Include only objects that cause an error when read. With the
+ `convert` command, this is most useful in conjunction with the
+ `--force` option.
+
+* `--members=MEMBER...`
+ Include only the objects that include a listed Zip file `MEMBER`.
+ More than one name may be included, comma-separated. The members
+ in an SPV file may be listed with the `dir` command by adding the
+ `--show-members` option or with the `zipinfo` program included with
+ many operating systems. Error messages that `pspp-output` prints
+ when it reads SPV files also often include member names.
--- /dev/null
+use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
+
+#[cfg(feature = "serde")]
+use crate::PaperSize;
+use crate::{Length, Unit};
+
+impl<'de> Deserialize<'de> for Length {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ String::deserialize(deserializer)?
+ .parse()
+ .map_err(D::Error::custom)
+ }
+}
+
+impl Serialize for Length {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ self.to_string().serialize(serializer)
+ }
+}
+
+impl Serialize for PaperSize {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ self.to_string().serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for PaperSize {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ String::deserialize(deserializer)?
+ .parse()
+ .map_err(D::Error::custom)
+ }
+}
+
+impl Serialize for Unit {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ self.to_string().serialize(serializer)
+ }
+}
+
+impl<'de> Deserialize<'de> for Unit {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ String::deserialize(deserializer)?
+ .parse()
+ .map_err(D::Error::custom)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{Length, PaperSize, Unit};
+
+ #[test]
+ fn unit() {
+ assert_eq!(serde_json::to_string(&Unit::Point).unwrap(), "\"pt\"");
+ assert_eq!(serde_json::from_str::<Unit>("\"pt\"").unwrap(), Unit::Point)
+ }
+
+ #[test]
+ fn length() {
+ assert_eq!(
+ serde_json::to_string(&Length::new(123.0, Unit::Millimeter)).unwrap(),
+ "\"123mm\""
+ );
+ assert_eq!(
+ serde_json::from_str::<Length>("\"123mm\"").unwrap(),
+ Length::new(123.0, Unit::Millimeter)
+ )
+ }
+
+ #[test]
+ fn paper_size() {
+ assert_eq!(
+ serde_json::to_string(&PaperSize::new(8.5, 11.0, Unit::Inch)).unwrap(),
+ "\"8.5x11in\""
+ );
+ assert_eq!(
+ serde_json::from_str::<PaperSize>("\"8.5x11in\"").unwrap(),
+ PaperSize::new(8.5, 11.0, Unit::Inch)
+ )
+ }
+}
serde_path_to_error = "0.1.20"
html_parser = "0.7.0"
paper-sizes = { path = "../paper-sizes", features = ["serde"] }
+enumset = "1.1.10"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.48.0", features = ["Win32_Globalization"] }
use encoding_rs::Encoding;
use thiserror::Error as ThisError;
-use crate::{convert::Convert, decrypt::Decrypt, show::Show, show_pc::ShowPc, show_por::ShowPor};
+use crate::{
+ convert::Convert, decrypt::Decrypt, show::Show, show_pc::ShowPc, show_por::ShowPor,
+ show_spv::ShowSpv,
+};
mod convert;
mod decrypt;
mod show;
mod show_pc;
mod show_por;
+mod show_spv;
/// PSPP, a program for statistical analysis of sampled data.
#[derive(Parser, Debug)]
Show(Show),
ShowPor(ShowPor),
ShowPc(ShowPc),
+ ShowSpv(ShowSpv),
}
impl Command {
Command::Show(show) => show.run(),
Command::ShowPor(show_por) => show_por.run(),
Command::ShowPc(show_pc) => show_pc.run(),
+ Command::ShowSpv(show_spv) => show_spv.run(),
}
}
}
#![allow(dead_code)]
use std::{
borrow::Cow,
+ str::FromStr,
sync::{Arc, OnceLock},
};
use enum_map::EnumMap;
+use enumset::{EnumSet, EnumSetType};
use pivot::PivotTable;
use serde::Serialize;
use crate::{
- message::Diagnostic,
+ message::{Diagnostic, Severity},
output::pivot::{Axis3, BorderStyle, Dimension, Group, Look},
};
/// Item details.
details: Details,
+
+ /// If the item was read from an SPV file, this is additional information
+ /// related to that file.
+ #[serde(skip_serializing)]
+ spv_info: Option<Box<SpvInfo>>,
}
impl Item {
command_name: details.command_name().cloned(),
show: true,
details,
+ spv_info: None,
}
}
..self
}
}
+
+ pub fn with_spv_info(self, spv_info: SpvInfo) -> Self {
+ Self {
+ spv_info: Some(Box::new(spv_info)),
+ ..self
+ }
+ }
}
impl<T> From<T> for Item
}
}
}
+
+/// Information for output items that were read from an SPV file.
+///
+/// This is for debugging and troubleshooting purposes.
+#[derive(Debug)]
+pub struct SpvInfo {
+ /// True if there was an error reading the output item (e.g. because of
+ /// corruption or because PSPP doesn't understand the format).
+ pub error: bool,
+
+ /// Name of structure member in ZIP file.
+ pub structure_member: String,
+
+ /// Additional members based on item type.
+ pub members: Option<SpvMembers>,
+}
+
+impl SpvInfo {
+ pub fn new(structure_member: &str) -> Self {
+ Self {
+ error: false,
+ structure_member: structure_member.into(),
+ members: None,
+ }
+ }
+
+ pub fn with_members(self, members: SpvMembers) -> Self {
+ Self {
+ members: Some(members),
+ ..self
+ }
+ }
+}
+
+/// Identifies ZIP file members for one kind of output item in an SPV file.
+#[derive(Debug)]
+pub enum SpvMembers {
+ /// Light detail members.
+ Light(
+ /// `.bin` member name.
+ String,
+ ),
+ /// Legacy detail members.
+ Legacy {
+ /// `.xml` member name.
+ xml: String,
+ /// `.bin` member name.
+ binary: String,
+ },
+ /// Image members.
+ Image(
+ /// `.png` file.
+ String,
+ ),
+}
+
+/// Classifications for output items. These only roughly correspond to the
+/// output item types; for example, "warnings" are a subset of text items.
+#[derive(Debug, EnumSetType)]
+pub enum Class {
+ Charts,
+ Headings,
+ Logs,
+ Models,
+ Tables,
+ Texts,
+ Trees,
+ Warnings,
+ OutlineHeaders,
+ PageTitle,
+ Notes,
+ Unknown,
+ Other,
+}
+
+impl FromStr for Class {
+ type Err = ();
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "charts" => Ok(Self::Charts),
+ "headings" => Ok(Self::Headings),
+ "logs" => Ok(Self::Logs),
+ "models" => Ok(Self::Models),
+ "tables" => Ok(Self::Tables),
+ "texts" => Ok(Self::Texts),
+ "trees" => Ok(Self::Trees),
+ "warnings" => Ok(Self::Warnings),
+ "outlineheaders" => Ok(Self::OutlineHeaders),
+ "pagetitle" => Ok(Self::PageTitle),
+ "notes" => Ok(Self::Notes),
+ "unknown" => Ok(Self::Unknown),
+ "other" => Ok(Self::Other),
+ _ => Err(()),
+ }
+ }
+}
+
+impl Item {
+ fn class(&self) -> Class {
+ let label = self.label.as_ref().map(|s| s.as_str());
+ match &self.details {
+ Details::Chart => Class::Charts,
+ Details::Image => Class::Other,
+ Details::Group(_) => Class::OutlineHeaders,
+ Details::Message(diagnostic) => match diagnostic.severity {
+ Severity::Note => Class::Notes,
+ Severity::Error | Severity::Warning => Class::Warnings,
+ },
+ Details::PageBreak => Class::Other,
+ Details::Table(_) => match label {
+ Some("Warnings") => Class::Warnings,
+ Some("Notes") => Class::Notes,
+ _ => Class::Tables,
+ },
+ Details::Text(_) => match label {
+ Some("Title") => Class::Headings,
+ Some("Log") => Class::Logs,
+ Some("Page Title") => Class::PageTitle,
+ _ => Class::Texts,
+ },
+ }
+ }
+}
+
+pub struct Match {
+ /// - `None`: Include all objects.
+ /// - `Some(false)`: Include only visible objects.
+ /// - `Some(true)`: Include only hidden objects.
+ hidden: Option<bool>,
+
+ /// - `None`: Include all objects.
+ /// - `Some(false)`: Include only objects with no error on loading.
+ /// - `Some(true)`: Include only objects with an error on loading.
+ error: Option<bool>,
+
+ /// Classes to include.
+ classes: EnumSet<Class>,
+
+ /// Command names to match.
+ commands: StringMatch,
+
+ /// Subtypes to match.
+ subtypes: StringMatch,
+
+ /// Labels to match.
+ labels: StringMatch,
+
+ /// Include objects under commands with indexes listed in COMMANDS. Indexes
+ /// are 1-based. Everything is included if N_COMMANDS is 0.
+ nth_commands: Vec<usize>,
+
+ /// Include XML and binary member names that match (except that everything
+ /// is included by default if empty).
+ members: Vec<String>,
+
+ /// Include the objects with indexes listed in INSTANCES within each of the
+ /// commands that are included. Indexes are 1-based. Index -1 means the
+ /// last object within a command.
+ instances: Vec<isize>,
+}
+
+pub enum StringMatch {
+ Include(Vec<String>),
+ Exclude(Vec<String>),
+}
+
+impl Default for StringMatch {
+ fn default() -> Self {
+ // Include everything, by excluding nothing.
+ Self::Exclude(Vec::new())
+ }
+}
+
+impl StringMatch {
+ fn matches(&self, s: &str) -> bool {
+ fn inner(items: &[String], s: &str) -> bool {
+ items.iter().any(|item| match item.strip_suffix('*') {
+ Some(prefix) => s.starts_with(prefix),
+ None => s == item,
+ })
+ }
+
+ match self {
+ StringMatch::Include(items) => inner(items, s),
+ StringMatch::Exclude(items) => !inner(items, s),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::output::StringMatch;
+
+ #[test]
+ fn string_matches() {
+ assert!(StringMatch::default().matches("xyzzy"));
+ assert!(StringMatch::Exclude(Vec::new()).matches("xyzzy"));
+ assert!(!StringMatch::Include(Vec::new()).matches("xyzzy"));
+
+ let m = StringMatch::Include(vec![String::from("xyz"), String::from("abc*")]);
+ assert!(m.matches("xyz"));
+ assert!(!m.matches("xyzzy"));
+ assert!(!m.matches("ab"));
+ assert!(m.matches("abc"));
+ assert!(m.matches("abcd"));
+
+ let m = StringMatch::Exclude(vec![String::from("xyz"), String::from("abc*")]);
+ assert!(!m.matches("xyz"));
+ assert!(m.matches("xyzzy"));
+ assert!(m.matches("ab"));
+ assert!(!m.matches("abc"));
+ assert!(!m.matches("abcd"));
+ }
+}
}
}
-fn xr_set_color(context: &Context, color: &Color) {
+fn xr_set_color(context: &Context, color: Color) {
fn as_frac(x: u8) -> f64 {
x as f64 / 255.0
}
self.context.move_to(xr_to_pt(x0), xr_to_pt(y0));
self.context.line_to(xr_to_pt(x1), xr_to_pt(y1));
if !self.style.use_system_colors {
- xr_set_color(self.context, &color);
+ xr_set_color(self.context, color);
}
if stroke == Stroke::Dashed {
self.context.set_dash(&[2.0], 0.0);
fn draw_cell(
&mut self,
draw_cell: &DrawCell,
- alternate_row: bool,
mut bb: Rect2,
valign_offset: usize,
spill: EnumMap<Axis2, [usize; 2]>,
clip: &Rect2,
) {
- let fg = &draw_cell.font_style.fg[alternate_row as usize];
- let bg = &draw_cell.font_style.bg[alternate_row as usize];
-
+ let bg = draw_cell.font_style.bg;
if (bg.r != 255 || bg.g != 255 || bg.b != 255) && bg.alpha != 0 {
self.context.save().unwrap();
let bg_clip = Rect2::from_fn(|axis| {
}
if !self.style.use_system_colors {
- xr_set_color(self.context, fg);
+ xr_set_color(self.context, draw_cell.font_style.fg);
}
self.context.save().unwrap();
self.put_cell(
DrawCell::new(cell.inner(), &title),
Rect2::new(0..1, 0..1),
- false,
"caption",
None,
)?;
self.put_cell(
DrawCell::new(cell.inner(), &layers),
Rect2::new(0..output.body.n[Axis2::X], 0..1),
- false,
"td",
None,
)?;
if cell.is_top_left() {
let is_header = x < output.body.h[Axis2::X] || y < output.body.h[Axis2::Y];
let tag = if is_header { "th" } else { "td" };
- let alternate_row = y
- .checked_sub(output.body.h[Axis2::Y])
- .is_some_and(|y| y % 2 == 1);
self.put_cell(
DrawCell::new(cell.inner(), &output.body),
cell.rect(),
- alternate_row,
tag,
Some(&output.body),
)?;
self.put_cell(
DrawCell::new(caption.get(Coord2::new(0, 0)).inner(), &caption),
Rect2::new(0..output.body.n[Axis2::X], 0..1),
- false,
"td",
None,
)?;
self.put_cell(
DrawCell::new(cell.inner(), &footnotes),
Rect2::new(0..output.body.n[Axis2::X], 0..1),
- false,
"td",
None,
)?;
&mut self,
cell: DrawCell<'_>,
rect: Rect2,
- alternate_row: bool,
tag: &str,
table: Option<&Table>,
) -> std::io::Result<()> {
if let Some(vert_align) = vert_align {
write!(&mut style, "vertical-align: {vert_align}; ").unwrap();
}
- let bg = cell.font_style.bg[alternate_row as usize];
+ let bg = cell.font_style.bg;
if bg != Color::WHITE {
write!(&mut style, "background: {}; ", bg.display_css()).unwrap();
}
- let fg = cell.font_style.fg[alternate_row as usize];
+ let fg = cell.font_style.fg;
if fg != Color::BLACK {
write!(&mut style, "color: {}; ", fg.display_css()).unwrap();
}
Area, AreaStyle, Axis2, Axis3, Border, BorderStyle, BoxBorder, Category, CellStyle,
Color, Dimension, FontStyle, Footnote, FootnoteMarkerPosition, FootnoteMarkerType,
Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Leaf, PivotTable,
- RowColBorder, Stroke, Value, ValueInner, ValueStyle, VertAlign,
+ RowColBorder, RowParity, Stroke, Value, ValueInner, ValueStyle, VertAlign,
},
},
settings::Show,
Area::Corner,
Area::Labels(Axis2::X),
Area::Labels(Axis2::Y),
- Area::Data,
+ Area::Data(RowParity::Even),
Area::Layers,
];
for (index, area) in SPV_AREAS.into_iter().enumerate() {
- self.style.look.areas[area].write_le_args(writer, index)?;
+ let odd_data_style = if let Area::Data(_) = area {
+ Some(&self.style.look.areas[Area::Data(RowParity::Odd)])
+ } else {
+ None
+ };
+ self.style.look.areas[area].write_le_args(writer, (index, odd_data_style))?;
}
// Borders.
}
impl BinWrite for AreaStyle {
- type Args<'a> = usize;
+ type Args<'a> = (usize, Option<&'a AreaStyle>);
fn write_options<W: Write + Seek>(
&self,
writer: &mut W,
endian: Endian,
- index: usize,
+ (index, odd_data_style): (usize, Option<&AreaStyle>),
) -> binrw::BinResult<()> {
let typeface = if self.font_style.font.is_empty() {
"SansSerif"
.horz_align
.map_or(64173, |horz_align| horz_align.as_spv(61453)),
self.cell_style.vert_align.as_spv(),
- self.font_style.fg[0],
- self.font_style.bg[0],
+ self.font_style.fg,
+ self.font_style.bg,
)
.write_options(writer, endian, ())?;
- if self.font_style.fg[0] != self.font_style.fg[1]
- || self.font_style.bg[0] != self.font_style.bg[1]
- {
- (SpvBool(true), self.font_style.fg[1], self.font_style.bg[1]).write_options(
- writer,
- endian,
- (),
- )?;
+ let alt_fg = odd_data_style.map_or(self.font_style.fg, |style| style.font_style.fg);
+ let alt_bg = odd_data_style.map_or(self.font_style.bg, |style| style.font_style.bg);
+ if self.font_style.fg != alt_fg || self.font_style.bg != alt_bg {
+ (SpvBool(true), alt_fg, alt_bg).write_options(writer, endian, ())?;
} else {
(SpvBool(false), SpvString(""), SpvString("")).write_options(writer, endian, ())?;
}
SpvBool(self.italic),
SpvBool(self.underline),
SpvBool(true),
- self.fg[0],
- self.bg[0],
+ self.fg,
+ self.bg,
SpvString(typeface),
(self.size as f64 * 1.33).ceil() as u8,
)
fn draw_cell(
&mut self,
cell: &DrawCell,
- _alternate_row: bool,
bb: Rect2,
valign_offset: usize,
_spill: EnumMap<Axis2, [usize; 2]>,
mod tlo;
/// Areas of a pivot table for styling purposes.
-#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
pub enum Area {
+ /// Title.
Title,
+
+ /// Caption.
Caption,
/// Footnotes,
// Top-left corner.
Corner,
- /// Labels for columns ([Axis2::X]) and rows ([Axis2::Y]).
- Labels(Axis2),
-
- #[default]
- Data,
+ /// Labels.
+ Labels(
+ /// - [Axis2::X]: Column labels, along the top of the table.
+ /// - [Axis2::Y]: Row labels, along the left side of the table.
+ Axis2,
+ ),
+
+ /// Data cells.
+ Data(
+ /// This allows styling for even rows and odd rows to differ
+ /// arbitrarily, but the SPV file format only distinguishes foreground
+ /// and background colors, so any other differences will be lost upon
+ /// save.
+ RowParity,
+ ),
/// Layer indication.
Layers,
}
+impl Default for Area {
+ fn default() -> Self {
+ Self::Data(RowParity::default())
+ }
+}
+
impl Display for Area {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Area::Footer => write!(f, "footer"),
Area::Corner => write!(f, "corner"),
Area::Labels(axis2) => write!(f, "labels({axis2})"),
- Area::Data => write!(f, "data"),
+ Area::Data(row) => write!(f, "data({row})"),
Area::Layers => write!(f, "layers"),
}
}
Area::Corner => (Some(Left), Bottom, [8, 11], [1, 1]),
Area::Labels(Axis2::X) => (Some(Center), Top, [8, 11], [1, 3]),
Area::Labels(Axis2::Y) => (Some(Left), Top, [8, 11], [1, 3]),
- Area::Data => (None, Top, [8, 11], [1, 1]),
+ Area::Data(_) => (None, Top, [8, 11], [1, 1]),
Area::Layers => (Some(Left), Bottom, [8, 11], [1, 3]),
};
CellStyle {
}
}
+/// Distinguishes [Area::Data] for even-numbered and odd-numbered rows.
+#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
+pub enum RowParity {
+ /// Even-numbered rows.
+ ///
+ /// The first row is row 0, hence even.
+ #[default]
+ Even,
+ /// Odd-numbered rows.
+ Odd,
+}
+
+impl From<usize> for RowParity {
+ fn from(value: usize) -> Self {
+ if value % 2 == 1 {
+ Self::Odd
+ } else {
+ Self::Even
+ }
+ }
+}
+
+impl Display for RowParity {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RowParity::Even => write!(f, "even"),
+ RowParity::Odd => write!(f, "odd"),
+ }
+ }
+}
+
/// Table borders for styling purposes.
#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
pub enum Border {
pub underline: bool,
pub markup: bool,
pub font: String,
-
- /// `fg[0]` is the usual foreground color.
- ///
- /// `fg[1]` is used only in [Area::Data] for odd-numbered rows.
- pub fg: [Color; 2],
-
- /// `bg[0]` is the usual background color.
- ///
- /// `bg[1]` is used only in [Area::Data] for odd-numbered rows.
- pub bg: [Color; 2],
+ pub fg: Color,
+ pub bg: Color,
/// In 1/72" units.
pub size: i32,
underline: false,
markup: false,
font: String::from("Sans Serif"),
- fg: [Color::BLACK; 2],
- bg: [Color::WHITE; 2],
+ fg: Color::BLACK,
+ bg: Color::WHITE,
size: 9,
}
}
}
}
pub fn with_fg(self, fg: Color) -> Self {
- Self {
- fg: [fg, fg],
- ..self
- }
- }
- pub fn with_alternate_fg(self, fg: [Color; 2]) -> Self {
Self { fg, ..self }
}
pub fn with_bg(self, fg: Color) -> Self {
- Self {
- fg: [fg, fg],
- ..self
- }
- }
- pub fn with_alternate_bg(self, bg: [Color; 2]) -> Self {
- Self { bg, ..self }
+ Self { fg, ..self }
}
}
format::Decimal,
output::pivot::{
Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, Color, FootnoteMarkerPosition,
- FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, VertAlign,
+ FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, RowParity,
+ VertAlign,
},
};
use thiserror::Error as ThisError;
footnote_marker_type: table_properties.footnote_properties.marker_type,
footnote_marker_position: table_properties.footnote_properties.marker_position,
areas: enum_map! {
- Area::Title => table_properties.cell_format_properties.title.style.as_area_style(),
- Area::Caption => table_properties.cell_format_properties.caption.style.as_area_style(),
- Area::Footer => table_properties.cell_format_properties.footnotes.style.as_area_style(),
- Area::Corner => table_properties.cell_format_properties.corner_labels.style.as_area_style(),
- Area::Labels(Axis2::X) => table_properties.cell_format_properties.column_labels.style.as_area_style(),
- Area::Labels(Axis2::Y) => table_properties.cell_format_properties.row_labels.style.as_area_style(),
- Area::Data => table_properties.cell_format_properties.data.style.as_area_style(),
- Area::Layers => table_properties.cell_format_properties.layers.style.as_area_style(),
+ Area::Title => table_properties.cell_format_properties.title.style.as_area_style(RowParity::Even),
+ Area::Caption => table_properties.cell_format_properties.caption.style.as_area_style(RowParity::Even),
+ Area::Footer => table_properties.cell_format_properties.footnotes.style.as_area_style(RowParity::Even),
+ Area::Corner => table_properties.cell_format_properties.corner_labels.style.as_area_style(RowParity::Even),
+ Area::Labels(Axis2::X) => table_properties.cell_format_properties.column_labels.style.as_area_style(RowParity::Even),
+ Area::Labels(Axis2::Y) => table_properties.cell_format_properties.row_labels.style.as_area_style(RowParity::Even),
+ Area::Data(row) => table_properties.cell_format_properties.data.style.as_area_style(row),
+ Area::Layers => table_properties.cell_format_properties.layers.style.as_area_style(RowParity::Even),
},
borders: enum_map! {
Border::Title => table_properties.border_properties.title_layer_separator,
}
impl CellStyle {
- fn as_area_style(&self) -> AreaStyle {
+ fn as_area_style(&self, data_row: RowParity) -> AreaStyle {
AreaStyle {
cell_style: super::CellStyle {
horz_align: match self.text_alignment {
underline: self.font_underline == FontUnderline::Underline,
markup: false,
font: self.font_family.clone(),
- fg: [
- self.color.unwrap_or(Color::BLACK),
- self.alternating_text_color.unwrap_or(Color::BLACK),
- ],
- bg: [
- self.color2.unwrap_or(Color::BLACK),
- self.alternating_color.unwrap_or(Color::BLACK),
- ],
+ fg: match data_row {
+ RowParity::Even => self.color.unwrap_or(Color::BLACK),
+ RowParity::Odd => self.alternating_text_color.unwrap_or(Color::BLACK),
+ },
+ bg: match data_row {
+ RowParity::Even => self.color2.unwrap_or(Color::BLACK),
+ RowParity::Odd => self.alternating_color.unwrap_or(Color::BLACK),
+ },
size: self.font_size.as_pt_i32(),
},
}
use itertools::Itertools;
use crate::output::{
- pivot::{HeadingRegion, LabelPosition, Path},
+ pivot::{HeadingRegion, LabelPosition, Path, RowParity},
table::{CellInner, Table},
};
let value = self.get(&*data_indexes);
body.put(
Rect2::new(x..x + 1, y..y + 1),
- CellInner {
- rotate: false,
- area: Area::Data,
- value: Box::new(value.cloned().unwrap_or_default()),
- },
+ CellInner::new(
+ Area::Data(RowParity::from(y - stub[Axis2::Y])),
+ Box::new(value.cloned().unwrap_or_default()),
+ ),
);
}
}
for (Range { start: x1, end: x2 }, name) in categories.iter().cloned() {
let y1 = v_ofs + row;
let y2 = y1 + 1;
+
+ let is_outer_row = y1 == 0;
+ let is_inner_row = y2 == self.height;
+ let rotate =
+ (rotate_inner_labels && is_inner_row) || (rotate_outer_labels && is_outer_row);
table.put(
Rect2::for_ranges((h, x1 + h_ofs..x2 + h_ofs), y1..y2),
- CellInner {
- rotate: {
- let is_outer_row = y1 == 0;
- let is_inner_row = y2 == self.height;
- (rotate_inner_labels && is_inner_row)
- || (rotate_outer_labels && is_outer_row)
- },
- area: Area::Labels(h),
- value: Box::new(name.clone()),
- },
+ CellInner::new(Area::Labels(h), Box::new(name.clone())).with_rotate(rotate),
);
// Draw all the vertical lines in our running example, other
if dimension_label_position == LabelPosition::Corner {
table.put(
Rect2::new(v_ofs..v_ofs + 1, 0..h_ofs),
- CellInner {
- rotate: false,
- area: Area::Corner,
- value: self.dimension.root.name.clone(),
- },
+ CellInner::new(Area::Corner, self.dimension.root.name.clone()),
);
}
}
Area::Corner => (&look.pv_text_style.corner).into(),
Area::Labels(Axis2::X) => (&look.pv_text_style.column_labels).into(),
Area::Labels(Axis2::Y) => (&look.pv_text_style.row_labels).into(),
- Area::Data => (&look.pv_text_style.data).into(),
+ Area::Data(_) => (&look.pv_text_style.data).into(),
Area::Layers => (&look.pv_text_style.layers).into(),
},
borders: enum_map! {
underline: style.underline,
markup: false,
font: style.font_name.string.clone(),
- fg: {
- let fg = style.text_color;
- [fg, fg]
- },
- bg: [bg, bg],
+ fg: style.text_color,
+ bg,
size: -style.font_size * 3 / 4,
},
}
fn draw_cell(
&mut self,
draw_cell: &DrawCell,
- alternate_row: bool,
bb: Rect2,
valign_offset: usize,
spill: EnumMap<Axis2, [usize; 2]>,
usize::saturating_sub(bb[Y].len(), height)
}
fn draw_cell(&self, device: &mut dyn Device, ofs: Coord2, cell: &RenderCell) {
- use Axis2::*;
-
let mut bb = Rect2::from_fn(|a| {
self.cp[a][cell.rect[a].start * 2 + 1]..self.cp[a][cell.rect[a].end * 2]
})
bb.clone()
};
- // Header rows are never alternate rows.
- let alternate_row =
- usize::checked_sub(cell.rect[Y].start, self.h[Y]).is_some_and(|row| row % 2 == 1);
-
let draw_cell = DrawCell::new(cell.content.inner(), &self.table);
let valign_offset = match draw_cell.cell_style.vert_align {
VertAlign::Top => 0,
VertAlign::Middle => self.extra_height(device, &bb, &draw_cell) / 2,
VertAlign::Bottom => self.extra_height(device, &bb, &draw_cell),
};
- device.draw_cell(&draw_cell, alternate_row, bb, valign_offset, spill, &clip)
+ device.draw_cell(&draw_cell, bb, valign_offset, spill, &clip)
}
}
use zip::{ZipArchive, result::ZipError};
use crate::output::{
- Details, Item, Text,
+ Details, Item, SpvInfo, SpvMembers, Text,
page::PageSetup,
pivot::{TableProperties, Value},
spv::light::{LightError, LightTable},
Self::from_spv_reader(File::open(path.as_ref())?)
}
- fn from_spv_reader<R>(reader: R) -> Result<(Self, Option<PageSetup>), Error>
+ fn from_spv_zip_archive<R>(
+ archive: &mut ZipArchive<R>,
+ ) -> Result<(Self, Option<PageSetup>), Error>
where
R: Read + Seek,
{
- // Open archive.
- let mut archive = ZipArchive::new(reader).map_err(|error| match error {
- ZipError::InvalidArchive(_) => Error::NotSpv,
- other => other.into(),
- })?;
-
// Check manifest.
let mut file = archive
.by_name("META-INF/MANIFEST.MF")
let mut items = Vec::new();
let mut page_setup = None;
for i in 0..archive.len() {
- let name = archive.name_for_index(i).unwrap();
+ let name = String::from(archive.name_for_index(i).unwrap());
if name.starts_with("outputViewer") && name.ends_with(".xml") {
- let name = String::from(name);
- let (mut new_items, ps) = read_heading(&mut archive, i)?;
+ let (mut new_items, ps) = read_heading(archive, i, &name)?;
items.append(&mut new_items);
page_setup = page_setup.or(ps);
}
page_setup,
))
}
+
+ fn from_spv_reader<R>(reader: R) -> Result<(Self, Option<PageSetup>), Error>
+ where
+ R: Read + Seek,
+ {
+ // Open archive.
+ let mut archive = ZipArchive::new(reader).map_err(|error| match error {
+ ZipError::InvalidArchive(_) => Error::NotSpv,
+ other => other.into(),
+ })?;
+ Self::from_spv_zip_archive(&mut archive)
+ }
}
fn read_heading<R>(
archive: &mut ZipArchive<R>,
file_number: usize,
+ structure_member: &str,
) -> Result<(Vec<Item>, Option<PageSetup>), Error>
where
R: Read + Seek,
{
- println!("{}", archive.by_index(file_number)?.name());
+ println!("{structure_member}");
let member = BufReader::new(archive.by_index(file_number)?);
let mut heading: Heading = match serde_path_to_error::deserialize(
&mut quick_xml::de::Deserializer::from_reader(member),
};
dbg!(&heading);
let page_setup = heading.page_setup.take();
- Ok((heading.decode(archive)?, page_setup))
+ Ok((heading.decode(archive, structure_member)?, page_setup))
}
#[derive(Deserialize, Debug)]
}
impl Heading {
- fn decode<R>(self, archive: &mut ZipArchive<R>) -> Result<Vec<Item>, Error>
+ fn decode<R>(
+ self,
+ archive: &mut ZipArchive<R>,
+ structure_member: &str,
+ ) -> Result<Vec<Item>, Error>
where
R: Read + Seek,
{
match child {
HeadingContent::Container(container) => {
if container.page_break_before {
- items.push(Item::new(Details::PageBreak));
+ items.push(
+ Item::new(Details::PageBreak)
+ .with_spv_info(SpvInfo::new(structure_member)),
+ );
}
match container.content {
ContainerContent::Table(table) => {
- items.push(table.decode(archive).unwrap() /* XXX*/);
+ items.push(
+ table.decode(archive, structure_member).unwrap(), /* XXX*/
+ );
}
ContainerContent::Text(container_text) => {
items.push(
Item::new(Details::Text(Box::new(Text::new_log(
container_text.decode(),
))))
- .with_command_name(container_text.command_name),
+ .with_command_name(container_text.command_name)
+ .with_spv_info(SpvInfo::new(structure_member)),
);
}
}
let show = !heading.visibility.is_some();
items.push(
Item::new(Details::Group(
- heading.decode(archive)?.into_iter().map_into().collect(),
+ heading
+ .decode(archive, structure_member)?
+ .into_iter()
+ .map_into()
+ .collect(),
))
- .with_show(show),
+ .with_show(show)
+ .with_spv_info(SpvInfo::new(structure_member)),
);
}
}
}
impl Table {
- fn decode<R>(&self, archive: &mut ZipArchive<R>) -> Result<Item, Error>
+ fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
where
R: Read + Seek,
{
let table = LightTable::read(&mut Cursor::new(data))?;
let pivot_table = table.decode()?;
println!("{}", &pivot_table);
- Ok(Item::new(Details::Table(Box::new(pivot_table))))
+ Ok(
+ Item::new(Details::Table(Box::new(pivot_table))).with_spv_info(
+ SpvInfo::new(structure_member)
+ .with_members(SpvMembers::Light(self.table_structure.data_path.clone())),
+ ),
+ )
} else {
todo!()
}
match key.as_ref() {
"color" => {
if let Ok(color) = value.parse() {
- self.fg = [color; 2];
+ self.fg = color;
}
}
"font-weight" => self.bold = value == "bold",
if self.size != base.size {
settings.push(format!("font-size: {}", self.size as i64 * 4 / 3));
}
- if self.fg[0] != base.fg[0] {
- settings.push(format!("color: {}", self.fg[0].display_css()));
+ if self.fg != base.fg {
+ settings.push(format!("color: {}", self.fg.display_css()));
}
settings
.is_empty()
assert_eq!(FontStyle::from_css(""), FontStyle::default());
assert_eq!(
FontStyle::from_css(r#"p{color:ff0000}"#),
- FontStyle {
- fg: [Color::RED; 2],
- ..FontStyle::default()
- }
+ FontStyle::default().with_fg(Color::RED)
);
assert_eq!(
FontStyle::from_css("p {font-weight: bold; text-decoration: underline}"),
- FontStyle {
- bold: true,
- underline: true,
- ..FontStyle::default()
- }
+ FontStyle::default().with_bold(true).with_underline(true)
);
assert_eq!(
FontStyle::from_css("p {font-family: Monospace}"),
- FontStyle {
- font: String::from("Monospace"),
- ..FontStyle::default()
- }
+ FontStyle::default().with_font("Monospace")
);
assert_eq!(
FontStyle::from_css("p {font-size: 24}"),
- FontStyle {
- size: 18,
- ..FontStyle::default()
- }
+ FontStyle::default().with_size(18)
);
assert_eq!(
FontStyle::from_css(
"<!--color: red; font-weight: bold; font-style: italic; text-decoration: underline; font-family: Serif-->"
),
- FontStyle {
- fg: [Color::RED, Color::RED],
- bold: true,
- italic: true,
- underline: true,
- font: String::from("Serif"),
- ..FontStyle::default()
- }
+ FontStyle::default()
+ .with_fg(Color::RED)
+ .with_bold(true)
+ .with_italic(true)
+ .with_underline(true)
+ .with_font("Serif")
);
}
output::pivot::{
self, AreaStyle, Axis2, Axis3, BoxBorder, Color, FootnoteMarkerPosition,
FootnoteMarkerType, Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Look,
- PivotTable, PivotTableMetadata, PivotTableStyle, PrecomputedIndex, RowColBorder,
+ PivotTable, PivotTableMetadata, PivotTableStyle, PrecomputedIndex, RowColBorder, RowParity,
StringValue, Stroke, TemplateValue, ValueStyle, VariableValue, VertAlign, parse_bool,
},
settings::Show,
pivot::Area::Corner => 3,
pivot::Area::Labels(Axis2::X) => 4,
pivot::Area::Labels(Axis2::Y) => 5,
- pivot::Area::Data => 6,
+ pivot::Area::Data(_) => 6,
pivot::Area::Layers => 7,
};
- self.areas[index].decode(encoding)
+ let data_row = match area {
+ pivot::Area::Data(row) => row,
+ _ => RowParity::default(),
+ };
+ self.areas[index].decode(encoding, data_row)
})
}
}
}
impl Area {
- fn decode(&self, encoding: &'static Encoding) -> AreaStyle {
+ fn decode(&self, encoding: &'static Encoding, data_row: RowParity) -> AreaStyle {
AreaStyle {
cell_style: pivot::CellStyle {
horz_align: match self.halign {
underline: self.underline,
markup: false,
font: self.typeface.decode(encoding),
- fg: [self.fg, if self.alternate { self.alt_fg } else { self.fg }],
- bg: [self.bg, if self.alternate { self.alt_bg } else { self.bg }],
+ fg: match data_row {
+ RowParity::Even => self.fg,
+ RowParity::Odd => self.alt_fg,
+ },
+ bg: match data_row {
+ RowParity::Even => self.bg,
+ RowParity::Odd => self.alt_bg,
+ },
size: (self.size / 1.33) as i32,
},
}
underline: font_style.underline,
markup: false,
font: font_style.typeface.decode(encoding),
- fg: [font_style.fg, font_style.fg],
- bg: [font_style.bg, font_style.bg],
+ fg: font_style.fg,
+ bg: font_style.bg,
size: (font_style.size as i32) * 4 / 3,
});
let cell_style = style_pair
}
}
+ pub fn with_rotate(self, rotate: bool) -> Self {
+ Self { rotate, ..self }
+ }
+
pub fn is_empty(&self) -> bool {
self.value.inner.is_empty()
}
--- /dev/null
+// 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 anyhow::{Result, anyhow};
+use clap::{Args, ValueEnum};
+use enumset::EnumSet;
+use pspp::output::Class;
+use std::{fmt::Display, path::PathBuf};
+
+/// Show information about SPSS viewer files (SPV files).
+#[derive(Args, Clone, Debug)]
+pub struct ShowSpv {
+ /// What to show.
+ #[arg(value_enum)]
+ mode: Mode,
+
+ /// File to show.
+ ///
+ /// For most modes, this should be a `.spv` file. For `convert-table-look`,
+ /// this should be a `.tlo` or `.stt` file.
+ #[arg(required = true)]
+ input: PathBuf,
+
+ /// Classes of objects to include or, with leading `^`, to exclude. The
+ /// supported classes are: charts, headings, logs, models, tables, texts,
+ /// trees, warnings, outlineheaders, pagetitle, notes, unknown, other.
+ #[arg(long, required = false, value_parser = parse_select, help_heading = "Input selection options")]
+ select: EnumSet<Class>,
+
+ /// Identifiers of commands to include or, with leading `^`, to exclude.
+ #[arg(long, required = false)]
+ commands: String,
+
+ /// Table subtypes to include or, with leading `^`, to exclude.
+ #[arg(long, required = false)]
+ subtypes: String,
+
+ /// Labels (table titles) to include or, with leading `^`, to exclude.
+ #[arg(long, required = false)]
+ labels: String,
+}
+
+fn parse_select(s: &str) -> Result<EnumSet<Class>, anyhow::Error> {
+ if s.is_empty() {
+ return Ok(EnumSet::all());
+ }
+ let (s, invert) = match s.strip_prefix('^') {
+ Some(rest) => (rest, true),
+ None => (s, false),
+ };
+ let mut classes = EnumSet::empty();
+ for name in s.split(',') {
+ if name == "all" {
+ classes = EnumSet::all();
+ } else {
+ classes.insert(
+ name.trim()
+ .parse()
+ .map_err(|_| anyhow!("unknown output class `{name}`"))?,
+ );
+ }
+ }
+ if invert {
+ classes = !classes;
+ }
+ Ok(classes)
+}
+
+/// What to show in a system file.
+#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
+enum Mode {
+ /// List tables and other items.
+ #[value(alias = "dir")]
+ Directory,
+
+ /// Copies first selected TableLook into output in `.stt` format.
+ GetTableLook,
+
+ /// Reads `.tlo` or `.stt` TableLook and outputs as `.stt` format.
+ ConvertTableLook,
+}
+
+impl Mode {
+ fn as_str(&self) -> &'static str {
+ match self {
+ Mode::Directory => "directory",
+ Mode::GetTableLook => "get-table-look",
+ Mode::ConvertTableLook => "convert-table-look",
+ }
+ }
+}
+
+impl Display for Mode {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+impl ShowSpv {
+ pub fn run(self) -> Result<()> {
+ todo!()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use enumset::EnumSet;
+
+ use crate::show_spv::parse_select;
+
+ #[test]
+ fn test_parse_select() {
+ assert_eq!(parse_select("").unwrap(), EnumSet::all());
+ assert_eq!(
+ parse_select("tables").unwrap(),
+ EnumSet::only(pspp::output::Class::Tables)
+ );
+ assert_eq!(
+ parse_select("tables,pagetitle").unwrap(),
+ EnumSet::only(pspp::output::Class::Tables)
+ | EnumSet::only(pspp::output::Class::PageTitle)
+ );
+ assert_eq!(
+ parse_select("^tables,pagetitle").unwrap(),
+ !(EnumSet::only(pspp::output::Class::Tables)
+ | EnumSet::only(pspp::output::Class::PageTitle))
+ );
+ }
+}