work
authorBen Pfaff <blp@cs.stanford.edu>
Fri, 10 Oct 2025 16:36:12 +0000 (09:36 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Fri, 10 Oct 2025 16:36:12 +0000 (09:36 -0700)
21 files changed:
rust/Cargo.lock
rust/doc/src/SUMMARY.md
rust/doc/src/invoking/pspp-show-spv.md [new file with mode: 0644]
rust/paper-sizes/src/serde.rs [new file with mode: 0644]
rust/pspp/Cargo.toml
rust/pspp/src/main.rs
rust/pspp/src/output.rs
rust/pspp/src/output/drivers/cairo/fsm.rs
rust/pspp/src/output/drivers/html.rs
rust/pspp/src/output/drivers/spv.rs
rust/pspp/src/output/drivers/text.rs
rust/pspp/src/output/pivot.rs
rust/pspp/src/output/pivot/look_xml.rs
rust/pspp/src/output/pivot/output.rs
rust/pspp/src/output/pivot/tlo.rs
rust/pspp/src/output/render.rs
rust/pspp/src/output/spv.rs
rust/pspp/src/output/spv/css.rs
rust/pspp/src/output/spv/light.rs
rust/pspp/src/output/table.rs
rust/pspp/src/show_spv.rs [new file with mode: 0644]

index 14e846d3aaf567620f9ebce406e71e02bce389ce..d4c99b8f0110a355165b325868c2d42c2e345894 100644 (file)
@@ -528,6 +528,40 @@ dependencies = [
  "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"
@@ -688,6 +722,27 @@ dependencies = [
  "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"
@@ -744,6 +799,12 @@ dependencies = [
  "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"
@@ -1151,6 +1212,12 @@ dependencies = [
  "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"
@@ -1811,6 +1878,7 @@ dependencies = [
  "encoding_rs",
  "enum-iterator",
  "enum-map",
+ "enumset",
  "flagset",
  "flate2",
  "hashbrown 0.15.5",
index 66f6dd5e19dc1e9c47b48a3ba36c4073ad07ad79..456092cf62ae3e58d31bfe8730207aff26af7119 100644 (file)
@@ -8,6 +8,7 @@
   - [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)
 
diff --git a/rust/doc/src/invoking/pspp-show-spv.md b/rust/doc/src/invoking/pspp-show-spv.md
new file mode 100644 (file)
index 0000000..e5854bb
--- /dev/null
@@ -0,0 +1,125 @@
+# 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.
diff --git a/rust/paper-sizes/src/serde.rs b/rust/paper-sizes/src/serde.rs
new file mode 100644 (file)
index 0000000..278f423
--- /dev/null
@@ -0,0 +1,100 @@
+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)
+        )
+    }
+}
index a46db9bf0f42536ebb20c53826726fdadb53d393..818e3b27739e5f6c408097245051b20cba760a5b 100644 (file)
@@ -58,6 +58,7 @@ codepage-437 = "0.1.0"
 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"] }
index 4fa6f80b557396ed11fcd2f1c08096f4358ebc20..de9293200f35e212196d618330406c62d34fcc13 100644 (file)
@@ -19,13 +19,17 @@ use clap::{Parser, Subcommand};
 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)]
@@ -42,6 +46,7 @@ enum Command {
     Show(Show),
     ShowPor(ShowPor),
     ShowPc(ShowPc),
+    ShowSpv(ShowSpv),
 }
 
 impl Command {
@@ -52,6 +57,7 @@ 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(),
         }
     }
 }
index 77f097fc9c90be7a78dfc11a0e8cdd4b7c68c463..6e60673776063d76177c798a8fec85eee8244e45 100644 (file)
 #![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},
 };
 
@@ -61,6 +63,11 @@ pub struct Item {
 
     /// 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 {
@@ -71,6 +78,7 @@ impl Item {
             command_name: details.command_name().cloned(),
             show: true,
             details,
+            spv_info: None,
         }
     }
 
@@ -91,6 +99,13 @@ impl Item {
             ..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
@@ -320,3 +335,218 @@ impl ItemCursor {
         }
     }
 }
+
+/// 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"));
+    }
+}
index 89d5d61c537a321202581c40af28c79a029e121c..e4b8002a50172135f0ccf979eb86f111219546bc 100644 (file)
@@ -220,7 +220,7 @@ fn xr_clip(context: &Context, clip: &Rect2) {
     }
 }
 
