From: Ben Pfaff Date: Wed, 24 Sep 2025 15:56:09 +0000 (-0700) Subject: work on reading spv files X-Git-Url: https://pintos-os.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=376567e0c68e8a5fafb12376358da11e5a445b39;p=pspp work on reading spv files --- diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 74ed653cff..2a7afa6850 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -227,6 +227,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -528,6 +534,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" @@ -625,6 +665,12 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "either" version = "1.15.0" @@ -682,6 +728,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" @@ -711,6 +778,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.12" @@ -721,12 +799,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "flagset" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" - [[package]] name = "flate2" version = "1.1.1" @@ -738,6 +810,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" @@ -1014,6 +1092,21 @@ dependencies = [ "digest", ] +[[package]] +name = "html_parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f56db07b6612644f6f7719f8ef944f75fff9d6378fdf3d316fd32194184abd" +dependencies = [ + "doc-comment", + "pest", + "pest_derive", + "serde", + "serde_derive", + "serde_json", + "thiserror", +] + [[package]] name = "httparse" version = "1.10.1" @@ -1130,6 +1223,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" @@ -1619,6 +1718,49 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -1731,7 +1873,7 @@ dependencies = [ "aes", "anyhow", "binrw", - "bitflags 2.9.1", + "bit-vec", "cairo-rs", "chardetng", "chrono", @@ -1747,10 +1889,12 @@ dependencies = [ "encoding_rs", "enum-iterator", "enum-map", - "flagset", + "enumset", + "erased-serde", "flate2", "hashbrown 0.15.5", "hexplay", + "html_parser", "indexmap", "itertools 0.14.0", "libc", @@ -1760,12 +1904,14 @@ dependencies = [ "ordered-float", "pango", "pangocairo", + "paper-sizes", "pspp-derive", "quick-xml", "rand", "readpass", "serde", "serde_json", + "serde_path_to_error", "smallstr", "smallvec", "thiserror", @@ -1803,9 +1949,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -1996,6 +2142,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2036,6 +2193,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2435,12 +2603,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.8.1" diff --git a/rust/doc/src/SUMMARY.md b/rust/doc/src/SUMMARY.md index 4c10ae8220..b35c29d723 100644 --- a/rust/doc/src/SUMMARY.md +++ b/rust/doc/src/SUMMARY.md @@ -4,11 +4,14 @@ [License](license.md) - [Running PSPP](invoking/index.md) - - [Converting Data](invoking/pspp-convert.md) + - [Converting Files](invoking/pspp-convert.md) - [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) + - [Identifying Files](invoking/pspp-identify.md) - [Decrypting Files](invoking/pspp-decrypt.md) + - [Output Driver Configuration](invoking/output.md) # Language Overview diff --git a/rust/doc/src/commands/set.md b/rust/doc/src/commands/set.md index 0814ba8942..774f57e197 100644 --- a/rust/doc/src/commands/set.md +++ b/rust/doc/src/commands/set.md @@ -24,7 +24,7 @@ SET /SCALEMIN=COUNT (data output) - /CC{A,B,C,D,E}={'NPRE,PRE,SUF,NSUF','NPRE.PRE.SUF.NSUF'} + /CC{A,B,C,D,E}='STRING' /DECIMAL={DOT,COMMA} /FORMAT=FMT_SPEC /LEADZERO={ON,OFF} @@ -46,13 +46,13 @@ SET /TVARS={NAMES,LABELS,BOTH} /TLOOK={NONE,FILE} -(logging) +(journal) /JOURNAL={ON,OFF} ['FILE_NAME'] (system files) /SCOMPRESSION={ON,OFF} -(miscellaneous) +(security) /SAFER=ON /LOCALE='STRING' @@ -62,7 +62,7 @@ SET /MITERATE=NUMBER /MNEST=NUMBER -(settings not yet implemented, but accepted and ignored) +(not yet implemented) /BASETEXTDIRECTION={AUTOMATIC,RIGHTTOLEFT,LEFTTORIGHT} /BLOCK='C' /BOX={'XXX','XXXXXXXXXXX'} @@ -80,8 +80,21 @@ subcommands are examined in groups. For subcommands that take boolean values, `ON` and `YES` are synonymous, as are `OFF` and `NO`, when used as subcommand values. + + +# Data Input + +``` +SET + /BLANKS={SYSMIS,'.',number} + /DECIMAL={DOT,COMMA} + /FORMAT=FMT_SPEC + /EPOCH={AUTOMATIC,YEAR} + /RIB={NATIVE,MSBFIRST,LSBFIRST} +``` + The data input subcommands affect the way that data is read from data -files. The data input subcommands are +files. The data input subcommands are: * `BLANKS` This is the value assigned to an item data item that is empty or @@ -122,6 +135,15 @@ files. The data input subcommands are default, is equivalent to `MSBFIRST` or `LSBFIRST` depending on the native format of the machine running PSPP. +# Interaction + +``` +SET + /MXERRS=MAX_ERRS + /MXWARNS=MAX_WARNINGS + /WORKSPACE=WORKSPACE_SIZE +``` + Interaction subcommands affect the way that PSPP interacts with an online user. The interaction subcommands are @@ -136,6 +158,18 @@ online user. The interaction subcommands are are issued, except a single initial warning advising you that warnings will not be given. The default value is 100. +# Syntax Execution + +``` +SET + /LOCALE='LOCALE' + /MXLOOPS=MAX_LOOPS + /SEED={RANDOM,SEED_VALUE} + /UNDEFINED={WARN,NOWARN} + /FUZZBITS=FUZZBITS + /SCALEMIN=COUNT +``` + Syntax execution subcommands control the way that PSPP commands execute. The syntax execution subcommands are @@ -184,6 +218,19 @@ execute. The syntax execution subcommands are virtual memory management, setting a very large workspace may cause PSPP to abort. +# Data Output + +``` +SET + /CC{A,B,C,D,E}='STRING' + /DECIMAL={DOT,COMMA} + /FORMAT=FMT_SPEC + /LEADZERO={ON,OFF} + /MDISPLAY={TEXT,TABLES} + /SMALL=NUMBER + /WIB={NATIVE,MSBFIRST,LSBFIRST} +``` + Data output subcommands affect the format of output data. These subcommands are @@ -236,6 +283,16 @@ subcommands are default, is equivalent to `MSBFIRST` or `LSBFIRST` depending on the native format of the machine running PSPP. +# Output Routing + +``` +SET + /ERRORS={ON,OFF,TERMINAL,LISTING,BOTH,NONE} + /MESSAGES={ON,OFF,TERMINAL,LISTING,BOTH,NONE} + /PRINTBACK={ON,OFF,TERMINAL,LISTING,BOTH,NONE} + /RESULTS={ON,OFF,TERMINAL,LISTING,BOTH,NONE} +``` + In the PSPP text-based interface, the output routing subcommands affect where output is sent. The following values are allowed for each of these subcommands: @@ -275,6 +332,18 @@ These output routing subcommands are: These subcommands have no effect on output in the PSPP GUI environment. +# Output Driver + +``` +SET + /HEADERS={NO,YES,BLANK} + /LENGTH={NONE,N_LINES} + /WIDTH={NARROW,WIDTH,N_CHARACTERS} + /TNUMBERS={VALUES,LABELS,BOTH} + /TVARS={NAMES,LABELS,BOTH} + /TLOOK={NONE,FILE} +``` + Output driver option subcommands affect output drivers' settings. These subcommands are: @@ -313,7 +382,14 @@ These subcommands are: `.tlo` file in the same way as specifying `--table-look=FILE` the PSPP command line (*note Main Options::). -Logging subcommands affect logging of commands executed to external +# Journal + +``` +SET + /JOURNAL={ON,OFF} ['FILE_NAME'] +``` + +Journal subcommands affect logging of commands executed to external files. These subcommands are * `JOURNAL` @@ -328,6 +404,13 @@ files. These subcommands are The journal is named `pspp.jnl` by default. A different name may be specified. +# System Files + +``` +SET + /SCOMPRESSION={ON,OFF} +``` + System file subcommands affect the default format of system files produced by PSPP. These subcommands are @@ -335,6 +418,14 @@ produced by PSPP. These subcommands are Whether system files created by `SAVE` or `XSAVE` are compressed by default. The default is `ON`. +# Security + +``` +SET + /SAFER=ON + /LOCALE='STRING' +``` + Security subcommands affect the operations that commands are allowed to perform. The security subcommands are @@ -377,6 +468,16 @@ to perform. The security subcommands are Contrary to intuition, this command does not affect any aspect of the system's locale. +# Macros + +``` +SET + /MEXPAND={ON,OFF} + /MPRINT={ON,OFF} + /MITERATE=NUMBER + /MNEST=NUMBER +``` + The following subcommands affect the interpretation of macros. For more information, see [Macro Settings](define.md#macro-settings). @@ -399,6 +500,20 @@ more information, see [Macro Settings](define.md#macro-settings). Limits the number of levels of nested macro expansions. This must be set to a positive integer. The default is 50. +# Not Yet Implemented + +``` +SET + /BASETEXTDIRECTION={AUTOMATIC,RIGHTTOLEFT,LEFTTORIGHT} + /BLOCK='C' + /BOX={'XXX','XXXXXXXXXXX'} + /CACHE={ON,OFF} + /CELLSBREAK=NUMBER + /COMPRESSION={ON,OFF} + /CMPTRANS={ON,OFF} + /HEADER={NO,YES,BLANK} +``` + The following subcommands are not yet implemented, but PSPP accepts them and ignores the settings: diff --git a/rust/doc/src/invoking/output.md b/rust/doc/src/invoking/output.md new file mode 100644 index 0000000000..920ffec573 --- /dev/null +++ b/rust/doc/src/invoking/output.md @@ -0,0 +1,273 @@ +# Output Drivers + +PSPP has output drivers for several formats. This section documents +the supported formats and how they can be configured: + + + +# Text Output (`.txt` and `.text`) + +PSPP can produce plain text output, drawing boxes using ASCII or +Unicode line drawing characters. + +Plain text output is encoded in UTF-8. + +This driver has the following options: + +* `width = ` + Sets the maximum page width to the specified number of columns. To + fit in the given width, output table columns will be word-wrapped + or, if necessary, tables will be broken into multiple chunks. The + default is no maximum width. + +* `boxes = "unicode"` + `boxes = "ascii"` + Sets the style used for boxes in the output. The following shows an + example of each style: + + ``` + unicode ascii + ┌────┬────┐ +----+----+ + │ │ │ | | | + ├────┼────┤ +----+----+ + │ │ │ | | | + └────┴────┘ +----+----+ + ``` + + Unicode boxes are generally more attractive but they can be harder + to work with in some environments. The default is `unicode`. + +* `emphasis = ` + If this is set to true, then the output includes bold and underline + emphasis with overstriking. This is supported by only some + software, mainly on Unix. The default is `false`. + +# PDF Output (`.pdf`) + +This driver has the following options: + +* `page_setup = ` + Sets the page size, margins, and other parameters. The following + sub-options are available: + + - `initial_page_number = ` + The page number to use for the first page of output. The default + is 1. + + - `paper = ""` + Sets the page size. `` takes the form `x`, + e.g. `8.5x11in` or `210x297mm`, or the name of a standard paper + size, such as `letter` or `a4`. The default is system- and + user-dependent. + + - `margins = ""` + `margins = ["", ""]` + `margins = ["", "", ""]` + `margins = ["", "", "", ""]` + Sets the margins. Each variable is a quoted string with a length + and a unit, e.g. `10mm`. The one-value form sets all margins to + the same length; the two-value form sets the top and bottom + margins separately from left and right; and so on. The default is + `0.5in`. + + - `orientation = "portrait"` + `orientation = "landscape"` + Controls the output page orientation. The default is `"portrait"`. + + - `object_spacing = ""` + Sets the vertical spacing between output objects, such as tables + or text. `` includes a length and a unit, e.g. `10mm`. + The default is `12pt`, or 1/6 of an inch. + + - `chart_spacing = "as_is"` + `chart_spacing = "full_height"` + `chart_spacing = "half_height"` + `chart_spacing = "quarter_height"` + Sets the size of charts and graphs in the output. The default, + `"as_is"`, uses the size specified in the charts themselves. The + other possibilities set chart size in terms of the height of the + page. + + - `header = ""` + `footer = ""` + Sets the header, output at the top of each page, and the footer, + output at the bottom of each page. Both header and footer are + expressed in HTML. Only simple HTML features are supported, + including the following HTML elements: + + * ``, optionally, as a top-level element. + + * ``, optionally, as a first element enclosing a ` + + +"#; + +impl Driver for HtmlDriver +where + W: Write + 'static, +{ + fn name(&self) -> Cow<'static, str> { + Cow::from("html") + } + + fn write(&mut self, item: &Arc) { + match &item.details { + Details::Chart | Details::Image(_) | Details::Heading(_) => todo!(), + Details::Message(_diagnostic) => todo!(), + Details::PageBreak => (), + Details::Table(pivot_table) => { + self.render(pivot_table).unwrap(); // XXX + } + Details::Text(_text) => todo!(), + } + } +} + +struct Escape<'a> { + string: &'a str, + space: &'static str, + newline: &'static str, + quote: &'static str, + apos: &'static str, +} + +impl<'a> Escape<'a> { + fn new(string: &'a str) -> Self { + Self { + string, + space: " ", + newline: "\n", + quote: """, + apos: "'", + } + } + fn with_space(self, space: &'static str) -> Self { + Self { space, ..self } + } + fn with_newline(self, newline: &'static str) -> Self { + Self { newline, ..self } + } + fn with_quote(self, quote: &'static str) -> Self { + Self { quote, ..self } + } + fn with_apos(self, apos: &'static str) -> Self { + Self { apos, ..self } + } +} + +impl Display for Escape<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for c in self.string.chars() { + match c { + '\n' => f.write_str(self.newline)?, + ' ' => f.write_str(self.space)?, + '&' => f.write_str("&")?, + '<' => f.write_str("<")?, + '>' => f.write_str(">")?, + '"' => f.write_str(self.quote)?, + '\'' => f.write_str(self.apos)?, + _ => f.write_char(c)?, + } + } + Ok(()) + } +} diff --git a/rust/pspp/src/output/drivers/json.rs b/rust/pspp/src/output/drivers/json.rs new file mode 100644 index 0000000000..d121a9a99d --- /dev/null +++ b/rust/pspp/src/output/drivers/json.rs @@ -0,0 +1,108 @@ +// 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 . + +use std::{ + borrow::Cow, + fs::File, + io::{BufWriter, Write, stdout}, + path::PathBuf, + sync::Arc, +}; + +use serde::{Deserialize, Serialize}; + +use super::{Driver, Item}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct JsonConfig { + file: Option, + + /// If false (the default), each output item is exactly one line of JSON, in + /// [newline-delimited JSON] format. + /// + /// If true, each output item is pretty-printed as multiple lines. + /// + /// [newline-delimited JSON]: https://github.com/ndjson/ndjson-spec + pretty: Option, + + /// If false (the default), output items that support a specialized + /// serialization format that accurately represents their semantics will be + /// output that way. (Output items that don't will be output as pivot + /// tables.) + /// + /// If true, output items always output as pivot tables and other SPSS + /// output items. (Some output items can't be output this way.) + tables: Option, +} + +pub struct JsonDriver { + file: Box, + pretty: bool, + tables: bool, +} + +impl JsonDriver { + pub fn new(config: &JsonConfig) -> std::io::Result { + Ok(Self { + file: match &config.file { + Some(file) => Box::new(BufWriter::new(File::create(file)?)), + None => Box::new(stdout()), + }, + pretty: config.pretty.unwrap_or_else(|| { + !config + .file + .as_ref() + .is_some_and(|file| file.ends_with(".ndjson")) + }), + tables: config.tables.unwrap_or_default(), + }) + } + + fn write_json(&mut self, item: &T) -> std::io::Result<()> + where + T: Serialize + ?Sized, + { + if self.pretty { + serde_json::to_writer_pretty(&mut self.file, item)?; + } else { + serde_json::to_writer(&mut self.file, item)?; + } + writeln!(&mut self.file) + } +} + +impl Driver for JsonDriver { + fn name(&self) -> Cow<'static, str> { + Cow::from("json") + } + + fn write(&mut self, item: &Arc) { + self.write_json(item).unwrap(); // XXX handle errors. + } + + fn can_serialize(&self) -> bool { + !self.tables + } + + fn serialize(&mut self, item: &dyn erased_serde::Serialize) { + assert!(!self.tables); + self.write_json(item).unwrap(); // XXX handle errors + } + + fn flush(&mut self) { + let _ = self.file.flush(); + } +} diff --git a/rust/pspp/src/output/drivers/por.rs b/rust/pspp/src/output/drivers/por.rs new file mode 100644 index 0000000000..723d0e2dab --- /dev/null +++ b/rust/pspp/src/output/drivers/por.rs @@ -0,0 +1,78 @@ +// 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 . + +use std::{borrow::Cow, fs::File, io::BufWriter, path::PathBuf, sync::Arc}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + data::{ByteString, Case, Datum}, + dictionary::Dictionary, + por::{WriteOptions, Writer}, +}; + +use super::{CaseWriter, Driver, Item}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PorConfig { + file: PathBuf, +} + +pub struct PorDriver { + file: PathBuf, +} + +impl PorDriver { + pub fn new(config: &PorConfig) -> std::io::Result { + Ok(Self { + file: config.file.clone(), + }) + } +} + +impl Driver for PorDriver { + fn name(&self) -> Cow<'static, str> { + Cow::from("por") + } + + fn write(&mut self, _item: &Arc) { + todo!() + } + + fn can_write_data_file(&self) -> bool { + true + } + + fn write_data_file<'a>( + &'a mut self, + dictionary: &'a Dictionary, + ) -> anyhow::Result>> { + Ok(Some(Box::new(PorDriverCaseWriter { + writer: WriteOptions::new().write_file(&dictionary, &self.file)?, + }))) + } +} + +struct PorDriverCaseWriter { + writer: Writer>, +} + +impl CaseWriter for PorDriverCaseWriter { + fn write_case(&mut self, case: Case>>) -> anyhow::Result<()> { + self.writer.write_case(case)?; + Ok(()) + } +} diff --git a/rust/pspp/src/output/drivers/sav.rs b/rust/pspp/src/output/drivers/sav.rs new file mode 100644 index 0000000000..75e1c6162a --- /dev/null +++ b/rust/pspp/src/output/drivers/sav.rs @@ -0,0 +1,83 @@ +// 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 . + +use std::{borrow::Cow, fs::File, io::BufWriter, path::PathBuf, sync::Arc}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + data::{ByteString, Case, Datum}, + dictionary::Dictionary, + sys::{WriteOptions, Writer, raw::records::Compression}, +}; + +use super::{CaseWriter, Driver, Item}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SavConfig { + file: PathBuf, + compression: Option, +} + +pub struct SavDriver { + file: PathBuf, + compression: Option, +} + +impl SavDriver { + pub fn new(config: &SavConfig) -> std::io::Result { + Ok(Self { + file: config.file.clone(), + compression: config.compression, + }) + } +} + +impl Driver for SavDriver { + fn name(&self) -> Cow<'static, str> { + Cow::from("sav") + } + + fn write(&mut self, _item: &Arc) { + todo!() + } + + fn can_write_data_file(&self) -> bool { + true + } + + fn write_data_file<'a>( + &'a mut self, + dictionary: &'a Dictionary, + ) -> anyhow::Result>> { + Ok(Some(Box::new(SavDriverCaseWriter { + writer: WriteOptions::new() + .with_compression(self.compression) + .write_file(&dictionary, &self.file)?, + }))) + } +} + +struct SavDriverCaseWriter { + writer: Writer>, +} + +impl CaseWriter for SavDriverCaseWriter { + fn write_case(&mut self, case: Case>>) -> anyhow::Result<()> { + self.writer.write_case(case)?; + Ok(()) + } +} diff --git a/rust/pspp/src/output/drivers/spv.rs b/rust/pspp/src/output/drivers/spv.rs new file mode 100644 index 0000000000..b2238ba542 --- /dev/null +++ b/rust/pspp/src/output/drivers/spv.rs @@ -0,0 +1,1413 @@ +// 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 . + +use core::f64; +use std::{ + borrow::Cow, + fs::File, + io::{Cursor, Seek, Write}, + iter::{repeat, repeat_n}, + path::PathBuf, + sync::Arc, +}; + +use binrw::{BinWrite, Endian}; +use chrono::Utc; +use enum_map::EnumMap; +use paper_sizes::Length; +use quick_xml::{ + ElementWriter, + events::{BytesText, attributes::Attribute}, + writer::Writer as XmlWriter, +}; +use serde::{Deserialize, Serialize}; +use zip::{ZipWriter, result::ZipResult, write::SimpleFileOptions}; + +use crate::{ + format::{Format, Type}, + output::{ + Details, Item, Text, + drivers::Driver, + page::{ChartSize, PageSetup}, + pivot::{ + Area, AreaStyle, Axis2, Axis3, Border, BorderStyle, BoxBorder, Category, CellStyle, + Color, Dimension, FontStyle, Footnote, FootnoteMarkerPosition, FootnoteMarkerType, + Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Leaf, PivotTable, + RowColBorder, RowParity, Stroke, Value, ValueInner, ValueStyle, VertAlign, + }, + spv::html::Document, + }, + settings::Show, + util::ToSmallString, +}; + +fn light_table_name(table_id: u64) -> String { + format!("{table_id:011}_lightTableData.bin") +} + +fn output_viewer_name(heading_id: u64, is_heading: bool) -> String { + format!( + "outputViewer{heading_id:010}{}.xml", + if is_heading { "_heading" } else { "" } + ) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SpvConfig { + /// Output file name. + pub file: PathBuf, + + /// Page setup. + pub page_setup: Option, +} + +pub struct SpvDriver +where + W: Write + Seek, +{ + writer: ZipWriter, + needs_page_break: bool, + next_table_id: u64, + next_heading_id: u64, + page_setup: Option, +} + +impl SpvDriver { + pub fn new(config: &SpvConfig) -> std::io::Result { + let mut driver = Self::for_writer(File::create(&config.file)?); + if let Some(page_setup) = &config.page_setup { + driver = driver.with_page_setup(page_setup.clone()); + } + Ok(driver) + } +} + +impl SpvDriver +where + W: Write + Seek, +{ + pub fn for_writer(writer: W) -> Self { + let mut writer = ZipWriter::new(writer); + writer + .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default()) + .unwrap(); + writer.write_all("allowPivoting=true".as_bytes()).unwrap(); + Self { + writer, + needs_page_break: false, + next_table_id: 1, + next_heading_id: 1, + page_setup: None, + } + } + + pub fn with_page_setup(self, page_setup: PageSetup) -> Self { + Self { + page_setup: Some(page_setup), + ..self + } + } + + pub fn close(mut self) -> ZipResult { + self.writer + .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default())?; + write!(&mut self.writer, "allowPivoting=true")?; + self.writer.finish() + } + + fn page_break_before(&mut self) -> bool { + let page_break_before = self.needs_page_break; + self.needs_page_break = false; + page_break_before + } + + fn write_table( + &mut self, + item: &Item, + pivot_table: &PivotTable, + structure: &mut XmlWriter, + ) where + X: Write, + { + let table_id = self.next_table_id; + self.next_table_id += 1; + + let mut content = Vec::new(); + let mut cursor = Cursor::new(&mut content); + pivot_table.write_le(&mut cursor).unwrap(); + + let table_name = light_table_name(table_id); + self.writer + .start_file(&table_name, SimpleFileOptions::default()) + .unwrap(); // XXX + self.writer.write_all(&content).unwrap(); // XXX + + self.container(structure, item, "vtb:table", |element| { + element + .with_attribute(("tableId", Cow::from(table_id.to_string()))) + .with_attribute(( + "commandName", + pivot_table + .metadata + .command_local + .as_ref() + .map_or("", |s| s.as_str()), + )) + .with_attribute(("type", "table" /*XXX*/)) + .with_attribute(( + "subType", + Cow::from(pivot_table.subtype().display(pivot_table).to_string()), + )) + .write_inner_content(|w| { + w.create_element("vtb:tableStructure") + .write_inner_content(|w| { + w.create_element("vtb:dataPath") + .write_text_content(BytesText::new(&table_name))?; + Ok(()) + })?; + Ok(()) + }) + .unwrap(); + }); + } + + fn write_text(&mut self, item: &Item, text: &Text, structure: &mut XmlWriter) + where + X: Write, + { + self.container(structure, item, "vtx:text", |w| { + w.with_attribute(("type", text.type_.as_xml_str())) + .write_text_content(BytesText::new(&text.content.display(()).to_string())) + .unwrap(); + }); + } + + fn write_item(&mut self, item: &Item, structure: &mut XmlWriter) + where + X: Write, + { + match &item.details { + Details::Chart | Details::Image(_) => todo!(), + Details::Heading(children) => { + let mut attributes = Vec::::new(); + if let Some(command_name) = &item.command_name { + attributes.push(("commandName", command_name.as_str()).into()); + } + if !item.show { + attributes.push(("visibility", "collapsed").into()); + } + structure + .create_element("heading") + .with_attributes(attributes) + .write_inner_content(|w| { + w.create_element("label") + .write_text_content(BytesText::new(&item.label()))?; + for child in &children.0 { + self.write_item(&child, w); + } + Ok(()) + }) + .unwrap(); + } + Details::Message(diagnostic) => { + self.write_text(item, &Text::from(diagnostic.as_ref()), structure) + } + Details::PageBreak => { + self.needs_page_break = true; + } + Details::Table(pivot_table) => self.write_table(item, pivot_table, structure), + Details::Text(text) => self.write_text(item, text, structure), + } + } + + fn container( + &mut self, + writer: &mut XmlWriter, + item: &Item, + inner_elem: &str, + closure: F, + ) where + X: Write, + F: FnOnce(ElementWriter), + { + writer + .create_element("container") + .with_attributes( + self.page_break_before() + .then_some(("page-break-before", "always")), + ) + .with_attribute(("visibility", if item.show { "visible" } else { "hidden" })) + .write_inner_content(|w| { + let mut element = w + .create_element("label") + .write_text_content(BytesText::new(&item.label())) + .unwrap() + .create_element(inner_elem); + if let Some(command_name) = &item.command_name { + element = element.with_attribute(("commandName", command_name.as_str())); + }; + closure(element); + Ok(()) + }) + .unwrap(); + } +} + +impl BinWrite for PivotTable { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + _args: (), + ) -> binrw::BinResult<()> { + // Header. + ( + 1u8, + 0u8, + 3u32, // version + SpvBool(true), // x0 + SpvBool(false), // x1 + SpvBool(self.style.rotate_inner_column_labels), + SpvBool(self.style.rotate_outer_row_labels), + SpvBool(true), // x2 + 0x15u32, // x3 + *self.style.look.heading_widths[HeadingRegion::Columns].start() as i32, + *self.style.look.heading_widths[HeadingRegion::Columns].end() as i32, + *self.style.look.heading_widths[HeadingRegion::Rows].start() as i32, + *self.style.look.heading_widths[HeadingRegion::Rows].end() as i32, + 0u64, + ) + .write_le(writer)?; + + // Titles. + ( + self.title(), + self.subtype(), + Optional(Some(self.title())), + Optional(self.metadata.corner_text.as_ref()), + Optional(self.metadata.caption.as_ref()), + ) + .write_le(writer)?; + + // Footnotes. + self.footnotes.write_le(writer)?; + + // Areas. + static SPV_AREAS: [Area; 8] = [ + Area::Title, + Area::Caption, + Area::Footer, + Area::Corner, + Area::Labels(Axis2::X), + Area::Labels(Axis2::Y), + Area::Data(RowParity::Even), + Area::Layers, + ]; + for (index, area) in SPV_AREAS.into_iter().enumerate() { + 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. + static SPV_BORDERS: [Border; 19] = [ + Border::Title, + Border::OuterFrame(BoxBorder::Left), + Border::OuterFrame(BoxBorder::Top), + Border::OuterFrame(BoxBorder::Right), + Border::OuterFrame(BoxBorder::Bottom), + Border::InnerFrame(BoxBorder::Left), + Border::InnerFrame(BoxBorder::Top), + Border::InnerFrame(BoxBorder::Right), + Border::InnerFrame(BoxBorder::Bottom), + Border::DataLeft, + Border::DataTop, + Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)), + Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::Y)), + Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)), + Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::Y)), + Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)), + Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::Y)), + Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)), + Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::Y)), + ]; + let borders_start = Count::new(writer)?; + (1, SPV_BORDERS.len() as u32).write_be(writer)?; + for (index, border) in SPV_BORDERS.into_iter().enumerate() { + self.style.look.borders[border].write_be_args(writer, index)?; + } + (SpvBool(self.style.show_grid_lines), 0u8, 0u16).write_le(writer)?; + borders_start.finish_le32(writer)?; + + // Print Settings. + Counted::new(( + 1u32, + SpvBool(self.style.look.print_all_layers), + SpvBool(self.style.look.paginate_layers), + SpvBool(self.style.look.shrink_to_fit[Axis2::X]), + SpvBool(self.style.look.shrink_to_fit[Axis2::Y]), + SpvBool(self.style.look.top_continuation), + SpvBool(self.style.look.bottom_continuation), + self.style.look.n_orphan_lines as u32, + SpvString( + self.style + .look + .continuation + .as_ref() + .map_or("", |s| s.as_str()), + ), + )) + .with_endian(Endian::Little) + .write_be(writer)?; + + // Table Settings. + Counted::new(( + 1u32, + 4u32, + self.spv_layer() as u32, + SpvBool(self.style.look.hide_empty), + SpvBool(self.style.look.row_label_position == LabelPosition::Corner), + SpvBool(self.style.look.footnote_marker_type == FootnoteMarkerType::Alphabetic), + SpvBool( + self.style.look.footnote_marker_position == FootnoteMarkerPosition::Superscript, + ), + 0u8, + Counted::new(( + 0u32, // n-row-breaks + 0u32, // n-column-breaks + 0u32, // n-row-keeps + 0u32, // n-column-keeps + 0u32, // n-row-point-keeps + 0u32, // n-column-point-keeps + )), + SpvString::optional(&self.metadata.notes), + SpvString::optional(&self.style.look.name), + Zeros(82), + )) + .with_endian(Endian::Little) + .write_be(writer)?; + + fn y0(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { + ( + pivot_table.style.settings.epoch.0 as u32, + u8::from(pivot_table.style.settings.decimal), + b',', + ) + } + + fn custom_currency(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { + ( + 5, + EnumMap::from_fn(|cc| { + SpvString( + pivot_table + .style + .settings + .number_style(Type::CC(cc)) + .to_string(), + ) + }) + .into_array(), + ) + } + + fn x1(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { + ( + 0u8, // x14 + if pivot_table.style.show_title { + 1u8 + } else { + 10u8 + }, + 0u8, // x16 + 0u8, // lang + Show::as_spv(&pivot_table.style.show_variables), + Show::as_spv(&pivot_table.style.show_values), + -1i32, // x18 + -1i32, // x19 + Zeros(17), + SpvBool(false), // x20 + SpvBool(pivot_table.style.show_caption), + ) + } + + fn x2() -> impl for<'a> BinWrite = ()> { + Counted::new(( + 0u32, // n-row-heights + 0u32, // n-style-maps + 0u32, // n-styles, + 0u32, + )) + } + + fn y1(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> + use<'_> { + ( + SpvString::optional(&pivot_table.metadata.command_c), + SpvString::optional(&pivot_table.metadata.command_local), + SpvString::optional(&pivot_table.metadata.language), + SpvString("UTF-8"), + SpvString::optional(&pivot_table.metadata.locale), + SpvBool(false), // x10 + SpvBool(pivot_table.style.settings.leading_zero), + SpvBool(true), // x12 + SpvBool(true), // x13 + y0(pivot_table), + ) + } + + fn y2(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { + (custom_currency(pivot_table), b'.', SpvBool(false)) + } + + fn x3(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> + use<'_> { + Counted::new(( + 1u8, + 0u8, + 4u8, // x21 + 0u8, + 0u8, + 0u8, + y1(pivot_table), + pivot_table.style.small, + 1u8, + SpvString::optional(&pivot_table.metadata.dataset), + SpvString::optional(&pivot_table.metadata.datafile), + 0u32, + pivot_table + .metadata + .date + .map_or(0i64, |date| date.and_utc().timestamp()), + y2(pivot_table), + )) + } + + // Formats. + ( + 0u32, + SpvString("en_US.ISO_8859-1:1987"), + 0u32, // XXX current_layer + SpvBool(false), // x7 + SpvBool(false), // x8 + SpvBool(false), // x9 + y0(self), + custom_currency(self), + Counted::new((Counted::new((x1(self), x2())), x3(self))), + ) + .write_le(writer)?; + + // Dimensions. + (self.dimensions().len() as u32).write_le(writer)?; + + let x2 = repeat_n(2, self.axes()[Axis3::Z].dimensions.len()) + .chain(repeat_n(0, self.axes()[Axis3::Y].dimensions.len())) + .chain(repeat(1)); + for ((index, dimension), x2) in self.dimensions().iter().enumerate().zip(x2) { + dimension.write_options(writer, endian, (index, x2))?; + } + + // Axes. + for axis in [Axis3::Z, Axis3::Y, Axis3::X] { + (self.axes()[axis].dimensions.len() as u32).write_le(writer)?; + } + for axis in [Axis3::Z, Axis3::Y, Axis3::X] { + for index in self.axes()[axis].dimensions.iter().copied() { + (index as u32).write_le(writer)?; + } + } + + // Cells. + (self.cells().len() as u32).write_le(writer)?; + for (index, value) in self.cells() { + (*index as u64, value).write_le(writer)?; + } + + Ok(()) + } +} + +impl PivotTable { + fn spv_layer(&self) -> usize { + let mut layer = 0; + for (dimension, layer_value) in self + .axis_dimensions(Axis3::Z) + .zip(self.current_layer.iter().copied()) + .rev() + { + layer = layer * dimension.len() + layer_value; + } + layer + } +} + +impl Driver for SpvDriver +where + W: Write + Seek + 'static, +{ + fn name(&self) -> Cow<'static, str> { + Cow::from("spv") + } + + fn write(&mut self, item: &Arc) { + if item.details.is_page_break() { + self.needs_page_break = true; + return; + } + + let mut headings = XmlWriter::new(Cursor::new(Vec::new())); + let element = headings + .create_element("heading") + .with_attribute(( + "creation-date-time", + Cow::from(Utc::now().format("%x %x").to_string()), + )) + .with_attribute(( + "creator", + Cow::from(format!( + "{} {}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + )), + )) + .with_attribute(("creator-version", "21")) + .with_attribute(("xmlns", "http://xml.spss.com/spss/viewer/viewer-tree")) + .with_attribute(( + "xmlns:vps", + "http://xml.spss.com/spss/viewer/viewer-pagesetup", + )) + .with_attribute(("xmlns:vtx", "http://xml.spss.com/spss/viewer/viewer-text")) + .with_attribute(("xmlns:vtb", "http://xml.spss.com/spss/viewer/viewer-table")); + element + .write_inner_content(|w| { + w.create_element("label") + .write_text_content(BytesText::new("Output"))?; + if let Some(page_setup) = self.page_setup.take() { + write_page_setup(&page_setup, w)?; + } + self.write_item(item, w); + Ok(()) + }) + .unwrap(); + + let headings = headings.into_inner().into_inner(); + let heading_id = self.next_heading_id; + self.next_heading_id += 1; + self.writer + .start_file( + output_viewer_name(heading_id, item.details.is_heading()), + SimpleFileOptions::default(), + ) + .unwrap(); // XXX + self.writer.write_all(&headings).unwrap(); // XXX + } + + fn setup(&mut self, page_setup: &PageSetup) -> bool { + self.page_setup = Some(page_setup.clone()); + true + } +} + +fn write_page_setup(page_setup: &PageSetup, writer: &mut XmlWriter) -> std::io::Result<()> +where + X: Write, +{ + fn length(length: Length) -> Cow<'static, str> { + Cow::from(length.to_string()) + } + + writer + .create_element("vps:pageSetup") + .with_attribute(( + "initial-page-number", + Cow::from(format!("{}", page_setup.initial_page_number)), + )) + .with_attribute(( + "chart-size", + match page_setup.chart_size { + ChartSize::AsIs => "as-is", + ChartSize::FullHeight => "full-height", + ChartSize::HalfHeight => "half-height", + ChartSize::QuarterHeight => "quarter-height", + }, + )) + .with_attribute(("margin-left", length(page_setup.margins.0[Axis2::X][0]))) + .with_attribute(("margin-right", length(page_setup.margins.0[Axis2::X][1]))) + .with_attribute(("margin-top", length(page_setup.margins.0[Axis2::Y][0]))) + .with_attribute(("margin-bottom", length(page_setup.margins.0[Axis2::Y][1]))) + .with_attribute(("paper-height", length(page_setup.paper.height()))) + .with_attribute(("paper-width", length(page_setup.paper.width()))) + .with_attribute(( + "reference-orientation", + match page_setup.orientation { + crate::output::page::Orientation::Portrait => "portrait", + crate::output::page::Orientation::Landscape => "landscape", + }, + )) + .with_attribute(("space-after", length(page_setup.object_spacing))) + .write_inner_content(|w| { + write_page_heading(&page_setup.header, "vps:pageHeader", w)?; + write_page_heading(&page_setup.footer, "vps:pageFooter", w)?; + Ok(()) + })?; + Ok(()) +} + +fn write_page_heading( + heading: &Document, + name: &str, + writer: &mut XmlWriter, +) -> std::io::Result<()> +where + X: Write, +{ + let element = writer.create_element(name); + if !heading.is_empty() { + element.write_inner_content(|w| { + w.create_element("vps:pageParagraph") + .write_inner_content(|w| { + w.create_element("vtx:text") + .with_attribute(("text", "title")) + .write_text_content(BytesText::new(&heading.to_html()))?; + Ok(()) + })?; + Ok(()) + })?; + } + Ok(()) +} + +fn maybe_with_attribute<'a, 'b, W, I>( + element: ElementWriter<'a, W>, + attr: Option, +) -> ElementWriter<'a, W> +where + I: Into>, +{ + if let Some(attr) = attr { + element.with_attribute(attr) + } else { + element + } +} + +impl BinWrite for Dimension { + type Args<'a> = (usize, u8); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + (index, x2): (usize, u8), + ) -> binrw::BinResult<()> { + ( + &self.root.name, + 0u8, // x1 + x2, + 2u32, // x3 + SpvBool(!self.root.show_label), + SpvBool(self.hide_all_labels), + SpvBool(true), + index as u32, + self.root.children.len() as u32, + ) + .write_options(writer, endian, ())?; + + let mut data_indexes = self.presentation_order.iter().copied(); + for child in &self.root.children { + child.write_le(writer, &mut data_indexes)?; + } + Ok(()) + } +} + +impl Category { + fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> + where + W: Write + Seek, + D: Iterator, + { + match self { + Category::Group(group) => group.write_le(writer, data_indexes), + Category::Leaf(leaf) => leaf.write_le(writer, data_indexes), + } + } +} + +impl Leaf { + fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> + where + W: Write + Seek, + D: Iterator, + { + ( + self.name(), + 0u8, + 0u8, + 0u8, + 2u32, + data_indexes.next().unwrap() as u32, + 0u32, + ) + .write_le(writer) + } +} + +impl Group { + fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> + where + W: Write + Seek, + D: Iterator, + { + ( + self.name(), + 0u8, // merge + 0u8, + 1u8, + 0u32, // x23 + -1i32, + self.children.len() as u32, + ) + .write_le(writer)?; + + for child in &self.children { + child.write_le(writer, data_indexes)?; + } + Ok(()) + } +} + +impl BinWrite for Footnote { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + ( + &self.content, + Optional(self.marker.as_ref()), + if self.show { 1i32 } else { -1 }, + ) + .write_options(writer, endian, args) + } +} + +impl BinWrite for Footnotes { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + (self.len() as u32).write_options(writer, endian, args)?; + for footnote in self { + footnote.write_options(writer, endian, args)?; + } + Ok(()) + } +} + +impl BinWrite for AreaStyle { + type Args<'a> = (usize, Option<&'a AreaStyle>); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + (index, odd_data_style): (usize, Option<&AreaStyle>), + ) -> binrw::BinResult<()> { + let typeface = if self.font_style.font.is_empty() { + "SansSerif" + } else { + self.font_style.font.as_str() + }; + ( + (index + 1) as u8, + 0x31u8, + SpvString(typeface), + self.font_style.size as f32 * 1.33, + self.font_style.bold as u32 + 2 * self.font_style.italic as u32, + SpvBool(self.font_style.underline), + self.cell_style + .horz_align + .map_or(64173, |horz_align| horz_align.as_spv(61453)), + self.cell_style.vert_align.as_spv(), + self.font_style.fg, + self.font_style.bg, + ) + .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, ())?; + } + + ( + self.cell_style.margins[Axis2::X][0], + self.cell_style.margins[Axis2::X][1], + self.cell_style.margins[Axis2::Y][0], + self.cell_style.margins[Axis2::Y][1], + ) + .write_options(writer, endian, ()) + } +} + +impl Stroke { + fn as_spv(&self) -> u32 { + match self { + Stroke::None => 0, + Stroke::Solid => 1, + Stroke::Dashed => 2, + Stroke::Thick => 3, + Stroke::Thin => 4, + Stroke::Double => 5, + } + } +} + +impl Color { + fn as_spv(&self) -> u32 { + ((self.alpha as u32) << 24) + | ((self.r as u32) << 16) + | ((self.g as u32) << 8) + | (self.b as u32) + } +} + +impl BinWrite for BorderStyle { + type Args<'a> = usize; + + fn write_options( + &self, + writer: &mut W, + _endian: Endian, + index: usize, + ) -> binrw::BinResult<()> { + (index as u32, self.stroke.as_spv(), self.color.as_spv()).write_be(writer) + } +} + +struct SpvBool(bool); +impl BinWrite for SpvBool { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + (self.0 as u8).write_options(writer, endian, args) + } +} + +struct SpvString(T); +impl<'a> SpvString<&'a str> { + fn optional(s: &'a Option) -> Self { + Self(s.as_ref().map_or("", |s| s.as_str())) + } +} +impl BinWrite for SpvString +where + T: AsRef, +{ + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + let s = self.0.as_ref(); + let length = s.len() as u32; + (length, s.as_bytes()).write_options(writer, endian, args) + } +} + +impl Show { + fn as_spv(this: &Option) -> u8 { + match this { + None => 0, + Some(Show::Value) => 1, + Some(Show::Label) => 2, + Some(Show::Both) => 3, + } + } +} + +struct Count(u64); + +impl Count { + fn new(writer: &mut W) -> binrw::BinResult + where + W: Write + Seek, + { + 0u32.write_le(writer)?; + Ok(Self(writer.stream_position()?)) + } + + fn finish(self, writer: &mut W, endian: Endian) -> binrw::BinResult<()> + where + W: Write + Seek, + { + let saved_position = writer.stream_position()?; + let n_bytes = saved_position - self.0; + writer.seek(std::io::SeekFrom::Start(self.0 - 4))?; + (n_bytes as u32).write_options(writer, endian, ())?; + writer.seek(std::io::SeekFrom::Start(saved_position))?; + Ok(()) + } + + fn finish_le32(self, writer: &mut W) -> binrw::BinResult<()> + where + W: Write + Seek, + { + self.finish(writer, Endian::Little) + } + + fn finish_be32(self, writer: &mut W) -> binrw::BinResult<()> + where + W: Write + Seek, + { + self.finish(writer, Endian::Big) + } +} + +struct Counted { + inner: T, + endian: Option, +} + +impl Counted { + fn new(inner: T) -> Self { + Self { + inner, + endian: None, + } + } + fn with_endian(self, endian: Endian) -> Self { + Self { + inner: self.inner, + endian: Some(endian), + } + } +} + +impl BinWrite for Counted +where + T: BinWrite, + for<'a> T: BinWrite = ()>, +{ + type Args<'a> = T::Args<'a>; + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + let start = Count::new(writer)?; + self.inner.write_options(writer, endian, args)?; + start.finish(writer, self.endian.unwrap_or(endian)) + } +} + +pub struct Zeros(pub usize); + +impl BinWrite for Zeros { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + _endian: Endian, + _args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + for _ in 0..self.0 { + writer.write_all(&[0u8])?; + } + Ok(()) + } +} + +#[derive(Default)] +struct StylePair<'a> { + font_style: Option<&'a FontStyle>, + cell_style: Option<&'a CellStyle>, +} + +impl BinWrite for Color { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + SpvString(&self.without_alpha().display_css().to_small_string::<16>()) + .write_options(writer, endian, args) + } +} + +impl BinWrite for FontStyle { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + let typeface = if self.font.is_empty() { + "SansSerif" + } else { + self.font.as_str() + }; + ( + SpvBool(self.bold), + SpvBool(self.italic), + SpvBool(self.underline), + SpvBool(true), + self.fg, + self.bg, + SpvString(typeface), + (self.size as f64 * 1.33).ceil() as u8, + ) + .write_options(writer, endian, args) + } +} + +impl HorzAlign { + fn as_spv(&self, decimal: u32) -> u32 { + match self { + HorzAlign::Right => 4, + HorzAlign::Left => 2, + HorzAlign::Center => 0, + HorzAlign::Decimal { .. } => decimal, + } + } + + fn decimal_offset(&self) -> Option { + match *self { + HorzAlign::Decimal { offset, .. } => Some(offset), + _ => None, + } + } +} + +impl VertAlign { + fn as_spv(&self) -> u32 { + match self { + VertAlign::Top => 1, + VertAlign::Middle => 0, + VertAlign::Bottom => 3, + } + } +} + +impl BinWrite for CellStyle { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + ( + self.horz_align + .map_or(0xffffffad, |horz_align| horz_align.as_spv(6)), + self.vert_align.as_spv(), + self.horz_align + .map(|horz_align| horz_align.decimal_offset()) + .unwrap_or_default(), + u16::try_from(self.margins[Axis2::X][0]).unwrap_or_default(), + u16::try_from(self.margins[Axis2::X][1]).unwrap_or_default(), + u16::try_from(self.margins[Axis2::Y][0]).unwrap_or_default(), + u16::try_from(self.margins[Axis2::Y][1]).unwrap_or_default(), + ) + .write_options(writer, endian, args) + } +} + +impl<'a> BinWrite for StylePair<'a> { + type Args<'b> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + ( + Optional(self.font_style.as_ref()), + Optional(self.cell_style.as_ref()), + ) + .write_options(writer, endian, args) + } +} + +struct Optional(Option); + +impl BinWrite for Optional +where + T: BinWrite, +{ + type Args<'a> = T::Args<'a>; + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + match &self.0 { + Some(value) => { + 0x31u8.write_le(writer)?; + value.write_options(writer, endian, args) + } + None => 0x58u8.write_le(writer), + } + } +} + +struct ValueMod<'a> { + style: &'a Option>, + template: Option<&'a str>, +} + +impl<'a> ValueMod<'a> { + fn new(value: &'a Value) -> Self { + Self { + style: &value.styling, + template: None, + } + } +} + +impl<'a> Default for ValueMod<'a> { + fn default() -> Self { + Self { + style: &None, + template: None, + } + } +} + +impl<'a> BinWrite for ValueMod<'a> { + type Args<'b> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + if self.style.as_ref().is_some_and(|style| !style.is_empty()) || self.template.is_some() { + 0x31u8.write_options(writer, endian, args)?; + let default_style = Default::default(); + let style = self.style.as_ref().unwrap_or(&default_style); + + (style.footnotes.len() as u32).write_options(writer, endian, args)?; + for footnote in &style.footnotes { + (footnote.index() as u16).write_options(writer, endian, args)?; + } + + (style.subscripts.len() as u32).write_options(writer, endian, args)?; + for subscript in &style.subscripts { + SpvString(subscript.as_str()).write_options(writer, endian, args)?; + } + let v3_start = Count::new(writer)?; + let template_string_start = Count::new(writer)?; + if let Some(template) = self.template { + Count::new(writer)?.finish_le32(writer)?; + (0x31u8, SpvString(template)).write_options(writer, endian, args)?; + } + template_string_start.finish_le32(writer)?; + StylePair { + font_style: style.font_style.as_ref(), + cell_style: style.cell_style.as_ref(), + } + .write_options(writer, endian, args)?; + v3_start.finish_le32(writer) + } else { + 0x58u8.write_options(writer, endian, args) + } + } +} + +struct SpvFormat { + format: Format, + honor_small: bool, +} + +impl BinWrite for SpvFormat { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + let type_ = if self.format.type_() == Type::F && self.honor_small { + 40 + } else { + self.format.type_().into() + }; + (((type_ as u32) << 16) | ((self.format.w() as u32) << 8) | (self.format.d() as u32)) + .write_options(writer, endian, args) + } +} + +impl BinWrite for Value { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + match &self.inner { + ValueInner::Number(number) => { + let format = SpvFormat { + format: number.format, + honor_small: number.honor_small, + }; + if number.variable.is_some() || number.value_label.is_some() { + ( + 2u8, + ValueMod::new(self), + format, + number.value.unwrap_or(f64::MIN), + SpvString::optional(&number.variable), + SpvString::optional(&number.value_label), + Show::as_spv(&number.show), + ) + .write_options(writer, endian, args)?; + } else { + ( + 1u8, + ValueMod::new(self), + format, + number.value.unwrap_or(f64::MIN), + ) + .write_options(writer, endian, args)?; + } + } + ValueInner::String(string) => { + ( + 4u8, + ValueMod::new(self), + SpvFormat { + format: if string.hex { + Format::new(Type::AHex, (string.s.len() * 2) as u16, 0).unwrap() + } else { + Format::new(Type::A, (string.s.len()) as u16, 0).unwrap() + }, + honor_small: false, + }, + SpvString::optional(&string.value_label), + SpvString::optional(&string.var_name), + Show::as_spv(&string.show), + SpvString(&string.s), + ) + .write_options(writer, endian, args)?; + } + ValueInner::Variable(variable) => { + ( + 5u8, + ValueMod::new(self), + SpvString(&variable.var_name), + SpvString::optional(&variable.variable_label), + Show::as_spv(&variable.show), + ) + .write_options(writer, endian, args)?; + } + ValueInner::Markup(markup) => { + let text = markup.to_string(); + ( + 3u8, + SpvString(&text), // XXX + ValueMod::new(self), + SpvString(&text), + SpvString(&text), + SpvBool(true), + ) + .write_options(writer, endian, args)?; + } + ValueInner::Text(text) => { + ( + 3u8, + SpvString(&text.localized), + ValueMod::new(self), + SpvString(text.id()), + SpvString(text.c()), + SpvBool(true), + ) + .write_options(writer, endian, args)?; + } + ValueInner::Template(template) => { + ( + 0u8, + ValueMod::new(self), + SpvString(&template.localized), + template.args.len() as u32, + ) + .write_options(writer, endian, args)?; + for arg in &template.args { + if arg.len() > 1 { + (arg.len() as u32, 0u32).write_options(writer, endian, args)?; + for (index, value) in arg.iter().enumerate() { + if index > 0 { + 0u32.write_le(writer)?; + } + value.write_options(writer, endian, args)?; + } + } else { + (0u32, arg).write_options(writer, endian, args)?; + } + } + } + ValueInner::Empty => { + ( + 3u8, + SpvString(""), + ValueMod::default(), + SpvString(""), + SpvString(""), + SpvBool(true), + ) + .write_options(writer, endian, args)?; + } + } + Ok(()) + } +} diff --git a/rust/pspp/src/output/drivers/text.rs b/rust/pspp/src/output/drivers/text.rs new file mode 100644 index 0000000000..42b0eacdeb --- /dev/null +++ b/rust/pspp/src/output/drivers/text.rs @@ -0,0 +1,707 @@ +// 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 . + +use std::{ + borrow::Cow, + fmt::{Display, Error as FmtError, Result as FmtResult, Write as FmtWrite}, + fs::File, + io::{BufWriter, Write as IoWrite, stdout}, + ops::{Index, Range}, + path::PathBuf, + sync::{Arc, LazyLock}, +}; + +use enum_map::{Enum, EnumMap, enum_map}; +use serde::{Deserialize, Serialize}; +use unicode_linebreak::{BreakOpportunity, linebreaks}; +use unicode_width::UnicodeWidthStr; + +use crate::output::{render::Extreme, table::DrawCell}; + +use crate::output::{ + Details, Item, + drivers::Driver, + pivot::{Axis2, BorderStyle, Coord2, HorzAlign, PivotTable, Rect2, Stroke}, + render::{Device, Pager, Params}, + table::Content, +}; + +mod text_line; +use text_line::{Emphasis, TextLine, clip_text}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Boxes { + Ascii, + #[default] + Unicode, +} + +impl Boxes { + fn box_chars(&self) -> &'static BoxChars { + match self { + Boxes::Ascii => &ASCII_BOX, + Boxes::Unicode => &UNICODE_BOX, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TextConfig { + /// Output file name. + file: Option, + + /// Renderer config. + #[serde(flatten)] + options: TextRendererOptions, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct TextRendererOptions { + /// Enable bold and underline in output? + pub emphasis: bool, + + /// Page width. + pub width: Option, + + /// ASCII or Unicode + pub boxes: Boxes, +} + +pub struct TextRenderer { + /// Enable bold and underline in output? + emphasis: bool, + + /// Page width. + width: usize, + + /// Minimum cell size to break across pages. + min_hbreak: usize, + + box_chars: &'static BoxChars, + + params: Params, + n_objects: usize, + lines: Vec, +} + +impl Default for TextRenderer { + fn default() -> Self { + Self::new(&TextRendererOptions::default()) + } +} + +impl TextRenderer { + pub fn new(config: &TextRendererOptions) -> Self { + let width = config.width.unwrap_or(usize::MAX); + Self { + emphasis: config.emphasis, + width, + min_hbreak: 20, + box_chars: config.boxes.box_chars(), + n_objects: 0, + params: Params { + size: Coord2::new(width, usize::MAX), + font_size: EnumMap::from_fn(|_| 1), + line_widths: EnumMap::from_fn(|stroke| if stroke == Stroke::None { 0 } else { 1 }), + px_size: None, + min_break: EnumMap::default(), + supports_margins: false, + rtl: false, + printing: true, + can_adjust_break: false, + can_scale: false, + }, + lines: Vec::new(), + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Enum)] +enum Line { + None, + Dashed, + Single, + Double, +} + +impl From for Line { + fn from(stroke: Stroke) -> Self { + match stroke { + Stroke::None => Self::None, + Stroke::Solid | Stroke::Thick | Stroke::Thin => Self::Single, + Stroke::Dashed => Self::Dashed, + Stroke::Double => Self::Double, + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Enum)] +struct Lines { + r: Line, + b: Line, + l: Line, + t: Line, +} + +#[derive(Default)] +struct BoxChars(EnumMap); + +impl BoxChars { + fn put(&mut self, r: Line, b: Line, l: Line, chars: [char; 4]) { + use Line::*; + for (t, c) in [None, Dashed, Single, Double] + .into_iter() + .zip(chars.into_iter()) + { + self.0[Lines { r, b, l, t }] = c; + } + } +} + +impl Index for BoxChars { + type Output = char; + + fn index(&self, lines: Lines) -> &Self::Output { + &self.0[lines] + } +} + +static ASCII_BOX: LazyLock = LazyLock::new(|| { + let mut ascii_box = BoxChars::default(); + let n = Line::None; + let d = Line::Dashed; + use Line::{Double as D, Single as S}; + ascii_box.put(n, n, n, [' ', '|', '|', '#']); + ascii_box.put(n, n, d, ['-', '+', '+', '#']); + ascii_box.put(n, n, S, ['-', '+', '+', '#']); + ascii_box.put(n, n, D, ['=', '#', '#', '#']); + ascii_box.put(n, d, n, ['|', '|', '|', '#']); + ascii_box.put(n, d, d, ['+', '+', '+', '#']); + ascii_box.put(n, d, S, ['+', '+', '+', '#']); + ascii_box.put(n, d, D, ['#', '#', '#', '#']); + ascii_box.put(n, S, n, ['|', '|', '|', '#']); + ascii_box.put(n, S, d, ['+', '+', '+', '#']); + ascii_box.put(n, S, S, ['+', '+', '+', '#']); + ascii_box.put(n, S, D, ['#', '#', '#', '#']); + ascii_box.put(n, D, n, ['#', '#', '#', '#']); + ascii_box.put(n, D, d, ['#', '#', '#', '#']); + ascii_box.put(n, D, S, ['#', '#', '#', '#']); + ascii_box.put(n, D, D, ['#', '#', '#', '#']); + ascii_box.put(d, n, n, ['-', '+', '+', '#']); + ascii_box.put(d, n, d, ['-', '+', '+', '#']); + ascii_box.put(d, n, S, ['-', '+', '+', '#']); + ascii_box.put(d, n, D, ['#', '#', '#', '#']); + ascii_box.put(d, d, n, ['+', '+', '+', '#']); + ascii_box.put(d, d, d, ['+', '+', '+', '#']); + ascii_box.put(d, d, S, ['+', '+', '+', '#']); + ascii_box.put(d, d, D, ['#', '#', '#', '#']); + ascii_box.put(d, S, n, ['+', '+', '+', '#']); + ascii_box.put(d, S, d, ['+', '+', '+', '#']); + ascii_box.put(d, S, S, ['+', '+', '+', '#']); + ascii_box.put(d, S, D, ['#', '#', '#', '#']); + ascii_box.put(d, D, n, ['#', '#', '#', '#']); + ascii_box.put(d, D, d, ['#', '#', '#', '#']); + ascii_box.put(d, D, S, ['#', '#', '#', '#']); + ascii_box.put(d, D, D, ['#', '#', '#', '#']); + ascii_box.put(S, n, n, ['-', '+', '+', '#']); + ascii_box.put(S, n, d, ['-', '+', '+', '#']); + ascii_box.put(S, n, S, ['-', '+', '+', '#']); + ascii_box.put(S, n, D, ['#', '#', '#', '#']); + ascii_box.put(S, d, n, ['+', '+', '+', '#']); + ascii_box.put(S, d, d, ['+', '+', '+', '#']); + ascii_box.put(S, d, S, ['+', '+', '+', '#']); + ascii_box.put(S, d, D, ['#', '#', '#', '#']); + ascii_box.put(S, S, n, ['+', '+', '+', '#']); + ascii_box.put(S, S, d, ['+', '+', '+', '#']); + ascii_box.put(S, S, S, ['+', '+', '+', '#']); + ascii_box.put(S, S, D, ['#', '#', '#', '#']); + ascii_box.put(S, D, n, ['#', '#', '#', '#']); + ascii_box.put(S, D, d, ['#', '#', '#', '#']); + ascii_box.put(S, D, S, ['#', '#', '#', '#']); + ascii_box.put(S, D, D, ['#', '#', '#', '#']); + ascii_box.put(D, n, n, ['=', '#', '#', '#']); + ascii_box.put(D, n, d, ['#', '#', '#', '#']); + ascii_box.put(D, n, S, ['#', '#', '#', '#']); + ascii_box.put(D, n, D, ['=', '#', '#', '#']); + ascii_box.put(D, d, n, ['#', '#', '#', '#']); + ascii_box.put(D, d, d, ['#', '#', '#', '#']); + ascii_box.put(D, d, S, ['#', '#', '#', '#']); + ascii_box.put(D, d, D, ['#', '#', '#', '#']); + ascii_box.put(D, S, n, ['#', '#', '#', '#']); + ascii_box.put(D, S, d, ['#', '#', '#', '#']); + ascii_box.put(D, S, S, ['#', '#', '#', '#']); + ascii_box.put(D, S, D, ['#', '#', '#', '#']); + ascii_box.put(D, D, n, ['#', '#', '#', '#']); + ascii_box.put(D, D, d, ['#', '#', '#', '#']); + ascii_box.put(D, D, S, ['#', '#', '#', '#']); + ascii_box.put(D, D, D, ['#', '#', '#', '#']); + ascii_box +}); + +static UNICODE_BOX: LazyLock = LazyLock::new(|| { + let mut unicode_box = BoxChars::default(); + let n = Line::None; + let d = Line::Dashed; + use Line::{Double as D, Single as S}; + unicode_box.put(n, n, n, [' ', '╵', '╵', '║']); + unicode_box.put(n, n, d, ['╌', '╯', '╯', '╜']); + unicode_box.put(n, n, S, ['╴', '╯', '╯', '╜']); + unicode_box.put(n, n, D, ['═', '╛', '╛', '╝']); + unicode_box.put(n, S, n, ['╷', '│', '│', '║']); + unicode_box.put(n, S, d, ['╮', '┤', '┤', '╢']); + unicode_box.put(n, S, S, ['╮', '┤', '┤', '╢']); + unicode_box.put(n, S, D, ['╕', '╡', '╡', '╣']); + unicode_box.put(n, d, n, ['╷', '┊', '│', '║']); + unicode_box.put(n, d, d, ['╮', '┤', '┤', '╢']); + unicode_box.put(n, d, S, ['╮', '┤', '┤', '╢']); + unicode_box.put(n, d, D, ['╕', '╡', '╡', '╣']); + unicode_box.put(n, D, n, ['║', '║', '║', '║']); + unicode_box.put(n, D, d, ['╖', '╢', '╢', '╢']); + unicode_box.put(n, D, S, ['╖', '╢', '╢', '╢']); + unicode_box.put(n, D, D, ['╗', '╣', '╣', '╣']); + unicode_box.put(d, n, n, ['╌', '╰', '╰', '╙']); + unicode_box.put(d, n, d, ['╌', '┴', '┴', '╨']); + unicode_box.put(d, n, S, ['─', '┴', '┴', '╨']); + unicode_box.put(d, n, D, ['═', '╧', '╧', '╩']); + unicode_box.put(d, d, n, ['╭', '├', '├', '╟']); + unicode_box.put(d, d, d, ['┬', '+', '┼', '╪']); + unicode_box.put(d, d, S, ['┬', '┼', '┼', '╪']); + unicode_box.put(d, d, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(d, S, n, ['╭', '├', '├', '╟']); + unicode_box.put(d, S, d, ['┬', '┼', '┼', '╪']); + unicode_box.put(d, S, S, ['┬', '┼', '┼', '╪']); + unicode_box.put(d, S, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(d, D, n, ['╓', '╟', '╟', '╟']); + unicode_box.put(d, D, d, ['╥', '╫', '╫', '╫']); + unicode_box.put(d, D, S, ['╥', '╫', '╫', '╫']); + unicode_box.put(d, D, D, ['╦', '╬', '╬', '╬']); + unicode_box.put(S, n, n, ['╶', '╰', '╰', '╙']); + unicode_box.put(S, n, d, ['─', '┴', '┴', '╨']); + unicode_box.put(S, n, S, ['─', '┴', '┴', '╨']); + unicode_box.put(S, n, D, ['═', '╧', '╧', '╩']); + unicode_box.put(S, d, n, ['╭', '├', '├', '╟']); + unicode_box.put(S, d, d, ['┬', '┼', '┼', '╪']); + unicode_box.put(S, d, S, ['┬', '┼', '┼', '╪']); + unicode_box.put(S, d, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(S, S, n, ['╭', '├', '├', '╟']); + unicode_box.put(S, S, d, ['┬', '┼', '┼', '╪']); + unicode_box.put(S, S, S, ['┬', '┼', '┼', '╪']); + unicode_box.put(S, S, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(S, D, n, ['╓', '╟', '╟', '╟']); + unicode_box.put(S, D, d, ['╥', '╫', '╫', '╫']); + unicode_box.put(S, D, S, ['╥', '╫', '╫', '╫']); + unicode_box.put(S, D, D, ['╦', '╬', '╬', '╬']); + unicode_box.put(D, n, n, ['═', '╘', '╘', '╚']); + unicode_box.put(D, n, d, ['═', '╧', '╧', '╩']); + unicode_box.put(D, n, S, ['═', '╧', '╧', '╩']); + unicode_box.put(D, n, D, ['═', '╧', '╧', '╩']); + unicode_box.put(D, d, n, ['╒', '╞', '╞', '╠']); + unicode_box.put(D, d, d, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, d, S, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, d, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, S, n, ['╒', '╞', '╞', '╠']); + unicode_box.put(D, S, d, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, S, S, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, S, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, D, n, ['╔', '╠', '╠', '╠']); + unicode_box.put(D, D, d, ['╠', '╬', '╬', '╬']); + unicode_box.put(D, D, S, ['╠', '╬', '╬', '╬']); + unicode_box.put(D, D, D, ['╦', '╬', '╬', '╬']); + unicode_box +}); + +impl PivotTable { + pub fn display(&self) -> DisplayPivotTable<'_> { + DisplayPivotTable::new(self) + } +} + +impl Display for PivotTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.display()) + } +} + +pub struct DisplayPivotTable<'a> { + pt: &'a PivotTable, +} + +impl<'a> DisplayPivotTable<'a> { + fn new(pt: &'a PivotTable) -> Self { + Self { pt } + } +} + +impl Display for DisplayPivotTable<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + TextRenderer::default().render_table(self.pt, f) + } +} + +impl Display for Item { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + TextRenderer::default().render(self, f) + } +} + +pub struct TextDriver { + file: Box, + renderer: TextRenderer, +} + +impl TextDriver { + pub fn new(config: &TextConfig) -> std::io::Result { + Ok(Self { + file: match &config.file { + Some(file) => Box::new(BufWriter::new(File::create(file)?)), + None => Box::new(stdout()), + }, + renderer: TextRenderer::new(&config.options), + }) + } +} + +impl TextRenderer { + fn render(&mut self, item: &Item, writer: &mut W) -> FmtResult + where + W: FmtWrite, + { + match &item.details { + Details::Chart | Details::Image(_) => todo!(), + Details::Heading(children) => { + for (index, child) in children.0.iter().enumerate() { + if index > 0 { + writeln!(writer)?; + } + self.render(child, writer)?; + } + Ok(()) + } + Details::Message(_diagnostic) => todo!(), + Details::PageBreak => Ok(()), + Details::Table(pivot_table) => self.render_table(pivot_table, writer), + Details::Text(text) => self.render_table(&PivotTable::from((**text).clone()), writer), + } + } + + fn render_table(&mut self, table: &PivotTable, writer: &mut W) -> FmtResult + where + W: FmtWrite, + { + for (index, layer_indexes) in table.layers(true).enumerate() { + if index > 0 { + writeln!(writer)?; + } + + let mut pager = Pager::new(self, table, Some(layer_indexes.as_slice())); + while pager.has_next(self) { + pager.draw_next(self, usize::MAX); + for line in self.lines.drain(..) { + writeln!(writer, "{line}")?; + } + } + } + Ok(()) + } + + fn layout_cell(&self, text: &str, bb: Rect2) -> Coord2 { + if text.is_empty() { + return Coord2::default(); + } + + use Axis2::*; + let breaks = new_line_breaks(text, bb[X].len()); + let mut size = Coord2::new(0, 0); + for text in breaks.take(bb[Y].len()) { + let width = text.width(); + if width > size[X] { + size[X] = width; + } + size[Y] += 1; + } + size + } + + fn get_line(&mut self, y: usize) -> &mut TextLine { + if y >= self.lines.len() { + self.lines.resize(y + 1, TextLine::new()); + } + &mut self.lines[y] + } +} + +struct LineBreaks<'a, B> +where + B: Iterator + Clone + 'a, +{ + text: &'a str, + max_width: usize, + indexes: Range, + width: usize, + saved: Option<(usize, BreakOpportunity)>, + breaks: B, + trailing_newlines: usize, +} + +impl<'a, B> Iterator for LineBreaks<'a, B> +where + B: Iterator + Clone + 'a, +{ + type Item = &'a str; + + fn next(&mut self) -> Option { + while let Some((postindex, opportunity)) = self.saved.take().or_else(|| self.breaks.next()) + { + let index = if postindex != self.text.len() { + self.text[..postindex].char_indices().next_back().unwrap().0 + } else { + postindex + }; + if index <= self.indexes.end { + continue; + } + + let segment_width = self.text[self.indexes.end..index].width(); + if self.width == 0 || self.width + segment_width <= self.max_width { + // Add this segment to the current line. + self.width += segment_width; + self.indexes.end = index; + + // If this was a new-line, we're done. + if opportunity == BreakOpportunity::Mandatory { + let segment = self.text[self.indexes.clone()].trim_end_matches('\n'); + self.indexes = postindex..postindex; + self.width = 0; + return Some(segment); + } + } else { + // Won't fit. Return what we've got and save this segment for next time. + // + // We trim trailing spaces from the line we return, and leading + // spaces from the position where we resume. + let segment = self.text[self.indexes.clone()].trim_end(); + + let start = self.text[self.indexes.end..].trim_start_matches([' ', '\t']); + let start_index = self.text.len() - start.len(); + self.indexes = start_index..start_index; + self.width = 0; + self.saved = Some((postindex, opportunity)); + return Some(segment); + } + } + if self.trailing_newlines > 1 { + self.trailing_newlines -= 1; + Some("") + } else { + None + } + } +} + +fn new_line_breaks( + text: &str, + width: usize, +) -> LineBreaks<'_, impl Iterator + Clone + '_> { + // Trim trailing new-lines from the text, because the linebreaking algorithm + // treats them as if they have width. That is, if you break `"a b c\na b + // c\n"` with a 5-character width, then you end up with: + // + // ```text + // a b c + // a b + // c + // ``` + // + // So, we trim trailing new-lines and then add in extra blank lines at the + // end if necessary. + // + // (The linebreaking algorithm treats new-lines in the middle of the text in + // a normal way, though.) + let trimmed = text.trim_end_matches('\n'); + LineBreaks { + text: trimmed, + max_width: width, + indexes: 0..0, + width: 0, + saved: None, + breaks: linebreaks(trimmed), + trailing_newlines: text.len() - trimmed.len(), + } +} + +impl Driver for TextDriver { + fn name(&self) -> Cow<'static, str> { + Cow::from("text") + } + + fn write(&mut self, item: &Arc) { + let _ = self.renderer.render(item, &mut FmtAdapter(&mut self.file)); + } +} + +struct FmtAdapter(W); + +impl FmtWrite for FmtAdapter +where + W: IoWrite, +{ + fn write_str(&mut self, s: &str) -> FmtResult { + self.0.write_all(s.as_bytes()).map_err(|_| FmtError) + } +} + +impl Device for TextRenderer { + fn params(&self) -> &Params { + &self.params + } + + fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap { + let text = cell.display().to_string(); + enum_map![ + Extreme::Min => self.layout_cell(&text, Rect2::new(0..1, 0..usize::MAX)).x(), + Extreme::Max => self.layout_cell(&text, Rect2::new(0..usize::MAX, 0..usize::MAX)).x(), + ] + } + + fn measure_cell_height(&self, cell: &DrawCell, width: usize) -> usize { + let text = cell.display().to_string(); + self.layout_cell(&text, Rect2::new(0..width, 0..usize::MAX)) + .y() + } + + fn adjust_break(&self, _cell: &Content, _size: Coord2) -> usize { + unreachable!() + } + + fn draw_line(&mut self, bb: Rect2, styles: EnumMap) { + use Axis2::*; + let x = bb[X].start.max(0)..bb[X].end.min(self.width); + let y = bb[Y].start.max(0)..bb[Y].end; + if x.is_empty() || x.end >= self.width { + return; + } + + let lines = Lines { + l: styles[Y][0].stroke.into(), + r: styles[Y][1].stroke.into(), + t: styles[X][0].stroke.into(), + b: styles[X][1].stroke.into(), + }; + let c = self.box_chars[lines]; + for y in y { + self.get_line(y).put_multiple(x.start, c, x.len()); + } + } + + fn draw_cell( + &mut self, + cell: &DrawCell, + bb: Rect2, + valign_offset: usize, + _spill: EnumMap, + clip: &Rect2, + ) { + let display = cell.display(); + let text = display.to_string(); + let horz_align = cell.horz_align(&display); + + use Axis2::*; + let breaks = new_line_breaks(&text, bb[X].len()); + for (text, y) in breaks.zip(bb[Y].start + valign_offset..bb[Y].end) { + let width = text.width(); + if !clip[Y].contains(&y) { + continue; + } + + let x = match horz_align { + HorzAlign::Right | HorzAlign::Decimal { .. } => bb[X].end - width, + HorzAlign::Left => bb[X].start, + HorzAlign::Center => (bb[X].start + bb[X].end - width).div_ceil(2), + }; + let Some((x, text)) = clip_text(text, &(x..x + width), &clip[X]) else { + continue; + }; + + let text = if self.emphasis { + Emphasis::from(cell.font_style).apply(text) + } else { + Cow::from(text) + }; + self.get_line(y).put(x, &text); + } + } + + fn scale(&mut self, _factor: f64) { + unimplemented!() + } +} + +#[cfg(test)] +mod tests { + use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + + use crate::output::drivers::text::new_line_breaks; + + #[test] + fn unicode_width() { + // `\n` is a control character, so [UnicodeWidthChar] considers it to + // have no width. + assert_eq!('\n'.width(), None); + + // But [UnicodeWidthStr] in unicode-width 0.1.14+ has a different idea. + assert_eq!("\n".width(), 1); + assert_eq!("\r\n".width(), 1); + } + + #[track_caller] + fn test_line_breaks(input: &str, width: usize, expected: Vec<&str>) { + let actual = new_line_breaks(input, width).collect::>(); + if expected != actual { + panic!( + "filling {input:?} to {width} columns:\nexpected: {expected:?}\nactual: {actual:?}" + ); + } + } + #[test] + fn line_breaks() { + test_line_breaks( + "One line of text\nOne line of text\n", + 16, + vec!["One line of text", "One line of text"], + ); + test_line_breaks("a b c\na b c\na b c\n", 5, vec!["a b c", "a b c", "a b c"]); + for width in 0..=6 { + test_line_breaks("abc def ghi", width, vec!["abc", "def", "ghi"]); + } + for width in 7..=10 { + test_line_breaks("abc def ghi", width, vec!["abc def", "ghi"]); + } + test_line_breaks("abc def ghi", 11, vec!["abc def ghi"]); + + for width in 0..=6 { + test_line_breaks("abc def ghi", width, vec!["abc", "def", "ghi"]); + } + test_line_breaks("abc def ghi", 7, vec!["abc", "def ghi"]); + for width in 8..=11 { + test_line_breaks("abc def ghi", width, vec!["abc def", "ghi"]); + } + test_line_breaks("abc def ghi", 12, vec!["abc def ghi"]); + + test_line_breaks("abc\ndef\nghi", 2, vec!["abc", "def", "ghi"]); + } +} diff --git a/rust/pspp/src/output/drivers/text/text_line.rs b/rust/pspp/src/output/drivers/text/text_line.rs new file mode 100644 index 0000000000..e4d7c5c370 --- /dev/null +++ b/rust/pspp/src/output/drivers/text/text_line.rs @@ -0,0 +1,610 @@ +// 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 . + +use enum_iterator::Sequence; +use std::{ + borrow::Cow, + cmp::Ordering, + fmt::{Debug, Display}, + ops::Range, +}; + +use unicode_width::UnicodeWidthChar; + +use crate::output::pivot::FontStyle; + +/// A line of text, encoded in UTF-8, with support functions that properly +/// handle double-width characters and backspaces. +/// +/// Designed to make appending text fast, and access and modification of other +/// column positions possible. +#[derive(Clone, Default)] +pub struct TextLine { + /// Content. + string: String, + + /// Display width, in character positions. + width: usize, +} + +impl TextLine { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.string.clear(); + self.width = 0; + } + + /// Changes the width of this line to `x` columns. If `x` is longer than + /// the current width, extends the line with spaces. If `x` is shorter than + /// the current width, removes trailing characters. + pub fn resize(&mut self, x: usize) { + match x.cmp(&self.width) { + Ordering::Greater => self.string.extend((self.width..x).map(|_| ' ')), + Ordering::Less => { + let pos = self.find_pos(x); + self.string.truncate(pos.offsets.start); + if x > pos.offsets.start { + self.string.extend((pos.offsets.start..x).map(|_| '?')); + } + } + Ordering::Equal => return, + } + self.width = x; + } + + fn put_closure(&mut self, x0: usize, w: usize, push_str: F) + where + F: FnOnce(&mut String), + { + let x1 = x0 + w; + if w == 0 { + // Nothing to do. + } else if x0 >= self.width { + // The common case: adding new characters at the end of a line. + self.string.extend((self.width..x0).map(|_| ' ')); + push_str(&mut self.string); + self.width = x1; + } else if x1 >= self.width { + let p0 = self.find_pos(x0); + + // If a double-width character occupies both `x0 - 1` and `x0`, then + // replace its first character width by `?`. + self.string.truncate(p0.offsets.start); + self.string.extend((p0.columns.start..x0).map(|_| '?')); + push_str(&mut self.string); + self.width = x1; + } else { + let span = self.find_span(x0, x1); + let tail = self.string.split_off(span.offsets.end); + self.string.truncate(span.offsets.start); + self.string.extend((span.columns.start..x0).map(|_| '?')); + push_str(&mut self.string); + self.string.extend((x1..span.columns.end).map(|_| '?')); + self.string.push_str(&tail); + } + } + + pub fn put(&mut self, x0: usize, s: &str) { + self.string.reserve(s.len()); + self.put_closure(x0, Widths::new(s).sum(), |dst| dst.push_str(s)); + } + + pub fn put_multiple(&mut self, x0: usize, c: char, n: usize) { + self.string.reserve(c.len_utf8() * n); + self.put_closure(x0, c.width().unwrap() * n, |dst| { + (0..n).for_each(|_| dst.push(c)) + }); + } + + fn find_span(&self, x0: usize, x1: usize) -> Position { + debug_assert!(x1 > x0); + let p0 = self.find_pos(x0); + let p1 = self.find_pos(x1 - 1); + Position { + columns: p0.columns.start..p1.columns.end, + offsets: p0.offsets.start..p1.offsets.end, + } + } + + // Returns the [Position] that contains column `target_x`. + fn find_pos(&self, target_x: usize) -> Position { + let mut x = 0; + let mut ofs = 0; + let mut widths = Widths::new(&self.string); + while let Some(w) = widths.next() { + if x + w > target_x { + return Position { + columns: x..x + w, + offsets: ofs..widths.offset(), + }; + } + ofs = widths.offset(); + x += w; + } + + // This can happen if there are non-printable characters in a line. + Position { + columns: x..x, + offsets: ofs..ofs, + } + } + + pub fn str(&self) -> &str { + &self.string + } +} + +impl Display for TextLine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.string) + } +} + +/// Position of one or more characters within a [TextLine]. +#[derive(Debug)] +struct Position { + /// 0-based display columns. + columns: Range, + + /// Byte offests. + offsets: Range, +} + +/// Iterates through the column widths in a string. +struct Widths<'a> { + s: &'a str, + base: &'a str, +} + +impl<'a> Widths<'a> { + fn new(s: &'a str) -> Self { + Self { s, base: s } + } + + /// Returns the amount of the string remaining to be visited. + fn as_str(&self) -> &str { + self.s + } + + // Returns the offset into the original string of the characters remaining + // to be visited. + fn offset(&self) -> usize { + self.base.len() - self.s.len() + } +} + +impl Iterator for Widths<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + let mut iter = self.s.char_indices(); + let (_, mut c) = iter.next()?; + while iter.as_str().starts_with('\x08') { + iter.next(); + c = match iter.next() { + Some((_, c)) => c, + _ => { + self.s = iter.as_str(); + return Some(0); + } + }; + } + + let w = c.width().unwrap_or_default(); + if w == 0 { + self.s = iter.as_str(); + return Some(0); + } + + for (index, c) in iter { + if c.width().is_some_and(|width| width > 0) { + self.s = &self.s[index..]; + return Some(w); + } + } + self.s = ""; + Some(w) + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Sequence)] +pub struct Emphasis { + pub bold: bool, + pub underline: bool, +} + +impl From<&FontStyle> for Emphasis { + fn from(style: &FontStyle) -> Self { + Self { + bold: style.bold, + underline: style.underline, + } + } +} + +impl Emphasis { + const fn plain() -> Self { + Self { + bold: false, + underline: false, + } + } + pub fn is_plain(&self) -> bool { + *self == Self::plain() + } + pub fn apply<'a>(&self, s: &'a str) -> Cow<'a, str> { + if self.is_plain() { + Cow::from(s) + } else { + let mut output = String::with_capacity( + s.len() * (1 + self.bold as usize * 2 + self.underline as usize * 2), + ); + for c in s.chars() { + if self.bold { + output.push(c); + output.push('\x08'); + } + if self.underline { + output.push('_'); + output.push('\x08'); + } + output.push(c); + } + Cow::from(output) + } + } +} + +impl Debug for Emphasis { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self { + bold: false, + underline: false, + } => "plain", + Self { + bold: true, + underline: false, + } => "bold", + Self { + bold: false, + underline: true, + } => "underline", + Self { + bold: true, + underline: true, + } => "bold+underline", + } + ) + } +} + +pub fn clip_text<'a>( + text: &'a str, + bb: &Range, + clip: &Range, +) -> Option<(usize, &'a str)> { + let mut x = bb.start; + let mut width = bb.len(); + + let mut iter = text.chars(); + while x < clip.start { + let c = iter.next()?; + if let Some(w) = c.width() { + x += w; + width = width.checked_sub(w)?; + } + } + if x + width > clip.end { + if x >= clip.end { + return None; + } + + while x + width > clip.end { + let c = iter.next_back()?; + if let Some(w) = c.width() { + width = width.checked_sub(w)?; + } + } + } + Some((x, iter.as_str())) +} + +#[cfg(test)] +mod tests { + use super::{Emphasis, TextLine}; + use enum_iterator::all; + + #[test] + fn overwrite_rest_of_line() { + for lowercase in all::() { + for uppercase in all::() { + let mut line = TextLine::new(); + line.put(0, &lowercase.apply("abc")); + line.put(1, &uppercase.apply("BCD")); + assert_eq!( + line.str(), + &format!("{}{}", lowercase.apply("a"), uppercase.apply("BCD")), + "uppercase={uppercase:?} lowercase={lowercase:?}" + ); + } + } + } + + #[test] + fn overwrite_partial_line() { + for lowercase in all::() { + for uppercase in all::() { + let mut line = TextLine::new(); + // Produces `AbCDEf`. + line.put(0, &lowercase.apply("abcdef")); + line.put(0, &uppercase.apply("A")); + line.put(2, &uppercase.apply("CDE")); + assert_eq!( + line.str().replace('\x08', "#"), + format!( + "{}{}{}{}", + uppercase.apply("A"), + lowercase.apply("b"), + uppercase.apply("CDE"), + lowercase.apply("f") + ) + .replace('\x08', "#"), + "uppercase={uppercase:?} lowercase={lowercase:?}" + ); + } + } + } + + #[test] + fn overwrite_rest_with_double_width() { + for lowercase in all::() { + for hiragana in all::() { + let mut line = TextLine::new(); + // Produces `kaきくけ"`. + line.put(0, &lowercase.apply("kakiku")); + line.put(2, &hiragana.apply("きくけ")); + assert_eq!( + line.str(), + &format!("{}{}", lowercase.apply("ka"), hiragana.apply("きくけ")), + "lowercase={lowercase:?} hiragana={hiragana:?}" + ); + } + } + } + + #[test] + fn overwrite_partial_with_double_width() { + for lowercase in all::() { + for hiragana in all::() { + let mut line = TextLine::new(); + // Produces `かkiくけko". + line.put(0, &lowercase.apply("kakikukeko")); + line.put(0, &hiragana.apply("か")); + line.put(4, &hiragana.apply("くけ")); + assert_eq!( + line.str(), + &format!( + "{}{}{}{}", + hiragana.apply("か"), + lowercase.apply("ki"), + hiragana.apply("くけ"), + lowercase.apply("ko") + ), + "lowercase={lowercase:?} hiragana={hiragana:?}" + ); + } + } + } + + /// Overwrite rest of line, aligned double-width over double-width + #[test] + fn aligned_double_width_rest_of_line() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `あきくけ`. + line.put(0, &bottom.apply("あいう")); + line.put(2, &top.apply("きくけ")); + assert_eq!( + line.str(), + &format!("{}{}", bottom.apply("あ"), top.apply("きくけ")), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite rest of line, misaligned double-width over double-width + #[test] + fn misaligned_double_width_rest_of_line() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `あきくけ`. + line.put(0, &bottom.apply("あいう")); + line.put(3, &top.apply("きくけ")); + assert_eq!( + line.str(), + &format!("{}?{}", bottom.apply("あ"), top.apply("きくけ")), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite partial line, aligned double-width over double-width + #[test] + fn aligned_double_width_partial() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `かいくけお`. + line.put(0, &bottom.apply("あいうえお")); + line.put(0, &top.apply("か")); + line.put(4, &top.apply("くけ")); + assert_eq!( + line.str(), + &format!( + "{}{}{}{}", + top.apply("か"), + bottom.apply("い"), + top.apply("くけ"), + bottom.apply("お") + ), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite partial line, misaligned double-width over double-width + #[test] + fn misaligned_double_width_partial() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `?か?うえおさ`. + line.put(0, &bottom.apply("あいうえおさ")); + line.put(1, &top.apply("か")); + assert_eq!( + line.str(), + &format!("?{}?{}", top.apply("か"), bottom.apply("うえおさ"),), + "bottom={bottom:?} top={top:?}" + ); + + // Produces `?か??くけ?さ`. + line.put(5, &top.apply("くけ")); + assert_eq!( + line.str(), + &format!( + "?{}??{}?{}", + top.apply("か"), + top.apply("くけ"), + bottom.apply("さ") + ), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite rest of line, aligned single-width over double-width. + #[test] + fn aligned_rest_single_over_double() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `あkikuko`. + line.put(0, &bottom.apply("あいう")); + line.put(2, &top.apply("kikuko")); + assert_eq!( + line.str(), + &format!("{}{}", bottom.apply("あ"), top.apply("kikuko"),), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite rest of line, misaligned single-width over double-width. + #[test] + fn misaligned_rest_single_over_double() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `あ?kikuko`. + line.put(0, &bottom.apply("あいう")); + line.put(3, &top.apply("kikuko")); + assert_eq!( + line.str(), + &format!("{}?{}", bottom.apply("あ"), top.apply("kikuko"),), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite partial line, aligned single-width over double-width. + #[test] + fn aligned_partial_single_over_double() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `kaいうえお`. + line.put(0, &bottom.apply("あいうえお")); + line.put(0, &top.apply("ka")); + assert_eq!( + line.str(), + &format!("{}{}", top.apply("ka"), bottom.apply("いうえお"),), + "bottom={bottom:?} top={top:?}" + ); + + // Produces `kaいkukeお`. + line.put(4, &top.apply("kuke")); + assert_eq!( + line.str(), + &format!( + "{}{}{}{}", + top.apply("ka"), + bottom.apply("い"), + top.apply("kuke"), + bottom.apply("お") + ), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite partial line, misaligned single-width over double-width. + #[test] + fn misaligned_partial_single_over_double() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `?aいうえおさ`. + line.put(0, &bottom.apply("あいうえおさ")); + line.put(1, &top.apply("a")); + assert_eq!( + line.str(), + &format!("?{}{}", top.apply("a"), bottom.apply("いうえおさ"),), + "bottom={bottom:?} top={top:?}" + ); + + // Produces `?aい?kuke?さ`. + line.put(5, &top.apply("kuke")); + assert_eq!( + line.str(), + &format!( + "?{}{}?{}?{}", + top.apply("a"), + bottom.apply("い"), + top.apply("kuke"), + bottom.apply("さ") + ), + "bottom={bottom:?} top={top:?}" + ); + } + } + } +} diff --git a/rust/pspp/src/output/html.rs b/rust/pspp/src/output/html.rs deleted file mode 100644 index 949724b9af..0000000000 --- a/rust/pspp/src/output/html.rs +++ /dev/null @@ -1,500 +0,0 @@ -// 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 . - -use std::{ - borrow::Cow, - fmt::{Display, Write as _}, - fs::File, - io::Write, - path::PathBuf, - sync::Arc, -}; - -use serde::{Deserialize, Serialize}; -use smallstr::SmallString; - -use crate::output::{ - Details, Item, - driver::Driver, - pivot::{Axis2, BorderStyle, Color, Coord2, HorzAlign, PivotTable, Rect2, Stroke, VertAlign}, - table::{DrawCell, Table}, -}; - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct HtmlConfig { - file: PathBuf, -} - -pub struct HtmlDriver { - writer: W, - fg: Color, - bg: Color, -} - -impl Stroke { - fn as_css(&self) -> Option<&'static str> { - match self { - Stroke::None => None, - Stroke::Solid => Some("1pt solid"), - Stroke::Dashed => Some("1pt dashed"), - Stroke::Thick => Some("2pt solid"), - Stroke::Thin => Some("0.5pt solid"), - Stroke::Double => Some("double"), - } - } -} - -impl HtmlDriver { - pub fn new(config: &HtmlConfig) -> std::io::Result { - Ok(Self::for_writer(File::create(&config.file)?)) - } -} - -impl HtmlDriver -where - W: Write, -{ - pub fn for_writer(mut writer: W) -> Self { - let _ = put_header(&mut writer); - Self { - fg: Color::BLACK, - bg: Color::WHITE, - writer, - } - } - - fn render(&mut self, pivot_table: &PivotTable) -> std::io::Result<()> { - for layer_indexes in pivot_table.layers(true) { - let output = pivot_table.output(&layer_indexes, false); - write!(&mut self.writer, "")?; - - if let Some(title) = output.title { - let cell = title.get(Coord2::new(0, 0)); - self.put_cell( - DrawCell::new(cell.inner(), &title), - Rect2::new(0..1, 0..1), - false, - "caption", - None, - )?; - } - - if let Some(layers) = output.layers { - writeln!(&mut self.writer, "")?; - for cell in layers.cells() { - writeln!(&mut self.writer, "")?; - self.put_cell( - DrawCell::new(cell.inner(), &layers), - Rect2::new(0..output.body.n[Axis2::X], 0..1), - false, - "td", - None, - )?; - writeln!(&mut self.writer, "")?; - } - writeln!(&mut self.writer, "")?; - } - - writeln!(&mut self.writer, "")?; - for y in 0..output.body.n.y() { - writeln!(&mut self.writer, "")?; - for x in output.body.iter_x(y) { - let cell = output.body.get(Coord2::new(x, y)); - 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), - )?; - } - } - writeln!(&mut self.writer, "")?; - } - writeln!(&mut self.writer, "")?; - - if output.caption.is_some() || output.footnotes.is_some() { - writeln!(&mut self.writer, "")?; - writeln!(&mut self.writer, "")?; - if let Some(caption) = output.caption { - 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, - )?; - } - writeln!(&mut self.writer, "")?; - - if let Some(footnotes) = output.footnotes { - for cell in footnotes.cells() { - writeln!(&mut self.writer, "")?; - self.put_cell( - DrawCell::new(cell.inner(), &footnotes), - Rect2::new(0..output.body.n[Axis2::X], 0..1), - false, - "td", - None, - )?; - writeln!(&mut self.writer, "")?; - } - } - writeln!(&mut self.writer, "")?; - } - } - Ok(()) - } - - fn put_cell( - &mut self, - cell: DrawCell<'_>, - rect: Rect2, - alternate_row: bool, - tag: &str, - table: Option<&Table>, - ) -> std::io::Result<()> { - write!(&mut self.writer, "<{tag}")?; - let (body, suffixes) = cell.display().split_suffixes(); - - let mut style = String::new(); - let horz_align = match cell.horz_align(&body) { - HorzAlign::Right | HorzAlign::Decimal { .. } => Some("right"), - HorzAlign::Center => Some("center"), - HorzAlign::Left => None, - }; - if let Some(horz_align) = horz_align { - write!(&mut style, "text-align: {horz_align}; ").unwrap(); - } - - if cell.rotate { - write!(&mut style, "writing-mode: sideways-lr; ").unwrap(); - } - - let vert_align = match cell.style.cell_style.vert_align { - VertAlign::Top => None, - VertAlign::Middle => Some("middle"), - VertAlign::Bottom => Some("bottom"), - }; - if let Some(vert_align) = vert_align { - write!(&mut style, "vertical-align: {vert_align}; ").unwrap(); - } - let bg = cell.style.font_style.bg[alternate_row as usize]; - if bg != Color::WHITE { - write!(&mut style, "background: {}; ", bg.display_css()).unwrap(); - } - - let fg = cell.style.font_style.fg[alternate_row as usize]; - if fg != Color::BLACK { - write!(&mut style, "color: {}; ", fg.display_css()).unwrap(); - } - - if !cell.style.font_style.font.is_empty() { - write!( - &mut style, - r#"font-family: "{}"; "#, - Escape::new(&cell.style.font_style.font) - ) - .unwrap(); - } - - if cell.style.font_style.bold { - write!(&mut style, "font-weight: bold; ").unwrap(); - } - if cell.style.font_style.italic { - write!(&mut style, "font-style: italic; ").unwrap(); - } - if cell.style.font_style.underline { - write!(&mut style, "text-decoration: underline; ").unwrap(); - } - if cell.style.font_style.size != 0 { - write!(&mut style, "font-size: {}pt; ", cell.style.font_style.size).unwrap(); - } - - if let Some(table) = table { - Self::put_border(&mut style, table.get_rule(Axis2::Y, rect.top_left()), "top"); - Self::put_border( - &mut style, - table.get_rule(Axis2::X, rect.top_left()), - "left", - ); - if rect[Axis2::X].end == table.n[Axis2::X] { - Self::put_border( - &mut style, - table.get_rule( - Axis2::X, - Coord2::new(rect[Axis2::X].end, rect[Axis2::Y].start), - ), - "right", - ); - } - if rect[Axis2::Y].end == table.n[Axis2::Y] { - Self::put_border( - &mut style, - table.get_rule( - Axis2::Y, - Coord2::new(rect[Axis2::X].start, rect[Axis2::Y].end), - ), - "bottom", - ); - } - } - - if !style.is_empty() { - write!( - &mut self.writer, - " style='{}'", - Escape::new(style.trim_end_matches("; ")) - .with_apos("'") - .with_quote("\"") - )?; - } - - let col_span = rect[Axis2::X].len(); - if col_span > 1 { - write!(&mut self.writer, r#" colspan="{col_span}""#)?; - } - - let row_span = rect[Axis2::Y].len(); - if row_span > 1 { - write!(&mut self.writer, r#" rowspan="{row_span}""#)?; - } - - write!(&mut self.writer, ">")?; - - let mut text = SmallString::<[u8; 64]>::new(); - write!(&mut text, "{body}").unwrap(); - write!( - &mut self.writer, - "{}", - Escape::new(&text).with_newline("
") - )?; - - if suffixes.has_subscripts() { - write!(&mut self.writer, "")?; - for (index, subscript) in suffixes.subscripts().enumerate() { - if index > 0 { - write!(&mut self.writer, ",")?; - } - write!( - &mut self.writer, - "{}", - Escape::new(subscript) - .with_space(" ") - .with_newline("
") - )?; - } - write!(&mut self.writer, "
")?; - } - - if suffixes.has_footnotes() { - write!(&mut self.writer, "")?; - for (index, footnote) in suffixes.footnotes().enumerate() { - if index > 0 { - write!(&mut self.writer, ",")?; - } - let mut marker = SmallString::<[u8; 8]>::new(); - write!(&mut marker, "{footnote}").unwrap(); - write!( - &mut self.writer, - "{}", - Escape::new(&marker) - .with_space(" ") - .with_newline("
") - )?; - } - write!(&mut self.writer, "
")?; - } - - writeln!(&mut self.writer, "") - } - - fn put_border(dst: &mut String, style: BorderStyle, border_name: &str) { - if let Some(css_style) = style.stroke.as_css() { - write!(dst, "border-{border_name}: {css_style}").unwrap(); - if style.color != Color::BLACK { - write!(dst, " {}", style.color.display_css()).unwrap(); - } - write!(dst, "; ").unwrap(); - } - } -} - -fn put_header(mut writer: W) -> std::io::Result<()> -where - W: Write, -{ - write!( - &mut writer, - r#" - - -PSPP Output - - -{} -"#, - Escape::new(env!("CARGO_PKG_VERSION")), - HEADER_CSS, - )?; - Ok(()) -} - -const HEADER_CSS: &str = r#" - - -"#; - -impl Driver for HtmlDriver -where - W: Write, -{ - fn name(&self) -> Cow<'static, str> { - Cow::from("html") - } - - fn write(&mut self, item: &Arc) { - match &item.details { - Details::Chart => todo!(), - Details::Image => todo!(), - Details::Group(_) => todo!(), - Details::Message(_diagnostic) => todo!(), - Details::PageBreak => (), - Details::Table(pivot_table) => { - self.render(pivot_table).unwrap(); // XXX - } - Details::Text(_text) => todo!(), - } - } -} - -struct Escape<'a> { - string: &'a str, - space: &'static str, - newline: &'static str, - quote: &'static str, - apos: &'static str, -} - -impl<'a> Escape<'a> { - fn new(string: &'a str) -> Self { - Self { - string, - space: " ", - newline: "\n", - quote: """, - apos: "'", - } - } - fn with_space(self, space: &'static str) -> Self { - Self { space, ..self } - } - fn with_newline(self, newline: &'static str) -> Self { - Self { newline, ..self } - } - fn with_quote(self, quote: &'static str) -> Self { - Self { quote, ..self } - } - fn with_apos(self, apos: &'static str) -> Self { - Self { apos, ..self } - } -} - -impl Display for Escape<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for c in self.string.chars() { - match c { - '\n' => f.write_str(self.newline)?, - ' ' => f.write_str(self.space)?, - '&' => f.write_str("&")?, - '<' => f.write_str("<")?, - '>' => f.write_str(">")?, - '"' => f.write_str(self.quote)?, - '\'' => f.write_str(self.apos)?, - _ => f.write_char(c)?, - } - } - Ok(()) - } -} diff --git a/rust/pspp/src/output/json.rs b/rust/pspp/src/output/json.rs deleted file mode 100644 index c7f52bd5e7..0000000000 --- a/rust/pspp/src/output/json.rs +++ /dev/null @@ -1,58 +0,0 @@ -// 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 . - -use std::{ - borrow::Cow, - fs::File, - io::{BufWriter, Write}, - path::PathBuf, - sync::Arc, -}; - -use serde::{Deserialize, Serialize}; - -use super::{Item, driver::Driver}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct JsonConfig { - file: PathBuf, -} - -pub struct JsonDriver { - file: BufWriter, -} - -impl JsonDriver { - pub fn new(config: &JsonConfig) -> std::io::Result { - Ok(Self { - file: BufWriter::new(File::create(&config.file)?), - }) - } -} - -impl Driver for JsonDriver { - fn name(&self) -> Cow<'static, str> { - Cow::from("json") - } - - fn write(&mut self, item: &Arc) { - serde_json::to_writer_pretty(&mut self.file, item).unwrap(); // XXX handle errors - } - - fn flush(&mut self) { - let _ = self.file.flush(); - } -} diff --git a/rust/pspp/src/output/page.rs b/rust/pspp/src/output/page.rs index 6872a6aeab..d8ea2e67d9 100644 --- a/rust/pspp/src/output/page.rs +++ b/rust/pspp/src/output/page.rs @@ -14,10 +14,15 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . +use std::{str::FromStr, sync::LazyLock}; + use enum_map::{EnumMap, enum_map}; -use serde::{Deserialize, Serialize}; +use paper_sizes::{Catalog, Length, PaperSize, Unit}; +use serde::{Deserialize, Deserializer, Serialize, de::Error}; + +use crate::output::spv::html::Document; -use super::pivot::{Axis2, HorzAlign}; +use super::pivot::Axis2; #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] @@ -45,65 +50,212 @@ pub enum ChartSize { QuarterHeight, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Paragraph { - pub markup: String, - pub horz_align: HorzAlign, -} - -impl Default for Paragraph { - fn default() -> Self { - Self { - markup: Default::default(), - horz_align: HorzAlign::Left, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct Heading(pub Vec); - -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(default)] pub struct PageSetup { /// Page number of first page. pub initial_page_number: i32, /// Paper size in inches. - pub paper: EnumMap, + #[serde(deserialize_with = "deserialize_paper_size")] + pub paper: PaperSize, - /// Margin width in inches. - pub margins: EnumMap, + /// Margin width. + pub margins: Margins, /// Portrait or landscape. pub orientation: Orientation, - /// Space between objects, in inches. - pub object_spacing: f64, + /// Space between objects. + pub object_spacing: Length, /// Size of charts. pub chart_size: ChartSize, - /// Header and footer. - pub headings: [Heading; 2], + /// Header. + pub header: Document, + + /// Footer. + pub footer: Document, +} + +static CATALOG: LazyLock = LazyLock::new(|| Catalog::new()); + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Margins(pub EnumMap); + +impl Margins { + fn new(top: Length, right: Length, bottom: Length, left: Length) -> Self { + Self(enum_map! { + Axis2::X => [left, right], + Axis2::Y => [top, bottom], + }) + } + + fn new_uniform(width: Length) -> Self { + Self(EnumMap::from_fn(|_| [width, width])) + } + + fn new_width_height(width: Length, height: Length) -> Self { + Self(enum_map! { + Axis2::X => [width, width], + Axis2::Y => [height, height], + }) + } + + fn total(&self, axis: Axis2, unit: Unit) -> f64 { + self.0[axis][0].into_unit(unit) + self.0[axis][1].into_unit(unit) + } +} + +impl Default for Margins { + fn default() -> Self { + Self::new_uniform(Length::new(0.5, Unit::Inch)) + } +} + +impl Serialize for Margins { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + { + let l = self.0[Axis2::X][0]; + let r = self.0[Axis2::X][1]; + let t = self.0[Axis2::Y][0]; + let b = self.0[Axis2::Y][1]; + if l == r { + if t == b { + if l == t { + l.serialize(serializer) + } else { + [t, l].serialize(serializer) + } + } else { + [t, l, b].serialize(serializer) + } + } else { + [t, r, b, l].serialize(serializer) + } + } + } +} + +impl<'de> Deserialize<'de> for Margins { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Margins { + Array(Vec), + Value(Length), + } + let (t, r, b, l) = match Margins::deserialize(deserializer)? { + Margins::Array(items) if items.len() == 1 => (items[0], items[0], items[0], items[0]), + Margins::Array(items) if items.len() == 2 => (items[0], items[1], items[0], items[1]), + Margins::Array(items) if items.len() == 3 => (items[0], items[1], items[2], items[1]), + Margins::Array(items) if items.len() == 4 => (items[0], items[1], items[2], items[3]), + Margins::Value(value) => (value, value, value, value), + _ => return Err(D::Error::custom("invalid margins")), + }; + Ok(Self(enum_map! { + Axis2::X => [l, r], + Axis2::Y => [t, b], + })) + } +} + +pub fn deserialize_paper_size<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + PaperSize::from_str(&s).or_else(|_| { + CATALOG + .get_by_name(&s) + .map(|spec| spec.size) + .ok_or_else(|| D::Error::custom("unknown or invalid paper size {size}")) + }) +} + +fn paper_size_to_enum_map(paper_size: PaperSize) -> EnumMap { + let (w, h) = paper_size + .as_unit(paper_sizes::Unit::Inch) + .into_width_height(); + enum_map! { + Axis2::X => w, + Axis2::Y => h + } } impl Default for PageSetup { fn default() -> Self { Self { initial_page_number: 1, - paper: enum_map! { Axis2::X => 8.5, Axis2::Y => 11.0 }, - margins: enum_map! { Axis2::X => [0.5, 0.5], Axis2::Y => [0.5, 0.5] }, + paper: CATALOG.default_paper().size, + margins: Margins::default(), orientation: Default::default(), - object_spacing: 12.0 / 72.0, + object_spacing: Length::new(12.0, Unit::Point), chart_size: Default::default(), - headings: Default::default(), + header: Document::from_html(r#"