-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
     }
@@ -490,7 +490,7 @@ impl CairoDevice<'_> {
         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);
@@ -698,15 +698,12 @@ impl Device for CairoDevice<'_> {
     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| {
@@ -733,7 +730,7 @@ impl Device for CairoDevice<'_> {
         }
 
         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();
index 411f7db8afca8819f1573f2385c7751143111029..2d3492ef0e39c459cf8e01b54e537db842e938e1 100644 (file)
@@ -90,7 +90,6 @@ where
                 self.put_cell(
                     DrawCell::new(cell.inner(), &title),
                     Rect2::new(0..1, 0..1),
-                    false,
                     "caption",
                     None,
                 )?;
@@ -103,7 +102,6 @@ where
                     self.put_cell(
                         DrawCell::new(cell.inner(), &layers),
                         Rect2::new(0..output.body.n[Axis2::X], 0..1),
-                        false,
                         "td",
                         None,
                     )?;
@@ -120,13 +118,9 @@ where
                     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),
                         )?;
@@ -143,7 +137,6 @@ where
                     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,
                     )?;
@@ -156,7 +149,6 @@ where
                         self.put_cell(
                             DrawCell::new(cell.inner(), &footnotes),
                             Rect2::new(0..output.body.n[Axis2::X], 0..1),
-                            false,
                             "td",
                             None,
                         )?;
@@ -173,7 +165,6 @@ where
         &mut self,
         cell: DrawCell<'_>,
         rect: Rect2,
-        alternate_row: bool,
         tag: &str,
         table: Option<&Table>,
     ) -> std::io::Result<()> {
@@ -202,12 +193,12 @@ where
         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();
         }
index 3a465420dfd1f462039e28b2ca2f4465171a7f76..907f96cd64f4880a63dd0f3614be0c4bf4a61cb7 100644 (file)
@@ -46,7 +46,7 @@ use crate::{
             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,
@@ -306,11 +306,16 @@ impl BinWrite for PivotTable {
             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.
@@ -811,13 +816,13 @@ impl BinWrite for Footnotes {
 }
 
 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"
@@ -835,19 +840,15 @@ impl BinWrite for AreaStyle {
                 .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, ())?;
         }
@@ -1080,8 +1081,8 @@ impl BinWrite for FontStyle {
             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,
         )
index 6c3e0a4afaf98e767ea709455b209937fd64b409..b9d4e7ee765e1a790286628e75f598040b7dcf87 100644 (file)
@@ -612,7 +612,6 @@ impl Device for TextRenderer {
     fn draw_cell(
         &mut self,
         cell: &DrawCell,
-        _alternate_row: bool,
         bb: Rect2,
         valign_offset: usize,
         _spill: EnumMap<Axis2, [usize; 2]>,
index 967ea845d105e3e5e8c514d98de232d54a7eb47b..4dce028d29e5b658976e4bd1693ca2b3cd67bbd4 100644 (file)
@@ -89,9 +89,12 @@ pub mod tests;
 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,
@@ -100,16 +103,32 @@ pub enum Area {
     // 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 {
@@ -118,7 +137,7 @@ impl Display for Area {
             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"),
         }
     }
@@ -144,7 +163,7 @@ impl Area {
             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 {
@@ -166,6 +185,37 @@ impl Area {
     }
 }
 
+/// 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 {
@@ -1045,16 +1095,8 @@ pub struct FontStyle {
     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,
@@ -1068,8 +1110,8 @@ impl Default for FontStyle {
             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,
         }
     }
@@ -1098,22 +1140,10 @@ impl FontStyle {
         }
     }
     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 }
     }
 }
 
index a9e1264f55e567a2fdf164539711149174771081..9a00bfe1c92bae83ce1c1cec54a6e8d3f350716d 100644 (file)
@@ -23,7 +23,8 @@ use crate::{
     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;
@@ -53,14 +54,14 @@ impl From<TableProperties> for Look {
                 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,
@@ -202,7 +203,7 @@ struct CellStyle {
 }
 
 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 {
@@ -231,14 +232,14 @@ impl CellStyle {
                 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(),
             },
         }
index 316634a12dd7913505ec4087f8098f538cba643b..e784063b0908de4a02d2c42ac93cbe442476d07f 100644 (file)
@@ -20,7 +20,7 @@ use enum_map::{EnumMap, enum_map};
 use itertools::Itertools;
 
 use crate::output::{
-    pivot::{HeadingRegion, LabelPosition, Path},
+    pivot::{HeadingRegion, LabelPosition, Path, RowParity},
     table::{CellInner, Table},
 };
 
@@ -206,11 +206,10 @@ impl PivotTable {
                 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()),
+                    ),
                 );
             }
         }
@@ -458,18 +457,14 @@ impl<'a> Heading<'a> {
             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
@@ -540,11 +535,7 @@ impl<'a> Heading<'a> {
         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()),
             );
         }
     }
index ca5fc629f52651e541cb41d864519359f80de8d0..12f474f12cce7906dcd683f16ef6b1c578c038f8 100644 (file)
@@ -103,7 +103,7 @@ impl From<TableLook> for Look {
                     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!  {
@@ -373,11 +373,8 @@ impl super::AreaStyle {
                 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,
             },
         }
index ef67425bda27bf8c078abf20c45957bb29491342..e8f900d97140f9b67c20e71967c374ff3922c8c7 100644 (file)
@@ -151,7 +151,6 @@ pub trait Device {
     fn draw_cell(
         &mut self,
         draw_cell: &DrawCell,
-        alternate_row: bool,
         bb: Rect2,
         valign_offset: usize,
         spill: EnumMap<Axis2, [usize; 2]>,
@@ -931,8 +930,6 @@ impl Page {
         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]
         })
@@ -969,17 +966,13 @@ impl Page {
             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)
     }
 }
 
index 0036bdb2aabaa685d2f02fa30080c4b11d7569f9..b24f506da7b65ff121ebd1d11e5a4dc617272e12 100644 (file)
@@ -27,7 +27,7 @@ use serde::Deserialize;
 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},
@@ -63,16 +63,12 @@ impl Item {
         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")
@@ -87,10 +83,9 @@ impl Item {
         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);
             }
@@ -101,16 +96,29 @@ impl Item {
             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),
@@ -120,7 +128,7 @@ where
     };
     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)]
@@ -137,7 +145,11 @@ struct Heading {
 }
 
 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,
     {
@@ -146,18 +158,24 @@ impl Heading {
             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)),
                             );
                         }
                     }
@@ -166,9 +184,14 @@ impl Heading {
                     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)),
                     );
                 }
             }
@@ -253,7 +276,7 @@ struct Table {
 }
 
 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,
     {
@@ -264,7 +287,12 @@ impl Table {
             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!()
         }
index 861182fb374f7bf6aa1ca83ff4377eabf1586d77..4248f28ae1bf14d4d82022c06391a64644394d8b 100644 (file)
@@ -106,7 +106,7 @@ impl FontStyle {
                 match key.as_ref() {
                     "color" => {
                         if let Ok(color) = value.parse() {
-                            self.fg = [color; 2];
+                            self.fg = color;
                         }
                     }
                     "font-weight" => self.bold = value == "bold",
@@ -160,8 +160,8 @@ impl FontStyle {
         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()
@@ -248,45 +248,30 @@ xyz'"#,
         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")
         );
     }
 
index cc38c8d8c2ae8ebe6ac9277ec6078f3d4b1a4cef..348932137ec539f186bf67ecf37644b540c488bb 100644 (file)
@@ -19,7 +19,7 @@ use crate::{
     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,
@@ -346,10 +346,14 @@ impl Areas {
                 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)
         })
     }
 }
@@ -399,7 +403,7 @@ struct Area {
 }
 
 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 {
@@ -424,8 +428,14 @@ impl Area {
                 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,
             },
         }
@@ -1277,8 +1287,8 @@ impl ValueMods {
                 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
index dfc148390d7debc56b9aacd1449a952997ab2315..5597ae794b52a856620a661f87d9dda7e5a78dae 100644 (file)
@@ -178,6 +178,10 @@ impl CellInner {
         }
     }
 
+    pub fn with_rotate(self, rotate: bool) -> Self {
+        Self { rotate, ..self }
+    }
+
     pub fn is_empty(&self) -> bool {
         self.value.inner.is_empty()
     }
diff --git a/rust/pspp/src/show_spv.rs b/rust/pspp/src/show_spv.rs
new file mode 100644 (file)
index 0000000..179ef79
--- /dev/null
@@ -0,0 +1,142 @@
+// 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))
+        );
+    }
+}