&[PageTitle]

"#), + footer: Document::from_html(r#"

Page &[Page]

"#), } } } impl PageSetup { pub fn printable_size(&self) -> EnumMap { - EnumMap::from_fn(|axis| self.paper[axis] - self.margins[axis][0] - self.margins[axis][1]) + let paper = paper_size_to_enum_map(self.paper); + EnumMap::from_fn(|axis| paper[axis] - self.margins.total(axis, Unit::Inch)) + } +} + +#[cfg(test)] +mod tests { + use paper_sizes::{Length, Unit}; + + use crate::output::page::{Margins, PageSetup}; + + #[test] + fn margins() { + let a = Length::new(1.0, Unit::Inch); + let b = Length::new(2.0, Unit::Point); + let c = Length::new(3.0, Unit::Millimeter); + let d = Length::new(4.5, Unit::Millimeter); + assert_eq!( + serde_json::to_string(&Margins::new_uniform(a)).unwrap(), + "\"1in\"" + ); + assert_eq!( + serde_json::from_str::("\"1in\"").unwrap(), + Margins::new_uniform(a) + ); + assert_eq!( + serde_json::from_str::("[\"1in\"]").unwrap(), + Margins::new_uniform(a) + ); + assert_eq!( + serde_json::to_string(&Margins::new_width_height(a, b)).unwrap(), + "[\"2pt\",\"1in\"]" + ); + assert_eq!( + serde_json::to_string(&Margins::new(a, b, c, b)).unwrap(), + "[\"1in\",\"2pt\",\"3mm\"]" + ); + assert_eq!( + serde_json::to_string(&Margins::new(a, b, c, d)).unwrap(), + "[\"1in\",\"2pt\",\"3mm\",\"4.5mm\"]" + ); + } + + #[test] + fn page_setup() { + let s = toml::to_string(&PageSetup::default()).unwrap(); + assert_eq!( + toml::from_str::(&s).unwrap(), + PageSetup::default() + ); } } diff --git a/rust/pspp/src/output/pivot.rs b/rust/pspp/src/output/pivot.rs index 13392f8ea6..6da0d028d1 100644 --- a/rust/pspp/src/output/pivot.rs +++ b/rust/pspp/src/output/pivot.rs @@ -58,7 +58,8 @@ pub use color::ParseError as ParseColorError; use color::{AlphaColor, Rgba8, Srgb, palette::css::TRANSPARENT}; use enum_iterator::Sequence; use enum_map::{Enum, EnumMap, enum_map}; -use look_xml::TableProperties; +use itertools::Itertools; +pub use look_xml::{Length, TableProperties}; use quick_xml::{DeError, de::from_str}; use serde::{ Deserialize, Serialize, Serializer, @@ -68,12 +69,17 @@ use serde::{ use smallstr::SmallString; use smallvec::SmallVec; use thiserror::Error as ThisError; +pub use tlo::parse_bool; use tlo::parse_tlo; use crate::{ calendar::date_time_to_pspp, - data::{ByteString, Datum, EncodedString, RawString}, - format::{Decimal, Format, Settings as FormatSettings, Type, UncheckedFormat}, + data::{ByteString, Datum, EncodedString}, + format::{ + DATETIME40_0, Decimal, F8_2, F40, F40_2, F40_3, Format, PCT40_1, + Settings as FormatSettings, Type, UncheckedFormat, + }, + output::spv::html::Markup, settings::{Settings, Show}, util::ToSmallString, variable::{VarType, Variable}, @@ -87,9 +93,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, @@ -98,16 +107,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 { @@ -116,7 +141,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"), } } @@ -131,44 +156,33 @@ impl Serialize for Area { } } -impl Area { - fn default_cell_style(self) -> CellStyle { - use HorzAlign::*; - use VertAlign::*; - let (horz_align, vert_align, hmargins, vmargins) = match self { - Area::Title => (Some(Center), Middle, [8, 11], [1, 8]), - Area::Caption => (Some(Left), Top, [8, 11], [1, 1]), - Area::Footer => (Some(Left), Top, [11, 8], [2, 3]), - 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::Layers => (Some(Left), Bottom, [8, 11], [1, 3]), - }; - CellStyle { - horz_align, - vert_align, - margins: enum_map! { Axis2::X => hmargins, Axis2::Y => vmargins }, - } - } +/// 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, +} - fn default_font_style(self) -> FontStyle { - FontStyle { - bold: self == Area::Title, - italic: false, - underline: false, - markup: false, - font: String::from("Sans Serif"), - fg: [Color::BLACK; 2], - bg: [Color::WHITE; 2], - size: 9, +impl From for RowParity { + fn from(value: usize) -> Self { + if value % 2 == 1 { + Self::Odd + } else { + Self::Even } } +} - fn default_area_style(self) -> AreaStyle { - AreaStyle { - cell_style: self.default_cell_style(), - font_style: self.default_font_style(), +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"), } } } @@ -214,6 +228,10 @@ impl Border { Self::Dimension(row_col_border) => Self::Category(row_col_border), } } + + pub fn default_borders() -> EnumMap { + EnumMap::from_fn(Border::default_border_style) + } } impl Display for Border { @@ -289,14 +307,14 @@ impl Display for RowColBorder { #[derive(Default, Clone, Debug, Serialize)] pub struct Sizing { /// Specific column widths, in 1/96" units. - widths: Vec, + pub widths: Vec, /// Specific page breaks: 0-based columns after which a page break must /// occur, e.g. a value of 1 requests a break after the second column. - breaks: Vec, + pub breaks: Vec, /// Keeps: columns to keep together on a page if possible. - keeps: Vec>, + pub keeps: Vec>, } #[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Sequence, Serialize)] @@ -362,19 +380,21 @@ impl Iterator for AxisIterator { } impl PivotTable { - pub fn with_look(mut self, look: Arc) -> Self { - self.look = look; - self + pub fn with_look(self, look: Arc) -> Self { + Self { + style: self.style.with_look(look), + ..self + } } pub fn insert_number(&mut self, data_indexes: &[usize], number: Option, class: Class) { let format = match class { Class::Other => Settings::global().default_format, - Class::Integer => Format::F40, - Class::Correlations => Format::F40_3, - Class::Significance => Format::F40_3, - Class::Percent => Format::PCT40_1, - Class::Residual => Format::F40_2, - Class::Count => Format::F40, // XXX + Class::Integer => F40, + Class::Correlations => F40_3, + Class::Significance => F40_3, + Class::Percent => PCT40_1, + Class::Residual => F40_2, + Class::Count => F40, // XXX }; let value = Value::new(ValueInner::Number(NumberValue { show: None, @@ -443,6 +463,8 @@ pub struct Path<'a> { leaf: &'a Leaf, } +pub type IndexVec = SmallVec<[usize; 4]>; + impl Dimension { pub fn new(root: Group) -> Self { Dimension { @@ -469,6 +491,10 @@ impl Dimension { self.root.leaf_path(index, SmallVec::new()) } + pub fn index_path(&self, index: usize) -> Option { + self.root.index_path(index, SmallVec::new()) + } + pub fn with_all_labels_hidden(self) -> Self { Self { hide_all_labels: true, @@ -477,6 +503,37 @@ impl Dimension { } } +/// Specifies a [Category] within a [Group]. +#[derive(Copy, Clone, Debug)] +pub struct CategoryLocator { + /// The index of the leaf to start from. + pub leaf_index: usize, + + /// The number of times to go up a level from the leaf. If this category is + /// a leaf, this is 0, otherwise it is positive. + pub level: usize, +} + +impl CategoryLocator { + pub fn new_leaf(leaf_index: usize) -> Self { + Self { + leaf_index, + level: 0, + } + } + + pub fn parent(&self) -> Self { + Self { + leaf_index: self.leaf_index, + level: self.level + 1, + } + } + + pub fn as_leaf(&self) -> Option { + (self.level == 0).then_some(self.leaf_index) + } +} + #[derive(Clone, Debug, Serialize)] pub struct Group { #[serde(skip)] @@ -509,7 +566,7 @@ impl Group { pub fn push(&mut self, child: impl Into) { let mut child = child.into(); - if let Category::Group(group) = &mut child { + if let Some(group) = child.as_group_mut() { group.show_label = true; } self.len += child.len(); @@ -561,6 +618,45 @@ impl Group { None } + fn index_path(&self, mut index: usize, mut path: IndexVec) -> Option { + for (i, child) in self.children.iter().enumerate() { + let len = child.len(); + if index < len { + path.push(i); + return child.index_path(index, path); + } + index -= len; + } + None + } + + fn locator_path(&self, locator: CategoryLocator) -> Option { + let mut path = self.index_path(locator.leaf_index, IndexVec::new())?; + path.truncate(path.len().checked_sub(locator.level)?); + Some(path) + } + + /// Returns `None` if `locator` is invalid (that is, if `locator.leaf_idx >= + /// self.len` or `locator.level` is greater than the depth of the leaf) or + /// if `locator` designates `self`. + pub fn category(&self, locator: CategoryLocator) -> Option<&Category> { + let path = self.locator_path(locator)?; + let mut this = &self.children[*path.get(0)?]; + for index in path[1..].iter().copied() { + this = &this.as_group().unwrap().children[index]; + } + Some(this) + } + + pub fn category_mut(&mut self, locator: CategoryLocator) -> Option<&mut Category> { + let path = self.locator_path(locator)?; + let mut this = &mut self.children[*path.get(0)?]; + for index in path[1..].iter().copied() { + this = &mut this.as_group_mut().unwrap().children[index]; + } + Some(this) + } + pub fn len(&self) -> usize { self.len } @@ -588,7 +684,7 @@ where } #[derive(Clone, Debug, Default, Serialize)] -pub struct Footnotes(pub Vec>); +pub struct Footnotes(Vec>); impl Footnotes { pub fn new() -> Self { @@ -604,21 +700,54 @@ impl Footnotes { pub fn is_empty(&self) -> bool { self.0.is_empty() } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn get(&self, index: usize) -> Option<&Arc> { + self.0.get(index) + } } -#[derive(Clone, Debug)] -pub struct Leaf { - name: Box, +impl Index for Footnotes { + type Output = Arc; + + fn index(&self, index: usize) -> &Self::Output { + &self.0[index] + } +} + +impl<'a> IntoIterator for &'a Footnotes { + type Item = &'a Arc; + + type IntoIter = std::slice::Iter<'a, Arc>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl FromIterator for Footnotes { + fn from_iter>(iter: T) -> Self { + Self( + iter.into_iter() + .enumerate() + .map(|(index, footnote)| Arc::new(footnote.with_index(index))) + .collect(), + ) + } } +#[derive(Clone, Debug)] +pub struct Leaf(Box); + impl Leaf { pub fn new(name: Value) -> Self { - Self { - name: Box::new(name), - } + Self(Box::new(name)) } pub fn name(&self) -> &Value { - &self.name + &self.0 } } @@ -627,7 +756,7 @@ impl Serialize for Leaf { where S: serde::Serializer, { - self.name.serialize(serializer) + self.0.serialize(serializer) } } @@ -646,7 +775,7 @@ pub enum Class { Count, } -/// A pivot_category is a leaf (a category) or a group. +/// A leaf category or a group of them. #[derive(Clone, Debug, Serialize)] pub enum Category { Group(Group), @@ -654,10 +783,45 @@ pub enum Category { } impl Category { + pub fn as_group(&self) -> Option<&Group> { + match self { + Category::Group(group) => Some(group), + Category::Leaf(_) => None, + } + } + + pub fn as_group_mut(&mut self) -> Option<&mut Group> { + match self { + Category::Group(group) => Some(group), + Category::Leaf(_) => None, + } + } + + pub fn as_leaf(&self) -> Option<&Leaf> { + match self { + Category::Leaf(leaf) => Some(leaf), + Category::Group(_) => None, + } + } + + pub fn as_leaf_mut(&mut self) -> Option<&mut Leaf> { + match self { + Category::Leaf(leaf) => Some(leaf), + Category::Group(_) => None, + } + } + pub fn name(&self) -> &Value { match self { Category::Group(group) => &group.name, - Category::Leaf(leaf) => &leaf.name, + Category::Leaf(leaf) => &leaf.0, + } + } + + pub fn name_mut(&mut self) -> &mut Value { + match self { + Category::Group(group) => &mut group.name, + Category::Leaf(leaf) => &mut leaf.0, } } @@ -675,27 +839,49 @@ impl Category { pub fn nth_leaf(&self, index: usize) -> Option<&Leaf> { match self { Category::Group(group) => group.nth_leaf(index), - Category::Leaf(leaf) => { - if index == 0 { - Some(leaf) - } else { - None - } - } + Category::Leaf(leaf) if index == 0 => Some(leaf), + _ => None, } } pub fn leaf_path<'a>(&'a self, index: usize, groups: GroupVec<'a>) -> Option> { match self { Category::Group(group) => group.leaf_path(index, groups), - Category::Leaf(leaf) => { - if index == 0 { - Some(Path { groups, leaf }) - } else { - None - } - } + Category::Leaf(leaf) if index == 0 => Some(Path { groups, leaf }), + _ => None, + } + } + + fn index_path(&self, index: usize, path: IndexVec) -> Option { + match self { + Category::Group(group) => group.index_path(index, path), + Category::Leaf(_) if index == 0 => Some(path), + _ => None, + } + } + + fn locator_path(&self, locator: CategoryLocator) -> Option { + let mut path = self.index_path(locator.leaf_index, IndexVec::new())?; + path.truncate(path.len().checked_sub(locator.level)?); + Some(path) + } + + /// Returns `None` if `locator` is invalid (that is, if `locator.leaf_idx >= + /// self.len` or `locator.level` is greater than the depth of the leaf). + pub fn category(&self, locator: CategoryLocator) -> Option<&Category> { + let mut this = self; + for index in this.locator_path(locator)? { + this = &this.as_group().unwrap().children[index]; + } + Some(this) + } + + pub fn category_mut(&mut self, locator: CategoryLocator) -> Option<&mut Category> { + let mut this = self; + for index in this.locator_path(locator)? { + this = &mut this.as_group_mut().unwrap().children[index]; } + Some(this) } pub fn show_label(&self) -> bool { @@ -753,7 +939,7 @@ impl From<&String> for Category { /// The division between this and the style information in [PivotTable] seems /// fairly arbitrary. The ultimate reason for the division is simply because /// that's how SPSS documentation and file formats do it. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct Look { pub name: Option, @@ -819,8 +1005,8 @@ impl Default for Look { }), footnote_marker_type: FootnoteMarkerType::default(), footnote_marker_position: FootnoteMarkerPosition::default(), - areas: EnumMap::from_fn(Area::default_area_style), - borders: EnumMap::from_fn(Border::default_border_style), + areas: EnumMap::from_fn(AreaStyle::default_for_area), + borders: Border::default_borders(), print_all_layers: false, paginate_layers: false, shrink_to_fit: EnumMap::from_fn(|_| false), @@ -886,7 +1072,7 @@ impl Look { /// Position for group labels. #[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub enum LabelPosition { - /// Hierarachically enclosing the categories. + /// Hierarchically enclosing the categories. /// /// For column labels, group labels appear above the categories. For row /// labels, group labels appear to the left of the categories. @@ -966,13 +1152,22 @@ impl From for HeadingRegion { } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct AreaStyle { pub cell_style: CellStyle, pub font_style: FontStyle, } -#[derive(Clone, Debug, Serialize)] +impl AreaStyle { + pub fn default_for_area(area: Area) -> Self { + Self { + cell_style: CellStyle::default_for_area(area), + font_style: FontStyle::default_for_area(area), + } + } +} + +#[derive(Clone, Debug, Serialize, PartialEq)] pub struct CellStyle { /// `None` means "mixed" alignment: align strings to the left, numbers to /// the right. @@ -988,6 +1183,43 @@ pub struct CellStyle { pub margins: EnumMap, } +impl Default for CellStyle { + fn default() -> Self { + Self::default_for_area(Area::default()) + } +} + +impl CellStyle { + pub fn default_for_area(area: Area) -> Self { + use HorzAlign::*; + use VertAlign::*; + let (horz_align, vert_align, hmargins, vmargins) = match area { + Area::Title => (Some(Center), Middle, [8, 11], [1, 8]), + Area::Caption => (Some(Left), Top, [8, 11], [1, 1]), + Area::Footer => (Some(Left), Top, [11, 8], [2, 3]), + Area::Corner => (Some(Left), Bottom, [8, 11], [1, 1]), + Area::Labels(Axis2::X) => (Some(Center), Bottom, [8, 11], [1, 3]), + Area::Labels(Axis2::Y) => (Some(Left), Top, [8, 11], [1, 3]), + Area::Data(_) => (None, Top, [8, 11], [1, 1]), + Area::Layers => (Some(Left), Bottom, [8, 11], [1, 3]), + }; + Self { + horz_align, + vert_align, + margins: enum_map! { Axis2::X => hmargins, Axis2::Y => vmargins }, + } + } + pub fn with_horz_align(self, horz_align: Option) -> Self { + Self { horz_align, ..self } + } + pub fn with_vert_align(self, vert_align: VertAlign) -> Self { + Self { vert_align, ..self } + } + pub fn with_margins(self, margins: EnumMap) -> Self { + Self { margins, ..self } + } +} + #[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum HorzAlign { @@ -1017,6 +1249,35 @@ impl HorzAlign { VarType::String => Self::Left, } } + + pub fn as_str(&self) -> Option<&'static str> { + match self { + HorzAlign::Right => Some("right"), + HorzAlign::Left => Some("left"), + HorzAlign::Center => Some("center"), + HorzAlign::Decimal { .. } => None, + } + } +} + +/// Unknown horizontal alignment. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct UnknownHorzAlign; + +impl FromStr for HorzAlign { + type Err = UnknownHorzAlign; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("left") { + Ok(Self::Left) + } else if s.eq_ignore_ascii_case("center") { + Ok(Self::Center) + } else if s.eq_ignore_ascii_case("right") { + Ok(Self::Right) + } else { + Err(UnknownHorzAlign) + } + } } #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] @@ -1032,28 +1293,63 @@ pub enum VertAlign { Bottom, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct FontStyle { pub bold: bool, pub italic: bool, 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, } +impl Default for FontStyle { + fn default() -> Self { + FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + } + } +} + +impl FontStyle { + pub fn default_for_area(area: Area) -> Self { + Self::default().with_bold(area == Area::Title) + } + pub fn with_size(self, size: i32) -> Self { + Self { size, ..self } + } + pub fn with_bold(self, bold: bool) -> Self { + Self { bold, ..self } + } + pub fn with_italic(self, italic: bool) -> Self { + Self { italic, ..self } + } + pub fn with_underline(self, underline: bool) -> Self { + Self { underline, ..self } + } + pub fn with_font(self, font: impl Into) -> Self { + Self { + font: font.into(), + ..self + } + } + pub fn with_fg(self, fg: Color) -> Self { + Self { fg, ..self } + } + pub fn with_bg(self, fg: Color) -> Self { + Self { fg, ..self } + } +} + #[derive(Copy, Clone, PartialEq, Eq)] pub struct Color { pub alpha: u8, @@ -1089,6 +1385,18 @@ impl Color { pub fn display_css(&self) -> DisplayCss { DisplayCss(*self) } + + pub fn into_rgb(&self) -> (u8, u8, u8) { + (self.r, self.g, self.b) + } + + pub fn into_rgb16(&self) -> (u16, u16, u16) { + ( + self.r as u16 * 257, + self.g as u16 * 257, + self.b as u16 * 257, + ) + } } impl Debug for Color { @@ -1112,14 +1420,8 @@ impl FromStr for Color { s.chars().count() == 6 && s.chars().all(|c| c.is_ascii_hexdigit()) } let color: AlphaColor = match s.parse() { - Err(ParseColorError::UnknownColorSyntax) if is_bare_hex(s) => { - ("#".to_owned() + s).parse() - } - Err(ParseColorError::UnknownColorSyntax) - if s.trim().eq_ignore_ascii_case("transparent") => - { - Ok(TRANSPARENT) - } + Err(_) if is_bare_hex(s) => ("#".to_owned() + s).parse(), + Err(_) if s.trim().eq_ignore_ascii_case("transparent") => Ok(TRANSPARENT), other => other, }?; Ok(color.to_rgba8().into()) @@ -1149,7 +1451,7 @@ impl<'de> Deserialize<'de> for Color { formatter.write_str("\"#rrggbb\" or \"rrggbb\" or web color name") } - fn visit_borrowed_str(self, v: &'de str) -> Result + fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { @@ -1173,7 +1475,7 @@ impl Display for DisplayCss { } } -#[derive(Copy, Clone, Debug, Deserialize)] +#[derive(Copy, Clone, Debug, PartialEq, Deserialize)] pub struct BorderStyle { #[serde(rename = "@borderStyleType")] pub stroke: Stroke, @@ -1194,14 +1496,24 @@ impl Serialize for BorderStyle { } } +impl From for BorderStyle { + fn from(value: Stroke) -> Self { + Self::new(value) + } +} + impl BorderStyle { - pub const fn none() -> Self { + pub const fn new(stroke: Stroke) -> Self { Self { - stroke: Stroke::None, + stroke, color: Color::BLACK, } } + pub const fn none() -> Self { + Self::new(Stroke::None) + } + pub fn is_none(&self) -> bool { self.stroke.is_none() } @@ -1279,6 +1591,21 @@ impl Not for Axis2 { } } +/// Can't convert `Axis3::Z` to `Axis2`. +pub struct ZAxis; + +impl TryFrom for Axis2 { + type Error = ZAxis; + + fn try_from(value: Axis3) -> Result { + match value { + Axis3::X => Ok(Axis2::X), + Axis3::Y => Ok(Axis2::Y), + Axis3::Z => Err(ZAxis), + } + } +} + /// A 2-dimensional `(x,y)` pair. #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] pub struct Coord2(pub EnumMap); @@ -1471,7 +1798,7 @@ impl IntoValueOptions for ValueOptions { } #[derive(Clone, Debug, Serialize)] -pub struct PivotTable { +pub struct PivotTableStyle { pub look: Arc, pub rotate_inner_column_labels: bool, @@ -1487,17 +1814,10 @@ pub struct PivotTable { pub show_values: Option, pub show_variables: Option, - - pub weight_format: Format, - - /// Current layer indexes, with `axes[Axis3::Z].dimensions.len()` elements. - /// `current_layer[i]` is an offset into - /// `axes[Axis3::Z].dimensions[i].data_leaves[]`, except that a dimension - /// can have zero leaves, in which case `current_layer[i]` is zero and - /// there's no corresponding leaf. - pub current_layer: Vec, - - /// Column and row sizing and page breaks. + /// Column and row sizing and page breaks: + /// + /// - `sizing[Axis2::X]` is sizes for columns. + /// - `sizing[Axis2::Y]` is sizes for rows. pub sizing: EnumMap>>, /// Format settings. @@ -1508,6 +1828,61 @@ pub struct PivotTable { pub small: f64, + pub weight_format: Format, +} + +impl Default for PivotTableStyle { + fn default() -> Self { + Self { + look: Look::shared_default(), + rotate_inner_column_labels: false, + rotate_outer_row_labels: false, + show_grid_lines: false, + show_title: true, + show_caption: true, + show_values: None, + show_variables: None, + sizing: EnumMap::default(), + settings: FormatSettings::default(), // XXX from settings + grouping: None, + small: 0.0001, // XXX from settings. + weight_format: F40, + } + } +} + +impl PivotTableStyle { + fn with_look(self, look: Arc) -> Self { + Self { look, ..self } + } + fn with_show_values(self, show_values: Option) -> Self { + Self { + show_values, + ..self + } + } + fn with_show_variables(self, show_variables: Option) -> Self { + Self { + show_variables, + ..self + } + } + fn with_show_title(self, show_title: bool) -> Self { + Self { show_title, ..self } + } + fn with_show_caption(self, show_caption: bool) -> Self { + Self { + show_caption, + ..self + } + } + pub fn look_mut(&mut self) -> &mut Look { + Arc::make_mut(&mut self.look) + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct PivotTableMetadata { pub command_local: Option, pub command_c: Option, pub language: Option, @@ -1515,56 +1890,107 @@ pub struct PivotTable { pub dataset: Option, pub datafile: Option, pub date: Option, - pub footnotes: Footnotes, pub title: Option>, pub subtype: Option>, pub corner_text: Option>, pub caption: Option>, pub notes: Option, - pub dimensions: Vec, - pub axes: EnumMap, - pub cells: HashMap, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PivotTable { + pub style: PivotTableStyle, + + /// Current layer indexes, with `axes[Axis3::Z].dimensions.len()` elements. + /// `current_layer[i]` is an offset into + /// `axes[Axis3::Z].dimensions[i].data_leaves[]`, except that a dimension + /// can have zero leaves, in which case `current_layer[i]` is zero and + /// there's no corresponding leaf. + pub current_layer: Vec, + + pub metadata: PivotTableMetadata, + pub footnotes: Footnotes, + dimensions: Vec, + axes: EnumMap, + cells: HashMap, +} + +impl PivotTableMetadata { + pub fn with_subtype(self, subtype: impl Into) -> Self { + Self { + subtype: Some(Box::new(subtype.into())), + ..self + } + } } impl PivotTable { + pub fn cells(&self) -> &HashMap { + &self.cells + } + pub fn dimensions(&self) -> &[Dimension] { + &self.dimensions + } + pub fn axes(&self) -> &EnumMap { + &self.axes + } + pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(Box::new(title.into())); - self.show_title = true; + self.metadata.title = Some(Box::new(title.into())); + self.style.show_title = true; self } pub fn with_caption(mut self, caption: impl Into) -> Self { - self.caption = Some(Box::new(caption.into())); - self.show_caption = true; + self.metadata.caption = Some(Box::new(caption.into())); + self.style.show_caption = true; self } pub fn with_corner_text(mut self, corner_text: impl Into) -> Self { - self.corner_text = Some(Box::new(corner_text.into())); + self.metadata.corner_text = Some(Box::new(corner_text.into())); self } pub fn with_subtype(self, subtype: impl Into) -> Self { Self { - subtype: Some(Box::new(subtype.into())), + metadata: self.metadata.with_subtype(subtype), ..self } } - pub fn with_show_title(mut self, show_title: bool) -> Self { - self.show_title = show_title; - self + pub fn with_show_values(self, show_values: Option) -> Self { + Self { + style: self.style.with_show_values(show_values), + ..self + } } - pub fn with_show_caption(mut self, show_caption: bool) -> Self { - self.show_caption = show_caption; - self + pub fn with_show_variables(self, show_variables: Option) -> Self { + Self { + style: self.style.with_show_variables(show_variables), + ..self + } + } + + pub fn with_show_title(self, show_title: bool) -> Self { + Self { + style: self.style.with_show_title(show_title), + ..self + } + } + + pub fn with_show_caption(self, show_caption: bool) -> Self { + Self { + style: self.style.with_show_caption(show_caption), + ..self + } } pub fn with_layer(mut self, layer: &[usize]) -> Self { debug_assert_eq!(layer.len(), self.current_layer.len()); - if self.look.print_all_layers { - self.look_mut().print_all_layers = false; + if self.style.look.print_all_layers { + self.style.look_mut().print_all_layers = false; } self.current_layer.clear(); self.current_layer.extend_from_slice(layer); @@ -1572,39 +1998,39 @@ impl PivotTable { } pub fn with_all_layers(mut self) -> Self { - if !self.look.print_all_layers { + if !self.style.look.print_all_layers { self.look_mut().print_all_layers = true; } self } pub fn look_mut(&mut self) -> &mut Look { - Arc::make_mut(&mut self.look) + self.style.look_mut() } pub fn with_show_empty(mut self) -> Self { - if self.look.hide_empty { + if self.style.look.hide_empty { self.look_mut().hide_empty = false; } self } pub fn with_hide_empty(mut self) -> Self { - if !self.look.hide_empty { + if !self.style.look.hide_empty { self.look_mut().hide_empty = true; } self } pub fn label(&self) -> String { - match &self.title { + match &self.metadata.title { Some(title) => title.display(self).to_string(), None => String::from("Table"), } } pub fn title(&self) -> &Value { - match &self.title { + match &self.metadata.title { Some(title) => title, None => { static EMPTY: Value = Value::empty(); @@ -1614,7 +2040,7 @@ impl PivotTable { } pub fn subtype(&self) -> &Value { - match &self.subtype { + match &self.metadata.subtype { Some(subtype) => subtype, None => { static EMPTY: Value = Value::empty(); @@ -1627,33 +2053,10 @@ impl PivotTable { impl Default for PivotTable { fn default() -> Self { Self { - look: Look::shared_default(), - rotate_inner_column_labels: false, - rotate_outer_row_labels: false, - show_grid_lines: false, - show_title: true, - show_caption: true, - show_values: None, - show_variables: None, - weight_format: Format::F40, + style: PivotTableStyle::default(), + metadata: PivotTableMetadata::default(), current_layer: Vec::new(), - sizing: EnumMap::default(), - settings: FormatSettings::default(), // XXX from settings - grouping: None, - small: 0.0001, // XXX from settings. - command_local: None, - command_c: None, // XXX from current command name. - language: None, - locale: None, - dataset: None, - datafile: None, - date: None, footnotes: Footnotes::new(), - subtype: None, - title: None, - corner_text: None, - caption: None, - notes: None, dimensions: Vec::new(), axes: EnumMap::default(), cells: HashMap::new(), @@ -1661,6 +2064,41 @@ impl Default for PivotTable { } } +pub trait CellIndex { + fn cell_index(self, dimensions: I) -> usize + where + I: ExactSizeIterator; +} + +impl CellIndex for T +where + T: AsRef<[usize]>, +{ + fn cell_index(self, dimensions: I) -> usize + where + I: ExactSizeIterator, + { + let data_indexes = self.as_ref(); + let mut index = 0; + for (dimension, data_index) in dimensions.zip_eq(data_indexes.iter()) { + debug_assert!(*data_index < dimension); + index = dimension * index + data_index; + } + index + } +} + +pub struct PrecomputedIndex(pub usize); + +impl CellIndex for PrecomputedIndex { + fn cell_index(self, _dimensions: I) -> usize + where + I: ExactSizeIterator, + { + self.0 + } +} + fn cell_index(data_indexes: &[usize], dimensions: I) -> usize where I: ExactSizeIterator, @@ -1683,34 +2121,49 @@ impl PivotTable { dimensions.push(dimension); } Self { - look: Settings::global().look.clone(), + style: PivotTableStyle::default().with_look(Settings::global().look.clone()), current_layer: repeat_n(0, axes[Axis3::Z].dimensions.len()).collect(), axes, dimensions, ..Self::default() } } - fn cell_index(&self, data_indexes: &[usize]) -> usize { - cell_index(data_indexes, self.dimensions.iter().map(|d| d.len())) + fn cell_index(&self, cell_index: C) -> usize + where + C: CellIndex, + { + cell_index.cell_index(self.dimensions.iter().map(|d| d.len())) } - pub fn insert(&mut self, data_indexes: &[usize], value: impl Into) { - self.cells - .insert(self.cell_index(data_indexes), value.into()); + pub fn insert(&mut self, cell_index: C, value: impl Into) + where + C: CellIndex, + { + self.cells.insert(self.cell_index(cell_index), value.into()); } - pub fn get(&self, data_indexes: &[usize]) -> Option<&Value> { - self.cells.get(&self.cell_index(data_indexes)) + pub fn get(&self, cell_index: C) -> Option<&Value> + where + C: CellIndex, + { + self.cells.get(&self.cell_index(cell_index)) } - pub fn with_data(mut self, iter: impl IntoIterator) -> Self + pub fn with_data(mut self, iter: impl IntoIterator) -> Self where - I: AsRef<[usize]>, + C: CellIndex, { self.extend(iter); self } + pub fn with_style(self, style: PivotTableStyle) -> Self { + Self { style, ..self } + } + pub fn with_metadata(self, metadata: PivotTableMetadata) -> Self { + Self { metadata, ..self } + } + /// Converts per-axis presentation-order indexes in `presentation_indexes`, /// into data indexes for each dimension. fn convert_indexes_ptod( @@ -1737,7 +2190,7 @@ impl PivotTable { /// /// - Otherwise, the iterator will just visit `self.current_layer`. pub fn layers(&self, print: bool) -> Box>> { - if print && self.look.print_all_layers { + if print && self.style.look.print_all_layers { Box::new(self.axis_values(Axis3::Z)) } else { Box::new(once(SmallVec::from_slice(&self.current_layer))) @@ -1746,10 +2199,10 @@ impl PivotTable { pub fn value_options(&self) -> ValueOptions { ValueOptions { - show_values: self.show_values, - show_variables: self.show_variables, - small: self.small, - footnote_marker_type: self.look.footnote_marker_type, + show_values: self.style.show_values, + show_variables: self.style.show_variables, + small: self.style.small, + footnote_marker_type: self.style.look.footnote_marker_type, } } @@ -1814,18 +2267,18 @@ impl PivotTable { } } -impl Extend<(I, Value)> for PivotTable +impl Extend<(C, Value)> for PivotTable where - I: AsRef<[usize]>, + C: CellIndex, { - fn extend>(&mut self, iter: T) { - for (data_indexes, value) in iter { - self.insert(data_indexes.as_ref(), value); + fn extend>(&mut self, iter: T) { + for (cell_index, value) in iter { + self.insert(cell_index, value); } } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, PartialEq)] pub struct Footnote { #[serde(skip)] index: usize, @@ -1843,19 +2296,22 @@ impl Footnote { show: true, } } - pub fn with_marker(mut self, marker: impl Into) -> Self { - self.marker = Some(Box::new(marker.into())); - self + pub fn with_marker(self, marker: Option) -> Self { + Self { + marker: marker.map(Box::new), + ..self + } + } + pub fn with_some_marker(self, marker: impl Into) -> Self { + Self::with_marker(self, Some(marker.into())) } - pub fn with_show(mut self, show: bool) -> Self { - self.show = show; - self + pub fn with_show(self, show: bool) -> Self { + Self { show, ..self } } - pub fn with_index(mut self, index: usize) -> Self { - self.index = index; - self + pub fn with_index(self, index: usize) -> Self { + Self { index, ..self } } pub fn display_marker(&self, options: impl IntoValueOptions) -> DisplayMarker<'_> { @@ -1874,6 +2330,12 @@ impl Footnote { } } +impl Default for Footnote { + fn default() -> Self { + Footnote::new(Value::default()) + } +} + pub struct DisplayMarker<'a> { footnote: &'a Footnote, options: ValueOptions, @@ -1968,7 +2430,7 @@ impl Display for Display26Adic { /// /// 5. A template. PSPP doesn't create these itself yet, but it can read and /// interpret those created by SPSS. -#[derive(Clone, Default)] +#[derive(Clone, Default, PartialEq)] pub struct Value { pub inner: ValueInner, pub styling: Option>, @@ -1997,19 +2459,20 @@ impl Value { ValueInner::String(string_value) => string_value.s.serialize(serializer), ValueInner::Variable(variable_value) => variable_value.var_name.serialize(serializer), ValueInner::Text(text_value) => text_value.localized.serialize(serializer), + ValueInner::Markup(markup) => markup.serialize(serializer), ValueInner::Template(template_value) => template_value.localized.serialize(serializer), ValueInner::Empty => serializer.serialize_none(), } } - fn new(inner: ValueInner) -> Self { + pub fn new(inner: ValueInner) -> Self { Self { inner, styling: None, } } pub fn new_date_time(date_time: NaiveDateTime) -> Self { - Self::new_number_with_format(Some(date_time_to_pspp(date_time)), Format::DATETIME40_0) + Self::new_number_with_format(Some(date_time_to_pspp(date_time)), DATETIME40_0) } pub fn new_number_with_format(x: Option, format: Format) -> Self { Self::new(ValueInner::Number(NumberValue { @@ -2037,14 +2500,15 @@ impl Value { Datum::String(string) => Self::new_user_text(string.as_str()), } } - pub fn new_variable_value(variable: &Variable, value: &Datum) -> Self { - let var_name = Some(variable.name.as_str().into()); - let value_label = variable.value_labels.get(value).map(String::from); + pub fn new_datum_with_format(value: &Datum, format: Format) -> Self + where + B: EncodedString, + { match value { Datum::Number(number) => Self::new(ValueInner::Number(NumberValue { show: None, - format: match variable.print_format.var_type() { - VarType::Numeric => variable.print_format, + format: match format.var_type() { + VarType::Numeric => format, VarType::String => { #[cfg(debug_assertions)] panic!("cannot create numeric pivot value with string format"); @@ -2055,30 +2519,46 @@ impl Value { }, honor_small: false, value: *number, - variable: var_name, - value_label, + variable: None, + value_label: None, })), Datum::String(string) => Self::new(ValueInner::String(StringValue { show: None, - hex: variable.print_format.type_() == Type::AHex, - s: string - .as_ref() - .with_encoding(variable.encoding()) - .into_string(), - var_name, - value_label, + hex: format.type_() == Type::AHex, + s: string.as_str().into_owned(), + var_name: None, + value_label: None, })), } } + pub fn new_variable_value(variable: &Variable, value: &Datum) -> Self { + Self::new_datum_with_format( + &value.as_encoded(variable.encoding()), + variable.print_format, + ) + .with_variable_name(Some(variable.name.as_str().into())) + .with_value_label(variable.value_labels.get(value).map(String::from)) + } pub fn new_number(x: Option) -> Self { - Self::new_number_with_format(x, Format::F8_2) + Self::new_number_with_format(x, F8_2) } pub fn new_integer(x: Option) -> Self { - Self::new_number_with_format(x, Format::F40) + Self::new_number_with_format(x, F40) } pub fn new_text(s: impl Into) -> Self { Self::new_user_text(s) } + pub fn new_general_text(localized: String, c: String, id: String, user_provided: bool) -> Self { + Self::new(ValueInner::Text(TextValue { + user_provided, + c: (c != localized).then_some(c), + id: (id != localized).then_some(id), + localized, + })) + } + pub fn new_markup(markup: Markup) -> Self { + Self::new(ValueInner::Markup(markup)) + } pub fn new_user_text(s: impl Into) -> Self { let s: String = s.into(); if s.is_empty() { @@ -2086,7 +2566,7 @@ impl Value { } else { Self::new(ValueInner::Text(TextValue { user_provided: true, - localized: s.clone(), + localized: s, c: None, id: None, })) @@ -2097,7 +2577,7 @@ impl Value { self } pub fn add_footnote(&mut self, footnote: &Arc) { - let footnotes = &mut self.styling.get_or_insert_default().footnotes; + let footnotes = &mut self.styling_mut().footnotes; footnotes.push(footnote.clone()); footnotes.sort_by_key(|f| f.index); } @@ -2126,6 +2606,59 @@ impl Value { } self } + pub fn with_variable_name(mut self, variable_name: Option) -> Self { + match &mut self.inner { + ValueInner::Number(NumberValue { variable, .. }) + | ValueInner::String(StringValue { + var_name: variable, .. + }) => *variable = variable_name, + ValueInner::Variable(VariableValue { + var_name: variable, .. + }) => { + if let Some(name) = variable_name { + *variable = name; + } + } + _ => (), + } + self + } + pub fn styling_mut(&mut self) -> &mut ValueStyle { + self.styling.get_or_insert_default() + } + pub fn with_font_style(mut self, font_style: FontStyle) -> Self { + self.styling_mut().font_style = Some(font_style); + self + } + pub fn with_cell_style(mut self, cell_style: CellStyle) -> Self { + self.styling_mut().cell_style = Some(cell_style); + self + } + pub fn with_styling(self, styling: Option>) -> Self { + Self { styling, ..self } + } + pub fn font_style(&self) -> Option<&FontStyle> { + self.styling + .as_ref() + .map(|styling| styling.font_style.as_ref()) + .flatten() + } + pub fn cell_style(&self) -> Option<&CellStyle> { + self.styling + .as_ref() + .map(|styling| styling.cell_style.as_ref()) + .flatten() + } + pub fn subscripts(&self) -> &[String] { + self.styling + .as_ref() + .map_or(&[], |styling| &styling.subscripts) + } + pub fn footnotes(&self) -> &[Arc] { + self.styling + .as_ref() + .map_or(&[], |styling| &styling.footnotes) + } pub const fn empty() -> Self { Value { inner: ValueInner::Empty, @@ -2157,7 +2690,6 @@ impl From<&Variable> for Value { pub struct DisplayValue<'a> { inner: &'a ValueInner, - markup: bool, subscripts: &'a [String], footnotes: &'a [Arc], options: ValueOptions, @@ -2193,6 +2725,10 @@ impl<'a> DisplayValue<'a> { } } + pub fn markup(&self) -> Option<&Markup> { + self.inner.markup() + } + /// Returns this display split into `(body, suffixes)` where `suffixes` is /// subscripts and footnotes and `body` is everything else. pub fn split_suffixes(self) -> (Self, Self) { @@ -2204,21 +2740,11 @@ impl<'a> DisplayValue<'a> { } pub fn with_styling(mut self, styling: &'a ValueStyle) -> Self { - if let Some(area_style) = &styling.style { - self.markup = area_style.font_style.markup; - } self.subscripts = styling.subscripts.as_slice(); self.footnotes = styling.footnotes.as_slice(); self } - pub fn with_font_style(self, font_style: &FontStyle) -> Self { - Self { - markup: font_style.markup, - ..self - } - } - pub fn with_subscripts(self, subscripts: &'a [String]) -> Self { Self { subscripts, ..self } } @@ -2407,20 +2933,11 @@ impl Display for DisplayValue<'_> { } } + ValueInner::Markup(markup) => write!(f, "{markup}"), + ValueInner::Text(TextValue { localized: local, .. - }) => { - /* - if self - .inner - .styling - .as_ref() - .is_some_and(|styling| styling.style.font_style.markup) - { - todo!(); - }*/ - f.write_str(local) - } + }) => f.write_str(local), ValueInner::Template(TemplateValue { args, @@ -2458,11 +2975,27 @@ impl Value { impl Debug for Value { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self.display(()).to_string()) + let name = match &self.inner { + ValueInner::Number(_) => "Number", + ValueInner::String(_) => "String", + ValueInner::Variable(_) => "Variable", + ValueInner::Text(_) => "Text", + ValueInner::Markup(_) => "Markup", + ValueInner::Template(_) => "Template", + ValueInner::Empty => "Empty", + }; + write!(f, "{name}:{:?}", self.display(()).to_string())?; + if let Some(markup) = self.inner.markup() { + write!(f, " (markup: {markup:?})")?; + } + if let Some(styling) = &self.styling { + write!(f, " ({styling:?})")?; + } + Ok(()) } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct NumberValue { /// The numerical value, or `None` if it is a missing value. pub value: Option, @@ -2523,7 +3056,7 @@ pub struct BareNumberValue<'a>( #[serde(serialize_with = "NumberValue::serialize_bare")] pub &'a NumberValue, ); -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, PartialEq)] pub struct StringValue { /// The string value. /// @@ -2540,14 +3073,14 @@ pub struct StringValue { pub value_label: Option, } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, PartialEq)] pub struct VariableValue { pub show: Option, pub var_name: String, pub variable_label: Option, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct TextValue { pub user_provided: bool, /// Localized. @@ -2595,20 +3128,21 @@ impl TextValue { } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, PartialEq)] pub struct TemplateValue { pub args: Vec>, pub localized: String, - pub id: String, + pub id: Option, } -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Default, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ValueInner { Number(NumberValue), String(StringValue), Variable(VariableValue), Text(TextValue), + Markup(Markup), Template(TemplateValue), #[default] @@ -2650,18 +3184,29 @@ impl ValueInner { _ => None, } } + + fn markup(&self) -> Option<&Markup> { + match self { + ValueInner::Markup(markup) => Some(markup), + _ => None, + } + } } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct ValueStyle { - pub style: Option, + pub cell_style: Option, + pub font_style: Option, pub subscripts: Vec, pub footnotes: Vec>, } impl ValueStyle { pub fn is_empty(&self) -> bool { - self.style.is_none() && self.subscripts.is_empty() && self.footnotes.is_empty() + self.font_style.is_none() + && self.cell_style.is_none() + && self.subscripts.is_empty() + && self.footnotes.is_empty() } } @@ -2689,7 +3234,6 @@ impl ValueInner { }; DisplayValue { inner: self, - markup: false, subscripts: &[], footnotes: &[], options, @@ -2792,7 +3336,21 @@ impl Serialize for MetadataEntry { #[cfg(test)] mod test { - use crate::output::pivot::{Display26Adic, MetadataEntry, MetadataValue, Value}; + use std::str::FromStr; + + use crate::output::pivot::{ + Color, Display26Adic, MetadataEntry, MetadataValue, Value, tests::assert_rendering, + }; + + #[test] + fn parse_color() { + assert_eq!(Color::from_str("red"), Ok(Color::new(255, 0, 0))); + assert_eq!(Color::from_str("transparent"), Ok(Color::TRANSPARENT)); + assert_eq!(Color::from_str("rgb(12,34,56)"), Ok(Color::new(12, 34, 56))); + assert_eq!(Color::from_str("#abcdef"), Ok(Color::new(0xab, 0xcd, 0xef))); + assert_eq!(Color::from_str("abcdef"), Ok(Color::new(0xab, 0xcd, 0xef))); + assert_eq!(Color::from_str("transparent"), Ok(Color::TRANSPARENT)); + } #[test] fn display_26adic() { @@ -2858,18 +3416,6 @@ mod test { }"# ); - assert_eq!( - tree.into_pivot_table().to_string(), - r#"╭────────────────────┬──────────╮ -│ Name 1 │Value 1 │ -├────────────────────┼──────────┤ -│Subgroup 1 Subname 1│Subvalue 1│ -│ Subname 2│Subvalue 2│ -│ Subname 3│ 3│ -├────────────────────┼──────────┤ -│ Name 2 │Value 2 │ -╰────────────────────┴──────────╯ -"# - ); + assert_rendering("metadata_entry", &tree.into_pivot_table()); } } diff --git a/rust/pspp/src/output/pivot/look_xml.rs b/rust/pspp/src/output/pivot/look_xml.rs index a9e1264f55..fcefe65c35 100644 --- a/rust/pspp/src/output/pivot/look_xml.rs +++ b/rust/pspp/src/output/pivot/look_xml.rs @@ -23,12 +23,13 @@ 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; -#[derive(Deserialize, Debug)] +#[derive(Clone, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct TableProperties { #[serde(rename = "@name")] @@ -53,14 +54,14 @@ impl From 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, @@ -114,7 +115,7 @@ impl From for Look { } } -#[derive(Deserialize, Debug)] +#[derive(Clone, Debug, Deserialize)] struct GeneralProperties { #[serde(rename = "@hideEmptyRows")] hide_empty_rows: bool, @@ -135,7 +136,7 @@ struct GeneralProperties { row_label_position: LabelPosition, } -#[derive(Deserialize, Debug)] +#[derive(Clone, Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct FootnoteProperties { #[serde(rename = "@markerPosition")] @@ -145,7 +146,7 @@ struct FootnoteProperties { marker_type: FootnoteMarkerType, } -#[derive(Deserialize, Debug)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CellFormatProperties { caption: CellStyleHolder, @@ -158,13 +159,13 @@ struct CellFormatProperties { title: CellStyleHolder, } -#[derive(Deserialize, Debug)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CellStyleHolder { style: CellStyle, } -#[derive(Deserialize, Debug, Default)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(default)] struct CellStyle { #[serde(rename = "@alternatingColor")] @@ -178,7 +179,7 @@ struct CellStyle { #[serde(rename = "@font-family")] font_family: String, #[serde(rename = "@font-size")] - font_size: Dimension, + font_size: Length, #[serde(rename = "@font-style")] font_style: FontStyle, #[serde(rename = "@font-weight")] @@ -188,21 +189,21 @@ struct CellStyle { #[serde(rename = "@labelLocationVertical")] label_location_vertical: LabelLocationVertical, #[serde(rename = "@margin-bottom")] - margin_bottom: Dimension, + margin_bottom: Length, #[serde(rename = "@margin-left")] - margin_left: Dimension, + margin_left: Length, #[serde(rename = "@margin-right")] - margin_right: Dimension, + margin_right: Length, #[serde(rename = "@margin-top")] - margin_top: Dimension, + margin_top: Length, #[serde(rename = "@textAlignment", default)] text_alignment: TextAlignment, #[serde(rename = "@decimal-offset")] - decimal_offset: Dimension, + decimal_offset: Length, } 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 { @@ -229,23 +230,22 @@ impl CellStyle { bold: self.font_weight == FontWeight::Bold, italic: self.font_style == FontStyle::Italic, 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(), }, } } } -#[derive(Deserialize, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] enum FontStyle { #[default] @@ -253,7 +253,7 @@ enum FontStyle { Italic, } -#[derive(Deserialize, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] enum FontWeight { #[default] @@ -261,7 +261,7 @@ enum FontWeight { Bold, } -#[derive(Deserialize, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] enum FontUnderline { #[default] @@ -269,7 +269,7 @@ enum FontUnderline { Underline, } -#[derive(Deserialize, Debug, Default)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] enum TextAlignment { Left, @@ -280,7 +280,7 @@ enum TextAlignment { Mixed, } -#[derive(Deserialize, Debug, Default)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] enum LabelLocationVertical { /// Top. @@ -294,7 +294,7 @@ enum LabelLocationVertical { Center, } -#[derive(Deserialize, Debug)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BorderProperties { bottom_inner_frame: BorderStyle, @@ -318,7 +318,7 @@ struct BorderProperties { vertical_dimension_border_columns: BorderStyle, } -#[derive(Deserialize, Debug, Default)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase", default)] struct PrintingProperties { #[serde(rename = "@printAllLayers")] @@ -347,41 +347,47 @@ struct PrintingProperties { } #[derive(Copy, Clone, Default, PartialEq)] -struct Dimension( +pub struct Length( /// In inches. - f64, + pub f64, ); -impl Dimension { - fn as_px_f64(self) -> f64 { +impl Length { + pub fn as_px_f64(self) -> f64 { self.0 * 96.0 } - fn as_px_i32(self) -> i32 { + pub fn as_px_i32(self) -> i32 { num::cast(self.as_px_f64() + 0.5).unwrap_or_default() } - fn as_pt_f64(self) -> f64 { + pub fn as_pt_f64(self) -> f64 { self.0 * 72.0 } - fn as_pt_i32(self) -> i32 { + pub fn as_pt_i32(self) -> i32 { num::cast(self.as_pt_f64() + 0.5).unwrap_or_default() } } -impl Debug for Dimension { +impl From for paper_sizes::Length { + fn from(value: Length) -> Self { + Self::new(value.0, paper_sizes::Unit::Inch) + } +} + +impl Debug for Length { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:.2}in", self.0) } } -impl FromStr for Dimension { - type Err = DimensionParseError; +impl FromStr for Length { + type Err = LengthParseError; fn from_str(s: &str) -> Result { let s = s.trim_start(); let unit = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.'); let number: f64 = s[..s.len() - unit.len()] .parse() - .map_err(DimensionParseError::ParseFloatError)?; + .map_err(LengthParseError::ParseFloatError)?; let divisor = match unit.trim() { // Inches. "in" | "인치" | "pol." | "cala" | "cali" => 1.0, @@ -395,14 +401,14 @@ impl FromStr for Dimension { // Centimeters. "cm" | "см" => 2.54, - other => return Err(DimensionParseError::InvalidUnit(other.into())), + other => return Err(LengthParseError::InvalidUnit(other.into())), }; - Ok(Dimension(number / divisor)) + Ok(Length(number / divisor)) } } #[derive(ThisError, Debug, PartialEq, Eq)] -enum DimensionParseError { +pub enum LengthParseError { /// Invalid number. #[error(transparent)] ParseFloatError(ParseFloatError), @@ -412,7 +418,7 @@ enum DimensionParseError { InvalidUnit(String), } -impl<'de> Deserialize<'de> for Dimension { +impl<'de> Deserialize<'de> for Length { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -420,13 +426,13 @@ impl<'de> Deserialize<'de> for Dimension { struct DimensionVisitor; impl<'de> Visitor<'de> for DimensionVisitor { - type Value = Dimension; + type Value = Length; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string") + formatter.write_str("a dimension expressed as a string, e.g. \"1.0 cm\"") } - fn visit_borrowed_str(self, v: &'de str) -> Result + fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { @@ -442,43 +448,49 @@ impl<'de> Deserialize<'de> for Dimension { mod tests { use std::str::FromStr; + use enum_map::{EnumMap, enum_map}; use quick_xml::de::from_str; - use crate::output::pivot::look_xml::{Dimension, DimensionParseError, TableProperties}; + use crate::output::pivot::{ + Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle, + FootnoteMarkerPosition, FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, + RowColBorder, RowParity, Stroke, VertAlign, + look_xml::{Length, LengthParseError, TableProperties}, + }; #[test] fn dimension() { - assert_eq!(Dimension::from_str("1"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1pt"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1пт"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1.0"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str(" 1.0"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str(" 1.0 "), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1.0 pt"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1.0pt "), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str(" 1.0pt "), Ok(Dimension(1.0 / 72.0))); + assert_eq!(Length::from_str("1"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1pt"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1пт"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1.0"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str(" 1.0"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str(" 1.0 "), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1.0 pt"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1.0pt "), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str(" 1.0pt "), Ok(Length(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1in"), Ok(Dimension(1.0))); + assert_eq!(Length::from_str("1in"), Ok(Length(1.0))); - assert_eq!(Dimension::from_str("96px"), Ok(Dimension(1.0))); + assert_eq!(Length::from_str("96px"), Ok(Length(1.0))); - assert_eq!(Dimension::from_str("2.54cm"), Ok(Dimension(1.0))); + assert_eq!(Length::from_str("2.54cm"), Ok(Length(1.0))); assert_eq!( - Dimension::from_str(""), - Err(DimensionParseError::ParseFloatError( + Length::from_str(""), + Err(LengthParseError::ParseFloatError( "".parse::().unwrap_err() )) ); assert_eq!( - Dimension::from_str("1.2.3"), - Err(DimensionParseError::ParseFloatError( + Length::from_str("1.2.3"), + Err(LengthParseError::ParseFloatError( "1.2.3".parse::().unwrap_err() )) ); assert_eq!( - Dimension::from_str("1asdf"), - Err(DimensionParseError::InvalidUnit("asdf".into())) + Length::from_str("1asdf"), + Err(LengthParseError::InvalidUnit("asdf".into())) ); } @@ -540,6 +552,409 @@ mod tests { "##; let table_properties: TableProperties = from_str(XML).unwrap(); - dbg!(&table_properties); + let look: Look = table_properties.into(); + dbg!(&look); + let expected = Look { + name: None, + hide_empty: true, + row_label_position: LabelPosition::Corner, + heading_widths: enum_map! { + HeadingRegion::Rows => 36..=120, + HeadingRegion::Columns => 36..=72, + }, + footnote_marker_type: FootnoteMarkerType::Alphabetic, + footnote_marker_position: FootnoteMarkerPosition::Subscript, + areas: enum_map! { + Area::Title => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Middle, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 8, + ], + }, + }, + font_style: FontStyle { + bold: true, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Caption => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 0, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Footer => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 11, + 8, + ], + Axis2::Y => [ + 1, + 3, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Corner => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Bottom, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 0, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Labels( + Axis2::X, + ) => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Center, + ), + vert_align: VertAlign::Bottom, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 3, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Labels( + Axis2::Y, + )=> AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 3, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Data( + RowParity::Even, + ) => AreaStyle { + cell_style: CellStyle { + horz_align: None, + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 0, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Data( + RowParity::Odd, + )=>AreaStyle { + cell_style: CellStyle { + horz_align: None, + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 0, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::BLACK, + size: 9, + }, + }, + Area::Layers => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Bottom, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 3, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + }, + borders: enum_map! { + Border::Title => BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::OuterFrame( + BoxBorder::Left, + )=>BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::OuterFrame( + BoxBorder::Top, + ) =>BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::OuterFrame( + BoxBorder::Right, + ) => BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::OuterFrame( + BoxBorder::Bottom, + )=> BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::InnerFrame( + BoxBorder::Left, + )=> BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::InnerFrame( + BoxBorder::Top, + )=> BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::InnerFrame( + BoxBorder::Right, + )=> BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::InnerFrame( + BoxBorder::Bottom, + )=> BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::Dimension( + RowColBorder( + HeadingRegion::Rows, + Axis2::X, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::Dimension( + RowColBorder( + HeadingRegion::Columns, + Axis2::X, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::Dimension( + RowColBorder( + HeadingRegion::Rows, + Axis2::Y, + ), + )=> BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::Dimension( + RowColBorder( + HeadingRegion::Columns, + Axis2::Y, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::Category( + RowColBorder( + HeadingRegion::Rows, + Axis2::X, + ), + )=> BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::Category( + RowColBorder( + HeadingRegion::Columns, + Axis2::X, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::Category( + RowColBorder( + HeadingRegion::Rows, + Axis2::Y, + ), + )=> BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::Category( + RowColBorder( + HeadingRegion::Columns, + Axis2::Y, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::DataLeft => BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::DataTop => BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + }, + print_all_layers: true, + paginate_layers: false, + shrink_to_fit: EnumMap::from_fn(|_| false), + top_continuation: false, + bottom_continuation: false, + continuation: None, + n_orphan_lines: 5, + }; + assert_eq!(&look, &expected); } } diff --git a/rust/pspp/src/output/pivot/output.rs b/rust/pspp/src/output/pivot/output.rs index 8df1ae5369..a2427b0d35 100644 --- a/rust/pspp/src/output/pivot/output.rs +++ b/rust/pspp/src/output/pivot/output.rs @@ -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}, }; @@ -92,7 +92,7 @@ impl PivotTable { }; presentation_indexes[vary_axis] = &vary_indexes; let data_indexes = self.convert_indexes_ptod(presentation_indexes); - if self.get(&data_indexes).is_some() { + if self.get(&*data_indexes).is_some() { return false; } } @@ -141,7 +141,7 @@ impl PivotTable { let mut table = Table::new( Coord2::new(1, rows.len()), Coord2::new(0, 0), - self.look.areas.clone(), + self.style.look.areas.clone(), self.borders(false), self.into_value_options(), ); @@ -167,7 +167,11 @@ impl PivotTable { fn borders(&self, printing: bool) -> EnumMap { EnumMap::from_fn(|border| { - resolve_border_style(border, &self.look.borders, printing && self.show_grid_lines) + resolve_border_style( + border, + &self.style.look.borders, + printing && self.style.show_grid_lines, + ) }) } @@ -182,7 +186,7 @@ impl PivotTable { let mut body = Table::new( Coord2::from_fn(|axis| data[axis] + stub[axis]), stub, - self.look.areas.clone(), + self.style.look.areas.clone(), self.borders(printing), self.into_value_options(), ); @@ -199,28 +203,30 @@ impl PivotTable { Axis3::Z => layer_indexes, }; let data_indexes = self.convert_indexes_ptod(presentation_indexes); - let value = self.get(&data_indexes); + 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()), + ), ); } } // Insert corner text, but only if there's a stub and only if row labels // are not in the corner. - if self.corner_text.is_some() - && self.look.row_label_position == LabelPosition::Nested + if self.metadata.corner_text.is_some() + && self.style.look.row_label_position == LabelPosition::Nested && stub.x() > 0 && stub.y() > 0 { body.put( Rect2::new(0..stub.x(), 0..stub.y()), - CellInner::new(Area::Corner, self.corner_text.clone().unwrap_or_default()), + CellInner::new( + Area::Corner, + self.metadata.corner_text.clone().unwrap_or_default(), + ), ); } @@ -245,7 +251,10 @@ impl PivotTable { } pub fn output_title(&self) -> Option { - Some(self.create_aux_table3(Area::Title, [self.title.as_ref()?.clone()].into_iter())) + Some(self.create_aux_table3( + Area::Title, + [self.metadata.title.as_ref()?.clone()].into_iter(), + )) } pub fn output_layers(&self, layer_indexes: &[usize]) -> Option
{ @@ -258,7 +267,7 @@ impl PivotTable { layer_indexes, ) { if !dimension.is_empty() { - layers.push(dimension.nth_leaf(layer_index).unwrap().name.clone()); + layers.push(dimension.nth_leaf(layer_index).unwrap().0.clone()); } } layers.reverse(); @@ -267,7 +276,10 @@ impl PivotTable { } pub fn output_caption(&self) -> Option
{ - Some(self.create_aux_table3(Area::Caption, [self.caption.as_ref()?.clone()].into_iter())) + Some(self.create_aux_table3( + Area::Caption, + [self.metadata.caption.as_ref()?.clone()].into_iter(), + )) } pub fn output_footnotes(&self, footnotes: &[Arc]) -> Option
{ @@ -285,10 +297,14 @@ impl PivotTable { pub fn output(&self, layer_indexes: &[usize], printing: bool) -> OutputTables { // Produce most of the tables. - let title = self.show_title.then(|| self.output_title()).flatten(); + let title = self.style.show_title.then(|| self.output_title()).flatten(); let layers = self.output_layers(layer_indexes); let body = self.output_body(layer_indexes, printing); - let caption = self.show_caption.then(|| self.output_caption()).flatten(); + let caption = self + .style + .show_caption + .then(|| self.output_caption()) + .flatten(); // Then collect the footnotes from those tables. let tables = [ @@ -352,11 +368,12 @@ pub struct OutputTables { } impl Path<'_> { - pub fn get(&self, y: usize, height: usize) -> Option<&Value> { - if y + 1 == height { - Some(&self.leaf.name) + pub fn get(&self, y: usize, height: usize) -> (&Value, Range) { + debug_assert!(height > self.groups.len()); + if let Some(group) = self.groups.get(y) { + (&*group.name, y..y + 1) } else { - self.groups.get(y).map(|group| &*group.name) + (&self.leaf.0, self.groups.len()..height) } } } @@ -422,44 +439,47 @@ impl<'a> Heading<'a> { ) { let v = !h; + // Go through the heading row by row. for row in 0..self.height { // Find all the categories, dropping columns without a category. let categories = self.columns.iter().enumerate().filter_map(|(x, column)| { - column.get(row, self.height).map(|name| (x..x + 1, name)) + let (name, y_range) = column.get(row, self.height); + (y_range.start == row).then_some((x..x + 1, y_range, name)) }); // Merge adjacent identical categories (but don't merge across a vertical rule). let categories = categories - .coalesce(|(a_r, a), (b_r, b)| { + .coalesce(|(a_r, a_yr, a), (b_r, b_yr, b)| { if a_r.end == b_r.start && !vrules[b_r.start] && std::ptr::eq(a, b) { - Ok((a_r.start..b_r.end, a)) + Ok((a_r.start..b_r.end, a_yr, a)) } else { - Err(((a_r, a), (b_r, b))) + Err(((a_r, a_yr, a), (b_r, b_yr, b))) } }) .collect::>(); - for (Range { start: x1, end: x2 }, name) in categories.iter().cloned() { - let y1 = v_ofs + row; - let y2 = y1 + 1; + for (Range { start: x1, end: x2 }, yr, name) in categories.iter().cloned() { + let y1 = v_ofs + yr.start; + let y2 = v_ofs + yr.end; + + 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 // than the far left and far right ones. Only the ones that // start in the last row of the heading are drawn with the - // "category" style, the rest with the "dimension" style, - // e.g. only the `║` below are category style: + // "category" style, the rest with the "dimension" style. + // + // # Example + // + // Suppose we have two dimensions `aaaa` and `bbbb`, each with + // three numbered categories. Only the `║` below are category + // style: // // ```text // ┌─────────────────────────────────────────────────────┐ @@ -485,11 +505,15 @@ impl<'a> Heading<'a> { } } - // Draws the horizontal lines within a dimension, that is, those + // Draw the horizontal lines within a dimension, that is, those // that separate a category (or group) from its parent group or - // dimension's label. Our running example doesn't have groups - // but the `═════` lines below show the separators between - // categories and their dimension label: + // dimension's label. + // + // # Example + // + // Our running example doesn't have groups but the `═════` lines + // below show the separators between categories and their + // dimension label: // // ```text // ┌─────────────────────────────────────────────────────┐ @@ -523,11 +547,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()), ); } } @@ -542,7 +562,8 @@ struct Headings<'a> { impl<'a> Headings<'a> { fn new(pt: &'a PivotTable, h: Axis2, layer_indexes: &[usize]) -> Self { - let column_enumeration = pt.enumerate_axis(h.into(), layer_indexes, pt.look.hide_empty); + let column_enumeration = + pt.enumerate_axis(h.into(), layer_indexes, pt.style.look.hide_empty); let mut headings = pt.axes[h.into()] .dimensions @@ -556,7 +577,7 @@ impl<'a> Headings<'a> { .collect::>(); let row_label_position = if h == Axis2::Y - && pt.look.row_label_position == LabelPosition::Corner + && pt.style.look.row_label_position == LabelPosition::Corner && headings .iter_mut() .map(|heading| heading.move_dimension_labels_to_corner()) @@ -620,8 +641,13 @@ impl<'a> Headings<'a> { ); v_ofs += heading.height; if !inner { - // Draw the horizontal line between dimensions, e.g. the `=====` - // line here: + // Draw the horizontal line between dimensions. + // + // # Example + // + // Suppose we have two dimensions `aaaa` and `bbbb`, each with + // three numbered categories. This code draws the `=====` line + // here: // // ```text // ┌─────────────────────────────────────────────────────┐ __ diff --git a/rust/pspp/src/output/pivot/testdata/caption.expected b/rust/pspp/src/output/pivot/testdata/caption.expected new file mode 100644 index 0000000000..73ff1fd39c --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/caption.expected @@ -0,0 +1,8 @@ +╭──┬──┬──┬──╮ +│ │a1│a2│a3│ +├──┼──┼──┼──┤ +│b1│ 0│ 1│ 2│ +│b2│ 3│ 4│ 5│ +│b3│ 6│ 7│ 8│ +╰──┴──┴──┴──╯ +Caption diff --git a/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_1.expected b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_1.expected new file mode 100644 index 0000000000..8957417c16 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_1.expected @@ -0,0 +1,24 @@ +Category and Dimension Borders 1 + b + bg1 │ + b1 │ b2 │ b3 + a │ a │ a + │ ag1 │ │ ag1 │ │ ag1 +d c a1│a2┊a3│a1│a2┊a3│a1│a2┊a3 +dg1 d1 c1 0│ 1┊ 2│ 3│ 4┊ 5│ 6│ 7┊ 8 + ╶─────────┼──┼──┼──┼──┼──┼──┼──┼── + cg1 c2 9│10┊11│12│13┊14│15│16┊17 + ╌╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ + c3 18│19┊20│21│22┊23│24│25┊26 + ╶────────────┼──┼──┼──┼──┼──┼──┼──┼── + d2 c1 27│28┊29│30│31┊32│33│34┊35 + ╶─────────┼──┼──┼──┼──┼──┼──┼──┼── + cg1 c2 36│37┊38│39│40┊41│42│43┊44 + ╌╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ + c3 45│46┊47│48│49┊50│51│52┊53 +────────────────┼──┼──┼──┼──┼──┼──┼──┼── +d3 c1 54│55┊56│57│58┊59│60│61┊62 + ╶─────────┼──┼──┼──┼──┼──┼──┼──┼── + cg1 c2 63│64┊65│66│67┊68│69│70┊71 + ╌╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ + c3 72│73┊74│75│76┊77│78│79┊80 diff --git a/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_2.expected b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_2.expected new file mode 100644 index 0000000000..6cb2aa3854 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_2.expected @@ -0,0 +1,21 @@ +Category and Dimension Borders 2 + b + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + bg1 + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + b1 b2 b3 + ╶────────────────────────── + a a a + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + ag1 ag1 ag1 + ╌╌╌╌╌╌╌ ╌╌╌╌╌╌╌ ╌╌╌╌╌╌ +d c a1 a2 a3 a1 a2 a3 a1 a2 a3 +dg1┊d1│c1 0 1 2 3 4 5 6 7 8 + ┊ │cg1┊c2 9 10 11 12 13 14 15 16 17 + ┊ │ ┊c3 18 19 20 21 22 23 24 25 26 + ┊d2│c1 27 28 29 30 31 32 33 34 35 + ┊ │cg1┊c2 36 37 38 39 40 41 42 43 44 + ┊ │ ┊c3 45 46 47 48 49 50 51 52 53 +d3 │c1 54 55 56 57 58 59 60 61 62 + │cg1┊c2 63 64 65 66 67 68 69 70 71 + │ ┊c3 72 73 74 75 76 77 78 79 80 diff --git a/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_3.expected b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_3.expected new file mode 100644 index 0000000000..fdf05f0a7c --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_3.expected @@ -0,0 +1,25 @@ +Category and Dimension Borders 3 + bg1 │ + ╌╌╌╌╌╌╌╌╌┬╌╌╌╌╌╌╌╌┤ + b1 │ b2 │ b3 + ╶──┬─────┼──┬─────┼──┬───── + │ ag1 │ │ ag1 │ │ ag1 + ├╌╌┬╌╌┤ ├╌╌┬╌╌┤ ├╌╌┬╌╌ + a1│a2┊a3│a1│a2┊a3│a1│a2┊a3 +dg1┊d1│c1 0│ 1┊ 2│ 3│ 4┊ 5│ 6│ 7┊ 8 + ┊ ├───┬─────┼──┼──┼──┼──┼──┼──┼──┼── + ┊ │cg1┊c2 9│10┊11│12│13┊14│15│16┊17 + ┊ │ ├╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ + ┊ │ ┊c3 18│19┊20│21│22┊23│24│25┊26 + ├──┼───┴─────┼──┼──┼──┼──┼──┼──┼──┼── + ┊d2│c1 27│28┊29│30│31┊32│33│34┊35 + ┊ ├───┬─────┼──┼──┼──┼──┼──┼──┼──┼── + ┊ │cg1┊c2 36│37┊38│39│40┊41│42│43┊44 + ┊ │ ├╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ + ┊ │ ┊c3 45│46┊47│48│49┊50│51│52┊53 +───┴──┼───┴─────┼──┼──┼──┼──┼──┼──┼──┼── +d3 │c1 54│55┊56│57│58┊59│60│61┊62 + ├───┬─────┼──┼──┼──┼──┼──┼──┼──┼── + │cg1┊c2 63│64┊65│66│67┊68│69│70┊71 + │ ├╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ + │ ┊c3 72│73┊74│75│76┊77│78│79┊80 diff --git a/rust/pspp/src/output/pivot/testdata/category_borders_1.expected b/rust/pspp/src/output/pivot/testdata/category_borders_1.expected new file mode 100644 index 0000000000..30412f9296 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/category_borders_1.expected @@ -0,0 +1,24 @@ +Category Borders 1 + b + bg1 ┊ + b1 ┊ b2 ┊ b3 + a ┊ a ┊ a + ┊ ag1 ┊ ┊ ag1 ┊ ┊ ag1 +d c a1┊a2┊a3┊a1┊a2┊a3┊a1┊a2┊a3 +dg1 d1 c1 0┊ 1┊ 2┊ 3┊ 4┊ 5┊ 6┊ 7┊ 8 + ╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ + cg1 c2 9┊10┊11┊12┊13┊14┊15┊16┊17 + ╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ + c3 18┊19┊20┊21┊22┊23┊24┊25┊26 + ╌╌╌╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ + d2 c1 27┊28┊29┊30┊31┊32┊33┊34┊35 + ╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ + cg1 c2 36┊37┊38┊39┊40┊41┊42┊43┊44 + ╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ + c3 45┊46┊47┊48┊49┊50┊51┊52┊53 +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ +d3 c1 54┊55┊56┊57┊58┊59┊60┊61┊62 + ╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ + cg1 c2 63┊64┊65┊66┊67┊68┊69┊70┊71 + ╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ + c3 72┊73┊74┊75┊76┊77┊78┊79┊80 diff --git a/rust/pspp/src/output/pivot/testdata/category_borders_2.expected b/rust/pspp/src/output/pivot/testdata/category_borders_2.expected new file mode 100644 index 0000000000..33d3afcafd --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/category_borders_2.expected @@ -0,0 +1,21 @@ +Category Borders 2 + b + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + bg1 + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + b1 b2 b3 + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + a a a + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + ag1 ag1 ag1 + ╌╌╌╌╌╌╌ ╌╌╌╌╌╌╌ ╌╌╌╌╌╌ +d c a1 a2 a3 a1 a2 a3 a1 a2 a3 +dg1┊d1┊c1 0 1 2 3 4 5 6 7 8 + ┊ ┊cg1┊c2 9 10 11 12 13 14 15 16 17 + ┊ ┊ ┊c3 18 19 20 21 22 23 24 25 26 + ┊d2┊c1 27 28 29 30 31 32 33 34 35 + ┊ ┊cg1┊c2 36 37 38 39 40 41 42 43 44 + ┊ ┊ ┊c3 45 46 47 48 49 50 51 52 53 +d3 ┊c1 54 55 56 57 58 59 60 61 62 + ┊cg1┊c2 63 64 65 66 67 68 69 70 71 + ┊ ┊c3 72 73 74 75 76 77 78 79 80 diff --git a/rust/pspp/src/output/pivot/testdata/d1_c.expected b/rust/pspp/src/output/pivot/testdata/d1_c.expected new file mode 100644 index 0000000000..d07377f167 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d1_c.expected @@ -0,0 +1,8 @@ +Columns +╭────────╮ +│ a │ +├──┬──┬──┤ +│a1│a2│a3│ +├──┼──┼──┤ +│ 0│ 1│ 2│ +╰──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d1_r.expected b/rust/pspp/src/output/pivot/testdata/d1_r.expected new file mode 100644 index 0000000000..70dc402e34 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d1_r.expected @@ -0,0 +1,8 @@ +Rows +╭──┬─╮ +│a │ │ +├──┼─┤ +│a1│0│ +│a2│1│ +│a3│2│ +╰──┴─╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_cc.expected b/rust/pspp/src/output/pivot/testdata/d2_cc.expected new file mode 100644 index 0000000000..593ffa956b --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cc.expected @@ -0,0 +1,8 @@ +Columns +╭────────┬────────┬────────╮ +│ b1 │ b2 │ b3 │ +├──┬──┬──┼──┬──┬──┼──┬──┬──┤ +│a1│a2│a3│a1│a2│a3│a1│a2│a3│ +├──┼──┼──┼──┼──┼──┼──┼──┼──┤ +│ 0│ 1│ 2│ 3│ 4│ 5│ 6│ 7│ 8│ +╰──┴──┴──┴──┴──┴──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_cc_with_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_cc_with_dim_labels.expected new file mode 100644 index 0000000000..e2acdbc038 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cc_with_dim_labels.expected @@ -0,0 +1,12 @@ +Columns +╭──────────────────────────╮ +│ b │ +├────────┬────────┬────────┤ +│ b1 │ b2 │ b3 │ +├────────┼────────┼────────┤ +│ a │ a │ a │ +├──┬──┬──┼──┬──┬──┼──┬──┬──┤ +│a1│a2│a3│a1│a2│a3│a1│a2│a3│ +├──┼──┼──┼──┼──┼──┼──┼──┼──┤ +│ 0│ 1│ 2│ 3│ 4│ 5│ 6│ 7│ 8│ +╰──┴──┴──┴──┴──┴──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_cl-all_layers.expected b/rust/pspp/src/output/pivot/testdata/d2_cl-all_layers.expected new file mode 100644 index 0000000000..cdb9255969 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cl-all_layers.expected @@ -0,0 +1,23 @@ +Column (All Layers) +b1 +╭──┬──┬──╮ +│a1│a2│a3│ +├──┼──┼──┤ +│ 0│ 1│ 2│ +╰──┴──┴──╯ + +Column (All Layers) +b2 +╭──┬──┬──╮ +│a1│a2│a3│ +├──┼──┼──┤ +│ 3│ 4│ 5│ +╰──┴──┴──╯ + +Column (All Layers) +b3 +╭──┬──┬──╮ +│a1│a2│a3│ +├──┼──┼──┤ +│ 6│ 7│ 8│ +╰──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_cl-layer0.expected b/rust/pspp/src/output/pivot/testdata/d2_cl-layer0.expected new file mode 100644 index 0000000000..48a28c4991 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cl-layer0.expected @@ -0,0 +1,7 @@ +Column x b1 +b1 +╭──┬──┬──╮ +│a1│a2│a3│ +├──┼──┼──┤ +│ 0│ 1│ 2│ +╰──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_cl-layer1.expected b/rust/pspp/src/output/pivot/testdata/d2_cl-layer1.expected new file mode 100644 index 0000000000..9e9323c810 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cl-layer1.expected @@ -0,0 +1,7 @@ +Column x b2 +b2 +╭──┬──┬──╮ +│a1│a2│a3│ +├──┼──┼──┤ +│ 3│ 4│ 5│ +╰──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_cr.expected b/rust/pspp/src/output/pivot/testdata/d2_cr.expected new file mode 100644 index 0000000000..12d2be6a32 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cr.expected @@ -0,0 +1,8 @@ +Column x Row +╭──┬──┬──┬──╮ +│ │a1│a2│a3│ +├──┼──┼──┼──┤ +│b1│ 0│ 1│ 2│ +│b2│ 3│ 4│ 5│ +│b3│ 6│ 7│ 8│ +╰──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_cr_with_corner_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_cr_with_corner_dim_labels.expected new file mode 100644 index 0000000000..8538b2d73d --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cr_with_corner_dim_labels.expected @@ -0,0 +1,10 @@ +Column x Row - Corner +╭──┬────────╮ +│ │ a │ +│ ├──┬──┬──┤ +│b │a1│a2│a3│ +├──┼──┼──┼──┤ +│b1│ 0│ 1│ 2│ +│b2│ 3│ 4│ 5│ +│b3│ 6│ 7│ 8│ +╰──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_cr_with_nested_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_cr_with_nested_dim_labels.expected new file mode 100644 index 0000000000..79b99dd902 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cr_with_nested_dim_labels.expected @@ -0,0 +1,10 @@ +Column x Row - Nested +╭────┬────────╮ +│ │ a │ +│ ├──┬──┬──┤ +│ │a1│a2│a3│ +├────┼──┼──┼──┤ +│b b1│ 0│ 1│ 2│ +│ b2│ 3│ 4│ 5│ +│ b3│ 6│ 7│ 8│ +╰────┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rc.expected b/rust/pspp/src/output/pivot/testdata/d2_rc.expected new file mode 100644 index 0000000000..2f337af67d --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rc.expected @@ -0,0 +1,8 @@ +Row x Column +╭──┬──┬──┬──╮ +│ │b1│b2│b3│ +├──┼──┼──┼──┤ +│a1│ 0│ 3│ 6│ +│a2│ 1│ 4│ 7│ +│a3│ 2│ 5│ 8│ +╰──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rc_with_corner_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_rc_with_corner_dim_labels.expected new file mode 100644 index 0000000000..2df469ae4a --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rc_with_corner_dim_labels.expected @@ -0,0 +1,10 @@ +Row x Column - Corner +╭──┬────────╮ +│ │ b │ +│ ├──┬──┬──┤ +│a │b1│b2│b3│ +├──┼──┼──┼──┤ +│a1│ 0│ 3│ 6│ +│a2│ 1│ 4│ 7│ +│a3│ 2│ 5│ 8│ +╰──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rc_with_nested_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_rc_with_nested_dim_labels.expected new file mode 100644 index 0000000000..37cc93f69d --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rc_with_nested_dim_labels.expected @@ -0,0 +1,10 @@ +Row x Column - Nested +╭────┬────────╮ +│ │ b │ +│ ├──┬──┬──┤ +│ │b1│b2│b3│ +├────┼──┼──┼──┤ +│a a1│ 0│ 3│ 6│ +│ a2│ 1│ 4│ 7│ +│ a3│ 2│ 5│ 8│ +╰────┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rl-all_layers.expected b/rust/pspp/src/output/pivot/testdata/d2_rl-all_layers.expected new file mode 100644 index 0000000000..0aa4682594 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rl-all_layers.expected @@ -0,0 +1,23 @@ +Row (All Layers) +b1 +╭──┬─╮ +│a1│0│ +│a2│1│ +│a3│2│ +╰──┴─╯ + +Row (All Layers) +b2 +╭──┬─╮ +│a1│3│ +│a2│4│ +│a3│5│ +╰──┴─╯ + +Row (All Layers) +b3 +╭──┬─╮ +│a1│6│ +│a2│7│ +│a3│8│ +╰──┴─╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rl-layer0.expected b/rust/pspp/src/output/pivot/testdata/d2_rl-layer0.expected new file mode 100644 index 0000000000..1f7667590a --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rl-layer0.expected @@ -0,0 +1,7 @@ +Row x b1 +b1 +╭──┬─╮ +│a1│0│ +│a2│1│ +│a3│2│ +╰──┴─╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rl-layer1.expected b/rust/pspp/src/output/pivot/testdata/d2_rl-layer1.expected new file mode 100644 index 0000000000..051797d192 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rl-layer1.expected @@ -0,0 +1,7 @@ +Row x b2 +b2 +╭──┬─╮ +│a1│3│ +│a2│4│ +│a3│5│ +╰──┴─╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rr.expected b/rust/pspp/src/output/pivot/testdata/d2_rr.expected new file mode 100644 index 0000000000..ff69fe4f48 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rr.expected @@ -0,0 +1,14 @@ +Rows +╭─────┬─╮ +│b1 a1│0│ +│ a2│1│ +│ a3│2│ +├─────┼─┤ +│b2 a1│3│ +│ a2│4│ +│ a3│5│ +├─────┼─┤ +│b3 a1│6│ +│ a2│7│ +│ a3│8│ +╰─────┴─╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rr_with_corner_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_rr_with_corner_dim_labels.expected new file mode 100644 index 0000000000..a1662c5ad1 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rr_with_corner_dim_labels.expected @@ -0,0 +1,16 @@ +Rows - Corner +╭─────┬─╮ +│b a │ │ +├─────┼─┤ +│b1 a1│0│ +│ a2│1│ +│ a3│2│ +├─────┼─┤ +│b2 a1│3│ +│ a2│4│ +│ a3│5│ +├─────┼─┤ +│b3 a1│6│ +│ a2│7│ +│ a3│8│ +╰─────┴─╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2_rr_with_nested_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_rr_with_nested_dim_labels.expected new file mode 100644 index 0000000000..f401eb1058 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rr_with_nested_dim_labels.expected @@ -0,0 +1,14 @@ +Rows - Nested +╭─────────┬─╮ +│b b1 a a1│0│ +│ a2│1│ +│ a3│2│ +│ ╶───────┼─┤ +│ b2 a a1│3│ +│ a2│4│ +│ a3│5│ +│ ╶───────┼─┤ +│ b3 a a1│6│ +│ a2│7│ +│ a3│8│ +╰─────────┴─╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2m_cc.expected b/rust/pspp/src/output/pivot/testdata/d2m_cc.expected new file mode 100644 index 0000000000..eaa9ee39c0 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2m_cc.expected @@ -0,0 +1,12 @@ +Columns +╭─────────────────┬────────┬────────┬────────╮ +│ b │ c │ │ │ +├────────┬────────┼────────┤ │ │ +│ b1 │ │ │ │ │ +├────────┤ │ │ │ │ +│ b2 │ b3 │ c1 │ d │ e │ +├──┬──┬──┼──┬──┬──┼──┬──┬──┼──┬──┬──┼──┬──┬──┤ +│a1│a2│a3│a1│a2│a3│a1│a2│a3│a1│a2│a3│a1│a2│a3│ +├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ +│ 0│ 1│ 2│ 3│ 4│ 5│ 6│ 7│ 8│ 9│10│11│12│13│14│ +╰──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2m_cr.expected b/rust/pspp/src/output/pivot/testdata/d2m_cr.expected new file mode 100644 index 0000000000..914989b5b7 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2m_cr.expected @@ -0,0 +1,14 @@ +Column x Row +╭───────┬──┬──┬──╮ +│ │a1│a2│a3│ +├───────┼──┼──┼──┤ +│b b1 b2│ 0│ 1│ 2│ +│ ╶─────┼──┼──┼──┤ +│ b3 │ 3│ 4│ 5│ +├───────┼──┼──┼──┤ +│c c1 │ 6│ 7│ 8│ +├───────┼──┼──┼──┤ +│d │ 9│10│11│ +├───────┼──┼──┼──┤ +│e │12│13│14│ +╰───────┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2m_rc.expected b/rust/pspp/src/output/pivot/testdata/d2m_rc.expected new file mode 100644 index 0000000000..d5e05cee16 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2m_rc.expected @@ -0,0 +1,12 @@ +Row x Column +╭──┬─────┬──┬──┬──╮ +│ │ b │ c│ │ │ +│ ├──┬──┼──┤ │ │ +│ │b1│ │ │ │ │ +│ ├──┤ │ │ │ │ +│ │b2│b3│c1│ d│ e│ +├──┼──┼──┼──┼──┼──┤ +│a1│ 0│ 3│ 6│ 9│12│ +│a2│ 1│ 4│ 7│10│13│ +│a3│ 2│ 5│ 8│11│14│ +╰──┴──┴──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d2m_rr.expected b/rust/pspp/src/output/pivot/testdata/d2m_rr.expected new file mode 100644 index 0000000000..737976b6f1 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2m_rr.expected @@ -0,0 +1,22 @@ +Rows +╭──────────┬──╮ +│b b1 b2 a1│ 0│ +│ a2│ 1│ +│ a3│ 2│ +│ ╶────────┼──┤ +│ b3 a1│ 3│ +│ a2│ 4│ +│ a3│ 5│ +├──────────┼──┤ +│c c1 a1│ 6│ +│ a2│ 7│ +│ a3│ 8│ +├──────────┼──┤ +│d a1│ 9│ +│ a2│10│ +│ a3│11│ +├──────────┼──┤ +│e a1│12│ +│ a2│13│ +│ a3│14│ +╰──────────┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d3-layer0_0.expected b/rust/pspp/src/output/pivot/testdata/d3-layer0_0.expected new file mode 100644 index 0000000000..c2eefe00c4 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d3-layer0_0.expected @@ -0,0 +1,8 @@ +Column x b1 x a1 +b1 +a1 +╭──┬──┬──┬──┬──╮ +│c1│c2│c3│c4│c5│ +├──┼──┼──┼──┼──┤ +│ 0│12│24│36│48│ +╰──┴──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d3-layer0_1.expected b/rust/pspp/src/output/pivot/testdata/d3-layer0_1.expected new file mode 100644 index 0000000000..aaa4395528 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d3-layer0_1.expected @@ -0,0 +1,8 @@ +Column x b2 x a1 +b2 +a1 +╭──┬──┬──┬──┬──╮ +│c1│c2│c3│c4│c5│ +├──┼──┼──┼──┼──┤ +│ 3│15│27│39│51│ +╰──┴──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/d3-layer1_2.expected b/rust/pspp/src/output/pivot/testdata/d3-layer1_2.expected new file mode 100644 index 0000000000..55fb1525e2 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d3-layer1_2.expected @@ -0,0 +1,8 @@ +Column x b3 x a2 +b3 +a2 +╭──┬──┬──┬──┬──╮ +│c1│c2│c3│c4│c5│ +├──┼──┼──┼──┼──┤ +│ 7│19│31│43│55│ +╰──┴──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/dimension_borders_1.expected b/rust/pspp/src/output/pivot/testdata/dimension_borders_1.expected new file mode 100644 index 0000000000..34419daa10 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/dimension_borders_1.expected @@ -0,0 +1,21 @@ +Dimension Borders 1 + b + bg1 │ + b1 │ b2 │ b3 + a │ a │ a + │ ag1 │ │ ag1 │ │ ag1 +d c a1│a2 a3│a1│a2 a3│a1│a2 a3 +dg1 d1 c1 0│ 1 2│ 3│ 4 5│ 6│ 7 8 + ╶─────────┼─────┼──┼─────┼──┼───── + cg1 c2 9│10 11│12│13 14│15│16 17 + c3 18│19 20│21│22 23│24│25 26 + ╶────────────┼─────┼──┼─────┼──┼───── + d2 c1 27│28 29│30│31 32│33│34 35 + ╶─────────┼─────┼──┼─────┼──┼───── + cg1 c2 36│37 38│39│40 41│42│43 44 + c3 45│46 47│48│49 50│51│52 53 +────────────────┼─────┼──┼─────┼──┼───── +d3 c1 54│55 56│57│58 59│60│61 62 + ╶─────────┼─────┼──┼─────┼──┼───── + cg1 c2 63│64 65│66│67 68│69│70 71 + c3 72│73 74│75│76 77│78│79 80 diff --git a/rust/pspp/src/output/pivot/testdata/dimension_borders_2.expected b/rust/pspp/src/output/pivot/testdata/dimension_borders_2.expected new file mode 100644 index 0000000000..5d3ba617fc --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/dimension_borders_2.expected @@ -0,0 +1,17 @@ +Dimension Borders 2 + b + bg1 + b1 b2 b3 + ╶────────────────────────── + a a a + ag1 ag1 ag1 +d c a1 a2 a3 a1 a2 a3 a1 a2 a3 +dg1 d1│c1 0 1 2 3 4 5 6 7 8 + │cg1 c2 9 10 11 12 13 14 15 16 17 + │ c3 18 19 20 21 22 23 24 25 26 + d2│c1 27 28 29 30 31 32 33 34 35 + │cg1 c2 36 37 38 39 40 41 42 43 44 + │ c3 45 46 47 48 49 50 51 52 53 +d3 │c1 54 55 56 57 58 59 60 61 62 + │cg1 c2 63 64 65 66 67 68 69 70 71 + │ c3 72 73 74 75 76 77 78 79 80 diff --git a/rust/pspp/src/output/pivot/testdata/empty_groups.expected b/rust/pspp/src/output/pivot/testdata/empty_groups.expected new file mode 100644 index 0000000000..46cebd3029 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/empty_groups.expected @@ -0,0 +1,7 @@ +Empty Groups +╭──┬──┬──╮ +│ │a1│a3│ +├──┼──┼──┤ +│b2│ 0│ 1│ +│b3│ 2│ 3│ +╰──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_subscript.expected b/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_subscript.expected new file mode 100644 index 0000000000..4878aa099c --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_subscript.expected @@ -0,0 +1,12 @@ +Pivot Table with Alphabetic Subscript Footnotes[*] +╭────────────┬──────────────────╮ +│ │ A[*] │ +│ ├───────┬──────────┤ +│Corner[*][b]│ B[b] │ C[*][b] │ +├────────────┼───────┼──────────┤ +│D[b] E[*] │ .00│ 1.00[*]│ +│ F[*][b]│2.00[b]│3.00[*][b]│ +╰────────────┴───────┴──────────╯ +Caption[*] +*. First footnote +b. Second footnote diff --git a/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_superscript.expected b/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_superscript.expected new file mode 100644 index 0000000000..c989f62f8a --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_superscript.expected @@ -0,0 +1,12 @@ +Pivot Table with Alphabetic Superscript Footnotes[*] +╭────────────┬──────────────────╮ +│ │ A[*] │ +│ ├───────┬──────────┤ +│Corner[*][b]│ B[b] │ C[*][b] │ +├────────────┼───────┼──────────┤ +│D[b] E[*] │ .00│ 1.00[*]│ +│ F[*][b]│2.00[b]│3.00[*][b]│ +╰────────────┴───────┴──────────╯ +Caption[*] +*. First footnote +b. Second footnote diff --git a/rust/pspp/src/output/pivot/testdata/footnote_hidden.expected b/rust/pspp/src/output/pivot/testdata/footnote_hidden.expected new file mode 100644 index 0000000000..ce76a3f630 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/footnote_hidden.expected @@ -0,0 +1,11 @@ +Pivot Table with Alphabetic Subscript Footnotes[*] +╭────────────┬──────────────────╮ +│ │ A[*] │ +│ ├───────┬──────────┤ +│Corner[*][b]│ B[b] │ C[*][b] │ +├────────────┼───────┼──────────┤ +│D[b] E[*] │ .00│ 1.00[*]│ +│ F[*][b]│2.00[b]│3.00[*][b]│ +╰────────────┴───────┴──────────╯ +Caption[*] +b. Second footnote diff --git a/rust/pspp/src/output/pivot/testdata/footnote_numeric_subscript.expected b/rust/pspp/src/output/pivot/testdata/footnote_numeric_subscript.expected new file mode 100644 index 0000000000..82c1ccf679 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/footnote_numeric_subscript.expected @@ -0,0 +1,12 @@ +Pivot Table with Numeric Subscript Footnotes[*] +╭────────────┬──────────────────╮ +│ │ A[*] │ +│ ├───────┬──────────┤ +│Corner[*][2]│ B[2] │ C[*][2] │ +├────────────┼───────┼──────────┤ +│D[2] E[*] │ .00│ 1.00[*]│ +│ F[*][2]│2.00[2]│3.00[*][2]│ +╰────────────┴───────┴──────────╯ +Caption[*] +*. First footnote +2. Second footnote diff --git a/rust/pspp/src/output/pivot/testdata/footnote_numeric_superscript.expected b/rust/pspp/src/output/pivot/testdata/footnote_numeric_superscript.expected new file mode 100644 index 0000000000..c6498624cc --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/footnote_numeric_superscript.expected @@ -0,0 +1,12 @@ +Pivot Table with Numeric Superscript Footnotes[*] +╭────────────┬──────────────────╮ +│ │ A[*] │ +│ ├───────┬──────────┤ +│Corner[*][2]│ B[2] │ C[*][2] │ +├────────────┼───────┼──────────┤ +│D[2] E[*] │ .00│ 1.00[*]│ +│ F[*][2]│2.00[2]│3.00[*][2]│ +╰────────────┴───────┴──────────╯ +Caption[*] +*. First footnote +2. Second footnote diff --git a/rust/pspp/src/output/pivot/testdata/metadata_entry.expected b/rust/pspp/src/output/pivot/testdata/metadata_entry.expected new file mode 100644 index 0000000000..9c2f712159 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/metadata_entry.expected @@ -0,0 +1,9 @@ +╭────────────────────┬──────────╮ +│Name 1 │Value 1 │ +├────────────────────┼──────────┤ +│Subgroup 1 Subname 1│Subvalue 1│ +│ Subname 2│Subvalue 2│ +│ Subname 3│ 3│ +├────────────────────┼──────────┤ +│Name 2 │Value 2 │ +╰────────────────────┴──────────╯ diff --git a/rust/pspp/src/output/pivot/testdata/no_dimension.expected b/rust/pspp/src/output/pivot/testdata/no_dimension.expected new file mode 100644 index 0000000000..86fbb7e47d --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/no_dimension.expected @@ -0,0 +1,3 @@ +No Dimensions +╭╮ +╰╯ diff --git a/rust/pspp/src/output/pivot/testdata/no_title_or_caption.expected b/rust/pspp/src/output/pivot/testdata/no_title_or_caption.expected new file mode 100644 index 0000000000..e61aeb1d9c --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/no_title_or_caption.expected @@ -0,0 +1,7 @@ +╭──┬──┬──┬──╮ +│ │a1│a2│a3│ +├──┼──┼──┼──┤ +│b1│ 0│ 1│ 2│ +│b2│ 3│ 4│ 5│ +│b3│ 6│ 7│ 8│ +╰──┴──┴──┴──╯ diff --git a/rust/pspp/src/output/pivot/testdata/one_empty_dimension.expected b/rust/pspp/src/output/pivot/testdata/one_empty_dimension.expected new file mode 100644 index 0000000000..e992c1d994 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/one_empty_dimension.expected @@ -0,0 +1 @@ +One Empty Dimension diff --git a/rust/pspp/src/output/pivot/testdata/small_numbers.expected b/rust/pspp/src/output/pivot/testdata/small_numbers.expected new file mode 100644 index 0000000000..22ad04e3f1 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/small_numbers.expected @@ -0,0 +1,21 @@ +small numbers +╭────────┬─────────────────────────────────────╮ +│ │ result class │ +│ ├───────────────────┬─────────────────┤ +│ │ general │ specific │ +│ ├───────────────────┼─────────────────┤ +│ │ sign │ sign │ +│ ├─────────┬─────────┼────────┬────────┤ +│exponent│ positive│ negative│positive│negative│ +├────────┼─────────┼─────────┼────────┼────────┤ +│0 │ 1.00│ 1.00│ -1.00│ -1.00│ +│-1 │ .10│ .10│ -.10│ -.10│ +│-2 │ .01│ .01│ -.01│ -.01│ +│-3 │ .00│ .00│ .00│ .00│ +│-4 │ .00│ .00│ .00│ .00│ +│-5 │1.00E-005│1.00E-005│ .00│ .00│ +│-6 │1.00E-006│1.00E-006│ .00│ .00│ +│-7 │1.00E-007│1.00E-007│ .00│ .00│ +│-8 │1.00E-008│1.00E-008│ .00│ .00│ +│-9 │1.00E-009│1.00E-009│ .00│ .00│ +╰────────┴─────────┴─────────┴────────┴────────╯ diff --git a/rust/pspp/src/output/pivot/testdata/three_dimensions_two_empty.expected b/rust/pspp/src/output/pivot/testdata/three_dimensions_two_empty.expected new file mode 100644 index 0000000000..63fd2c257f --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/three_dimensions_two_empty.expected @@ -0,0 +1 @@ +Three Dimensions, Two Empty diff --git a/rust/pspp/src/output/pivot/testdata/title_and_caption.expected b/rust/pspp/src/output/pivot/testdata/title_and_caption.expected new file mode 100644 index 0000000000..270bb08578 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/title_and_caption.expected @@ -0,0 +1,9 @@ +Title +╭──┬──┬──┬──╮ +│ │a1│a2│a3│ +├──┼──┼──┼──┤ +│b1│ 0│ 1│ 2│ +│b2│ 3│ 4│ 5│ +│b3│ 6│ 7│ 8│ +╰──┴──┴──┴──╯ +Caption diff --git a/rust/pspp/src/output/pivot/testdata/two_empty_dimensions.expected b/rust/pspp/src/output/pivot/testdata/two_empty_dimensions.expected new file mode 100644 index 0000000000..1da8f2b12e --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/two_empty_dimensions.expected @@ -0,0 +1 @@ +Two Empty Dimensions diff --git a/rust/pspp/src/output/pivot/tests.rs b/rust/pspp/src/output/pivot/tests.rs index 27c3f1975d..23df42aca4 100644 --- a/rust/pspp/src/output/pivot/tests.rs +++ b/rust/pspp/src/output/pivot/tests.rs @@ -19,16 +19,18 @@ use std::{fmt::Display, fs::File, path::Path, sync::Arc}; use enum_map::EnumMap; use crate::output::{ - Details, Item, - cairo::{CairoConfig, CairoDriver}, - driver::Driver, - html::HtmlDriver, + Text, + drivers::{ + Driver, + cairo::{CairoConfig, CairoDriver}, + html::HtmlDriver, + spv::SpvDriver, + }, pivot::{ Area, Axis2, Border, BorderStyle, Class, Color, Dimension, Footnote, FootnoteMarkerPosition, FootnoteMarkerType, Footnotes, Group, HeadingRegion, LabelPosition, Look, PivotTable, RowColBorder, Stroke, }, - spv::SpvDriver, }; use super::{Axis3, Value}; @@ -65,38 +67,12 @@ fn d1(title: &str, axis: Axis3) -> PivotTable { #[test] fn d1_c() { - assert_rendering( - "d1_c", - &d1("Columns", Axis3::X), - "\ -Columns -╭────────╮ -│ a │ -├──┬──┬──┤ -│a1│a2│a3│ -├──┼──┼──┤ -│ 0│ 1│ 2│ -╰──┴──┴──╯ -", - ); + assert_rendering("d1_c", &d1("Columns", Axis3::X)); } #[test] fn d1_r() { - assert_rendering( - "d1_r", - &d1("Rows", Axis3::Y), - "\ -Rows -╭──┬─╮ -│a │ │ -├──┼─┤ -│a1│0│ -│a2│1│ -│a3│2│ -╰──┴─╯ -", - ); + assert_rendering("d1_r", &d1("Rows", Axis3::Y)); } fn test_look() -> Look { @@ -164,48 +140,56 @@ where } #[track_caller] -pub fn assert_rendering(name: &str, pivot_table: &PivotTable, expected: &str) { - assert_lines_eq( - expected, - format!("{name} expected"), - &pivot_table.to_string(), - format!("{name} actual"), - ); - - let item = Arc::new(Item::new(Details::Table(Box::new(pivot_table.clone())))); +pub fn assert_rendering(name: &str, pivot_table: &PivotTable) { + let item = Arc::new(pivot_table.clone().into_item()); if let Some(dir) = std::env::var_os("PSPP_TEST_HTML_DIR") { let writer = File::create(Path::new(&dir).join(name).with_extension("html")).unwrap(); HtmlDriver::for_writer(writer).write(&item); } - let item = Arc::new(Item::new(Details::Table(Box::new(pivot_table.clone())))); + let item = Arc::new(pivot_table.clone().into_item()); if let Some(dir) = std::env::var_os("PSPP_TEST_PDF_DIR") { let config = CairoConfig::new(Path::new(&dir).join(name).with_extension("pdf")); - CairoDriver::new(&config).unwrap().write(&item); + let mut pdf_driver = CairoDriver::new(&config).unwrap(); + pdf_driver.write(&Arc::new( + Text::new(crate::output::TextType::PageTitle, "page title").into_item(), + )); + pdf_driver.write(&item); } if let Some(dir) = std::env::var_os("PSPP_TEST_SPV_DIR") { let writer = File::create(Path::new(&dir).join(name).with_extension("spv")).unwrap(); - SpvDriver::for_writer(writer).write(&item); + let mut spv_driver = SpvDriver::for_writer(writer); + spv_driver.write(&Arc::new( + Text::new(crate::output::TextType::PageTitle, "page title").into_item(), + )); + spv_driver.write(&item); } + + let expected_filename = Path::new("src/output/pivot/testdata") + .join(name) + .with_extension("expected"); + let actual = pivot_table.to_string(); + let expected = std::fs::read_to_string(&expected_filename).unwrap(); + if expected != actual { + if std::env::var("PSPP_REFRESH_EXPECTED").is_ok() { + std::fs::write(&expected_filename, actual).unwrap(); + panic!("{}: refreshed output", expected_filename.display()); + } else { + eprintln!("note: rerun with PSPP_REFRESH_EXPECTED=1 to refresh expected output"); + } + } + assert_lines_eq( + &expected, + expected_filename.display(), + &actual, + format!("actual"), + ); } #[test] fn d2_cc() { - assert_rendering( - "d2_cc", - &d2("Columns", [Axis3::X, Axis3::X], None), - "\ -Columns -╭────────┬────────┬────────╮ -│ b1 │ b2 │ b3 │ -├──┬──┬──┼──┬──┬──┼──┬──┬──┤ -│a1│a2│a3│a1│a2│a3│a1│a2│a3│ -├──┼──┼──┼──┼──┼──┼──┼──┼──┤ -│ 0│ 1│ 2│ 3│ 4│ 5│ 6│ 7│ 8│ -╰──┴──┴──┴──┴──┴──┴──┴──┴──╯ -", - ); + assert_rendering("d2_cc", &d2("Columns", [Axis3::X, Axis3::X], None)); } #[test] @@ -213,45 +197,12 @@ fn d2_cc_with_dim_labels() { assert_rendering( "d2_cc_with_dim_labels", &d2("Columns", [Axis3::X, Axis3::X], Some(LabelPosition::Corner)), - "\ -Columns -╭──────────────────────────╮ -│ b │ -├────────┬────────┬────────┤ -│ b1 │ b2 │ b3 │ -├────────┼────────┼────────┤ -│ a │ a │ a │ -├──┬──┬──┼──┬──┬──┼──┬──┬──┤ -│a1│a2│a3│a1│a2│a3│a1│a2│a3│ -├──┼──┼──┼──┼──┼──┼──┼──┼──┤ -│ 0│ 1│ 2│ 3│ 4│ 5│ 6│ 7│ 8│ -╰──┴──┴──┴──┴──┴──┴──┴──┴──╯ -", ); } #[test] fn d2_rr() { - assert_rendering( - "d2_rr", - &d2("Rows", [Axis3::Y, Axis3::Y], None), - "\ -Rows -╭─────┬─╮ -│b1 a1│0│ -│ a2│1│ -│ a3│2│ -├─────┼─┤ -│b2 a1│3│ -│ a2│4│ -│ a3│5│ -├─────┼─┤ -│b3 a1│6│ -│ a2│7│ -│ a3│8│ -╰─────┴─╯ -", - ); + assert_rendering("d2_rr", &d2("Rows", [Axis3::Y, Axis3::Y], None)); } #[test] @@ -263,24 +214,6 @@ fn d2_rr_with_corner_dim_labels() { [Axis3::Y, Axis3::Y], Some(LabelPosition::Corner), ), - "\ -Rows - Corner -╭─────┬─╮ -│b a │ │ -├─────┼─┤ -│b1 a1│0│ -│ a2│1│ -│ a3│2│ -├─────┼─┤ -│b2 a1│3│ -│ a2│4│ -│ a3│5│ -├─────┼─┤ -│b3 a1│6│ -│ a2│7│ -│ a3│8│ -╰─────┴─╯ -", ); } @@ -293,41 +226,12 @@ fn d2_rr_with_nested_dim_labels() { [Axis3::Y, Axis3::Y], Some(LabelPosition::Nested), ), - "\ -Rows - Nested -╭─────────┬─╮ -│b b1 a a1│0│ -│ a2│1│ -│ a3│2│ -│ ╶───────┼─┤ -│ b2 a a1│3│ -│ a2│4│ -│ a3│5│ -│ ╶───────┼─┤ -│ b3 a a1│6│ -│ a2│7│ -│ a3│8│ -╰─────────┴─╯ -", ); } #[test] fn d2_cr() { - assert_rendering( - "d2_cr", - &d2("Column x Row", [Axis3::X, Axis3::Y], None), - "\ -Column x Row -╭──┬──┬──┬──╮ -│ │a1│a2│a3│ -├──┼──┼──┼──┤ -│b1│ 0│ 1│ 2│ -│b2│ 3│ 4│ 5│ -│b3│ 6│ 7│ 8│ -╰──┴──┴──┴──╯ -", - ); + assert_rendering("d2_cr", &d2("Column x Row", [Axis3::X, Axis3::Y], None)); } #[test] @@ -339,18 +243,6 @@ fn d2_cr_with_corner_dim_labels() { [Axis3::X, Axis3::Y], Some(LabelPosition::Corner), ), - "\ -Column x Row - Corner -╭──┬────────╮ -│ │ a │ -│ ├──┬──┬──┤ -│b │a1│a2│a3│ -├──┼──┼──┼──┤ -│b1│ 0│ 1│ 2│ -│b2│ 3│ 4│ 5│ -│b3│ 6│ 7│ 8│ -╰──┴──┴──┴──╯ -", ); } @@ -363,37 +255,12 @@ fn d2_cr_with_nested_dim_labels() { [Axis3::X, Axis3::Y], Some(LabelPosition::Nested), ), - "\ -Column x Row - Nested -╭────┬────────╮ -│ │ a │ -│ ├──┬──┬──┤ -│ │a1│a2│a3│ -├────┼──┼──┼──┤ -│b b1│ 0│ 1│ 2│ -│ b2│ 3│ 4│ 5│ -│ b3│ 6│ 7│ 8│ -╰────┴──┴──┴──╯ -", ); } #[test] fn d2_rc() { - assert_rendering( - "d2_rc", - &d2("Row x Column", [Axis3::Y, Axis3::X], None), - "\ -Row x Column -╭──┬──┬──┬──╮ -│ │b1│b2│b3│ -├──┼──┼──┼──┤ -│a1│ 0│ 3│ 6│ -│a2│ 1│ 4│ 7│ -│a3│ 2│ 5│ 8│ -╰──┴──┴──┴──╯ -", - ); + assert_rendering("d2_rc", &d2("Row x Column", [Axis3::Y, Axis3::X], None)); } #[test] @@ -405,18 +272,6 @@ fn d2_rc_with_corner_dim_labels() { [Axis3::Y, Axis3::X], Some(LabelPosition::Corner), ), - "\ -Row x Column - Corner -╭──┬────────╮ -│ │ b │ -│ ├──┬──┬──┤ -│a │b1│b2│b3│ -├──┼──┼──┼──┤ -│a1│ 0│ 3│ 6│ -│a2│ 1│ 4│ 7│ -│a3│ 2│ 5│ 8│ -╰──┴──┴──┴──╯ -", ); } @@ -429,155 +284,92 @@ fn d2_rc_with_nested_dim_labels() { [Axis3::Y, Axis3::X], Some(LabelPosition::Nested), ), - "\ -Row x Column - Nested -╭────┬────────╮ -│ │ b │ -│ ├──┬──┬──┤ -│ │b1│b2│b3│ -├────┼──┼──┼──┤ -│a a1│ 0│ 3│ 6│ -│ a2│ 1│ 4│ 7│ -│ a3│ 2│ 5│ 8│ -╰────┴──┴──┴──╯ -", ); } #[test] fn d2_cl() { let pivot_table = d2("Column x b1", [Axis3::X, Axis3::Z], None); - assert_rendering( - "d2_cl-layer0", - &pivot_table, - "\ -Column x b1 -b1 -╭──┬──┬──╮ -│a1│a2│a3│ -├──┼──┼──┤ -│ 0│ 1│ 2│ -╰──┴──┴──╯ -", - ); + assert_rendering("d2_cl-layer0", &pivot_table); let pivot_table = pivot_table .with_layer(&[1]) .with_title(Value::new_text("Column x b2")); - assert_rendering( - "d2_cl-layer1", - &pivot_table, - "\ -Column x b2 -b2 -╭──┬──┬──╮ -│a1│a2│a3│ -├──┼──┼──┤ -│ 3│ 4│ 5│ -╰──┴──┴──╯ -", - ); + assert_rendering("d2_cl-layer1", &pivot_table); let pivot_table = pivot_table .with_all_layers() .with_title(Value::new_text("Column (All Layers)")); - assert_rendering( - "d2_cl-all_layers", - &pivot_table, - "\ -Column (All Layers) -b1 -╭──┬──┬──╮ -│a1│a2│a3│ -├──┼──┼──┤ -│ 0│ 1│ 2│ -╰──┴──┴──╯ - -Column (All Layers) -b2 -╭──┬──┬──╮ -│a1│a2│a3│ -├──┼──┼──┤ -│ 3│ 4│ 5│ -╰──┴──┴──╯ - -Column (All Layers) -b3 -╭──┬──┬──╮ -│a1│a2│a3│ -├──┼──┼──┤ -│ 6│ 7│ 8│ -╰──┴──┴──╯ -", - ); + assert_rendering("d2_cl-all_layers", &pivot_table); } #[test] fn d2_rl() { let pivot_table = d2("Row x b1", [Axis3::Y, Axis3::Z], None); - assert_rendering( - "d2_rl-layer0", - &pivot_table, - "\ -Row x b1 -b1 -╭──┬─╮ -│a1│0│ -│a2│1│ -│a3│2│ -╰──┴─╯ -", - ); + assert_rendering("d2_rl-layer0", &pivot_table); let pivot_table = pivot_table .with_layer(&[1]) .with_title(Value::new_text("Row x b2")); - assert_rendering( - "d2_rl-layer1", - &pivot_table, - "\ -Row x b2 -b2 -╭──┬─╮ -│a1│3│ -│a2│4│ -│a3│5│ -╰──┴─╯ -", - ); + assert_rendering("d2_rl-layer1", &pivot_table); let pivot_table = pivot_table .with_all_layers() .with_title(Value::new_text("Row (All Layers)")); - assert_rendering( - "d2_rl-all_layers", - &pivot_table, - "\ -Row (All Layers) -b1 -╭──┬─╮ -│a1│0│ -│a2│1│ -│a3│2│ -╰──┴─╯ - -Row (All Layers) -b2 -╭──┬─╮ -│a1│3│ -│a2│4│ -│a3│5│ -╰──┴─╯ - -Row (All Layers) -b3 -╭──┬─╮ -│a1│6│ -│a2│7│ -│a3│8│ -╰──┴─╯ -", + assert_rendering("d2_rl-all_layers", &pivot_table); +} + +fn d2m(title: &str, axes: [Axis3; 2], dimension_labels: Option) -> PivotTable { + let d1 = Dimension::new( + Group::new("a") + .with_show_label(dimension_labels.is_some()) + .with("a1") + .with("a2") + .with("a3"), ); + + let d2 = Dimension::new( + Group::new("outer") + .with_show_label(dimension_labels.is_some()) + .with(Group::new("b").with(Group::new("b1").with("b2")).with("b3")) + .with(Group::new("c").with("c1")) + .with("d") + .with("e"), + ); + + let mut pt = PivotTable::new([(axes[0], d1), (axes[1], d2)]).with_title(title); + let mut i = 0; + for b in 0..5 { + for a in 0..3 { + pt.insert(&[a, b], Value::new_integer(Some(i as f64))); + i += 1; + } + } + let look = match dimension_labels { + Some(position) => test_look().with_row_label_position(position), + None => test_look(), + }; + pt.with_look(Arc::new(look)) +} + +#[test] +fn d2m_cc() { + assert_rendering("d2m_cc", &d2m("Columns", [Axis3::X, Axis3::X], None)); +} + +#[test] +fn d2m_rr() { + assert_rendering("d2m_rr", &d2m("Rows", [Axis3::Y, Axis3::Y], None)); +} + +#[test] +fn d2m_rc() { + assert_rendering("d2m_rc", &d2m("Row x Column", [Axis3::Y, Axis3::X], None)); +} + +#[test] +fn d2m_cr() { + assert_rendering("d2m_cr", &d2m("Column x Row", [Axis3::X, Axis3::Y], None)); } #[test] @@ -613,111 +405,33 @@ fn d3() { } } } - assert_rendering( - "d3-layer0_0", - &pt, - "\ -Column x b1 x a1 -b1 -a1 -╭──┬──┬──┬──┬──╮ -│c1│c2│c3│c4│c5│ -├──┼──┼──┼──┼──┤ -│ 0│12│24│36│48│ -╰──┴──┴──┴──┴──╯ -", - ); + assert_rendering("d3-layer0_0", &pt); let pt = pt.with_layer(&[0, 1]).with_title("Column x b2 x a1"); - assert_rendering( - "d3-layer0_1", - &pt, - "\ -Column x b2 x a1 -b2 -a1 -╭──┬──┬──┬──┬──╮ -│c1│c2│c3│c4│c5│ -├──┼──┼──┼──┼──┤ -│ 3│15│27│39│51│ -╰──┴──┴──┴──┴──╯ -", - ); + assert_rendering("d3-layer0_1", &pt); let pt = pt.with_layer(&[1, 2]).with_title("Column x b3 x a2"); - assert_rendering( - "d3-layer1_2", - &pt, - "\ -Column x b3 x a2 -b3 -a2 -╭──┬──┬──┬──┬──╮ -│c1│c2│c3│c4│c5│ -├──┼──┼──┼──┼──┤ -│ 7│19│31│43│55│ -╰──┴──┴──┴──┴──╯ -", - ); + assert_rendering("d3-layer1_2", &pt); } #[test] fn title_and_caption() { let pivot_table = d2("Title", [Axis3::X, Axis3::Y], None).with_caption(Value::new_text("Caption")); - assert_rendering( - "title_and_caption", - &pivot_table, - "\ -Title -╭──┬──┬──┬──╮ -│ │a1│a2│a3│ -├──┼──┼──┼──┤ -│b1│ 0│ 1│ 2│ -│b2│ 3│ 4│ 5│ -│b3│ 6│ 7│ 8│ -╰──┴──┴──┴──╯ -Caption -", - ); + assert_rendering("title_and_caption", &pivot_table); let pivot_table = pivot_table.with_show_title(false); - assert_rendering( - "caption", - &pivot_table, - "\ -╭──┬──┬──┬──╮ -│ │a1│a2│a3│ -├──┼──┼──┼──┤ -│b1│ 0│ 1│ 2│ -│b2│ 3│ 4│ 5│ -│b3│ 6│ 7│ 8│ -╰──┴──┴──┴──╯ -Caption -", - ); + assert_rendering("caption", &pivot_table); let pivot_table = pivot_table.with_show_caption(false); - assert_rendering( - "no_title_or_caption", - &pivot_table, - "\ -╭──┬──┬──┬──╮ -│ │a1│a2│a3│ -├──┼──┼──┼──┤ -│b1│ 0│ 1│ 2│ -│b2│ 3│ 4│ 5│ -│b3│ 6│ 7│ 8│ -╰──┴──┴──┴──╯ -", - ); + assert_rendering("no_title_or_caption", &pivot_table); } fn footnote_table(show_f0: bool) -> PivotTable { let mut footnotes = Footnotes::new(); let f0 = footnotes.push( Footnote::new("First footnote") - .with_marker("*") + .with_some_marker("*") .with_show(show_f0), ); let f1 = footnotes.push(Footnote::new("Second footnote")); @@ -764,24 +478,7 @@ fn footnote_table(show_f0: bool) -> PivotTable { #[test] fn footnote_alphabetic_subscript() { - assert_rendering( - "footnote_alphabetic_subscript", - &footnote_table(true), - "\ -Pivot Table with Alphabetic Subscript Footnotes[*] -╭────────────┬──────────────────╮ -│ │ A[*] │ -│ ├───────┬──────────┤ -│Corner[*][b]│ B[b] │ C[*][b] │ -├────────────┼───────┼──────────┤ -│D[b] E[*] │ .00│ 1.00[*]│ -│ F[*][b]│2.00[b]│3.00[*][b]│ -╰────────────┴───────┴──────────╯ -Caption[*] -*. First footnote -b. Second footnote -", - ); + assert_rendering("footnote_alphabetic_subscript", &footnote_table(true)); } #[test] @@ -792,24 +489,7 @@ fn footnote_alphabetic_superscript() { Value::new_text("Pivot Table with Alphabetic Superscript Footnotes").with_footnote(&f0), ); pt.look_mut().footnote_marker_position = FootnoteMarkerPosition::Superscript; - assert_rendering( - "footnote_alphabetic_superscript", - &pt, - "\ -Pivot Table with Alphabetic Superscript Footnotes[*] -╭────────────┬──────────────────╮ -│ │ A[*] │ -│ ├───────┬──────────┤ -│Corner[*][b]│ B[b] │ C[*][b] │ -├────────────┼───────┼──────────┤ -│D[b] E[*] │ .00│ 1.00[*]│ -│ F[*][b]│2.00[b]│3.00[*][b]│ -╰────────────┴───────┴──────────╯ -Caption[*] -*. First footnote -b. Second footnote -", - ); + assert_rendering("footnote_alphabetic_superscript", &pt); } #[test] @@ -820,24 +500,7 @@ fn footnote_numeric_subscript() { Value::new_text("Pivot Table with Numeric Subscript Footnotes").with_footnote(&f0), ); pt.look_mut().footnote_marker_type = FootnoteMarkerType::Numeric; - assert_rendering( - "footnote_numeric_subscript", - &pt, - "\ -Pivot Table with Numeric Subscript Footnotes[*] -╭────────────┬──────────────────╮ -│ │ A[*] │ -│ ├───────┬──────────┤ -│Corner[*][2]│ B[2] │ C[*][2] │ -├────────────┼───────┼──────────┤ -│D[2] E[*] │ .00│ 1.00[*]│ -│ F[*][2]│2.00[2]│3.00[*][2]│ -╰────────────┴───────┴──────────╯ -Caption[*] -*. First footnote -2. Second footnote -", - ); + assert_rendering("footnote_numeric_subscript", &pt); } #[test] @@ -849,45 +512,12 @@ fn footnote_numeric_superscript() { ); pt.look_mut().footnote_marker_type = FootnoteMarkerType::Numeric; pt.look_mut().footnote_marker_position = FootnoteMarkerPosition::Superscript; - assert_rendering( - "footnote_numeric_superscript", - &pt, - "\ -Pivot Table with Numeric Superscript Footnotes[*] -╭────────────┬──────────────────╮ -│ │ A[*] │ -│ ├───────┬──────────┤ -│Corner[*][2]│ B[2] │ C[*][2] │ -├────────────┼───────┼──────────┤ -│D[2] E[*] │ .00│ 1.00[*]│ -│ F[*][2]│2.00[2]│3.00[*][2]│ -╰────────────┴───────┴──────────╯ -Caption[*] -*. First footnote -2. Second footnote -", - ); + assert_rendering("footnote_numeric_superscript", &pt); } #[test] fn footnote_hidden() { - assert_rendering( - "footnote_hidden", - &footnote_table(false), - "\ -Pivot Table with Alphabetic Subscript Footnotes[*] -╭────────────┬──────────────────╮ -│ │ A[*] │ -│ ├───────┬──────────┤ -│Corner[*][b]│ B[b] │ C[*][b] │ -├────────────┼───────┼──────────┤ -│D[b] E[*] │ .00│ 1.00[*]│ -│ F[*][b]│2.00[b]│3.00[*][b]│ -╰────────────┴───────┴──────────╯ -Caption[*] -b. Second footnote -", - ); + assert_rendering("footnote_hidden", &footnote_table(false)); } #[test] @@ -895,14 +525,7 @@ fn no_dimension() { let pivot_table = PivotTable::new([]) .with_title("No Dimensions") .with_look(Arc::new(test_look())); - assert_rendering( - "no_dimension", - &pivot_table, - "No Dimensions -╭╮ -╰╯ -", - ); + assert_rendering("no_dimension", &pivot_table); } #[test] @@ -913,18 +536,14 @@ fn empty_dimensions() { let pivot_table = PivotTable::new([d1]) .with_title("One Empty Dimension") .with_look(look.clone()); - assert_rendering("one_empty_dimension", &pivot_table, "One Empty Dimension\n"); + assert_rendering("one_empty_dimension", &pivot_table); let d1 = (Axis3::X, Dimension::new(Group::new("a"))); let d2 = (Axis3::X, Dimension::new(Group::new("b").with_label_shown())); let pivot_table = PivotTable::new([d1, d2]) .with_title("Two Empty Dimensions") .with_look(look.clone()); - assert_rendering( - "two_empty_dimensions", - &pivot_table, - "Two Empty Dimensions\n", - ); + assert_rendering("two_empty_dimensions", &pivot_table); let d1 = (Axis3::X, Dimension::new(Group::new("a"))); let d2 = (Axis3::X, Dimension::new(Group::new("b").with_label_shown())); @@ -935,11 +554,7 @@ fn empty_dimensions() { let pivot_table = PivotTable::new([d1, d2, d3]) .with_title("Three Dimensions, Two Empty") .with_look(look.clone()); - assert_rendering( - "three_dimensions_two_empty", - &pivot_table, - "Three Dimensions, Two Empty\n", - ); + assert_rendering("three_dimensions_two_empty", &pivot_table); } #[test] @@ -963,19 +578,7 @@ fn empty_groups() { } } let pivot_table = pt.with_look(Arc::new(test_look().with_omit_empty(false))); - assert_rendering( - "empty_groups", - &pivot_table, - "\ -Empty Groups -╭──┬──┬──╮ -│ │a1│a3│ -├──┼──┼──┤ -│b2│ 0│ 1│ -│b3│ 2│ 3│ -╰──┴──┴──╯ -", - ); + assert_rendering("empty_groups", &pivot_table); } fn d4( @@ -1047,33 +650,7 @@ fn dimension_borders_1() { }), true, ); - assert_rendering( - "dimension_borders_1", - &pivot_table, - "\ -Dimension Borders 1 - b - bg1 │ - b1 │ b2 │ b3 - a │ a │ a - │ ag1 │ │ ag1 │ │ ag1 -d c a1│a2 a3│a1│a2 a3│a1│a2 a3 -dg1 d1 c1 0│ 1 2│ 3│ 4 5│ 6│ 7 8 - ╶─────────┼─────┼──┼─────┼──┼───── - cg1 c2 9│10 11│12│13 14│15│16 17 - c3 18│19 20│21│22 23│24│25 26 - ╶────────────┼─────┼──┼─────┼──┼───── - d2 c1 27│28 29│30│31 32│33│34 35 - ╶─────────┼─────┼──┼─────┼──┼───── - cg1 c2 36│37 38│39│40 41│42│43 44 - c3 45│46 47│48│49 50│51│52 53 -────────────────┼─────┼──┼─────┼──┼───── - d3 c1 54│55 56│57│58 59│60│61 62 - ╶─────────┼─────┼──┼─────┼──┼───── - cg1 c2 63│64 65│66│67 68│69│70 71 - c3 72│73 74│75│76 77│78│79 80 -", - ); + assert_rendering("dimension_borders_1", &pivot_table); } #[test] @@ -1087,29 +664,7 @@ fn dimension_borders_2() { }), true, ); - assert_rendering( - "dimension_borders_2", - &pivot_table, - "\ -Dimension Borders 2 - b - bg1 - b1 b2 b3 - ╶────────────────────────── - a a a - ag1 ag1 ag1 -d c a1 a2 a3 a1 a2 a3 a1 a2 a3 -dg1 d1│ c1 0 1 2 3 4 5 6 7 8 - │cg1 c2 9 10 11 12 13 14 15 16 17 - │ c3 18 19 20 21 22 23 24 25 26 - d2│ c1 27 28 29 30 31 32 33 34 35 - │cg1 c2 36 37 38 39 40 41 42 43 44 - │ c3 45 46 47 48 49 50 51 52 53 - d3│ c1 54 55 56 57 58 59 60 61 62 - │cg1 c2 63 64 65 66 67 68 69 70 71 - │ c3 72 73 74 75 76 77 78 79 80 -", - ); + assert_rendering("dimension_borders_2", &pivot_table); } #[test] @@ -1123,36 +678,7 @@ fn category_borders_1() { }), true, ); - assert_rendering( - "category_borders_1", - &pivot_table, - "\ -Category Borders 1 - b - bg1 ┊ - b1 ┊ b2 ┊ b3 - a ┊ a ┊ a - ┊ ag1 ┊ ┊ ag1 ┊ ┊ ag1 -d c a1┊a2┊a3┊a1┊a2┊a3┊a1┊a2┊a3 -dg1 d1 c1 0┊ 1┊ 2┊ 3┊ 4┊ 5┊ 6┊ 7┊ 8 - ╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ - cg1 c2 9┊10┊11┊12┊13┊14┊15┊16┊17 - ╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ - c3 18┊19┊20┊21┊22┊23┊24┊25┊26 - ╌╌╌╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ - d2 c1 27┊28┊29┊30┊31┊32┊33┊34┊35 - ╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ - cg1 c2 36┊37┊38┊39┊40┊41┊42┊43┊44 - ╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ - c3 45┊46┊47┊48┊49┊50┊51┊52┊53 -╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ - d3 c1 54┊55┊56┊57┊58┊59┊60┊61┊62 - ╌╌╌╌╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ - cg1 c2 63┊64┊65┊66┊67┊68┊69┊70┊71 - ╌╌╌╌╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌+╌╌ - c3 72┊73┊74┊75┊76┊77┊78┊79┊80 -", - ); + assert_rendering("category_borders_1", &pivot_table); } #[test] @@ -1166,33 +692,7 @@ fn category_borders_2() { }), true, ); - assert_rendering( - "category_borders_2", - &pivot_table, - "\ -Category Borders 2 - b - ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - bg1 - ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - b1 b2 b3 - ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - a a a - ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - ag1 ag1 ag1 - ╌╌╌╌╌╌╌ ╌╌╌╌╌╌╌ ╌╌╌╌╌╌ -d c a1 a2 a3 a1 a2 a3 a1 a2 a3 -dg1┊d1┊ c1 0 1 2 3 4 5 6 7 8 - ┊ ┊cg1┊c2 9 10 11 12 13 14 15 16 17 - ┊ ┊ ┊c3 18 19 20 21 22 23 24 25 26 - ┊d2┊ c1 27 28 29 30 31 32 33 34 35 - ┊ ┊cg1┊c2 36 37 38 39 40 41 42 43 44 - ┊ ┊ ┊c3 45 46 47 48 49 50 51 52 53 - d3┊ c1 54 55 56 57 58 59 60 61 62 - ┊cg1┊c2 63 64 65 66 67 68 69 70 71 - ┊ ┊c3 72 73 74 75 76 77 78 79 80 -", - ); + assert_rendering("category_borders_2", &pivot_table); } #[test] @@ -1208,36 +708,7 @@ fn category_and_dimension_borders_1() { }), true, ); - assert_rendering( - "category_and_dimension_borders_1", - &pivot_table, - "\ -Category and Dimension Borders 1 - b - bg1 │ - b1 │ b2 │ b3 - a │ a │ a - │ ag1 │ │ ag1 │ │ ag1 -d c a1│a2┊a3│a1│a2┊a3│a1│a2┊a3 -dg1 d1 c1 0│ 1┊ 2│ 3│ 4┊ 5│ 6│ 7┊ 8 - ╶─────────┼──┼──┼──┼──┼──┼──┼──┼── - cg1 c2 9│10┊11│12│13┊14│15│16┊17 - ╌╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ - c3 18│19┊20│21│22┊23│24│25┊26 - ╶────────────┼──┼──┼──┼──┼──┼──┼──┼── - d2 c1 27│28┊29│30│31┊32│33│34┊35 - ╶─────────┼──┼──┼──┼──┼──┼──┼──┼── - cg1 c2 36│37┊38│39│40┊41│42│43┊44 - ╌╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ - c3 45│46┊47│48│49┊50│51│52┊53 -────────────────┼──┼──┼──┼──┼──┼──┼──┼── - d3 c1 54│55┊56│57│58┊59│60│61┊62 - ╶─────────┼──┼──┼──┼──┼──┼──┼──┼── - cg1 c2 63│64┊65│66│67┊68│69│70┊71 - ╌╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ - c3 72│73┊74│75│76┊77│78│79┊80 -", - ); + assert_rendering("category_and_dimension_borders_1", &pivot_table); } #[test] @@ -1253,33 +724,7 @@ fn category_and_dimension_borders_2() { }), true, ); - assert_rendering( - "category_and_dimension_borders_2", - &pivot_table, - "\ -Category and Dimension Borders 2 - b - ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - bg1 - ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - b1 b2 b3 - ╶────────────────────────── - a a a - ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - ag1 ag1 ag1 - ╌╌╌╌╌╌╌ ╌╌╌╌╌╌╌ ╌╌╌╌╌╌ -d c a1 a2 a3 a1 a2 a3 a1 a2 a3 -dg1┊d1│ c1 0 1 2 3 4 5 6 7 8 - ┊ │cg1┊c2 9 10 11 12 13 14 15 16 17 - ┊ │ ┊c3 18 19 20 21 22 23 24 25 26 - ┊d2│ c1 27 28 29 30 31 32 33 34 35 - ┊ │cg1┊c2 36 37 38 39 40 41 42 43 44 - ┊ │ ┊c3 45 46 47 48 49 50 51 52 53 - d3│ c1 54 55 56 57 58 59 60 61 62 - │cg1┊c2 63 64 65 66 67 68 69 70 71 - │ ┊c3 72 73 74 75 76 77 78 79 80 -", - ); + assert_rendering("category_and_dimension_borders_2", &pivot_table); } const SOLID_BLUE: BorderStyle = BorderStyle { @@ -1303,37 +748,7 @@ fn category_and_dimension_borders_3() { }), false, ); - assert_rendering( - "category_and_dimension_borders_3", - &pivot_table, - "\ -Category and Dimension Borders 3 - bg1 │ - ╌╌╌╌╌╌╌╌╌┬╌╌╌╌╌╌╌╌┤ - b1 │ b2 │ b3 - ╶──┬─────┼──┬─────┼──┬───── - │ ag1 │ │ ag1 │ │ ag1 - ├╌╌┬╌╌┤ ├╌╌┬╌╌┤ ├╌╌┬╌╌ - a1│a2┊a3│a1│a2┊a3│a1│a2┊a3 -dg1┊d1│ c1 0│ 1┊ 2│ 3│ 4┊ 5│ 6│ 7┊ 8 - ┊ ├───┬─────┼──┼──┼──┼──┼──┼──┼──┼── - ┊ │cg1┊c2 9│10┊11│12│13┊14│15│16┊17 - ┊ │ ├╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ - ┊ │ ┊c3 18│19┊20│21│22┊23│24│25┊26 - ├──┼───┴─────┼──┼──┼──┼──┼──┼──┼──┼── - ┊d2│ c1 27│28┊29│30│31┊32│33│34┊35 - ┊ ├───┬─────┼──┼──┼──┼──┼──┼──┼──┼── - ┊ │cg1┊c2 36│37┊38│39│40┊41│42│43┊44 - ┊ │ ├╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ - ┊ │ ┊c3 45│46┊47│48│49┊50│51│52┊53 -───┴──┼───┴─────┼──┼──┼──┼──┼──┼──┼──┼── - d3│ c1 54│55┊56│57│58┊59│60│61┊62 - ├───┬─────┼──┼──┼──┼──┼──┼──┼──┼── - │cg1┊c2 63│64┊65│66│67┊68│69│70┊71 - │ ├╌╌╌╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌┼╌╌┼╌╌+╌╌ - │ ┊c3 72│73┊74│75│76┊77│78│79┊80 -", - ); + assert_rendering("category_and_dimension_borders_3", &pivot_table); } #[test] @@ -1415,31 +830,5 @@ fn small_numbers() { pt.insert_number(&[8, 1, 1], Some(-0.00000001), Class::Residual); pt.insert_number(&[9, 1, 1], Some(-0.000000001), Class::Residual); let pivot_table = pt.with_look(Arc::new(test_look())); - assert_rendering( - "small_numbers", - &pivot_table, - "\ -small numbers -╭────────┬─────────────────────────────────────╮ -│ │ result class │ -│ ├───────────────────┬─────────────────┤ -│ │ general │ specific │ -│ ├───────────────────┼─────────────────┤ -│ │ sign │ sign │ -│ ├─────────┬─────────┼────────┬────────┤ -│exponent│ positive│ negative│positive│negative│ -├────────┼─────────┼─────────┼────────┼────────┤ -│0 │ 1.00│ 1.00│ -1.00│ -1.00│ -│-1 │ .10│ .10│ -.10│ -.10│ -│-2 │ .01│ .01│ -.01│ -.01│ -│-3 │ .00│ .00│ .00│ .00│ -│-4 │ .00│ .00│ .00│ .00│ -│-5 │1.00E-005│1.00E-005│ .00│ .00│ -│-6 │1.00E-006│1.00E-006│ .00│ .00│ -│-7 │1.00E-007│1.00E-007│ .00│ .00│ -│-8 │1.00E-008│1.00E-008│ .00│ .00│ -│-9 │1.00E-009│1.00E-009│ .00│ .00│ -╰────────┴─────────┴─────────┴────────┴────────╯ -", - ); + assert_rendering("small_numbers", &pivot_table); } diff --git a/rust/pspp/src/output/pivot/tlo.rs b/rust/pspp/src/output/pivot/tlo.rs index e857784809..b83526186a 100644 --- a/rust/pspp/src/output/pivot/tlo.rs +++ b/rust/pspp/src/output/pivot/tlo.rs @@ -103,7 +103,7 @@ impl From 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! { @@ -218,6 +218,7 @@ enum Separator { None, #[br(magic = 1u16)] Some { + #[br(parse_with(parse_tlo_color))] color: Color, style: u16, width: u16, @@ -249,17 +250,10 @@ impl From for BorderStyle { } } -impl BinRead for Color { - type Args<'a> = (); - - fn read_options( - reader: &mut R, - endian: binrw::Endian, - _args: (), - ) -> BinResult { - let raw = ::read_options(reader, endian, ())?; - Ok(Color::new(raw as u8, (raw >> 8) as u8, (raw >> 16) as u8)) - } +#[binrw::parser(reader, endian)] +fn parse_tlo_color() -> BinResult { + let raw = ::read_options(reader, endian, ())?; + Ok(Color::new(raw as u8, (raw >> 8) as u8, (raw >> 16) as u8)) } #[binread] @@ -277,8 +271,9 @@ struct PvCellStyle { #[br(little)] #[derive(Debug)] struct AreaColor { - #[br(magic = b"\0\x01\0")] + #[br(magic(b"\0\x01\0"), parse_with(parse_tlo_color))] color10: Color, + #[br(parse_with(parse_tlo_color))] color0: Color, shading: u8, #[br(temp, magic = 0u8)] @@ -290,18 +285,8 @@ impl From for Color { match area_color.shading { 0 => area_color.color0, x1 @ 1..=9 => { - let Color { - r: r0, - g: g0, - b: b0, - .. - } = area_color.color0; - let Color { - r: r1, - g: g1, - b: b1, - .. - } = area_color.color10; + let (r0, g0, b0) = area_color.color0.into_rgb(); + let (r1, g1, b1) = area_color.color10.into_rgb(); fn mix(c0: u32, c1: u32, x1: u32) -> u8 { let x0 = 10 - x1; ((c0 * x0 + c1 * x1) / 10) as u8 @@ -386,13 +371,9 @@ impl super::AreaStyle { bold: style.weight > 400, italic: style.italic, 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, }, } @@ -427,6 +408,7 @@ struct AreaStyle { rtf_charset_number: u32, x: u8, font_name: U8String, + #[br(parse_with(parse_tlo_color))] text_color: Color, #[br(temp, magic = 0u16)] _tmp: (), @@ -490,7 +472,7 @@ impl Default for V2Styles { } #[binrw::parser(reader, endian)] -fn parse_bool() -> BinResult { +pub fn parse_bool() -> BinResult { let byte = ::read_options(reader, endian, ())?; match byte { 0 => Ok(false), diff --git a/rust/pspp/src/output/render.rs b/rust/pspp/src/output/render.rs index 61ac68af47..e8f900d971 100644 --- a/rust/pspp/src/output/render.rs +++ b/rust/pspp/src/output/render.rs @@ -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, @@ -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.style.cell_style.vert_align { + 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) } } @@ -1370,10 +1363,10 @@ impl Pager { // Figure out the width of the body of the table. Use this to determine // the base scale. - let body_page = Page::new(Arc::new(output.body), device, 0, &pivot_table.look); + let body_page = Page::new(Arc::new(output.body), device, 0, &pivot_table.style.look); let body_width = body_page.width(Axis2::X); let mut scale = if body_width > device.params().size[Axis2::X] - && pivot_table.look.shrink_to_fit[Axis2::X] + && pivot_table.style.look.shrink_to_fit[Axis2::X] && device.params().can_scale { device.params().size[Axis2::X] as f64 / body_width as f64 @@ -1387,7 +1380,7 @@ impl Pager { Arc::new(table), device, body_width, - &pivot_table.look, + &pivot_table.style.look, ))); } pages.push(Arc::new(body_page)); @@ -1396,7 +1389,7 @@ impl Pager { Arc::new(table), device, 0, - &pivot_table.look, + &pivot_table.style.look, ))); } pages.reverse(); @@ -1410,7 +1403,7 @@ impl Pager { // shrinking the table vertically more than the scale would imply. // Shrinking only as much as necessary would require an iterative // search. - if pivot_table.look.shrink_to_fit[Axis2::Y] && device.params().can_scale { + if pivot_table.style.look.shrink_to_fit[Axis2::Y] && device.params().can_scale { let total_height = pages .iter() .map(|page: &Arc| page.total_size(Axis2::Y)) diff --git a/rust/pspp/src/output/spv.rs b/rust/pspp/src/output/spv.rs index 9f7290f1b3..d87c608669 100644 --- a/rust/pspp/src/output/spv.rs +++ b/rust/pspp/src/output/spv.rs @@ -14,1360 +14,761 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . -use core::f64; use std::{ - borrow::Cow, fs::File, - io::{Cursor, Seek, Write}, - iter::{repeat, repeat_n}, - path::PathBuf, - sync::Arc, + io::{BufReader, Cursor, Read, Seek}, + path::Path, }; -use binrw::{BinWrite, Endian}; -use chrono::Utc; -use enum_map::EnumMap; -use quick_xml::{ - ElementWriter, - events::{BytesText, attributes::Attribute}, - writer::Writer as XmlWriter, -}; -use serde::{Deserialize, Serialize}; -use zip::{ZipWriter, result::ZipResult, write::SimpleFileOptions}; +use anyhow::{Context, anyhow}; +use binrw::{BinRead, error::ContextExt}; +use cairo::ImageSurface; +use displaydoc::Display; +use paper_sizes::PaperSize; +use serde::Deserialize; +use zip::{ZipArchive, result::ZipError}; use crate::{ - format::{Format, Type}, + crypto::EncryptedFile, output::{ - Item, Text, - driver::Driver, - page::{Heading, PageSetup}, - pivot::{ - 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, + Details, Item, SpvInfo, SpvMembers, Text, + page::{self}, + pivot::{Axis2, Length, Look, TableProperties, Value}, + spv::{ + html::Document, + legacy_bin::LegacyBin, + legacy_xml::Visualization, + light::{LightError, LightTable}, }, }, - settings::Show, - util::ToSmallString, }; -fn light_table_name(table_id: u64) -> String { - format!("{table_id:011}_lightTableData.bin") -} - -fn output_viewer_name(heading_id: u64, is_heading: bool) -> String { - format!( - "outputViewer{heading_id:010}{}.xml", - if is_heading { "_heading" } else { "" } - ) -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SpvConfig { - /// Output file name. - pub file: PathBuf, - - /// Page setup. - pub page_setup: Option, -} +mod css; +pub mod html; +mod legacy_bin; +mod legacy_xml; +mod light; -pub struct SpvDriver -where - W: Write + Seek, -{ - writer: ZipWriter, - needs_page_break: bool, - next_table_id: u64, - next_heading_id: u64, - page_setup: Option, +/// Options for reading an SPV file. +#[derive(Clone, Debug, Default)] +pub struct ReadOptions { + /// Password to use to unlock an encrypted SPV file. + /// + /// For an encrypted SPV file, this must be set to the (encoded or + /// unencoded) password. + /// + /// For a plaintext SPV file, this must be None. + pub password: Option, } -impl SpvDriver { - pub fn new(config: &SpvConfig) -> std::io::Result { - let mut driver = Self::for_writer(File::create(&config.file)?); - if let Some(page_setup) = &config.page_setup { - driver = driver.with_page_setup(page_setup.clone()); - } - Ok(driver) +impl ReadOptions { + /// Construct a new [ReadOptions] without a password. + pub fn new() -> Self { + Self::default() } -} -impl SpvDriver -where - W: Write + Seek, -{ - pub fn for_writer(writer: W) -> Self { - let mut writer = ZipWriter::new(writer); - writer - .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default()) - .unwrap(); - writer.write_all("allowPivoting=true".as_bytes()).unwrap(); - Self { - writer, - needs_page_break: false, - next_table_id: 1, - next_heading_id: 1, - page_setup: None, - } + /// Causes the file to be read by decrypting it with the given `password` or + /// without decrypting if `password` is None. + pub fn with_password(self, password: Option) -> Self { + Self { password } } - pub fn with_page_setup(self, page_setup: PageSetup) -> Self { - Self { - page_setup: Some(page_setup), - ..self + /// Opens the file at `path`. + pub fn open_file

(mut self, path: P) -> Result + where + P: AsRef, + { + let file = File::open(path)?; + if let Some(password) = self.password.take() { + self.open_reader_encrypted(file, password) + } else { + Self::open_reader_inner(file) } } - pub fn close(mut self) -> ZipResult { - self.writer - .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default())?; - write!(&mut self.writer, "allowPivoting=true")?; - self.writer.finish() - } - - fn page_break_before(&mut self) -> bool { - let page_break_before = self.needs_page_break; - self.needs_page_break = false; - page_break_before + /// Opens the file read from `reader`. + fn open_reader_encrypted(self, reader: R, password: String) -> Result + where + R: Read + Seek + 'static, + { + Self::open_reader_inner( + EncryptedFile::new(reader)? + .unlock(password.as_bytes()) + .map_err(|_| anyhow!("Incorrect password."))?, + ) } - fn write_table( - &mut self, - item: &Item, - pivot_table: &PivotTable, - structure: &mut XmlWriter, - ) where - X: Write, + /// Opens the file read from `reader`. + pub fn open_reader(mut self, reader: R) -> Result + where + R: Read + Seek + 'static, { - let table_id = self.next_table_id; - self.next_table_id += 1; - - let mut content = Vec::new(); - let mut cursor = Cursor::new(&mut content); - pivot_table.write_le(&mut cursor).unwrap(); - - let table_name = light_table_name(table_id); - self.writer - .start_file(&table_name, SimpleFileOptions::default()) - .unwrap(); // XXX - self.writer.write_all(&content).unwrap(); // XXX - - self.container(structure, item, "vtb:table", |element| { - element - .with_attribute(("tableId", Cow::from(table_id.to_string()))) - .with_attribute(( - "subType", - Cow::from(pivot_table.subtype().display(pivot_table).to_string()), - )) - .write_inner_content(|w| { - w.create_element("vtb:tableStructure") - .write_inner_content(|w| { - w.create_element("vtb:dataPath") - .write_text_content(BytesText::new(&table_name))?; - Ok(()) - })?; - Ok(()) - }) - .unwrap(); - }); + if let Some(password) = self.password.take() { + self.open_reader_encrypted(reader, password) + } else { + Self::open_reader_inner(reader) + } } - fn write_text(&mut self, item: &Item, text: &Text, structure: &mut XmlWriter) + fn open_reader_inner(reader: R) -> Result where - X: Write, + R: Read + Seek + 'static, { - self.container(structure, item, "vtx:text", |w| { - w.with_attribute(("type", text.type_.as_xml_str())) - .write_text_content(BytesText::new(&text.content.display(()).to_string())) - .unwrap(); - }); + // Open archive. + let mut archive = ZipArchive::new(reader).map_err(|error| match error { + ZipError::InvalidArchive(_) => Error::NotSpv, + other => other.into(), + })?; + Ok(Self::from_spv_zip_archive(&mut archive)?) } - fn write_item(&mut self, item: &Item, structure: &mut XmlWriter) + fn from_spv_zip_archive(archive: &mut ZipArchive) -> Result where - X: Write, + R: Read + Seek, { - match &item.details { - super::Details::Chart => todo!(), - super::Details::Image => todo!(), - super::Details::Group(children) => { - let mut attributes = Vec::::new(); - if let Some(command_name) = &item.command_name { - attributes.push(("commandName", command_name.as_str()).into()); - } - if !item.show { - attributes.push(("visibility", "collapsed").into()); - } - structure - .create_element("heading") - .with_attributes(attributes) - .write_inner_content(|w| { - w.create_element("label") - .write_text_content(BytesText::new(&item.label()))?; - for child in children { - self.write_item(child, w); - } - Ok(()) - }) - .unwrap(); - } - super::Details::Message(diagnostic) => { - self.write_text(item, &Text::from(diagnostic.as_ref()), structure) + // Check manifest. + let mut file = archive + .by_name("META-INF/MANIFEST.MF") + .map_err(|_| Error::NotSpv)?; + let mut string = String::new(); + file.read_to_string(&mut string)?; + if string.trim() != "allowPivoting=true" { + return Err(Error::NotSpv); + } + drop(file); + + let mut items = Vec::new(); + let mut page_setup = None; + for i in 0..archive.len() { + let name = String::from(archive.name_for_index(i).unwrap()); + if name.starts_with("outputViewer") && name.ends_with(".xml") { + let (mut new_items, ps) = read_heading(archive, i, &name)?; + items.append(&mut new_items); + page_setup = page_setup.or(ps); } - super::Details::PageBreak => { - self.needs_page_break = true; - } - super::Details::Table(pivot_table) => self.write_table(item, pivot_table, structure), - super::Details::Text(text) => self.write_text(item, text, structure), } - } - fn container( - &mut self, - writer: &mut XmlWriter, - item: &Item, - inner_elem: &str, - closure: F, - ) where - X: Write, - F: FnOnce(ElementWriter), - { - writer - .create_element("container") - .with_attributes( - self.page_break_before() - .then_some(("page-break-before", "always")), - ) - .with_attribute(("visibility", if item.show { "visible" } else { "hidden" })) - .write_inner_content(|w| { - let mut element = w - .create_element("label") - .write_text_content(BytesText::new(&item.label())) - .unwrap() - .create_element(inner_elem); - if let Some(command_name) = &item.command_name { - element = element.with_attribute(("commandName", command_name.as_str())); - }; - closure(element); - Ok(()) - }) - .unwrap(); + Ok(SpvFile { + item: items.into_iter().collect(), + page_setup, + }) } } -impl BinWrite for PivotTable { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - _args: (), - ) -> binrw::BinResult<()> { - // Header. - ( - 1u8, - 0u8, - 3u32, // version - SpvBool(true), // x0 - SpvBool(false), // x1 - SpvBool(self.rotate_inner_column_labels), - SpvBool(self.rotate_outer_row_labels), - SpvBool(true), // x2 - 0x15u32, // x3 - *self.look.heading_widths[HeadingRegion::Columns].start() as i32, - *self.look.heading_widths[HeadingRegion::Columns].end() as i32, - *self.look.heading_widths[HeadingRegion::Rows].start() as i32, - *self.look.heading_widths[HeadingRegion::Rows].end() as i32, - 0u64, - ) - .write_le(writer)?; - - // Titles. - ( - self.title(), - self.subtype(), - Optional(Some(self.title())), - Optional(self.corner_text.as_ref()), - Optional(self.caption.as_ref()), - ) - .write_le(writer)?; - - // Footnotes. - self.footnotes.write_le(writer)?; - - // Areas. - static SPV_AREAS: [Area; 8] = [ - Area::Title, - Area::Caption, - Area::Footer, - Area::Corner, - Area::Labels(Axis2::X), - Area::Labels(Axis2::Y), - Area::Data, - Area::Layers, - ]; - for (index, area) in SPV_AREAS.into_iter().enumerate() { - self.look.areas[area].write_le_args(writer, index)?; - } - - // Borders. - static SPV_BORDERS: [Border; 19] = [ - Border::Title, - Border::OuterFrame(BoxBorder::Left), - Border::OuterFrame(BoxBorder::Top), - Border::OuterFrame(BoxBorder::Right), - Border::OuterFrame(BoxBorder::Bottom), - Border::InnerFrame(BoxBorder::Left), - Border::InnerFrame(BoxBorder::Top), - Border::InnerFrame(BoxBorder::Right), - Border::InnerFrame(BoxBorder::Bottom), - Border::DataLeft, - Border::DataTop, - Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)), - Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::Y)), - Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)), - Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::Y)), - Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)), - Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::Y)), - Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)), - Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::Y)), - ]; - let borders_start = Count::new(writer)?; - (1, SPV_BORDERS.len() as u32).write_be(writer)?; - for (index, border) in SPV_BORDERS.into_iter().enumerate() { - self.look.borders[border].write_be_args(writer, index)?; - } - (SpvBool(self.show_grid_lines), 0u8, 0u16).write_le(writer)?; - borders_start.finish_le32(writer)?; - - // Print Settings. - Counted::new(( - 1u32, - SpvBool(self.look.print_all_layers), - SpvBool(self.look.paginate_layers), - SpvBool(self.look.shrink_to_fit[Axis2::X]), - SpvBool(self.look.shrink_to_fit[Axis2::Y]), - SpvBool(self.look.top_continuation), - SpvBool(self.look.bottom_continuation), - self.look.n_orphan_lines as u32, - SpvString(self.look.continuation.as_ref().map_or("", |s| s.as_str())), - )) - .with_endian(Endian::Little) - .write_be(writer)?; - - // Table Settings. - Counted::new(( - 1u32, - 4u32, - self.spv_layer() as u32, - SpvBool(self.look.hide_empty), - SpvBool(self.look.row_label_position == LabelPosition::Corner), - SpvBool(self.look.footnote_marker_type == FootnoteMarkerType::Alphabetic), - SpvBool(self.look.footnote_marker_position == FootnoteMarkerPosition::Superscript), - 0u8, - Counted::new(( - 0u32, // n-row-breaks - 0u32, // n-column-breaks - 0u32, // n-row-keeps - 0u32, // n-column-keeps - 0u32, // n-row-point-keeps - 0u32, // n-column-point-keeps - )), - SpvString::optional(&self.notes), - SpvString::optional(&self.look.name), - Zeros(82), - )) - .with_endian(Endian::Little) - .write_be(writer)?; - - fn y0(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { - ( - pivot_table.settings.epoch.0 as u32, - u8::from(pivot_table.settings.decimal), - b',', - ) - } - - fn custom_currency(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { - ( - 5, - EnumMap::from_fn(|cc| { - SpvString(pivot_table.settings.number_style(Type::CC(cc)).to_string()) - }) - .into_array(), - ) - } - - fn x1(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { - ( - 0u8, // x14 - if pivot_table.show_title { 1u8 } else { 10u8 }, - 0u8, // x16 - 0u8, // lang - Show::as_spv(&pivot_table.show_variables), - Show::as_spv(&pivot_table.show_values), - -1i32, // x18 - -1i32, // x19 - Zeros(17), - SpvBool(false), // x20 - SpvBool(pivot_table.show_caption), - ) - } - - fn x2() -> impl for<'a> BinWrite = ()> { - Counted::new(( - 0u32, // n-row-heights - 0u32, // n-style-maps - 0u32, // n-styles, - 0u32, - )) - } - - fn y1(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> + use<'_> { - ( - SpvString::optional(&pivot_table.command_c), - SpvString::optional(&pivot_table.command_local), - SpvString::optional(&pivot_table.language), - SpvString("UTF-8"), - SpvString::optional(&pivot_table.locale), - SpvBool(false), // x10 - SpvBool(pivot_table.settings.leading_zero), - SpvBool(true), // x12 - SpvBool(true), // x13 - y0(pivot_table), - ) - } - - fn y2(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { - (custom_currency(pivot_table), b'.', SpvBool(false)) - } - - fn x3(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> + use<'_> { - Counted::new(( - 1u8, - 0u8, - 4u8, // x21 - 0u8, - 0u8, - 0u8, - y1(pivot_table), - pivot_table.small, - 1u8, - SpvString::optional(&pivot_table.dataset), - SpvString::optional(&pivot_table.datafile), - 0u32, - pivot_table - .date - .map_or(0i64, |date| date.and_utc().timestamp()), - y2(pivot_table), - )) - } +pub struct SpvFile { + /// SPV file contents. + pub item: Vec, - // Formats. - ( - 0u32, - SpvString("en_US.ISO_8859-1:1987"), - 0u32, // XXX current_layer - SpvBool(false), // x7 - SpvBool(false), // x8 - SpvBool(false), // x9 - y0(self), - custom_currency(self), - Counted::new((Counted::new((x1(self), x2())), x3(self))), - ) - .write_le(writer)?; - - // Dimensions. - (self.dimensions.len() as u32).write_le(writer)?; - - let x2 = repeat_n(2, self.axes[Axis3::Z].dimensions.len()) - .chain(repeat_n(0, self.axes[Axis3::Y].dimensions.len())) - .chain(repeat(1)); - for ((index, dimension), x2) in self.dimensions.iter().enumerate().zip(x2) { - dimension.write_options(writer, endian, (index, x2))?; - } - - // Axes. - for axis in [Axis3::Z, Axis3::Y, Axis3::X] { - (self.axes[axis].dimensions.len() as u32).write_le(writer)?; - } - for axis in [Axis3::Z, Axis3::Y, Axis3::X] { - for index in self.axes[axis].dimensions.iter().copied() { - (index as u32).write_le(writer)?; - } - } - - // Cells. - (self.cells.len() as u32).write_le(writer)?; - for (index, value) in &self.cells { - (*index as u64, value).write_le(writer)?; - } + /// The page setup in the SPV file, if any. + pub page_setup: Option, +} - Ok(()) +impl SpvFile { + pub fn into_parts(self) -> (Vec, Option) { + (self.item, self.page_setup) } -} -impl PivotTable { - fn spv_layer(&self) -> usize { - let mut layer = 0; - for (dimension, layer_value) in self - .axis_dimensions(Axis3::Z) - .zip(self.current_layer.iter().copied()) - .rev() - { - layer = layer * dimension.len() + layer_value; - } - layer + pub fn into_items(self) -> Vec { + self.item } } -impl Driver for SpvDriver -where - W: Write + Seek, -{ - fn name(&self) -> Cow<'static, str> { - Cow::from("spv") - } +#[derive(Debug, Display, thiserror::Error)] +pub enum Error { + /// Not an SPV file. + NotSpv, - fn write(&mut self, item: &Arc) { - if item.details.is_page_break() { - self.needs_page_break = true; - return; - } + /// {0} + ZipError(#[from] ZipError), - let mut headings = XmlWriter::new(Cursor::new(Vec::new())); - let element = headings - .create_element("heading") - .with_attribute(( - "creation-date-time", - Cow::from(Utc::now().format("%x %x").to_string()), - )) - .with_attribute(( - "creator", - Cow::from(format!( - "{} {}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION") - )), - )) - .with_attribute(("creator-version", "21")) - .with_attribute(("xmlns", "http://xml.spss.com/spss/viewer/viewer-tree")) - .with_attribute(( - "xmlns:vps", - "http://xml.spss.com/spss/viewer/viewer-pagesetup", - )) - .with_attribute(("xmlns:vtx", "http://xml.spss.com/spss/viewer/viewer-text")) - .with_attribute(("xmlns:vtb", "http://xml.spss.com/spss/viewer/viewer-table")); - element - .write_inner_content(|w| { - w.create_element("label") - .write_text_content(BytesText::new("Output"))?; - if let Some(page_setup) = self.page_setup.take() { - write_page_setup(&page_setup, w)?; - } - self.write_item(item, w); - Ok(()) - }) - .unwrap(); - - let headings = headings.into_inner().into_inner(); - let heading_id = self.next_heading_id; - self.next_heading_id += 1; - self.writer - .start_file( - output_viewer_name(heading_id, item.details.as_group().is_some()), - SimpleFileOptions::default(), - ) - .unwrap(); // XXX - self.writer.write_all(&headings).unwrap(); // XXX - } -} + /// {0} + IoError(#[from] std::io::Error), -fn write_page_setup(page_setup: &PageSetup, writer: &mut XmlWriter) -> std::io::Result<()> -where - X: Write, -{ - fn inches<'a>(x: f64) -> Cow<'a, str> { - Cow::from(format!("{x:.2}in")) - } + /// {0} + DeError(#[from] quick_xml::DeError), - writer - .create_element("vps:pageSetup") - .with_attribute(( - "initial-page-number", - Cow::from(format!("{}", page_setup.initial_page_number)), - )) - .with_attribute(( - "chart-size", - match page_setup.chart_size { - super::page::ChartSize::AsIs => "as-is", - super::page::ChartSize::FullHeight => "full-height", - super::page::ChartSize::HalfHeight => "half-height", - super::page::ChartSize::QuarterHeight => "quarter-height", - }, - )) - .with_attribute(("margin-left", inches(page_setup.margins[Axis2::X][0]))) - .with_attribute(("margin-right", inches(page_setup.margins[Axis2::X][1]))) - .with_attribute(("margin-top", inches(page_setup.margins[Axis2::Y][0]))) - .with_attribute(("margin-bottom", inches(page_setup.margins[Axis2::Y][1]))) - .with_attribute(("paper-height", inches(page_setup.paper[Axis2::Y]))) - .with_attribute(("paper-width", inches(page_setup.paper[Axis2::X]))) - .with_attribute(( - "reference-orientation", - match page_setup.orientation { - crate::output::page::Orientation::Portrait => "portrait", - crate::output::page::Orientation::Landscape => "landscape", - }, - )) - .with_attribute(( - "space-after", - Cow::from(format!("{:.1}pt", page_setup.object_spacing * 72.0)), - )) - .write_inner_content(|w| { - write_page_heading(&page_setup.headings[0], "vps:pageHeader", w)?; - write_page_heading(&page_setup.headings[1], "vps:pageFooter", w)?; - Ok(()) - })?; - Ok(()) -} + /// {0} + BinrwError(#[from] binrw::Error), -fn write_page_heading( - heading: &Heading, - name: &str, - writer: &mut XmlWriter, -) -> std::io::Result<()> -where - X: Write, -{ - let element = writer.create_element(name); - if !heading.0.is_empty() { - element.write_inner_content(|w| { - w.create_element("vps:pageParagraph") - .write_inner_content(|w| { - for paragraph in &heading.0 { - w.create_element("vtx:text") - .with_attribute(("text", "title")) - .write_text_content(BytesText::new(¶graph.markup))?; - } - Ok(()) - })?; - Ok(()) - })?; - } - Ok(()) -} + /// {0} + LightError(#[from] LightError), -fn maybe_with_attribute<'a, 'b, W, I>( - element: ElementWriter<'a, W>, - attr: Option, -) -> ElementWriter<'a, W> -where - I: Into>, -{ - if let Some(attr) = attr { - element.with_attribute(attr) - } else { - element - } + /// {0} + CairoError(#[from] cairo::IoError), } -impl BinWrite for Dimension { - type Args<'a> = (usize, u8); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - (index, x2): (usize, u8), - ) -> binrw::BinResult<()> { - ( - &self.root.name, - 0u8, // x1 - x2, - 2u32, // x3 - SpvBool(!self.root.show_label), - SpvBool(self.hide_all_labels), - SpvBool(true), - index as u32, - self.root.children.len() as u32, - ) - .write_options(writer, endian, ())?; - - let mut data_indexes = self.presentation_order.iter().copied(); - for child in &self.root.children { - child.write_le(writer, &mut data_indexes)?; - } - Ok(()) - } +fn new_error_item(message: impl Into) -> Item { + Text::new_log(message).into_item().with_label("Error") } -impl Category { - fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> - where - W: Write + Seek, - D: Iterator, +fn read_heading( + archive: &mut ZipArchive, + file_number: usize, + structure_member: &str, +) -> Result<(Vec, Option), Error> +where + R: Read + Seek, +{ + 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), + ) + .with_context(|| format!("Failed to parse {structure_member}")) { - match self { - Category::Group(group) => group.write_le(writer, data_indexes), - Category::Leaf(leaf) => leaf.write_le(writer, data_indexes), - } - } -} + Ok(result) => result, + Err(error) => panic!("{error:?}"), + }; + let _page_setup = heading.page_setup.take(); + // XXX convert page_setup to the internal format + Ok((heading.decode(archive, structure_member)?, None)) +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Heading { + #[serde(rename = "@visibility")] + visibility: Option, + #[serde(rename = "@commandName")] + command_name: Option, + label: Label, + page_setup: Option, -impl Leaf { - fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> - where - W: Write + Seek, - D: Iterator, - { - ( - self.name(), - 0u8, - 0u8, - 0u8, - 2u32, - data_indexes.next().unwrap() as u32, - 0u32, - ) - .write_le(writer) - } + #[serde(rename = "$value")] + #[serde(default)] + children: Vec, } -impl Group { - fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> +impl Heading { + fn decode( + self, + archive: &mut ZipArchive, + structure_member: &str, + ) -> Result, Error> where - W: Write + Seek, - D: Iterator, + R: Read + Seek, { - ( - self.name(), - 0u8, - 0u8, - 1u8, - 0u32, // x23 - -1i32, - ) - .write_le(writer)?; - - for child in &self.children { - child.write_le(writer, data_indexes)?; + let mut items = Vec::new(); + for child in self.children { + match child { + HeadingContent::Container(container) => { + if container.page_break_before == PageBreakBefore::Always { + items.push( + Details::PageBreak + .into_item() + .with_spv_info(SpvInfo::new(structure_member)), + ); + } + let item = match container.content { + ContainerContent::Table(table) => { + table.decode(archive, structure_member).unwrap() /* XXX*/ + } + ContainerContent::Graph(graph) => graph.decode(structure_member), + ContainerContent::Text(container_text) => Text::new( + match container_text.text_type { + TextType::Title => crate::output::TextType::Title, + TextType::Log | TextType::Text => crate::output::TextType::Log, + TextType::PageTitle => crate::output::TextType::PageTitle, + }, + container_text.decode(), + ) + .into_item() + .with_command_name(container_text.command_name) + .with_spv_info(SpvInfo::new(structure_member)), + ContainerContent::Image(image) => { + image.decode(archive, structure_member).unwrap() + } /*XXX*/, + ContainerContent::Object(object) => { + object.decode(archive, structure_member).unwrap() + } /*XXX*/, + ContainerContent::Model => new_error_item("models not yet implemented") + .with_spv_info(SpvInfo::new(structure_member).with_error()), + ContainerContent::Tree => new_error_item("trees not yet implemented") + .with_spv_info(SpvInfo::new(structure_member).with_error()), + }; + items.push(item); + } + HeadingContent::Heading(mut heading) => { + let show = !heading.visibility.is_some(); + let label = std::mem::take(&mut heading.label.text); + let command_name = heading.command_name.take(); + items.push( + heading + .decode(archive, structure_member)? + .into_iter() + .collect::() + .with_show(show) + .with_label(label) + .with_command_name(command_name) + .with_spv_info(SpvInfo::new(structure_member)), + ); + } + } + } + Ok(items) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageSetup { + #[serde(rename = "@initial-page-number")] + pub initial_page_number: Option, + #[serde(rename = "@chart-size")] + pub chart_size: Option, + #[serde(rename = "@margin-left")] + pub margin_left: Option, + #[serde(rename = "@margin-right")] + pub margin_right: Option, + #[serde(rename = "@margin-top")] + pub margin_top: Option, + #[serde(rename = "@margin-bottom")] + pub margin_bottom: Option, + #[serde(rename = "@paper-height")] + pub paper_height: Option, + #[serde(rename = "@paper-width")] + pub paper_width: Option, + #[serde(rename = "@reference-orientation")] + pub reference_orientation: Option, + #[serde(rename = "@space-after")] + pub space_after: Option, + pub page_header: PageHeader, + pub page_footer: PageFooter, +} + +impl PageSetup { + fn decode(&self) -> page::PageSetup { + let mut setup = page::PageSetup::default(); + if let Some(initial_page_number) = self.initial_page_number { + setup.initial_page_number = initial_page_number; + } + if let Some(chart_size) = self.chart_size { + setup.chart_size = chart_size.into(); + } + if let Some(margin_left) = self.margin_left { + setup.margins.0[Axis2::X][0] = margin_left.into(); + } + if let Some(margin_right) = self.margin_right { + setup.margins.0[Axis2::X][1] = margin_right.into(); + } + if let Some(margin_top) = self.margin_top { + setup.margins.0[Axis2::Y][0] = margin_top.into(); + } + if let Some(margin_bottom) = self.margin_bottom { + setup.margins.0[Axis2::Y][1] = margin_bottom.into(); + } + match (self.paper_width, self.paper_height) { + (Some(width), Some(height)) => { + setup.paper = PaperSize::new(width.0, height.0, paper_sizes::Unit::Inch) + } + (Some(length), None) | (None, Some(length)) => { + setup.paper = PaperSize::new(length.0, length.0, paper_sizes::Unit::Inch) + } + (None, None) => (), + } + if let Some(reference_orientation) = self.reference_orientation { + setup.orientation = reference_orientation.into(); + } + if let Some(space_after) = self.space_after { + setup.object_spacing = space_after.into(); + } + if let Some(PageParagraph { text }) = &self.page_header.page_paragraph { + setup.header = text.decode(); } - Ok(()) + if let Some(PageParagraph { text }) = &self.page_footer.page_paragraph { + setup.footer = text.decode(); + } + setup } } -impl BinWrite for Footnote { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - ( - &self.content, - Optional(self.marker.as_ref()), - if self.show { 1i32 } else { -1 }, - ) - .write_options(writer, endian, args) - } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageHeader { + page_paragraph: Option, } -impl BinWrite for Footnotes { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - (self.0.len() as u32).write_options(writer, endian, args)?; - for footnote in &self.0 { - footnote.write_options(writer, endian, args)?; - } - Ok(()) - } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageFooter { + page_paragraph: Option, } -impl BinWrite for AreaStyle { - type Args<'a> = usize; - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - index: usize, - ) -> binrw::BinResult<()> { - let typeface = if self.font_style.font.is_empty() { - "SansSerif" - } else { - self.font_style.font.as_str() - }; - ( - (index + 1) as u8, - 0x31u8, - SpvString(typeface), - self.font_style.size as f32 * 1.33, - self.font_style.bold as u32 + 2 * self.font_style.italic as u32, - SpvBool(self.font_style.underline), - self.cell_style - .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], - ) - .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, - (), - )?; - } else { - (SpvBool(false), SpvString(""), SpvString("")).write_options(writer, endian, ())?; - } - - ( - self.cell_style.margins[Axis2::X][0], - self.cell_style.margins[Axis2::X][1], - self.cell_style.margins[Axis2::Y][0], - self.cell_style.margins[Axis2::Y][1], - ) - .write_options(writer, endian, ()) - } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageParagraph { + text: PageParagraphText, } -impl Stroke { - fn as_spv(&self) -> u32 { - match self { - Stroke::None => 0, - Stroke::Solid => 1, - Stroke::Dashed => 2, - Stroke::Thick => 3, - Stroke::Thin => 4, - Stroke::Double => 5, - } - } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageParagraphText { + #[serde(default, rename = "$text")] + text: String, } -impl Color { - fn as_spv(&self) -> u32 { - ((self.alpha as u32) << 24) - | ((self.r as u32) << 16) - | ((self.g as u32) << 8) - | (self.b as u32) +impl PageParagraphText { + fn decode(&self) -> Document { + Document::from_html(&self.text) } } -impl BinWrite for BorderStyle { - type Args<'a> = usize; +#[derive(Copy, Clone, Debug, Default, Deserialize)] +#[serde(rename = "snake_case")] +pub enum ReferenceOrientation { + #[serde(alias = "0")] + #[serde(alias = "0deg")] + #[serde(alias = "inherit")] + #[default] + Portrait, - fn write_options( - &self, - writer: &mut W, - _endian: Endian, - index: usize, - ) -> binrw::BinResult<()> { - (index as u32, self.stroke.as_spv(), self.color.as_spv()).write_be(writer) - } -} + #[serde(alias = "90")] + #[serde(alias = "90deg")] + #[serde(alias = "-270")] + #[serde(alias = "-270deg")] + Landscape, -struct SpvBool(bool); -impl BinWrite for SpvBool { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - (self.0 as u8).write_options(writer, endian, args) - } -} + #[serde(alias = "180")] + #[serde(alias = "180deg")] + #[serde(alias = "-1280")] + #[serde(alias = "-180deg")] + ReversePortrait, -struct SpvString(T); -impl<'a> SpvString<&'a str> { - fn optional(s: &'a Option) -> Self { - Self(s.as_ref().map_or("", |s| s.as_str())) - } -} -impl BinWrite for SpvString -where - T: AsRef, -{ - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - let s = self.0.as_ref(); - let length = s.len() as u32; - (length, s.as_bytes()).write_options(writer, endian, args) - } + #[serde(alias = "270")] + #[serde(alias = "270deg")] + #[serde(alias = "-90")] + #[serde(alias = "-90deg")] + Seascape, } -impl Show { - fn as_spv(this: &Option) -> u8 { - match this { - None => 0, - Some(Show::Value) => 1, - Some(Show::Label) => 2, - Some(Show::Both) => 3, +impl From for page::Orientation { + fn from(value: ReferenceOrientation) -> Self { + match value { + ReferenceOrientation::Portrait | ReferenceOrientation::ReversePortrait => { + page::Orientation::Portrait + } + ReferenceOrientation::Landscape | ReferenceOrientation::Seascape => { + page::Orientation::Landscape + } } } } -struct Count(u64); +/// Chart size. +#[derive(Copy, Clone, Debug, Default, Deserialize)] +pub enum ChartSize { + #[default] + #[serde(rename = "as-is")] + AsIs, -impl Count { - fn new(writer: &mut W) -> binrw::BinResult - where - W: Write + Seek, - { - 0u32.write_le(writer)?; - Ok(Self(writer.stream_position()?)) - } + #[serde(rename = "full-height")] + FullHeight, - fn finish(self, writer: &mut W, endian: Endian) -> binrw::BinResult<()> - where - W: Write + Seek, - { - let saved_position = writer.stream_position()?; - let n_bytes = saved_position - self.0; - writer.seek(std::io::SeekFrom::Start(self.0 - 4))?; - (n_bytes as u32).write_options(writer, endian, ())?; - writer.seek(std::io::SeekFrom::Start(saved_position))?; - Ok(()) - } - - fn finish_le32(self, writer: &mut W) -> binrw::BinResult<()> - where - W: Write + Seek, - { - self.finish(writer, Endian::Little) - } - - fn finish_be32(self, writer: &mut W) -> binrw::BinResult<()> - where - W: Write + Seek, - { - self.finish(writer, Endian::Big) - } -} + #[serde(rename = "half-height")] + HalfHeight, -struct Counted { - inner: T, - endian: Option, + #[serde(rename = "quarter-height")] + QuarterHeight, } -impl Counted { - fn new(inner: T) -> Self { - Self { - inner, - endian: None, - } - } - fn with_endian(self, endian: Endian) -> Self { - Self { - inner: self.inner, - endian: Some(endian), - } +impl From for page::ChartSize { + fn from(value: ChartSize) -> Self { + match value { + ChartSize::AsIs => page::ChartSize::AsIs, + ChartSize::FullHeight => page::ChartSize::FullHeight, + ChartSize::HalfHeight => page::ChartSize::HalfHeight, + ChartSize::QuarterHeight => page::ChartSize::QuarterHeight, + } + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +enum HeadingContent { + Container(Container), + Heading(Box), +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Label { + #[serde(default, rename = "$text")] + text: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Container { + #[serde(default, rename = "@visibility")] + visibility: Visibility, + #[serde(rename = "@page-break-before")] + #[serde(default)] + page_break_before: PageBreakBefore, + #[serde(rename = "@text-align")] + text_align: Option, + #[serde(rename = "@width")] + width: Option, + label: Label, + + #[serde(rename = "$value")] + content: ContainerContent, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +enum PageBreakBefore { + #[default] + Auto, + Always, + Avoid, + Left, + Right, + Inherit, +} + +#[derive(Deserialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +enum Visibility { + #[default] + Visible, + Hidden, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +enum TextAlign { + Left, + Center, + Right, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +enum ContainerContent { + Table(Table), + Text(ContainerText), + Graph(Graph), + Model, + Object(Object), + Image(Image), + Tree, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Graph { + #[serde(rename = "@commandName")] + command_name: String, + data_path: Option, + path: String, + csv_path: Option, +} + +impl Graph { + fn decode(&self, structure_member: &str) -> Item { + crate::output::Chart + .into_item() + .with_spv_info( + SpvInfo::new(structure_member).with_members(SpvMembers::Graph { + data: self.data_path.clone(), + xml: self.path.clone(), + csv: self.csv_path.clone(), + }), + ) } } -impl BinWrite for Counted +fn decode_image( + archive: &mut ZipArchive, + structure_member: &str, + command_name: &Option, + image_name: &str, +) -> Result where - T: BinWrite, - for<'a> T: BinWrite = ()>, + R: Read + Seek, { - type Args<'a> = T::Args<'a>; - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - let start = Count::new(writer)?; - self.inner.write_options(writer, endian, args)?; - start.finish(writer, self.endian.unwrap_or(endian)) - } -} - -pub struct Zeros(pub usize); - -impl BinWrite for Zeros { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - _endian: Endian, - _args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - for _ in 0..self.0 { - writer.write_all(&[0u8])?; - } - Ok(()) - } -} - -#[derive(Default)] -struct StylePair<'a> { - font_style: Option<&'a FontStyle>, - cell_style: Option<&'a CellStyle>, + let mut png = archive.by_name(image_name)?; + let image = ImageSurface::create_from_png(&mut png)?; + Ok(Details::Image(image) + .into_item() + .with_command_name(command_name.clone()) + .with_spv_info( + SpvInfo::new(structure_member).with_members(SpvMembers::Image(image_name.into())), + )) } -impl BinWrite for Color { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - SpvString(&self.without_alpha().display_css().to_small_string::<16>()) - .write_options(writer, endian, args) - } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Image { + #[serde(rename = "@commandName")] + command_name: Option, + data_path: String, } -impl BinWrite for FontStyle { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - let typeface = if self.font.is_empty() { - "SansSerif" - } else { - self.font.as_str() - }; - ( - SpvBool(self.bold), - SpvBool(self.italic), - SpvBool(self.underline), - SpvBool(true), - self.fg[0], - self.bg[0], - SpvString(typeface), - (self.size as f64 * 1.33).ceil() as u8, +impl Image { + fn decode(&self, archive: &mut ZipArchive, structure_member: &str) -> Result + where + R: Read + Seek, + { + decode_image( + archive, + structure_member, + &self.command_name, + &self.data_path, ) - .write_options(writer, endian, args) - } -} - -impl HorzAlign { - fn as_spv(&self, decimal: u32) -> u32 { - match self { - HorzAlign::Right => 4, - HorzAlign::Left => 2, - HorzAlign::Center => 0, - HorzAlign::Decimal { .. } => decimal, - } - } - - fn decimal_offset(&self) -> Option { - match *self { - HorzAlign::Decimal { offset, .. } => Some(offset), - _ => None, - } } } -impl VertAlign { - fn as_spv(&self) -> u32 { - match self { - VertAlign::Top => 1, - VertAlign::Middle => 0, - VertAlign::Bottom => 3, - } - } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Object { + #[serde(rename = "@commandName")] + command_name: Option, + #[serde(rename = "@uri")] + uri: String, } -impl BinWrite for CellStyle { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - ( - self.horz_align - .map_or(0xffffffad, |horz_align| horz_align.as_spv(6)), - self.vert_align.as_spv(), - self.horz_align - .map(|horz_align| horz_align.decimal_offset()) - .unwrap_or_default(), - u16::try_from(self.margins[Axis2::X][0]).unwrap_or_default(), - u16::try_from(self.margins[Axis2::X][1]).unwrap_or_default(), - u16::try_from(self.margins[Axis2::Y][0]).unwrap_or_default(), - u16::try_from(self.margins[Axis2::Y][1]).unwrap_or_default(), - ) - .write_options(writer, endian, args) +impl Object { + fn decode(&self, archive: &mut ZipArchive, structure_member: &str) -> Result + where + R: Read + Seek, + { + decode_image(archive, structure_member, &self.command_name, &self.uri) } } -impl<'a> BinWrite for StylePair<'a> { - type Args<'b> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - ( - Optional(self.font_style.as_ref()), - Optional(self.cell_style.as_ref()), - ) - .write_options(writer, endian, args) - } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Table { + #[serde(rename = "@commandName")] + command_name: String, + #[serde(rename = "@subType")] + sub_type: String, + #[serde(rename = "@tableId")] + table_id: Option, + #[serde(rename = "@type")] + table_type: TableType, + properties: Option, + table_structure: TableStructure, } -struct Optional(Option); +impl Table { + fn decode(&self, archive: &mut ZipArchive, structure_member: &str) -> Result + where + R: Read + Seek, + { + match &self.table_structure.path { + None => { + let member_name = &self.table_structure.data_path; + let mut light = archive.by_name(member_name)?; + let mut data = Vec::with_capacity(light.size() as usize); + light.read_to_end(&mut data)?; + let mut cursor = Cursor::new(data); + let table = LightTable::read(&mut cursor).map_err(|e| { + e.with_message(format!( + "While parsing {member_name:?} as light binary SPV member" + )) + })?; + let pivot_table = table.decode()?; + Ok(pivot_table.into_item().with_spv_info( + SpvInfo::new(structure_member) + .with_members(SpvMembers::Light(self.table_structure.data_path.clone())), + )) + } + Some(xml_member_name) => { + let bin_member_name = &self.table_structure.data_path; + let mut bin_member = archive.by_name(bin_member_name)?; + let mut bin_data = Vec::with_capacity(bin_member.size() as usize); + bin_member.read_to_end(&mut bin_data)?; + let mut cursor = Cursor::new(bin_data); + let legacy_bin = LegacyBin::read(&mut cursor).map_err(|e| { + e.with_message(format!( + "While parsing {bin_member_name:?} as legacy binary SPV member" + )) + })?; + let data = legacy_bin.decode(); + drop(bin_member); -impl BinWrite for Optional -where - T: BinWrite, -{ - type Args<'a> = T::Args<'a>; - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - match &self.0 { - Some(value) => { - 0x31u8.write_le(writer)?; - value.write_options(writer, endian, args) + let member = BufReader::new(archive.by_name(&xml_member_name)?); + let visualization: Visualization = match serde_path_to_error::deserialize( + &mut quick_xml::de::Deserializer::from_reader(member), + ) + .with_context(|| format!("Failed to parse {xml_member_name}")) + { + Ok(result) => result, + Err(error) => panic!("{error:?}"), + }; + let pivot_table = visualization.decode( + data, + self.properties + .as_ref() + .map_or_else(Look::default, |properties| properties.clone().into()), + )?; + + Ok(pivot_table.into_item().with_spv_info( + SpvInfo::new(structure_member).with_members(SpvMembers::Legacy { + xml: xml_member_name.clone(), + binary: bin_member_name.clone(), + }), + )) } - None => 0x58u8.write_le(writer), } } } -struct ValueMod<'a> { - style: &'a Option>, - template: Option<&'a str>, +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +enum TableType { + Table, + Note, + Warning, } -impl<'a> ValueMod<'a> { - fn new(value: &'a Value) -> Self { - Self { - style: &value.styling, - template: None, - } - } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct ContainerText { + #[serde(rename = "@type")] + text_type: TextType, + #[serde(rename = "@commandName")] + command_name: Option, + html: String, } -impl<'a> Default for ValueMod<'a> { - fn default() -> Self { - Self { - style: &None, - template: None, - } +impl ContainerText { + fn decode(&self) -> Value { + html::Document::from_html(&self.html).into_value() } } -impl<'a> BinWrite for ValueMod<'a> { - type Args<'b> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - if self.style.as_ref().is_some_and(|style| !style.is_empty()) || self.template.is_some() { - 0x31u8.write_options(writer, endian, args)?; - let default_style = Default::default(); - let style = self.style.as_ref().unwrap_or(&default_style); - - (style.footnotes.len() as u32).write_options(writer, endian, args)?; - for footnote in &style.footnotes { - (footnote.index() as u16).write_options(writer, endian, args)?; - } - - (style.subscripts.len() as u32).write_options(writer, endian, args)?; - for subscript in &style.subscripts { - SpvString(subscript.as_str()).write_options(writer, endian, args)?; - } - let v3_start = Count::new(writer)?; - let template_string_start = Count::new(writer)?; - if let Some(template) = self.template { - Count::new(writer)?.finish_le32(writer)?; - (0x31u8, SpvString(template)).write_options(writer, endian, args)?; - } - template_string_start.finish_le32(writer)?; - style - .style - .as_ref() - .map_or_else(StylePair::default, |area_style| StylePair { - font_style: Some(&area_style.font_style), - cell_style: Some(&area_style.cell_style), - }) - .write_options(writer, endian, args)?; - v3_start.finish_le32(writer) - } else { - 0x58u8.write_options(writer, endian, args) - } - } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +enum TextType { + Title, + Log, + Text, + #[serde(rename = "page-title")] + PageTitle, } -struct SpvFormat { - format: Format, - honor_small: bool, +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct TableStructure { + /// The `.xml` member name, for legacy members only. + path: Option, + /// The `.bin` member name. + data_path: String, + /// Rarely used, not understood. + csv_path: Option, } -impl BinWrite for SpvFormat { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - let type_ = if self.format.type_() == Type::F && self.honor_small { - 40 - } else { - self.format.type_().into() - }; - (((type_ as u32) << 16) | ((self.format.w() as u32) << 8) | (self.format.d() as u32)) - .write_options(writer, endian, args) - } -} - -impl BinWrite for Value { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - match &self.inner { - ValueInner::Number(number) => { - let format = SpvFormat { - format: number.format, - honor_small: number.honor_small, - }; - if number.variable.is_some() || number.value_label.is_some() { - ( - 2u8, - ValueMod::new(self), - format, - number.value.unwrap_or(f64::MIN), - SpvString::optional(&number.variable), - SpvString::optional(&number.value_label), - Show::as_spv(&number.show), - ) - .write_options(writer, endian, args)?; - } else { - ( - 1u8, - ValueMod::new(self), - format, - number.value.unwrap_or(f64::MIN), - ) - .write_options(writer, endian, args)?; - } - } - ValueInner::String(string) => { - ( - 4u8, - ValueMod::new(self), - SpvFormat { - format: if string.hex { - Format::new(Type::AHex, (string.s.len() * 2) as u16, 0).unwrap() - } else { - Format::new(Type::A, (string.s.len()) as u16, 0).unwrap() - }, - honor_small: false, - }, - SpvString::optional(&string.value_label), - SpvString::optional(&string.var_name), - Show::as_spv(&string.show), - SpvString(&string.s), - ) - .write_options(writer, endian, args)?; - } - ValueInner::Variable(variable) => { - ( - 5u8, - ValueMod::new(self), - SpvString(&variable.var_name), - SpvString::optional(&variable.variable_label), - Show::as_spv(&variable.show), - ) - .write_options(writer, endian, args)?; - } - ValueInner::Text(text) => { - ( - 3u8, - SpvString(&text.localized), - ValueMod::new(self), - SpvString(text.id()), - SpvString(text.c()), - SpvBool(true), - ) - .write_options(writer, endian, args)?; - } - ValueInner::Template(template) => { - ( - 0u8, - ValueMod::new(self), - SpvString(&template.localized), - template.args.len() as u32, - ) - .write_options(writer, endian, args)?; - for arg in &template.args { - if arg.len() > 1 { - (arg.len() as u32, 0u32).write_options(writer, endian, args)?; - for (index, value) in arg.iter().enumerate() { - if index > 0 { - 0u32.write_le(writer)?; - } - value.write_options(writer, endian, args)?; - } - } else { - (0u32, arg).write_options(writer, endian, args)?; - } - } - } - ValueInner::Empty => { - ( - 3u8, - SpvString(""), - ValueMod::default(), - SpvString(""), - SpvString(""), - SpvBool(true), - ) - .write_options(writer, endian, args)?; - } - } - Ok(()) +#[cfg(test)] +#[test] +fn test_spv() { + let items = ReadOptions::new() + .open_file("/home/blp/pspp/rust/tests/utilities/regress.spv") + .unwrap() + .into_items(); + for item in items { + println!("{item}"); } + todo!() } diff --git a/rust/pspp/src/output/spv/css.rs b/rust/pspp/src/output/spv/css.rs new file mode 100644 index 0000000000..17fbe92435 --- /dev/null +++ b/rust/pspp/src/output/spv/css.rs @@ -0,0 +1,377 @@ +use std::{ + borrow::Cow, + fmt::{Display, Write}, + mem::discriminant, + ops::Not, +}; + +use itertools::Itertools; + +use crate::output::{ + pivot::{FontStyle, HorzAlign}, + spv::html::Style, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Token<'a> { + Id(Cow<'a, str>), + LeftCurly, + RightCurly, + Colon, + Semicolon, + Error, +} + +struct Lexer<'a>(&'a str); + +impl<'a> Iterator for Lexer<'a> { + type Item = Token<'a>; + + fn next(&mut self) -> Option { + let mut s = self.0; + loop { + s = s.trim_start(); + if let Some(rest) = s.strip_prefix("") { + s = rest; + } else { + break; + } + } + let mut iter = s.chars(); + let (c, mut rest) = (iter.next()?, iter.as_str()); + let (token, rest) = match c { + '{' => (Token::LeftCurly, rest), + '}' => (Token::RightCurly, rest), + ':' => (Token::Colon, rest), + ';' => (Token::Semicolon, rest), + '\'' | '"' => { + let quote = c; + let mut s = String::new(); + while let Some(c) = iter.next() { + if c == quote { + break; + } else if c != '\\' { + s.push(c); + } else { + let start = iter.as_str(); + match iter.next() { + None => break, + Some(a) if a.is_ascii_alphanumeric() => { + let n = start + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .take(6) + .count(); + iter = start[n..].chars(); + if let Ok(code_point) = u32::from_str_radix(&start[..n], 16) + && let Ok(c) = char::try_from(code_point) + { + s.push(c); + } + } + Some('\n') => (), + Some(other) => s.push(other), + } + } + } + (Token::Id(Cow::from(s)), iter.as_str()) + } + _ => { + while !iter.as_str().starts_with("-->") + && let Some(c) = iter.next() + && !c.is_whitespace() + && c != '{' + && c != '}' + && c != ':' + && c != ';' + { + rest = iter.as_str(); + } + let id_len = s.len() - rest.len(); + let (id, rest) = s.split_at(id_len); + (Token::Id(Cow::from(id)), rest) + } + }; + self.0 = rest; + Some(token) + } +} + +impl HorzAlign { + pub fn from_css(s: &str) -> Option { + let mut lexer = Lexer(s); + while let Some(token) = lexer.next() { + if let Token::Id(key) = token + && let Some(Token::Colon) = lexer.next() + && let Some(Token::Id(value)) = lexer.next() + && key.as_ref() == "text-align" + && let Ok(align) = value.parse() + { + return Some(align); + } + } + None + } +} + +impl Style { + pub fn parse_css(styles: &mut Vec