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=adafb79c136716b3230badb2b3c8524cc7a10b55;p=pspp work on reading spv files --- diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 74ed653cff..48a5a75043 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", @@ -1776,7 +1922,6 @@ dependencies = [ "unicode-segmentation", "unicode-width", "windows-sys 0.48.0", - "xmlwriter", "zeroize", "zip", ] @@ -1803,9 +1948,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 +2141,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 +2192,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 +2602,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" @@ -2939,12 +3118,6 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" -[[package]] -name = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - [[package]] name = "yoke" version = "0.8.0" 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..22dde37235 --- /dev/null +++ b/rust/pspp/src/output/drivers/spv.rs @@ -0,0 +1,93 @@ +// 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::{Seek, Write}, + path::PathBuf, + sync::Arc, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + output::{Item, drivers::Driver, page::PageSetup}, + spv::Writer, +}; + +#[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: Writer, +} + +impl Driver for SpvDriver +where + W: Write + Seek + 'static, +{ + fn name(&self) -> Cow<'static, str> { + Cow::from("spv") + } + + fn write(&mut self, item: &Arc) { + self.writer.write(item).unwrap(); + } + + fn setup(&mut self, page_setup: &PageSetup) -> bool { + self.writer.set_page_setup(page_setup.clone()); + true + } + + fn handles_show(&self) -> bool { + true + } + + fn handles_groups(&self) -> bool { + true + } +} + +impl SpvDriver { + pub fn new(config: &SpvConfig) -> std::io::Result { + let mut writer = Writer::for_writer(File::create(&config.file)?).unwrap(); + if let Some(page_setup) = &config.page_setup { + writer = writer.with_page_setup(page_setup.clone()); + } + Ok(Self { writer }) + } +} + +impl SpvDriver +where + W: Write + Seek, +{ + pub fn for_writer(writer: W) -> Self { + Self { + writer: Writer::for_writer(writer).unwrap(), + } + } +} diff --git a/rust/pspp/src/output/drivers/text.rs b/rust/pspp/src/output/drivers/text.rs new file mode 100644 index 0000000000..5827fef5f8 --- /dev/null +++ b/rust/pspp/src/output/drivers/text.rs @@ -0,0 +1,720 @@ +// 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::{ItemRefIterator, render::Extreme, table::DrawCell}; + +use crate::output::{ + Details, Item, + drivers::Driver, + pivot::{ + Axis2, Coord2, PivotTable, Rect2, + look::{BorderStyle, HorzAlign, 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: isize, + + /// 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).min(isize::MAX as usize) as isize; + Self { + emphasis: config.emphasis, + width, + min_hbreak: 20, + box_chars: config.boxes.box_chars(), + n_objects: 0, + params: Params { + size: Coord2::new(width, isize::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: enum_map! { + Axis2::X => width / 2, + Axis2::Y => isize::MAX, + }, + 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 { + /// Returns a object that will format the pivot table as text, with Unicode + /// box drawing characters. + pub fn display(&self) -> impl Display { + DisplayPivotTable::new(self) + } +} + +impl Display for PivotTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.display()) + } +} + +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, + { + for (index, item) in ItemRefIterator::without_hidden(item) + .filter(|item| !item.details.is_heading()) + .enumerate() + { + if index > 0 { + writeln!(writer)?; + } + match &item.details { + Details::Chart => writeln!(writer, "Omitting chart from text output")?, + Details::Image(_) => writeln!(writer, "Omitting image from text output")?, + Details::Heading(_) => unreachable!(), + Details::Message(_diagnostic) => todo!(), + Details::PageBreak => (), + Details::Table(pivot_table) => self.render_table(pivot_table, writer)?, + Details::Text(text) => { + self.render_table(&PivotTable::from((**text).clone()), writer)? + } + } + } + Ok(()) + } + + 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).is_some() { + pager.draw_next(self, isize::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() as isize; + 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..isize::MAX)).x(), + Extreme::Max => self.layout_cell(&text, Rect2::new(0..isize::MAX, 0..isize::MAX)).x(), + ] + } + + fn measure_cell_height(&self, cell: &DrawCell, width: isize) -> isize { + let text = cell.display().to_string(); + self.layout_cell(&text, Rect2::new(0..width, 0..isize::MAX)) + .y() + } + + fn adjust_break(&self, _cell: &Content, _size: Coord2) -> isize { + 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.start >= 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 as usize) + .put_multiple(x.start as usize, c, x.len()); + } + } + + fn draw_cell( + &mut self, + cell: &DrawCell, + bb: Rect2, + valign_offset: isize, + _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() as isize; + if y < 0 || !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) + 1) / 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 as usize).put(x as usize, &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..bd31c5f932 --- /dev/null +++ b/rust/pspp/src/output/drivers/text/text_line.rs @@ -0,0 +1,617 @@ +// 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::look::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<(isize, &'a str)> { + let mut x = bb.start; + let mut width = bb.len() as isize; + + let mut iter = text.chars(); + while x < clip.start { + let c = iter.next()?; + if let Some(w) = c.width() { + let w = w as isize; + x += w; + width -= w; + if width < 0 { + return None; + } + } + } + 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 -= w as isize; + if width < 0 { + return None; + } + } + } + } + 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..3104f8ba16 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::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..679ae347e1 100644 --- a/rust/pspp/src/output/pivot.rs +++ b/rust/pspp/src/output/pivot.rs @@ -42,273 +42,63 @@ //! a category for each dimension to a value, which is commonly a number but //! could also be a variable name or an arbitrary text string. +// Warn about missing docs, but not for items declared with `#[cfg(test)]`. +#![cfg_attr(not(test), warn(missing_docs))] + use std::{ collections::HashMap, - fmt::{Debug, Display, Write}, - io::Read, - iter::{FusedIterator, once, repeat, repeat_n}, - ops::{Index, IndexMut, Not, Range, RangeInclusive}, - str::{FromStr, Utf8Error, from_utf8}, - sync::{Arc, OnceLock}, + fmt::{Debug, Display}, + iter::{FusedIterator, once, repeat_n}, + ops::{Index, IndexMut, Not, Range}, + sync::Arc, }; -use binrw::Error as BinError; use chrono::NaiveDateTime; -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 quick_xml::{DeError, de::from_str}; -use serde::{ - Deserialize, Serialize, Serializer, - de::Visitor, - ser::{SerializeMap, SerializeStruct}, -}; -use smallstr::SmallString; +use itertools::Itertools; +pub use look_xml::{Length, TableProperties}; +use serde::{Deserialize, Serialize, ser::SerializeMap}; use smallvec::SmallVec; -use thiserror::Error as ThisError; -use tlo::parse_tlo; use crate::{ - calendar::date_time_to_pspp, - data::{ByteString, Datum, EncodedString, RawString}, - format::{Decimal, Format, Settings as FormatSettings, Type, UncheckedFormat}, + format::{F40, F40_2, F40_3, Format, PCT40_1, Settings as FormatSettings}, + output::pivot::{ + look::{Look, Sizing}, + value::{BareValue, Value, ValueOptions}, + }, settings::{Settings, Show}, - util::ToSmallString, - variable::{VarType, Variable}, + variable::Variable, }; +pub(crate) use tlo::parse_bool; -pub mod output; - +mod output; +pub use output::OutputTables; mod look_xml; -#[cfg(test)] -pub mod tests; mod tlo; +pub mod value; -/// Areas of a pivot table for styling purposes. -#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)] -pub enum Area { - Title, - Caption, - - /// Footnotes, - Footer, - - // Top-left corner. - Corner, - - /// Labels for columns ([Axis2::X]) and rows ([Axis2::Y]). - Labels(Axis2), - - #[default] - Data, - - /// Layer indication. - Layers, -} - -impl Display for Area { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Area::Title => write!(f, "title"), - Area::Caption => write!(f, "caption"), - Area::Footer => write!(f, "footer"), - Area::Corner => write!(f, "corner"), - Area::Labels(axis2) => write!(f, "labels({axis2})"), - Area::Data => write!(f, "data"), - Area::Layers => write!(f, "layers"), - } - } -} - -impl Serialize for Area { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_small_string::<16>()) - } -} - -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 }, - } - } - - 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, - } - } - - fn default_area_style(self) -> AreaStyle { - AreaStyle { - cell_style: self.default_cell_style(), - font_style: self.default_font_style(), - } - } -} - -/// Table borders for styling purposes. -#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)] -pub enum Border { - Title, - OuterFrame(BoxBorder), - InnerFrame(BoxBorder), - Dimension(RowColBorder), - Category(RowColBorder), - DataLeft, - DataTop, -} - -impl Border { - pub fn default_stroke(self) -> Stroke { - match self { - Self::InnerFrame(_) | Self::DataLeft | Self::DataTop => Stroke::Thick, - Self::Dimension( - RowColBorder(HeadingRegion::Columns, _) | RowColBorder(_, Axis2::X), - ) - | Self::Category(RowColBorder(HeadingRegion::Columns, _)) => Stroke::Solid, - _ => Stroke::None, - } - } - pub fn default_border_style(self) -> BorderStyle { - BorderStyle { - stroke: self.default_stroke(), - color: Color::BLACK, - } - } - - fn fallback(self) -> Self { - match self { - Self::Title - | Self::OuterFrame(_) - | Self::InnerFrame(_) - | Self::DataLeft - | Self::DataTop - | Self::Category(_) => self, - Self::Dimension(row_col_border) => Self::Category(row_col_border), - } - } -} - -impl Display for Border { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Border::Title => write!(f, "title"), - Border::OuterFrame(box_border) => write!(f, "outer_frame({box_border})"), - Border::InnerFrame(box_border) => write!(f, "inner_frame({box_border})"), - Border::Dimension(row_col_border) => write!(f, "dimension({row_col_border})"), - Border::Category(row_col_border) => write!(f, "category({row_col_border})"), - Border::DataLeft => write!(f, "data(left)"), - Border::DataTop => write!(f, "data(top)"), - } - } -} - -impl Serialize for Border { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_small_string::<32>()) - } -} - -/// The borders on a box. -#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum BoxBorder { - Left, - Top, - Right, - Bottom, -} - -impl BoxBorder { - fn as_str(&self) -> &'static str { - match self { - BoxBorder::Left => "left", - BoxBorder::Top => "top", - BoxBorder::Right => "right", - BoxBorder::Bottom => "bottom", - } - } -} - -impl Display for BoxBorder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// Borders between rows and columns. -#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub struct RowColBorder( - /// Row or column headings. - pub HeadingRegion, - /// Horizontal ([Axis2::X]) or vertical ([Axis2::Y]) borders. - pub Axis2, -); - -impl Display for RowColBorder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{}", self.0, self.1) - } -} - -/// Sizing for rows or columns of a rendered table. -/// -/// The comments below talk about columns and their widths but they apply -/// equally to rows and their heights. -#[derive(Default, Clone, Debug, Serialize)] -pub struct Sizing { - /// Specific column widths, in 1/96" units. - 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, +#[cfg(test)] +pub mod tests; - /// Keeps: columns to keep together on a page if possible. - keeps: Vec>, -} +pub mod look; +/// A 3-dimensional axis. #[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Sequence, Serialize)] #[serde(rename_all = "snake_case")] pub enum Axis3 { + /// X axis. X, + /// Y axis. Y, + /// Z axis. Z, } impl Axis3 { - fn transpose(&self) -> Option { + /// Transposes the X and Y axes. Returns `None` if this represents the Z + /// axis. + pub fn transpose(&self) -> Option { match self { Axis3::X => Some(Axis3::Y), Axis3::Y => Some(Axis3::X), @@ -333,7 +123,14 @@ pub struct Axis { pub dimensions: Vec, } -pub struct AxisIterator { +/// Iterator over one of the [Axis3] axes in a [PivotTable]. +/// +/// The items for this iterator are the index values for each of the dimensions +/// along the axis, each along `0..n` where `n` is the number of leaves in the +/// dimension. +/// +/// Use [PivotTable::axis_values] to construct an `AxisIterator`. +struct AxisIterator { indexes: SmallVec<[usize; 4]>, lengths: SmallVec<[usize; 4]>, done: bool, @@ -362,2355 +159,1700 @@ impl Iterator for AxisIterator { } impl PivotTable { - pub fn with_look(mut self, look: Arc) -> Self { - self.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 - }; - let value = Value::new(ValueInner::Number(NumberValue { - show: None, - format, - honor_small: class == Class::Other, - value: number, - variable: None, - value_label: None, - })); - self.insert(data_indexes, value); - } - - pub fn with_footnotes(mut self, footnotes: Footnotes) -> Self { - debug_assert!(self.footnotes.is_empty()); - self.footnotes = footnotes; - self - } - fn axis_values(&self, axis: Axis3) -> AxisIterator { - AxisIterator { - indexes: repeat_n(0, self.axes[axis].dimensions.len()).collect(), - lengths: self.axis_dimensions(axis).map(|d| d.len()).collect(), - done: self.axis_extent(axis) == 0, + /// Constructs a new `PivotTable` with the given `dimensions` along the + /// specified axes. + /// + /// The caller should add a title to the pivot table using [with_title] and + /// add data with [with_data] or [insert]. + /// + /// [with_title]: Self::with_title + /// [with_data]: Self::with_data + /// [insert]: Self::insert + pub fn new(dimensions: impl IntoIterator) -> Self { + let mut dims = Vec::new(); + let mut axes = EnumMap::::default(); + for (axis, dimension) in dimensions { + axes[axis].dimensions.push(dims.len()); + dims.push(dimension); + } + Self { + style: PivotTableStyle::default().with_look(Settings::global().look.clone()), + layer: repeat_n(0, axes[Axis3::Z].dimensions.len()).collect(), + axes, + dimensions: dims, + ..Self::default() } } - fn axis_extent(&self, axis: Axis3) -> usize { - self.axis_dimensions(axis).map(|d| d.len()).product() + /// Returns this pivot table with the given `title`. + /// + /// The title is displayed above the pivot table. Every pivot table should + /// have a title. + pub fn with_title(mut self, title: impl Into) -> Self { + self.metadata.title = Some(Box::new(title.into())); + self.style.show_title = true; + self } -} -/// Dimensions. -/// -/// A [Dimension] identifies the categories associated with a single dimension -/// within a multidimensional pivot table. -/// -/// A dimension contains a collection of categories, which are the leaves in a -/// tree of groups. -/// -/// (A dimension or a group can contain zero categories, but this is unusual. -/// If a dimension contains no categories, then its table cannot contain any -/// data.) -#[derive(Clone, Debug, Serialize)] -pub struct Dimension { - /// Hierarchy of categories within the dimension. The groups and categories - /// are sorted in the order that should be used for display. This might be - /// different from the original order produced for output if the user - /// adjusted it. + /// Returns this pivot table with the given `caption`. /// - /// The root must always be a group, although it is allowed to have no - /// subcategories. - pub root: Group, + /// The caption is displayed below the pivot table. Captions are optional. + pub fn with_caption(mut self, caption: impl Into) -> Self { + self.metadata.caption = Some(Box::new(caption.into())); + self.style.show_caption = true; + self + } - /// Ordering of leaves for presentation. + /// Returns this pivot table with the given `corner_text`. /// - /// This is a permutation of `0..n` where `n` is the number of leaves. It - /// maps from an index in presentation order to an index in data order. - pub presentation_order: Vec, - - /// Display. - pub hide_all_labels: bool, -} - -pub type GroupVec<'a> = SmallVec<[&'a Group; 4]>; -pub struct Path<'a> { - groups: GroupVec<'a>, - leaf: &'a Leaf, -} - -impl Dimension { - pub fn new(root: Group) -> Self { - Dimension { - presentation_order: (0..root.len()).collect(), - root, - hide_all_labels: false, - } + /// The corner text is displayed in the top-left corner of the pivot table, + /// above the row headings and to the left of the column headings. The + /// space used by corner text can also be used for [Dimension] titles. + pub fn with_corner_text(mut self, corner_text: impl Into) -> Self { + self.metadata.corner_text = Some(Box::new(corner_text.into())); + self } - pub fn is_empty(&self) -> bool { - self.len() == 0 + /// Returns this pivot table with the given `footnotes`. + pub fn with_footnotes(mut self, footnotes: Footnotes) -> Self { + debug_assert!(self.footnotes.is_empty()); + self.footnotes = footnotes; + self } - /// Returns the number of (leaf) categories in this dimension. - pub fn len(&self) -> usize { - self.root.len() + /// Returns this pivot table with the given `look`. + pub fn with_look(self, look: Arc) -> Self { + Self { + style: self.style.with_look(look), + ..self + } } - pub fn nth_leaf(&self, index: usize) -> Option<&Leaf> { - self.root.nth_leaf(index) + /// Returns this pivot table with the given `style`. + pub fn with_style(self, style: PivotTableStyle) -> Self { + Self { style, ..self } } - pub fn leaf_path(&self, index: usize) -> Option> { - self.root.leaf_path(index, SmallVec::new()) + /// Returns this pivot table with the given `metadata`. + pub fn with_metadata(self, metadata: PivotTableMetadata) -> Self { + Self { metadata, ..self } } - pub fn with_all_labels_hidden(self) -> Self { + /// Returns this pivot table with the given `subtype`. + /// + /// A subtype is a locale-invariant command ID for the particular kind of + /// output that this table represents in the procedure. This can be the + /// same as the command name, e.g. `Frequencies`, or different, e.g. `Case + /// Processing Summary`. + /// + /// `Notes` and `Warnings` are common generic subtypes. + pub fn with_subtype(self, subtype: impl Into) -> Self { Self { - hide_all_labels: true, + metadata: self.metadata.with_subtype(subtype), ..self } } -} - -#[derive(Clone, Debug, Serialize)] -pub struct Group { - #[serde(skip)] - len: usize, - pub name: Box, - /// The child categories. - /// - /// A group usually has multiple children, but it is allowed to have - /// only one or even (pathologically) none. - pub children: Vec, - - /// Whether to show the group's label. - pub show_label: bool, -} - -impl Group { - pub fn new(name: impl Into) -> Self { - Self::with_capacity(name, 0) + /// Returns this pivot table with the given `show_values`. + pub fn with_show_values(self, show_values: Option) -> Self { + Self { + style: self.style.with_show_values(show_values), + ..self + } } - pub fn with_capacity(name: impl Into, capacity: usize) -> Self { + /// Returns this pivot table with the given `show_variables`. + pub fn with_show_variables(self, show_variables: Option) -> Self { Self { - len: 0, - name: Box::new(name.into()), - children: Vec::with_capacity(capacity), - show_label: false, + style: self.style.with_show_variables(show_variables), + ..self } } - pub fn push(&mut self, child: impl Into) { - let mut child = child.into(); - if let Category::Group(group) = &mut child { - group.show_label = true; + /// Returns this pivot table with the given `show_title`. + pub fn with_show_title(self, show_title: bool) -> Self { + Self { + style: self.style.with_show_title(show_title), + ..self } - self.len += child.len(); - self.children.push(child); } - pub fn with(mut self, child: impl Into) -> Self { - self.push(child); - self + /// Returns this pivot table with the given `show_caption`. + pub fn with_show_caption(self, show_caption: bool) -> Self { + Self { + style: self.style.with_show_caption(show_caption), + ..self + } } - pub fn with_multiple(mut self, children: impl IntoIterator) -> Self - where - C: Into, - { - self.extend(children); + /// Returns this pivot table with its [Look] modified to show empty rows and + /// columns. + pub fn with_show_empty(mut self) -> Self { + if self.style.look.hide_empty { + self.look_mut().hide_empty = false; + } self } - pub fn with_label_shown(self) -> Self { - self.with_show_label(true) + /// Returns this pivot table with its [Look] modified to hide empty rows and + /// columns. + pub fn with_hide_empty(mut self) -> Self { + if !self.style.look.hide_empty { + self.look_mut().hide_empty = true; + } + self } - pub fn with_show_label(mut self, show_label: bool) -> Self { - self.show_label = show_label; + /// Returns this pivot table with the current layer set to `layer` and its + /// look modified (if necessary) to print just a single layer. + pub fn with_layer(mut self, layer: &[usize]) -> Self { + // XXX verify that `layer` is valid + debug_assert_eq!(layer.len(), self.layer.len()); + if self.style.look.print_all_layers { + self.style.look_mut().print_all_layers = false; + } + self.layer.clear(); + self.layer.extend_from_slice(layer); self } - pub fn nth_leaf(&self, mut index: usize) -> Option<&Leaf> { - for child in &self.children { - let len = child.len(); - if index < len { - return child.nth_leaf(index); - } - index -= len; + /// Returns this pivot table set to print all layers. + pub fn with_all_layers(mut self) -> Self { + if !self.style.look.print_all_layers { + self.look_mut().print_all_layers = true; } - None + self } - pub fn leaf_path<'a>(&'a self, mut index: usize, mut groups: GroupVec<'a>) -> Option> { - for child in &self.children { - let len = child.len(); - if index < len { - groups.push(self); - return child.leaf_path(index, groups); - } - index -= len; - } - None - } - - pub fn len(&self) -> usize { - self.len - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - pub fn name(&self) -> &Value { - &self.name + /// Inserts `number` into the cell with the given `data_indexes`, drawing + /// its format from `class`. + 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 => 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_number(number) + .with_format(format) + .with_honor_small(class == Class::Other); + self.insert(data_indexes, value); } -} -impl Extend for Group -where - C: Into, -{ - fn extend>(&mut self, children: T) { - let children = children.into_iter(); - self.children.reserve(children.size_hint().0); - for child in children { - self.push(child); + /// Returns an iterator for all the values along `axis`. + fn axis_values(&self, axis: Axis3) -> AxisIterator { + AxisIterator { + indexes: repeat_n(0, self.axes[axis].dimensions.len()).collect(), + lengths: self.axis_dimensions(axis).map(|d| d.len()).collect(), + done: self.axis_extent(axis) == 0, } } -} - -#[derive(Clone, Debug, Default, Serialize)] -pub struct Footnotes(pub Vec>); - -impl Footnotes { - pub fn new() -> Self { - Self::default() - } - - pub fn push(&mut self, footnote: Footnote) -> Arc { - let footnote = Arc::new(footnote.with_index(self.0.len())); - self.0.push(footnote.clone()); - footnote - } - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -#[derive(Clone, Debug)] -pub struct Leaf { - name: Box, -} - -impl Leaf { - pub fn new(name: Value) -> Self { - Self { - name: Box::new(name), - } - } - pub fn name(&self) -> &Value { - &self.name + fn axis_extent(&self, axis: Axis3) -> usize { + self.axis_dimensions(axis).map(|d| d.len()).product() } -} -impl Serialize for Leaf { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.name.serialize(serializer) + /// Returns the indexes for the layer to be printed or display on-screen. + pub fn layer(&self) -> &[usize] { + &self.layer } -} - -/// Pivot result classes. -/// -/// These are used to mark [Leaf] categories as having particular types of data, -/// to set their numeric formats. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Class { - Other, - Integer, - Correlations, - Significance, - Percent, - Residual, - Count, -} -/// A pivot_category is a leaf (a category) or a group. -#[derive(Clone, Debug, Serialize)] -pub enum Category { - Group(Group), - Leaf(Leaf), -} - -impl Category { - pub fn name(&self) -> &Value { - match self { - Category::Group(group) => &group.name, - Category::Leaf(leaf) => &leaf.name, - } + /// Returns the pivot table's dimensions. + pub fn dimensions(&self) -> &[Dimension] { + &self.dimensions } - pub fn is_empty(&self) -> bool { - self.len() == 0 + /// Returns the pivot table's axes. + pub fn axes(&self) -> &EnumMap { + &self.axes } - pub fn len(&self) -> usize { - match self { - Category::Group(group) => group.len, - Category::Leaf(_) => 1, - } + /// Returns the pivot table's [Look], for modification. + pub fn look_mut(&mut self) -> &mut Look { + self.style.look_mut() } - 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 - } - } + /// Returns a label for the table, which is either its title or a default + /// string. + pub fn label(&self) -> String { + match &self.metadata.title { + Some(title) => title.display(self).to_string(), + None => String::from("Table"), } } - 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 - } - } + /// Returns the table's title, or an empty value if it doesn't have one. + pub fn title(&self) -> &Value { + match &self.metadata.title { + Some(title) => title, + None => Value::static_empty(), } } - pub fn show_label(&self) -> bool { - match self { - Category::Group(group) => group.show_label, - Category::Leaf(_) => true, + /// Returns the table's subtype, or an empty value if it doesn't have one. + pub fn subtype(&self) -> &Value { + match &self.metadata.subtype { + Some(subtype) => subtype, + None => Value::static_empty(), } } -} - -impl From for Category { - fn from(group: Group) -> Self { - Self::Group(group) - } -} -impl From for Category { - fn from(group: Leaf) -> Self { - Self::Leaf(group) + /// Returns the `HashMap` for cells. Indexes in the map can be computed + /// with [cell_index](Self::cell_index). + pub fn cells(&self) -> &HashMap { + &self.cells } -} -impl From for Category { - fn from(name: Value) -> Self { - Leaf::new(name).into() - } -} - -impl From<&Variable> for Category { - fn from(variable: &Variable) -> Self { - Value::new_variable(variable).into() + /// Computes an index into the `cells` `HashMap` for `cell_index`. + pub fn cell_index(&self, cell_index: C) -> usize + where + C: CellIndex, + { + cell_index.cell_index(self.dimensions.iter().map(|d| d.len())) } -} -impl From<&str> for Category { - fn from(name: &str) -> Self { - Self::Leaf(Leaf::new(Value::new_text(name))) - } -} - -impl From for Category { - fn from(name: String) -> Self { - Self::Leaf(Leaf::new(Value::new_text(name))) + /// Inserts a cell with the given `value` and `cell_index`. + pub fn insert(&mut self, cell_index: C, value: impl Into) + where + C: CellIndex, + { + self.cells.insert(self.cell_index(cell_index), value.into()); } -} -impl From<&String> for Category { - fn from(name: &String) -> Self { - Self::Leaf(Leaf::new(Value::new_text(name))) + /// Returns the cell with the given `cell_index`, if there is one. + pub fn get(&self, cell_index: C) -> Option<&Value> + where + C: CellIndex, + { + self.cells.get(&self.cell_index(cell_index)) } -} - -/// Styling for a pivot table. -/// -/// 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)] -pub struct Look { - pub name: Option, - - /// Whether to hide rows or columns whose cells are all empty. - pub hide_empty: bool, - - pub row_label_position: LabelPosition, - - /// Ranges of column widths in the two heading regions, in 1/96" units. - pub heading_widths: EnumMap>, - - /// Kind of markers to use for footnotes. - pub footnote_marker_type: FootnoteMarkerType, - - /// Where to put the footnote markers. - pub footnote_marker_position: FootnoteMarkerPosition, - /// Styles for areas of the pivot table. - pub areas: EnumMap, - - /// Styles for borders in the pivot table. - pub borders: EnumMap, - - pub print_all_layers: bool, - - pub paginate_layers: bool, - - pub shrink_to_fit: EnumMap, - - pub top_continuation: bool, - - pub bottom_continuation: bool, - - pub continuation: Option, - - pub n_orphan_lines: usize, -} - -impl Look { - pub fn with_omit_empty(mut self, omit_empty: bool) -> Self { - self.hide_empty = omit_empty; - self - } - pub fn with_row_label_position(mut self, row_label_position: LabelPosition) -> Self { - self.row_label_position = row_label_position; - self - } - pub fn with_borders(mut self, borders: EnumMap) -> Self { - self.borders = borders; + /// Returns the pivot table with cell indexes and values from `iter` + /// inserted as data. + pub fn with_data(mut self, iter: impl IntoIterator) -> Self + where + C: CellIndex, + { + self.extend(iter); self } -} -impl Default for Look { - fn default() -> Self { - Self { - name: None, - hide_empty: true, - row_label_position: LabelPosition::default(), - heading_widths: EnumMap::from_fn(|region| match region { - HeadingRegion::Rows => 36..=72, - HeadingRegion::Columns => 36..=120, - }), - 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), - print_all_layers: false, - paginate_layers: false, - shrink_to_fit: EnumMap::from_fn(|_| false), - top_continuation: false, - bottom_continuation: false, - continuation: None, - n_orphan_lines: 0, - } - } -} - -#[derive(ThisError, Debug)] -pub enum ParseLookError { - #[error(transparent)] - XmlError(#[from] DeError), - - #[error(transparent)] - Utf8Error(#[from] Utf8Error), - - #[error(transparent)] - BinError(#[from] BinError), - - #[error(transparent)] - IoError(#[from] std::io::Error), -} - -impl Look { - pub fn shared_default() -> Arc { - static LOOK: OnceLock> = OnceLock::new(); - LOOK.get_or_init(|| Arc::new(Look::default())).clone() - } - - pub fn from_xml(xml: &str) -> Result { - Ok(from_str::(xml) - .map_err(ParseLookError::from)? - .into()) - } - - pub fn from_binary(tlo: &[u8]) -> Result { - parse_tlo(tlo).map_err(ParseLookError::from) - } - - pub fn from_data(data: &[u8]) -> Result { - if data.starts_with(b"\xff\xff\0\0") { - Self::from_binary(data) - } else { - Self::from_xml(from_utf8(data).map_err(ParseLookError::from)?) + /// Converts per-axis presentation-order indexes in `presentation_indexes`, + /// into data indexes for each dimension. + fn convert_indexes_ptod( + &self, + presentation_indexes: EnumMap, + ) -> SmallVec<[usize; 4]> { + let mut data_indexes = SmallVec::from_elem(0, self.dimensions.len()); + for (axis, presentation_indexes) in presentation_indexes { + for (&dim_index, &pindex) in self.axes[axis] + .dimensions + .iter() + .zip(presentation_indexes.iter()) + { + data_indexes[dim_index] = self.dimensions[dim_index].presentation_order[pindex]; + } } + data_indexes } - pub fn from_reader(mut reader: R) -> Result - where - R: Read, - { - let mut buffer = Vec::new(); - reader - .read_to_end(&mut buffer) - .map_err(ParseLookError::from)?; - Self::from_data(&buffer) - } -} - -/// Position for group labels. -#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -pub enum LabelPosition { - /// Hierarachically enclosing the categories. - /// - /// For column labels, group labels appear above the categories. For row - /// labels, group labels appear to the left of the categories. + /// Returns an iterator for the layer axis: /// - /// ```text - /// ┌────┬──────────────┐ ┌─────────┬──────────┐ - /// │ │ nested │ │ │ columns │ - /// │ ├────┬────┬────┤ ├──────┬──┼──────────┤ - /// │ │ a1 │ a2 │ a3 │ │ │a1│...data...│ - /// ├────┼────┼────┼────┤ │nested│a2│...data...│ - /// │ │data│data│data│ │ │a3│...data...│ - /// │ │ . │ . │ . │ └──────┴──┴──────────┘ - /// │rows│ . │ . │ . │ - /// │ │ . │ . │ . │ - /// └────┴────┴────┴────┘ - /// ``` - #[serde(rename = "nested")] - Nested, - - /// In the corner (row labels only). + /// - If `print` is true and `self.look.print_all_layers`, then the iterator + /// will visit all values of the layer axis. /// - /// ```text - /// ┌──────┬──────────┐ - /// │corner│ columns │ - /// ├──────┼──────────┤ - /// │ a1│...data...│ - /// │ a2│...data...│ - /// │ a3│...data...│ - /// └──────┴──────────┘ - /// ``` - #[default] - #[serde(rename = "inCorner")] - Corner, -} - -/// The heading region of a rendered pivot table: -/// -/// ```text -/// ┌──────────────────┬─────────────────────────────────────────────────┐ -/// │ │ column headings │ -/// │ ├─────────────────────────────────────────────────┤ -/// │ corner │ │ -/// │ and │ │ -/// │ row headings │ data │ -/// │ │ │ -/// │ │ │ -/// └──────────────────┴─────────────────────────────────────────────────┘ -/// ``` -#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum HeadingRegion { - Rows, - Columns, -} - -impl HeadingRegion { - pub fn as_str(&self) -> &'static str { - match self { - HeadingRegion::Rows => "rows", - HeadingRegion::Columns => "columns", + /// - Otherwise, the iterator will just visit `self.current_layer`. + pub fn layers(&self, print: bool) -> Box>> { + if print && self.style.look.print_all_layers { + Box::new(self.axis_values(Axis3::Z)) + } else { + Box::new(once(SmallVec::from_slice(&self.layer))) } } -} -impl Display for HeadingRegion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) + /// Transposes row and columns. + pub fn transpose(&mut self) { + self.axes.swap(Axis3::X, Axis3::Y); } -} -impl From for HeadingRegion { - fn from(axis: Axis2) -> Self { - match axis { - Axis2::X => HeadingRegion::Columns, - Axis2::Y => HeadingRegion::Rows, - } + /// Returns an iterator through dimensions on the given `axis`. + pub fn axis_dimensions( + &self, + axis: Axis3, + ) -> impl DoubleEndedIterator + ExactSizeIterator { + self.axes[axis] + .dimensions + .iter() + .copied() + .map(|index| &self.dimensions[index]) } -} - -#[derive(Clone, Debug, Serialize)] -pub struct AreaStyle { - pub cell_style: CellStyle, - pub font_style: FontStyle, -} - -#[derive(Clone, Debug, Serialize)] -pub struct CellStyle { - /// `None` means "mixed" alignment: align strings to the left, numbers to - /// the right. - pub horz_align: Option, - pub vert_align: VertAlign, - - /// Margins in 1/96" units. - /// - /// `margins[Axis2::X][0]` is the left margin. - /// `margins[Axis2::X][1]` is the right margin. - /// `margins[Axis2::Y][0]` is the top margin. - /// `margins[Axis2::Y][1]` is the bottom margin. - pub margins: EnumMap, -} - -#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum HorzAlign { - /// Right aligned. - Right, - - /// Left aligned. - Left, - - /// Centered. - Center, - /// Align the decimal point at the specified position. - Decimal { - /// Decimal offset from the right side of the cell, in 1/96" units. - offset: f64, - - /// Decimal character. - decimal: Decimal, - }, -} - -impl HorzAlign { - pub fn for_mixed(var_type: VarType) -> Self { - match var_type { - VarType::Numeric => Self::Right, - VarType::String => Self::Left, + fn find_dimension(&self, dim_index: usize) -> Option<(Axis3, usize)> { + debug_assert!(dim_index < self.dimensions.len()); + for axis in enum_iterator::all::() { + for (position, dimension) in self.axes[axis].dimensions.iter().copied().enumerate() { + if dimension == dim_index { + return Some((axis, position)); + } + } } + None } -} -#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum VertAlign { - /// Top alignment. - Top, - - /// Centered, - Middle, - - /// Bottom alignment. - Bottom, -} - -#[derive(Clone, Debug, 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. + /// Moves dimension with index `dim_index` from its current axis to + /// `new_axis` in position `new_position`. /// - /// `fg[1]` is used only in [Area::Data] for odd-numbered rows. - pub fg: [Color; 2], - - /// `bg[0]` is the usual background color. + /// `dim_index` is an overall dimension index, in the order passed to + /// [PivotTable::new] and returned by [PivotTable::dimensions]. This method + /// doesn't change these indexes. /// - /// `bg[1]` is used only in [Area::Data] for odd-numbered rows. - pub bg: [Color; 2], - - /// In 1/72" units. - pub size: i32, -} - -#[derive(Copy, Clone, PartialEq, Eq)] -pub struct Color { - pub alpha: u8, - pub r: u8, - pub g: u8, - pub b: u8, -} - -impl Color { - pub const BLACK: Color = Color::new(0, 0, 0); - pub const WHITE: Color = Color::new(255, 255, 255); - pub const RED: Color = Color::new(255, 0, 0); - pub const BLUE: Color = Color::new(0, 0, 255); - pub const TRANSPARENT: Color = Color::new(0, 0, 0).with_alpha(0); - - pub const fn new(r: u8, g: u8, b: u8) -> Self { - Self { - alpha: 255, - r, - g, - b, + /// `new_position` is an index within `new_axis`, in the range `0..n` where + /// `n` is the final number of dimensions along that axis. + /// + /// # Panic + /// + /// Panics if `dim_index` or `new_position` is outside the valid range. + pub fn move_dimension(&mut self, dim_index: usize, new_axis: Axis3, new_position: usize) { + let (old_axis, old_position) = self.find_dimension(dim_index).unwrap(); + if old_axis == new_axis && old_position == new_position { + return; } - } - - pub const fn with_alpha(self, alpha: u8) -> Self { - Self { alpha, ..self } - } - pub const fn without_alpha(self) -> Self { - self.with_alpha(255) - } - - pub fn display_css(&self) -> DisplayCss { - DisplayCss(*self) - } -} - -impl Debug for Color { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.display_css()) - } -} - -impl From for Color { - fn from(Rgba8 { r, g, b, a }: Rgba8) -> Self { - Self::new(r, g, b).with_alpha(a) - } -} - -impl FromStr for Color { - type Err = ParseColorError; - - fn from_str(s: &str) -> Result { - fn is_bare_hex(s: &str) -> bool { - let s = s.trim(); - 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) + // Update the current layer, if necessary. If we're moving within the + // layer axis, preserve the current layer. + match (old_axis, new_axis) { + (Axis3::Z, Axis3::Z) => { + // Rearrange the layer axis. + if old_position < new_position { + self.layer[old_position..=new_position].rotate_left(1); + } else { + self.layer[new_position..=old_position].rotate_right(1); + } } - other => other, - }?; - Ok(color.to_rgba8().into()) - } -} - -impl Serialize for Color { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.display_css().to_small_string::<32>()) - } -} - -impl<'de> Deserialize<'de> for Color { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct ColorVisitor; - - impl<'de> Visitor<'de> for ColorVisitor { - type Value = Color; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("\"#rrggbb\" or \"rrggbb\" or web color name") + (Axis3::Z, _) => { + // A layer is becoming a row or column. + self.layer.remove(old_position); } - - fn visit_borrowed_str(self, v: &'de str) -> Result - where - E: serde::de::Error, - { - v.parse().map_err(E::custom) + (_, Axis3::Z) => { + // A row or column is becoming a layer. + self.layer.insert(new_position, 0); } + _ => (), } - deserializer.deserialize_str(ColorVisitor) - } -} - -pub struct DisplayCss(Color); - -impl Display for DisplayCss { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Color { alpha, r, g, b } = self.0; - match alpha { - 255 => write!(f, "#{r:02x}{g:02x}{b:02x}"), - _ => write!(f, "rgb({r}, {g}, {b}, {:.2})", alpha as f64 / 255.0), - } - } -} - -#[derive(Copy, Clone, Debug, Deserialize)] -pub struct BorderStyle { - #[serde(rename = "@borderStyleType")] - pub stroke: Stroke, - - #[serde(rename = "@color")] - pub color: Color, -} - -impl Serialize for BorderStyle { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut s = serializer.serialize_struct("BorderStyle", 2)?; - s.serialize_field("stroke", &self.stroke)?; - s.serialize_field("color", &self.color)?; - s.end() + self.axes[old_axis].dimensions.remove(old_position); + self.axes[new_axis] + .dimensions + .insert(new_position, dim_index); } } -impl BorderStyle { - pub const fn none() -> Self { - Self { - stroke: Stroke::None, - color: Color::BLACK, - } - } - - pub fn is_none(&self) -> bool { - self.stroke.is_none() - } - - /// Returns a border style that "combines" the two arguments, that is, that - /// gives a reasonable choice for a rule for different reasons should have - /// both styles. - pub fn combine(self, other: BorderStyle) -> Self { - Self { - stroke: self.stroke.combine(other.stroke), - color: self.color, +impl From<&PivotTable> for ValueOptions { + fn from(value: &PivotTable) -> Self { + ValueOptions { + show_values: value.style.show_values, + show_variables: value.style.show_variables, + small: value.style.small, + footnote_marker_type: value.style.look.footnote_marker_type, + settings: value.style.settings.clone(), } } } -#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum Stroke { - None, - Solid, - Dashed, - Thick, - Thin, - Double, -} - -impl Stroke { - pub fn is_none(&self) -> bool { - self == &Self::None - } - - /// Returns a stroke that "combines" the two arguments, that is, that gives - /// a reasonable stroke choice for a rule for different reasons should have - /// both styles. - pub fn combine(self, other: Stroke) -> Self { - self.max(other) - } -} - -/// An axis of a 2-dimensional table. -#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Axis2 { - X, - Y, -} +/// A dimension. +/// +/// A [Dimension] identifies the categories associated with a single dimension +/// within a multidimensional pivot table. +/// +/// A dimension contains a collection of categories, which are the leaves in a +/// tree of groups. +/// +/// (A dimension or a group can contain zero categories, but this is unusual. +/// If a dimension contains no categories, then its table cannot contain any +/// data.) +#[derive(Clone, Debug, Serialize)] +pub struct Dimension { + /// Hierarchy of categories within the dimension. The groups and categories + /// are sorted in the order that should be used for display. This might be + /// different from the original order produced for output if the user + /// adjusted it. + /// + /// The root must always be a group, although it is allowed to have no + /// subcategories. + pub root: Group, -impl Axis2 { - pub fn new_enum(x: T, y: T) -> EnumMap { - EnumMap::from_array([x, y]) - } + /// Ordering of leaves for presentation. + /// + /// This is a permutation of `0..n` where `n` is the number of leaves. It + /// maps from an index in presentation order to an index in data order. + pub presentation_order: Vec, - pub fn as_str(&self) -> &'static str { - match self { - Axis2::X => "x", - Axis2::Y => "y", - } - } + /// Display. + pub hide_all_labels: bool, } -impl Display for Axis2 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} +/// A vector of references to [Group]s. +/// +/// Used to represent a sequence of groups along a [Path]. This is a [SmallVec] +/// because groups are usually not deeply nested. +pub type GroupVec<'a> = SmallVec<[&'a Group; 4]>; -impl Not for Axis2 { - type Output = Self; +/// A path from the root of a [Dimension] to a [Leaf]. +pub struct Path<'a> { + /// Groups along the path. + groups: GroupVec<'a>, - fn not(self) -> Self::Output { - match self { - Self::X => Self::Y, - Self::Y => Self::X, - } - } + /// The leaf. + /// + /// This is a child of the last [Group]. + leaf: &'a Leaf, } -/// A 2-dimensional `(x,y)` pair. -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] -pub struct Coord2(pub EnumMap); - -impl Coord2 { - pub fn new(x: usize, y: usize) -> Self { - use Axis2::*; - Self(enum_map! { - X => x, - Y => y - }) - } - - pub fn for_axis((a, az): (Axis2, usize), bz: usize) -> Self { - let mut coord = Self::default(); - coord[a] = az; - coord[!a] = bz; - coord - } +/// Group indexes visited along a [Path]. +pub type IndexVec = SmallVec<[usize; 4]>; - pub fn from_fn(f: F) -> Self - where - F: FnMut(Axis2) -> usize, - { - Self(EnumMap::from_fn(f)) +impl Dimension { + /// Constructs a new [Dimension] with the given `root`. + pub fn new(root: Group) -> Self { + Dimension { + presentation_order: (0..root.len()).collect(), + root, + hide_all_labels: false, + } } - pub fn x(&self) -> usize { - self.0[Axis2::X] + /// Returns this dimension with [Dimension::hide_all_labels] set to true. + pub fn with_all_labels_hidden(self) -> Self { + Self { + hide_all_labels: true, + ..self + } } - pub fn y(&self) -> usize { - self.0[Axis2::Y] + /// Returns true if [Dimension] has no leaf categories. + /// + /// A dimension without leaf categories might still contain a hierarchy of + /// groups, just none of them with leaves. + pub fn is_empty(&self) -> bool { + self.len() == 0 } - pub fn get(&self, axis: Axis2) -> usize { - self.0[axis] + /// Returns the number of leaf categories in this dimension. + pub fn len(&self) -> usize { + self.root.len() } -} -impl From> for Coord2 { - fn from(value: EnumMap) -> Self { - Self(value) + /// Returns the leaf with the given 0-based `index`, or `None` if `index >= + /// self.len()`. + pub fn nth_leaf(&self, index: usize) -> Option<&Leaf> { + self.root.nth_leaf(index) } -} - -impl Index for Coord2 { - type Output = usize; - fn index(&self, index: Axis2) -> &Self::Output { - &self.0[index] + /// Returns the path to the leaf with the given 0-based `index`, or `None` + /// if `index >= self.len()`. + pub fn leaf_path(&self, index: usize) -> Option> { + self.root.leaf_path(index, SmallVec::new()) } -} -impl IndexMut for Coord2 { - fn index_mut(&mut self, index: Axis2) -> &mut Self::Output { - &mut self.0[index] + /// Returns the series of group child indexes followed to the leaf with the + /// given 0-based `index`, or `None` if `index >= self.len()`. + pub fn index_path(&self, index: usize) -> Option { + self.root.index_path(index, SmallVec::new()) } } -#[derive(Clone, Debug, Default)] -pub struct Rect2(pub EnumMap>); +/// Specifies how to find a [Category] within a [Group]. +#[derive(Copy, Clone, Debug)] +pub struct CategoryLocator { + /// The index of the leaf to start from. + pub leaf_index: usize, -impl Rect2 { - pub fn new(x_range: Range, y_range: Range) -> Self { - Self(enum_map! { - Axis2::X => x_range.clone(), - Axis2::Y => y_range.clone(), - }) - } - pub fn for_cell(cell: Coord2) -> Self { - Self::new(cell.x()..cell.x() + 1, cell.y()..cell.y() + 1) - } - pub fn for_ranges((a, a_range): (Axis2, Range), b_range: Range) -> Self { - let b = !a; - let mut ranges = EnumMap::default(); - ranges[a] = a_range; - ranges[b] = b_range; - Self(ranges) - } - pub fn top_left(&self) -> Coord2 { - use Axis2::*; - Coord2::new(self[X].start, self[Y].start) - } - pub fn from_fn(f: F) -> Self - where - F: FnMut(Axis2) -> Range, - { - Self(EnumMap::from_fn(f)) - } - pub fn translate(self, offset: Coord2) -> Rect2 { - Self::from_fn(|axis| self[axis].start + offset[axis]..self[axis].end + offset[axis]) - } - pub fn is_empty(&self) -> bool { - self[Axis2::X].is_empty() || self[Axis2::Y].is_empty() - } + /// 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 From>> for Rect2 { - fn from(value: EnumMap>) -> Self { - Self(value) +impl CategoryLocator { + /// Constructs a `CategoryLocator` for the leaf with the given 0-based + /// index. + pub fn new_leaf(leaf_index: usize) -> Self { + Self { + leaf_index, + level: 0, + } } -} - -impl Index for Rect2 { - type Output = Range; - fn index(&self, index: Axis2) -> &Self::Output { - &self.0[index] + /// Returns a `CategoryLocator` for the parent category of this one. + pub fn parent(&self) -> Self { + Self { + leaf_index: self.leaf_index, + level: self.level + 1, + } } -} -impl IndexMut for Rect2 { - fn index_mut(&mut self, index: Axis2) -> &mut Self::Output { - &mut self.0[index] + /// If this is a leaf, returns its 0-based index; otherwise, returns `None`. + pub fn as_leaf(&self) -> Option { + (self.level == 0).then_some(self.leaf_index) } } -#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum FootnoteMarkerType { - /// a, b, c, ... - #[default] - Alphabetic, +/// A group of categories within a dimension. +#[derive(Clone, Debug, Serialize)] +pub struct Group { + /// Number of leaves contained by this group. + #[serde(skip)] + len: usize, - /// 1, 2, 3, ... - Numeric, -} + /// The label displayed for the group. + pub name: Box, -#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum FootnoteMarkerPosition { - /// Subscripts. - #[default] - Subscript, + /// Whether to show the label. + pub show_label: bool, - /// Superscripts. - Superscript, + /// The child categories. + /// + /// A group usually has multiple children, but it is allowed to have + /// only one or even (pathologically) none. + pub children: Vec, } -#[derive(Copy, Clone, Debug)] -pub struct ValueOptions { - pub show_values: Option, - - pub show_variables: Option, - - pub small: f64, - - /// Where to put the footnote markers. - pub footnote_marker_type: FootnoteMarkerType, -} +impl Group { + /// Constructs a new `Group` with the given name. The group initially has + /// no children. + pub fn new(name: impl Into) -> Self { + Self::with_capacity(name, 0) + } -impl Default for ValueOptions { - fn default() -> Self { + /// Constructs a new `Group` with the given name and initial child + /// `capacity`. The group initially has no children. + pub fn with_capacity(name: impl Into, capacity: usize) -> Self { Self { - show_values: None, - show_variables: None, - small: 0.0001, - footnote_marker_type: FootnoteMarkerType::default(), + len: 0, + name: Box::new(name.into()), + children: Vec::with_capacity(capacity), + show_label: false, } } -} -pub trait IntoValueOptions { - fn into_value_options(self) -> ValueOptions; -} + /// Appends `child` to the group's categories. + pub fn push(&mut self, child: impl Into) { + let mut child = child.into(); + if let Some(group) = child.as_group_mut() { + group.show_label = true; + } + self.len += child.len(); + self.children.push(child); + } -impl IntoValueOptions for () { - fn into_value_options(self) -> ValueOptions { - ValueOptions::default() + /// Returns this group with the given `child` appended to its categories. + pub fn with(mut self, child: impl Into) -> Self { + self.push(child); + self } -} -impl IntoValueOptions for &PivotTable { - fn into_value_options(self) -> ValueOptions { - self.value_options() + /// Returns this group with the given `children` appended to its categories. + pub fn with_multiple(mut self, children: impl IntoIterator) -> Self + where + C: Into, + { + self.extend(children); + self } -} -impl IntoValueOptions for &ValueOptions { - fn into_value_options(self) -> ValueOptions { - *self + /// Returns this group with `show_label` set to true. + pub fn with_label_shown(self) -> Self { + self.with_show_label(true) } -} -impl IntoValueOptions for ValueOptions { - fn into_value_options(self) -> ValueOptions { + /// Returns this group with `show_label` set as specified. + pub fn with_show_label(mut self, show_label: bool) -> Self { + self.show_label = show_label; self } -} - -#[derive(Clone, Debug, Serialize)] -pub struct PivotTable { - pub look: Arc, - - pub rotate_inner_column_labels: bool, - pub rotate_outer_row_labels: bool, + /// Returns the leaf with the given 0-based `index`, or `None` if `index >= + /// self.len()`. + fn nth_leaf(&self, mut index: usize) -> Option<&Leaf> { + for child in &self.children { + let len = child.len(); + if index < len { + return child.nth_leaf(index); + } + index -= len; + } + None + } - pub show_grid_lines: bool, + /// Returns the path to the leaf with the given 0-based `index`, or `None` + /// if `index >= self.len()`. `groups` must be the groups already traversed + /// to arrive at this group. + fn leaf_path<'a>(&'a self, mut index: usize, mut groups: GroupVec<'a>) -> Option> { + for child in &self.children { + let len = child.len(); + if index < len { + groups.push(self); + return child.leaf_path(index, groups); + } + index -= len; + } + None + } - pub show_title: bool, + 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 + } - pub show_caption: bool, + 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) + } - pub show_values: Option, + /// Returns the category corresponding to `locator`. 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 show_variables: Option, + /// Returns the category corresponding to `locator`. 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_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 weight_format: Format, + /// Returns the number of leaves contained within this group. + pub fn len(&self) -> usize { + self.len + } - /// 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, + /// Returns true if this group contains no leaves. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } - /// Column and row sizing and page breaks. - pub sizing: EnumMap>>, + /// Returns this group's label. + pub fn name(&self) -> &Value { + &self.name + } +} - /// Format settings. - pub settings: FormatSettings, +impl Extend for Group +where + C: Into, +{ + fn extend>(&mut self, children: T) { + let children = children.into_iter(); + self.children.reserve(children.size_hint().0); + for child in children { + self.push(child); + } + } +} - /// Numeric grouping character (usually `.` or `,`). - pub grouping: Option, +/// A collection of [Footnote]s for a [PivotTable]. +/// +/// Any [Value] in a pivot table can refer to a footnote. All of the footnotes +/// used in any [Value] within a given pivot table must be collected into the +/// [Footnotes] attached to the pivot table. (Footnotes used but not collected +/// might not display as intended, but it's not a safety issue.) +#[derive(Clone, Debug, Default, Serialize)] +pub struct Footnotes(Vec>); - pub small: f64, +impl Footnotes { + /// Constructs a new, empty collection of footnotes. + pub fn new() -> Self { + Self::default() + } - pub command_local: Option, - pub command_c: Option, - pub language: Option, - pub locale: Option, - 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, -} + /// Returns the number of footnotes in the collection. + pub fn len(&self) -> usize { + self.0.len() + } -impl PivotTable { - pub fn with_title(mut self, title: impl Into) -> Self { - self.title = Some(Box::new(title.into())); - self.show_title = true; - self + /// Returns true if the collection contains no footnotes. + pub fn is_empty(&self) -> bool { + self.0.is_empty() } - pub fn with_caption(mut self, caption: impl Into) -> Self { - self.caption = Some(Box::new(caption.into())); - self.show_caption = true; - self + /// Adds `footnote` to the collection. + pub fn push(&mut self, footnote: Footnote) -> Arc { + let footnote = Arc::new(footnote.with_index(self.0.len())); + self.0.push(footnote.clone()); + footnote } - pub fn with_corner_text(mut self, corner_text: impl Into) -> Self { - self.corner_text = Some(Box::new(corner_text.into())); - self + /// Returns the footnote with 0-based index `index`, or `None` if `index >= + /// self.len()`. + pub fn get(&self, index: usize) -> Option<&Arc> { + self.0.get(index) } +} - pub fn with_subtype(self, subtype: impl Into) -> Self { - Self { - subtype: Some(Box::new(subtype.into())), - ..self - } +impl Index for Footnotes { + type Output = Arc; + + fn index(&self, index: usize) -> &Self::Output { + &self.0[index] } +} - pub fn with_show_title(mut self, show_title: bool) -> Self { - self.show_title = show_title; - self +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() } +} - pub fn with_show_caption(mut self, show_caption: bool) -> Self { - self.show_caption = show_caption; - self +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(), + ) } +} - 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; - } - self.current_layer.clear(); - self.current_layer.extend_from_slice(layer); - self +/// A leaf category within a [Group] and ultimately within a [Dimension]. +#[derive(Clone, Debug)] +pub struct Leaf(pub Box); + +impl Leaf { + /// Constructs a new `Leaf` with the given label. + pub fn new(name: Value) -> Self { + Self(Box::new(name)) } - pub fn with_all_layers(mut self) -> Self { - if !self.look.print_all_layers { - self.look_mut().print_all_layers = true; - } - self + /// Returns the leaf's label. + pub fn name(&self) -> &Value { + &self.0 } - pub fn look_mut(&mut self) -> &mut Look { - Arc::make_mut(&mut self.look) + /// Returns a sequence of `Leaf`s for the given range, for use with + /// [Group::with_multiple] or [Group::extend]. + /// + /// This is useful for constructing dimensions that aren't meant to be + /// shown. + pub fn numbers(range: Range) -> impl Iterator { + range.map(|i| Self::new(Value::new_integer(Some(i as f64)))) } +} - pub fn with_show_empty(mut self) -> Self { - if self.look.hide_empty { - self.look_mut().hide_empty = false; - } - self +impl Serialize for Leaf { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) } +} - pub fn with_hide_empty(mut self) -> Self { - if !self.look.hide_empty { - self.look_mut().hide_empty = true; +/// Pivot result classes. +/// +/// Used by [PivotTable::insert_number]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Class { + /// An integer value. + Integer, + /// A correlation value. + Correlations, + /// A measure of significance. + Significance, + /// A percentage. + Percent, + /// A residual value. + Residual, + /// A count. + /// + /// With a weighted dataset, these are by default displayed with the weight + /// variable's format. + Count, + /// A value that doesn't fit in one of the other categories. + Other, +} + +/// A leaf category or a group of them. +#[derive(Clone, Debug, Serialize)] +pub enum Category { + /// A group. + Group( + /// The group. + Group, + ), + /// A leaf. + Leaf( + /// The leaf. + Leaf, + ), +} + +impl Category { + /// Returns the [Group] in this `Category`, if there is one. + pub fn as_group(&self) -> Option<&Group> { + match self { + Category::Group(group) => Some(group), + Category::Leaf(_) => None, } - self } - pub fn label(&self) -> String { - match &self.title { - Some(title) => title.display(self).to_string(), - None => String::from("Table"), + /// Returns the [Group] in this `Category`, if there is one, for + /// modification. + pub fn as_group_mut(&mut self) -> Option<&mut Group> { + match self { + Category::Group(group) => Some(group), + Category::Leaf(_) => None, } } - pub fn title(&self) -> &Value { - match &self.title { - Some(title) => title, - None => { - static EMPTY: Value = Value::empty(); - &EMPTY - } + /// Returns the [Leaf] in this `Category`, if there is one. + pub fn as_leaf(&self) -> Option<&Leaf> { + match self { + Category::Leaf(leaf) => Some(leaf), + Category::Group(_) => None, } } - pub fn subtype(&self) -> &Value { - match &self.subtype { - Some(subtype) => subtype, - None => { - static EMPTY: Value = Value::empty(); - &EMPTY - } + /// Returns the [Leaf] in this `Category`, if there is one, for + /// modification. + pub fn as_leaf_mut(&mut self) -> Option<&mut Leaf> { + match self { + Category::Leaf(leaf) => Some(leaf), + Category::Group(_) => None, } } -} -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, - 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(), + /// Returns the category's label. + pub fn name(&self) -> &Value { + match self { + Category::Group(group) => &group.name, + Category::Leaf(leaf) => &leaf.0, } } -} -fn cell_index(data_indexes: &[usize], dimensions: I) -> usize -where - I: ExactSizeIterator, -{ - debug_assert_eq!(data_indexes.len(), dimensions.len()); - let mut index = 0; - for (dimension, data_index) in dimensions.zip(data_indexes.iter()) { - debug_assert!(*data_index < dimension); - index = dimension * index + data_index; + /// Returns the category's label, for modification. + pub fn name_mut(&mut self) -> &mut Value { + match self { + Category::Group(group) => &mut group.name, + Category::Leaf(leaf) => &mut leaf.0, + } } - index -} -impl PivotTable { - pub fn new(axes_and_dimensions: impl IntoIterator) -> Self { - let mut dimensions = Vec::new(); - let mut axes = EnumMap::::default(); - for (axis, dimension) in axes_and_dimensions { - axes[axis].dimensions.push(dimensions.len()); - dimensions.push(dimension); - } - Self { - look: Settings::global().look.clone(), - current_layer: repeat_n(0, axes[Axis3::Z].dimensions.len()).collect(), - axes, - dimensions, - ..Self::default() + /// Returns true if this category's label should be shown. + /// + /// A leaf category's label is always shown, unless + /// [Dimension::hide_all_labels] is true. + pub fn show_label(&self) -> bool { + match self { + Category::Group(group) => group.show_label, + Category::Leaf(_) => true, } } - fn cell_index(&self, data_indexes: &[usize]) -> usize { - cell_index(data_indexes, self.dimensions.iter().map(|d| d.len())) + /// Returns true if the category contains no leaves. + /// + /// If this is a leaf category, this always returns false. + pub fn is_empty(&self) -> bool { + self.len() == 0 } - pub fn insert(&mut self, data_indexes: &[usize], value: impl Into) { - self.cells - .insert(self.cell_index(data_indexes), value.into()); + /// Returns the number of leaves that this category contains. + pub fn len(&self) -> usize { + match self { + Category::Group(group) => group.len, + Category::Leaf(_) => 1, + } } - pub fn get(&self, data_indexes: &[usize]) -> Option<&Value> { - self.cells.get(&self.cell_index(data_indexes)) + 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), + _ => None, + } } - pub fn with_data(mut self, iter: impl IntoIterator) -> Self - where - I: AsRef<[usize]>, - { - self.extend(iter); - self + 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 }), + _ => None, + } } - /// Converts per-axis presentation-order indexes in `presentation_indexes`, - /// into data indexes for each dimension. - fn convert_indexes_ptod( - &self, - presentation_indexes: EnumMap, - ) -> SmallVec<[usize; 4]> { - let mut data_indexes = SmallVec::from_elem(0, self.dimensions.len()); - for (axis, presentation_indexes) in presentation_indexes { - for (&dim_index, &pindex) in self.axes[axis] - .dimensions - .iter() - .zip(presentation_indexes.iter()) - { - data_indexes[dim_index] = self.dimensions[dim_index].presentation_order[pindex]; - } + 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, } - data_indexes } - /// Returns an iterator for the layer axis: - /// - /// - If `print` is true and `self.look.print_all_layers`, then the iterator - /// will visit all values of the layer axis. - /// - /// - Otherwise, the iterator will just visit `self.current_layer`. - pub fn layers(&self, print: bool) -> Box>> { - if print && self.look.print_all_layers { - Box::new(self.axis_values(Axis3::Z)) - } else { - Box::new(once(SmallVec::from_slice(&self.current_layer))) + 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). + 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 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, + 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 transpose(&mut self) { - self.axes.swap(Axis3::X, Axis3::Y); +impl From for Category { + fn from(group: Group) -> Self { + Self::Group(group) } +} - pub fn axis_dimensions( - &self, - axis: Axis3, - ) -> impl DoubleEndedIterator + ExactSizeIterator { - self.axes[axis] - .dimensions - .iter() - .copied() - .map(|index| &self.dimensions[index]) +impl From for Category { + fn from(group: Leaf) -> Self { + Self::Leaf(group) } +} - fn find_dimension(&self, dim_index: usize) -> Option<(Axis3, usize)> { - debug_assert!(dim_index < self.dimensions.len()); - for axis in enum_iterator::all::() { - for (position, dimension) in self.axes[axis].dimensions.iter().copied().enumerate() { - if dimension == dim_index { - return Some((axis, position)); - } - } - } - None +impl From for Category { + fn from(name: Value) -> Self { + Leaf::new(name).into() } - pub fn move_dimension(&mut self, dim_index: usize, new_axis: Axis3, new_position: usize) { - let (old_axis, old_position) = self.find_dimension(dim_index).unwrap(); - if old_axis == new_axis && old_position == new_position { - return; - } +} - // Update the current layer, if necessary. If we're moving within the - // layer axis, preserve the current layer. - match (old_axis, new_axis) { - (Axis3::Z, Axis3::Z) => { - // Rearrange the layer axis. - if old_position < new_position { - self.current_layer[old_position..=new_position].rotate_left(1); - } else { - self.current_layer[new_position..=old_position].rotate_right(1); - } - } - (Axis3::Z, _) => { - // A layer is becoming a row or column. - self.current_layer.remove(old_position); - } - (_, Axis3::Z) => { - // A row or column is becoming a layer. - self.current_layer.insert(new_position, 0); - } - _ => (), - } +impl From<&Variable> for Category { + fn from(variable: &Variable) -> Self { + Value::new_variable(variable).into() + } +} - self.axes[old_axis].dimensions.remove(old_position); - self.axes[new_axis] - .dimensions - .insert(new_position, dim_index); +impl From<&str> for Category { + fn from(name: &str) -> Self { + Self::Leaf(Leaf::new(Value::new_text(name))) + } +} + +impl From for Category { + fn from(name: String) -> Self { + Self::Leaf(Leaf::new(Value::new_text(name))) + } +} + +impl From<&String> for Category { + fn from(name: &String) -> Self { + Self::Leaf(Leaf::new(Value::new_text(name))) } } -impl Extend<(I, Value)> for PivotTable -where - I: AsRef<[usize]>, -{ - fn extend>(&mut self, iter: T) { - for (data_indexes, value) in iter { - self.insert(data_indexes.as_ref(), value); +/// An axis of a 2-dimensional table. +#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Axis2 { + /// X axis. + X, + /// Y axis. + Y, +} + +impl Axis2 { + /// Returns a map from [Axis2::X] to `x` and from [Axis2::Y] to `y`. + pub fn new_enum(x: T, y: T) -> EnumMap { + EnumMap::from_array([x, y]) + } + + /// Returns the axis's name, in lowercase, as a static string. + pub fn as_str(&self) -> &'static str { + match self { + Axis2::X => "x", + Axis2::Y => "y", } } } -#[derive(Clone, Debug, Serialize)] -pub struct Footnote { - #[serde(skip)] - index: usize, - pub content: Box, - pub marker: Option>, - pub show: bool, +impl Display for Axis2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } } -impl Footnote { - pub fn new(content: impl Into) -> Self { - Self { - index: 0, - content: Box::new(content.into()), - marker: None, - show: true, +impl Not for Axis2 { + type Output = Self; + + fn not(self) -> Self::Output { + match self { + Self::X => Self::Y, + Self::Y => Self::X, } } - pub fn with_marker(mut self, marker: impl Into) -> Self { - self.marker = Some(Box::new(marker.into())); - self - } +} - pub fn with_show(mut self, show: bool) -> Self { - self.show = show; - self +/// Error converting [Axis3::Z] to [Axis2]. +#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("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), + } } +} - pub fn with_index(mut self, index: usize) -> Self { - self.index = index; - self +/// A 2-dimensional `(x,y)` pair. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct Coord2(pub EnumMap); + +impl Coord2 { + /// Constructs a new `Coord2` with the given `x` and `y` coordinates. + pub fn new(x: isize, y: isize) -> Self { + use Axis2::*; + Self(enum_map! { + X => x, + Y => y + }) } - pub fn display_marker(&self, options: impl IntoValueOptions) -> DisplayMarker<'_> { - DisplayMarker { - footnote: self, - options: options.into_value_options(), - } + /// Constructs a new `Coord2` with coordinate `az` on axis `a` and + /// coordinate `bz` on the other axis. + pub fn for_axis((a, az): (Axis2, isize), bz: isize) -> Self { + let mut coord = Self::default(); + coord[a] = az; + coord[!a] = bz; + coord } - pub fn display_content(&self, options: impl IntoValueOptions) -> DisplayValue<'_> { - self.content.display(options) + /// Constructs a new `Coord2` with coordinates from function `f`. + pub fn from_fn(f: F) -> Self + where + F: FnMut(Axis2) -> isize, + { + Self(EnumMap::from_fn(f)) } - pub fn index(&self) -> usize { - self.index + /// Returns the X coordinate. + pub fn x(&self) -> isize { + self.0[Axis2::X] } -} -pub struct DisplayMarker<'a> { - footnote: &'a Footnote, - options: ValueOptions, -} + /// Returns the Y coordinate. + pub fn y(&self) -> isize { + self.0[Axis2::Y] + } -impl Display for DisplayMarker<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(marker) = &self.footnote.marker { - write!(f, "{}", marker.display(self.options).without_suffixes()) - } else { - let i = self.footnote.index + 1; - match self.options.footnote_marker_type { - FootnoteMarkerType::Alphabetic => write!(f, "{}", Display26Adic::new_lowercase(i)), - FootnoteMarkerType::Numeric => write!(f, "{i}"), - } - } + /// Returns the coordinate on the given `axis`. + pub fn get(&self, axis: Axis2) -> isize { + self.0[axis] } } -/// Displays a number in 26adic notation. -/// -/// Zero is displayed as the empty string, 1 through 26 as `a` through `z`, 27 -/// through 52 as `aa` through `az`, and so on. -pub struct Display26Adic { - value: usize, - base: u8, +impl From> for Coord2 { + fn from(value: EnumMap) -> Self { + Self(value) + } } -impl Display26Adic { - /// Constructs a `Display26Adic` for `value`, with letters in lowercase. - pub fn new_lowercase(value: usize) -> Self { - Self { value, base: b'a' } - } +impl Index for Coord2 { + type Output = isize; - /// Constructs a `Display26Adic` for `value`, with letters in uppercase. - pub fn new_uppercase(value: usize) -> Self { - Self { value, base: b'A' } + fn index(&self, index: Axis2) -> &Self::Output { + &self.0[index] } } -impl Display for Display26Adic { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut output = SmallVec::<[u8; 16]>::new(); - let mut number = self.value; - while number > 0 { - number -= 1; - let digit = (number % 26) as u8; - output.push(digit + self.base); - number /= 26; - } - output.reverse(); - write!(f, "{}", from_utf8(&output).unwrap()) +impl IndexMut for Coord2 { + fn index_mut(&mut self, index: Axis2) -> &mut Self::Output { + &mut self.0[index] } } -/// The content of a single pivot table cell. -/// -/// A [Value] is also a pivot table's title, caption, footnote marker and -/// contents, and so on. -/// -/// A given [Value] is one of: -/// -/// 1. A number resulting from a calculation. -/// -/// A number has an associated display format (usually [F] or [Pct]). This -/// format can be set directly, but that is not usually the easiest way. -/// Instead, it is usually true that all of the values in a single category -/// should have the same format (e.g. all "Significance" values might use -/// format `F40.3`), so PSPP makes it easy to set the default format for a -/// category while creating the category. See pivot_dimension_create() for -/// more details. -/// -/// [F]: crate::format::Type::F -/// [Pct]: crate::format::Type::Pct -/// -/// 2. A numeric or string value obtained from data ([ValueInner::Number] or -/// [ValueInner::String]). If such a value corresponds to a variable, then the -/// variable's name can be attached to the pivot_value. If the value has a -/// value label, then that can also be attached. When a label is present, -/// the user can control whether to show the value or the label or both. -/// -/// 3. A variable name ([ValueInner::Variable]). The variable label, if any, can -/// be attached too, and again the user can control whether to show the value -/// or the label or both. -/// -/// 4. A text string ([ValueInner::Text). The value stores the string in English -/// and translated into the output language (localized). Use -/// pivot_value_new_text() or pivot_value_new_text_format() for those cases. -/// In some cases, only an English or a localized version is available for -/// one reason or another, although this is regrettable; in those cases, use -/// pivot_value_new_user_text() or pivot_value_new_user_text_nocopy(). -/// -/// 5. A template. PSPP doesn't create these itself yet, but it can read and -/// interpret those created by SPSS. -#[derive(Clone, Default)] -pub struct Value { - pub inner: ValueInner, - pub styling: Option>, -} +/// A 2-dimensional rectangle. +#[derive(Clone, Debug, Default)] +pub struct Rect2( + /// The range along each axis. + pub EnumMap>, +); -impl Serialize for Value { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.inner.serialize(serializer) +impl Rect2 { + /// Constructs a new `Rect2` that covers the given X and Y ranges. + pub fn new(x_range: Range, y_range: Range) -> Self { + Self(enum_map! { + Axis2::X => x_range.clone(), + Axis2::Y => y_range.clone(), + }) } -} -/// Wrapper for [Value] that uses [Value::serialize_bare] for serialization. -#[derive(Serialize)] -struct BareValue<'a>(#[serde(serialize_with = "Value::serialize_bare")] pub &'a Value); + /// Construct a new `Rect2` that covers `a_range` along axis `a` and + /// `b_range` along the other axis. + pub fn for_ranges((a, a_range): (Axis2, Range), b_range: Range) -> Self { + let b = !a; + let mut ranges = EnumMap::default(); + ranges[a] = a_range; + ranges[b] = b_range; + Self(ranges) + } -impl Value { - pub fn serialize_bare(&self, serializer: S) -> Result - where - S: Serializer, - { - match &self.inner { - ValueInner::Number(number_value) => number_value.serialize_bare(serializer), - 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::Template(template_value) => template_value.localized.serialize(serializer), - ValueInner::Empty => serializer.serialize_none(), - } + /// Returns the top-left corner of the rectangle. + pub fn top_left(&self) -> Coord2 { + use Axis2::*; + Coord2::new(self[X].start, self[Y].start) } - 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) - } - pub fn new_number_with_format(x: Option, format: Format) -> Self { - Self::new(ValueInner::Number(NumberValue { - show: None, - format, - honor_small: false, - value: x, - variable: None, - value_label: None, - })) - } - pub fn new_variable(variable: &Variable) -> Self { - Self::new(ValueInner::Variable(VariableValue { - show: None, - var_name: String::from(variable.name.as_str()), - variable_label: variable.label.clone(), - })) - } - pub fn new_datum(value: &Datum) -> Self + /// Construct a new `Rect2` that covers the ranges returned by `f`. + pub fn from_fn(f: F) -> Self where - B: EncodedString, + F: FnMut(Axis2) -> Range, { - match value { - Datum::Number(number) => Self::new_number(*number), - 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); - match value { - Datum::Number(number) => Self::new(ValueInner::Number(NumberValue { - show: None, - format: match variable.print_format.var_type() { - VarType::Numeric => variable.print_format, - VarType::String => { - #[cfg(debug_assertions)] - panic!("cannot create numeric pivot value with string format"); - - #[cfg(not(debug_assertions))] - Format::F8_2 - } - }, - honor_small: false, - value: *number, - variable: var_name, - value_label, - })), - 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, - })), - } - } - pub fn new_number(x: Option) -> Self { - Self::new_number_with_format(x, Format::F8_2) - } - pub fn new_integer(x: Option) -> Self { - Self::new_number_with_format(x, Format::F40) - } - pub fn new_text(s: impl Into) -> Self { - Self::new_user_text(s) - } - pub fn new_user_text(s: impl Into) -> Self { - let s: String = s.into(); - if s.is_empty() { - Self::default() - } else { - Self::new(ValueInner::Text(TextValue { - user_provided: true, - localized: s.clone(), - c: None, - id: None, - })) - } - } - pub fn with_footnote(mut self, footnote: &Arc) -> Self { - self.add_footnote(footnote); - self - } - pub fn add_footnote(&mut self, footnote: &Arc) { - let footnotes = &mut self.styling.get_or_insert_default().footnotes; - footnotes.push(footnote.clone()); - footnotes.sort_by_key(|f| f.index); - } - pub fn with_show_value_label(mut self, show: Option) -> Self { - let new_show = show; - match &mut self.inner { - ValueInner::Number(NumberValue { show, .. }) - | ValueInner::String(StringValue { show, .. }) => { - *show = new_show; - } - _ => (), - } - self - } - pub fn with_show_variable_label(mut self, show: Option) -> Self { - if let ValueInner::Variable(variable_value) = &mut self.inner { - variable_value.show = show; - } - self - } - pub fn with_value_label(mut self, label: Option) -> Self { - match &mut self.inner { - ValueInner::Number(NumberValue { value_label, .. }) - | ValueInner::String(StringValue { value_label, .. }) => *value_label = label.clone(), - _ => (), - } - self + Self(EnumMap::from_fn(f)) } - pub const fn empty() -> Self { - Value { - inner: ValueInner::Empty, - styling: None, - } + + /// Shifts the rectangle by `offset` along its axes. + pub fn translate(self, offset: Coord2) -> Rect2 { + Self::from_fn(|axis| self[axis].start + offset[axis]..self[axis].end + offset[axis]) } - pub const fn is_empty(&self) -> bool { - self.inner.is_empty() && self.styling.is_none() + + /// Returns true if the rectangle is empty. + pub fn is_empty(&self) -> bool { + self[Axis2::X].is_empty() || self[Axis2::Y].is_empty() } } -impl From<&str> for Value { - fn from(value: &str) -> Self { - Self::new_text(value) +impl From>> for Rect2 { + fn from(value: EnumMap>) -> Self { + Self(value) } } -impl From for Value { - fn from(value: String) -> Self { - Self::new_text(value) +impl Index for Rect2 { + type Output = Range; + + fn index(&self, index: Axis2) -> &Self::Output { + &self.0[index] } } -impl From<&Variable> for Value { - fn from(variable: &Variable) -> Self { - Self::new_variable(variable) +impl IndexMut for Rect2 { + fn index_mut(&mut self, index: Axis2) -> &mut Self::Output { + &mut self.0[index] } } -pub struct DisplayValue<'a> { - inner: &'a ValueInner, - markup: bool, - subscripts: &'a [String], - footnotes: &'a [Arc], - options: ValueOptions, - show_value: bool, - show_label: Option<&'a str>, +/// What to show in a footnote marker. +#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum FootnoteMarkerType { + /// a, b, c, ... + #[default] + Alphabetic, + + /// 1, 2, 3, ... + Numeric, +} + +/// How to show a footnote marker. +#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum FootnoteMarkerPosition { + /// Subscripts. + #[default] + Subscript, + + /// Superscripts. + Superscript, } -impl<'a> DisplayValue<'a> { - pub fn subscripts(&self) -> impl Iterator { - self.subscripts.iter().map(String::as_str) - } +/// A [Look] and other styling for a [PivotTable]. +#[derive(Clone, Debug, Serialize)] +pub struct PivotTableStyle { + /// The [Look]. + /// + /// The division between [Look] and the rest of the styling in this + /// structure is fairly arbitrary. The ultimate reason for the division is + /// simply because that's how SPSS documentation and file formats do it. + pub look: Arc, + + /// Display inner column labels as vertical text? + pub rotate_inner_column_labels: bool, + + /// Display outer row labels as vertical text? + pub rotate_outer_row_labels: bool, + + /// Show grid lines between data cells? + pub show_grid_lines: bool, + + /// Display the title? + pub show_title: bool, + + /// Display the caption? + pub show_caption: bool, + + /// Default [Show] value for showing values in [Value]s that don't specify + /// their own. + /// + /// If this is `None` then a global default is used. + pub show_values: Option, + + /// Default [Show] value for showing variables in [Value]s that don't + /// specify their own. + /// + /// If this is `None` then a global default is used. + pub show_variables: Option, + + /// 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. + pub settings: Arc, + + /// Numeric grouping character (usually `.` or `,`). + pub grouping: Option, + + /// The threshold for [DatumValue::honor_small]. + /// + /// [DatumValue::honor_small]: value::DatumValue::honor_small + pub small: f64, - pub fn has_subscripts(&self) -> bool { - !self.subscripts.is_empty() - } + /// The format to use for weight and count variables. + pub weight_format: Format, +} - pub fn footnotes(&self) -> impl Iterator> { - self.footnotes - .iter() - .filter(|f| f.show) - .map(|f| f.display_marker(self.options)) +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: Default::default(), // XXX from settings + grouping: None, + small: 0.0001, // XXX from settings. + weight_format: F40, + } } +} - pub fn has_footnotes(&self) -> bool { - self.footnotes().next().is_some() +impl PivotTableStyle { + /// Returns this style with the given `look`. + pub fn with_look(self, look: Arc) -> Self { + Self { look, ..self } } - pub fn without_suffixes(self) -> Self { + /// Returns this style with the given `show_values`. + pub fn with_show_values(self, show_values: Option) -> Self { Self { - subscripts: &[], - footnotes: &[], + show_values, ..self } } - /// 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) { - let suffixes = Self { - inner: &ValueInner::Empty, + /// Returns this style with the given `show_variables`. + pub fn with_show_variables(self, show_variables: Option) -> Self { + Self { + show_variables, ..self - }; - (self.without_suffixes(), suffixes) + } } - 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 + /// Returns this style with the given `show_title`. + pub fn with_show_title(self, show_title: bool) -> Self { + Self { show_title, ..self } } - pub fn with_font_style(self, font_style: &FontStyle) -> Self { + /// Returns this style with the given `show_caption`. + pub fn with_show_caption(self, show_caption: bool) -> Self { Self { - markup: font_style.markup, + show_caption, ..self } } - pub fn with_subscripts(self, subscripts: &'a [String]) -> Self { - Self { subscripts, ..self } + /// Returns a mutable reference to this style's [Look]. + /// + /// This unshares the [Arc] used to refer to the [Look]. + pub fn look_mut(&mut self) -> &mut Look { + Arc::make_mut(&mut self.look) } +} - pub fn with_footnotes(self, footnotes: &'a [Arc]) -> Self { - Self { footnotes, ..self } - } +/// Metadata for a [PivotTable]. +#[derive(Clone, Debug, Default, Serialize)] +pub struct PivotTableMetadata { + /// Title. + /// + /// The title is displayed above the table. Every table should have a + /// title. + pub title: Option>, - pub fn is_empty(&self) -> bool { - self.inner.is_empty() && self.subscripts.is_empty() && self.footnotes.is_empty() - } + /// Caption. + /// + /// The caption is displayed below the table. Captions are optional. + pub caption: Option>, - fn small(&self) -> f64 { - self.options.small - } + /// Corner text, displayed in the top-left corner of the table. Corner text + /// is optional. + pub corner_text: Option>, - pub fn var_type(&self) -> VarType { - match self.inner { - ValueInner::Number(NumberValue { .. }) if self.show_label.is_none() => VarType::Numeric, - _ => VarType::String, - } - } + /// User-specified optional notes, with special variables expanded into + /// their values. + pub notes: Option, - fn template( - &self, - f: &mut std::fmt::Formatter<'_>, - template: &str, - args: &[Vec], - ) -> std::fmt::Result { - let mut iter = template.as_bytes().iter(); - while let Some(c) = iter.next() { - match c { - b'\\' => { - let c = *iter.next().unwrap_or(&b'\\') as char; - let c = if c == 'n' { '\n' } else { c }; - write!(f, "{c}")?; - } - b'^' => { - let (index, rest) = consume_int(iter.as_slice()); - iter = rest.iter(); - let Some(arg) = args.get(index.wrapping_sub(1)) else { - continue; - }; - if let Some(arg) = arg.first() { - write!(f, "{}", arg.display(self.options))?; - } - } - b'[' => { - let (a, rest) = extract_inner_template(iter.as_slice()); - let (b, rest) = extract_inner_template(rest); - let rest = rest.strip_prefix(b"]").unwrap_or(rest); - let (index, rest) = consume_int(rest); - iter = rest.iter(); - - let Some(mut args) = args.get(index.wrapping_sub(1)).map(|vec| vec.as_slice()) - else { - continue; - }; - let (mut template, mut escape) = - if !a.is_empty() { (a, b'%') } else { (b, b'^') }; - while !args.is_empty() { - let n_consumed = self.inner_template(f, template, escape, args)?; - if n_consumed == 0 { - break; - } - args = &args[n_consumed..]; - - template = b; - escape = b'^'; - } - } - c => write!(f, "{c}")?, - } - } - Ok(()) - } + /// User-specified optional notes, with special variables left in their + /// original forms. + /// + /// This allows the notes to be edited in their original form and then + /// expanded for display. + pub notes_unexpanded: Option, - fn inner_template( - &self, - f: &mut std::fmt::Formatter<'_>, - template: &[u8], - escape: u8, - args: &[Value], - ) -> Result { - let mut iter = template.iter(); - let mut args_consumed = 0; - while let Some(c) = iter.next() { - match c { - b'\\' => { - let c = *iter.next().unwrap_or(&b'\\') as char; - let c = if c == 'n' { '\n' } else { c }; - write!(f, "{c}")?; - } - c if *c == escape => { - let (index, rest) = consume_int(iter.as_slice()); - iter = rest.iter(); - let Some(arg) = args.get(index.wrapping_sub(1)) else { - continue; - }; - args_consumed = args_consumed.max(index); - write!(f, "{}", arg.display(self.options))?; - } - c => write!(f, "{c}")?, - } - } - Ok(args_consumed) - } -} + /// The localized name of the command that produced this pivot table, + /// e.g. `Frequencies` translated into the local language. + pub command_local: Option, -fn consume_int(input: &[u8]) -> (usize, &[u8]) { - let mut n = 0; - for (index, c) in input.iter().enumerate() { - if !c.is_ascii_digit() { - return (n, &input[index..]); - } - n = n * 10 + (c - b'0') as usize; - } - (n, &[]) -} + /// The locale-invariant name of the command that produced this pivot table, + /// e.g. `Frequencies`. + pub command_c: Option, -fn extract_inner_template(input: &[u8]) -> (&[u8], &[u8]) { - for (index, c) in input.iter().copied().enumerate() { - if c == b':' && (index == 0 || input[index - 1] != b'\\') { - return input.split_at(index); - } - } - (input, &[]) + /// The locale-invariant command ID for the particular kind of output that + /// this table represents in the procedure. This can be the same as + /// `command_c`, e.g. `Frequencies`, or different, e.g. `Case Processing + /// Summary`. + /// + /// `Notes` and `Warnings` are common generic subtypes. + pub subtype: Option>, + + /// The language used in output. + pub language: Option, + + /// A locale, including an encoding, such as `en_US.windows-1252` or + /// `it_IT.windows-1252`. + pub locale: Option, + + /// Name of the dataset analyzed to produce the output, e.g. `DataSet1`. + pub dataset: Option, + + /// Name of the file that the dataset is from, e.g. `C:\Users\foo\bar.sav`. + pub datafile: Option, + + /// Creation date for the table. + pub date: Option, } -fn interpret_show( - global_show: impl Fn() -> Show, - table_show: Option, - value_show: Option, - label: &str, -) -> (bool, Option<&str>) { - match value_show.or(table_show).unwrap_or_else(global_show) { - Show::Value => (true, None), - Show::Label => (false, Some(label)), - Show::Both => (true, Some(label)), +impl PivotTableMetadata { + /// Return this metadata with the given `subtype`. + pub fn with_subtype(self, subtype: impl Into) -> Self { + Self { + subtype: Some(Box::new(subtype.into())), + ..self + } } } -impl Display for DisplayValue<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.inner { - ValueInner::Number(NumberValue { - format, - honor_small, - value, - .. - }) => { - if self.show_value { - let format = if format.type_() == Type::F - && *honor_small - && value.is_some_and(|value| value != 0.0 && value.abs() < self.small()) - { - UncheckedFormat::new(Type::E, 40, format.d() as u8).fix() - } else { - *format - }; - let mut buf = SmallString::<[u8; 40]>::new(); - write!( - &mut buf, - "{}", - Datum::<&str>::Number(*value).display(format) - ) - .unwrap(); - write!(f, "{}", buf.trim_start_matches(' '))?; - } - if let Some(label) = self.show_label { - if self.show_value { - write!(f, " ")?; - } - f.write_str(label)?; - } - Ok(()) - } - - ValueInner::String(StringValue { s, .. }) - | ValueInner::Variable(VariableValue { var_name: s, .. }) => { - match (self.show_value, self.show_label) { - (true, None) => write!(f, "{s}"), - (false, Some(label)) => write!(f, "{label}"), - (true, Some(label)) => write!(f, "{s} {label}"), - (false, None) => unreachable!(), - } - } +/// A pivot table. +/// +/// Pivot tables are PSPP's primary form of output. They are analogous to the +/// pivot tables you might be familiar with from spreadsheets and databases. +/// See for a brief introduction to +/// the overall concept of a pivot table. +#[derive(Clone, Debug, Serialize)] +pub struct PivotTable { + /// Style. + pub style: PivotTableStyle, - 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) - } + /// Current layer indexes, with `axes[Axis3::Z].dimensions.len()` elements. + /// `layer[i]` is an offset into + /// `axes[Axis3::Z].dimensions[i].data_leaves[]`, except that a dimension + /// can have zero leaves, in which case `layer[i]` is zero and there's no + /// corresponding leaf. + layer: Vec, - ValueInner::Template(TemplateValue { - args, - localized: local, - .. - }) => self.template(f, local, args), + /// Metadata. + pub metadata: PivotTableMetadata, - ValueInner::Empty => Ok(()), - }?; + /// Footnotes. + pub footnotes: Footnotes, - for (subscript, delimiter) in self.subscripts.iter().zip(once('_').chain(repeat(','))) { - write!(f, "{delimiter}{subscript}")?; - } + /// Dimensions. + dimensions: Vec, - for footnote in self.footnotes { - write!(f, "[{}]", footnote.display_marker(self.options))?; - } + /// Axes. + axes: EnumMap, - Ok(()) - } + /// Data. + cells: HashMap, } -impl Value { - // Returns an object that will format this value, including subscripts and - // superscripts and footnotes. `options` controls whether variable and - // value labels are included. - pub fn display(&self, options: impl IntoValueOptions) -> DisplayValue<'_> { - let display = self.inner.display(options.into_value_options()); - match &self.styling { - Some(styling) => display.with_styling(styling), - None => display, +impl Default for PivotTable { + fn default() -> Self { + Self { + style: PivotTableStyle::default(), + metadata: PivotTableMetadata::default(), + layer: Vec::new(), + footnotes: Footnotes::new(), + dimensions: Vec::new(), + axes: EnumMap::default(), + cells: HashMap::new(), } } } -impl Debug for Value { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self.display(()).to_string()) - } -} - -#[derive(Clone, Debug)] -pub struct NumberValue { - /// The numerical value, or `None` if it is a missing value. - pub value: Option, - pub format: Format, - pub show: Option, - pub honor_small: bool, - pub variable: Option, - pub value_label: Option, +/// A type that can calculate the index into a [PivotTable]'s data hashmap. +pub trait CellIndex { + /// Given the pivot table's `dimensions`, returns an index. + fn cell_index(self, dimensions: I) -> usize + where + I: ExactSizeIterator; } -impl Serialize for NumberValue { - fn serialize(&self, serializer: S) -> Result +impl CellIndex for T +where + T: AsRef<[usize]>, +{ + fn cell_index(self, dimensions: I) -> usize where - S: serde::Serializer, + I: ExactSizeIterator, { - if self.format.type_() == Type::F && self.variable.is_none() && self.value_label.is_none() { - self.value.serialize(serializer) - } else { - let mut s = serializer.serialize_map(None)?; - s.serialize_entry("value", &self.value)?; - s.serialize_entry("format", &self.format)?; - if let Some(show) = self.show { - s.serialize_entry("show", &show)?; - } - if self.honor_small { - s.serialize_entry("honor_small", &self.honor_small)?; - } - if let Some(variable) = &self.variable { - s.serialize_entry("variable", variable)?; - } - if let Some(value_label) = &self.value_label { - s.serialize_entry("value_label", value_label)?; - } - s.end() + 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 } } -impl NumberValue { - pub fn serialize_bare(&self, serializer: S) -> Result +/// A precomputed index. +pub struct PrecomputedIndex( + /// The index. + pub usize, +); + +impl CellIndex for PrecomputedIndex { + fn cell_index(self, _dimensions: I) -> usize where - S: Serializer, + I: ExactSizeIterator, { - if let Some(number) = self.value - && number.trunc() == number - && number >= -(1i64 << 53) as f64 - && number <= (1i64 << 53) as f64 - { - (number as u64).serialize(serializer) - } else { - self.value.serialize(serializer) - } + self.0 } } -#[derive(Serialize)] -pub struct BareNumberValue<'a>( - #[serde(serialize_with = "NumberValue::serialize_bare")] pub &'a NumberValue, -); - -#[derive(Clone, Debug, Serialize)] -pub struct StringValue { - /// The string value. - /// - /// If `hex` is true, this should contain hex digits, not raw binary data - /// (otherwise it would be impossible to encode non-UTF-8 data). - pub s: String, +impl Extend<(C, Value)> for PivotTable +where + C: CellIndex, +{ + fn extend>(&mut self, iter: T) { + for (cell_index, value) in iter { + self.insert(cell_index, value); + } + } +} - /// True if `s` is hex digits. - pub hex: bool, +/// A footnote in a [PivotTable]. +/// +/// A footnote is attached directly to a [Value], but it will only be displayed +/// correctly if it is also added to [PivotTable::footnotes] for its pivot +/// table. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct Footnote { + /// The index within [Footnotes]. + #[serde(skip)] + index: usize, - pub show: Option, + /// The footnote text. + pub content: Box, - pub var_name: Option, - pub value_label: Option, -} + /// The footnote marker. + /// + /// This is usually `None`, in which case [FootnoteMarkerType] determines + /// the default marker. + pub marker: Option>, -#[derive(Clone, Debug, Serialize)] -pub struct VariableValue { - pub show: Option, - pub var_name: String, - pub variable_label: Option, + /// Whether to show the footnote. + pub show: bool, } -#[derive(Clone, Debug)] -pub struct TextValue { - pub user_provided: bool, - /// Localized. - pub localized: String, - /// English. - pub c: Option, - /// Identifier. - pub id: Option, -} +impl Footnote { + /// Constructs a new footnote. + pub fn new(content: impl Into) -> Self { + Self { + index: 0, + content: Box::new(content.into()), + marker: None, + show: true, + } + } -impl Serialize for TextValue { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - if self.user_provided && self.c.is_none() && self.id.is_none() { - serializer.serialize_str(&self.localized) - } else { - let mut s = serializer.serialize_struct( - "TextValue", - 2 + self.c.is_some() as usize + self.id.is_some() as usize, - )?; - s.serialize_field("user_provided", &self.user_provided)?; - s.serialize_field("localized", &self.localized)?; - if let Some(c) = &self.c { - s.serialize_field("c", &c)?; - } - if let Some(id) = &self.id { - s.serialize_field("id", &id)?; - } - s.end() + /// Returns the footnote with the given optional marker. + pub fn with_marker(self, marker: Option) -> Self { + Self { + marker: marker.map(Box::new), + ..self } } -} -impl TextValue { - pub fn localized(&self) -> &str { - self.localized.as_str() + /// Returns the footnote with the given marker. + pub fn with_some_marker(self, marker: impl Into) -> Self { + Self::with_marker(self, Some(marker.into())) } - pub fn c(&self) -> &str { - self.c.as_ref().unwrap_or(&self.localized).as_str() + + /// Return the footnote with the given `show`. + pub fn with_show(self, show: bool) -> Self { + Self { show, ..self } } - pub fn id(&self) -> &str { - self.id.as_ref().unwrap_or(&self.localized).as_str() + + /// Return the footnote with the given `index`. + pub fn with_index(self, index: usize) -> Self { + Self { index, ..self } } -} -#[derive(Clone, Debug, Serialize)] -pub struct TemplateValue { - pub args: Vec>, - pub localized: String, - pub id: String, -} + /// Returns an object for formatting the footnote's marker. + pub fn display_marker(&self, options: impl Into) -> impl Display { + DisplayMarker { + footnote: self, + options: options.into(), + } + } -#[derive(Clone, Debug, Default, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ValueInner { - Number(NumberValue), - String(StringValue), - Variable(VariableValue), - Text(TextValue), - Template(TemplateValue), + /// Returns an object for formatting the footnote's text. + pub fn display_content(&self, options: impl Into) -> impl Display { + self.content.display(options) + } - #[default] - Empty, + /// Returns the footnote's index. + pub fn index(&self) -> usize { + self.index + } } -impl ValueInner { - pub const fn is_empty(&self) -> bool { - matches!(self, Self::Empty) - } - fn show(&self) -> Option { - match self { - ValueInner::Number(NumberValue { show, .. }) - | ValueInner::String(StringValue { show, .. }) - | ValueInner::Variable(VariableValue { show, .. }) => *show, - _ => None, - } +impl Default for Footnote { + fn default() -> Self { + Footnote::new(Value::default()) } +} - fn label(&self) -> Option<&str> { - self.value_label().or_else(|| self.variable_label()) - } +struct DisplayMarker<'a> { + footnote: &'a Footnote, + options: ValueOptions, +} - fn value_label(&self) -> Option<&str> { - match self { - ValueInner::Number(NumberValue { value_label, .. }) - | ValueInner::String(StringValue { value_label, .. }) => { - value_label.as_ref().map(String::as_str) +impl Display for DisplayMarker<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(marker) = &self.footnote.marker { + write!(f, "{}", marker.display(&self.options).without_suffixes()) + } else { + let i = self.footnote.index + 1; + match self.options.footnote_marker_type { + FootnoteMarkerType::Alphabetic => write!(f, "{}", Display26Adic::new_lowercase(i)), + FootnoteMarkerType::Numeric => write!(f, "{i}"), } - _ => None, } } +} - fn variable_label(&self) -> Option<&str> { - match self { - ValueInner::Variable(VariableValue { variable_label, .. }) => { - variable_label.as_ref().map(String::as_str) - } - _ => None, - } - } +/// Displays a number in 26adic notation. +/// +/// Zero is displayed as the empty string, 1 through 26 as `a` through `z`, 27 +/// through 52 as `aa` through `az`, and so on. +pub struct Display26Adic { + value: usize, + base: u8, } -#[derive(Clone, Debug, Default)] -pub struct ValueStyle { - pub style: Option, - pub subscripts: Vec, - pub footnotes: Vec>, +impl Display26Adic { + /// Constructs a `Display26Adic` for `value`, with letters in lowercase. + pub fn new_lowercase(value: usize) -> Self { + Self { value, base: b'a' } + } + + /// Constructs a `Display26Adic` for `value`, with letters in uppercase. + pub fn new_uppercase(value: usize) -> Self { + Self { value, base: b'A' } + } } -impl ValueStyle { - pub fn is_empty(&self) -> bool { - self.style.is_none() && self.subscripts.is_empty() && self.footnotes.is_empty() - } -} - -impl ValueInner { - // Returns an object that will format this value. Settings on `options` - // control whether variable and value labels are included. - pub fn display(&self, options: impl IntoValueOptions) -> DisplayValue<'_> { - let options = options.into_value_options(); - let (show_value, show_label) = if let Some(value_label) = self.value_label() { - interpret_show( - || Settings::global().show_values, - options.show_values, - self.show(), - value_label, - ) - } else if let Some(variable_label) = self.variable_label() { - interpret_show( - || Settings::global().show_variables, - options.show_variables, - self.show(), - variable_label, - ) - } else { - (true, None) - }; - DisplayValue { - inner: self, - markup: false, - subscripts: &[], - footnotes: &[], - options, - show_value, - show_label, +impl Display for Display26Adic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut output = SmallVec::<[u8; 16]>::new(); + let mut number = self.value; + while number > 0 { + number -= 1; + let digit = (number % 26) as u8; + output.push(digit + self.base); + number /= 26; } + output.reverse(); + write!(f, "{}", str::from_utf8(&output).unwrap()) } } +/// An entry in a metadata table. pub struct MetadataEntry { + /// The label for the entry. pub name: Value, + + /// The value for the entry. pub value: MetadataValue, } impl MetadataEntry { + /// Constructs a new metadata entry with the given name and value. pub fn new(name: impl Into, value: MetadataValue) -> Self { Self { name: name.into(), value, } } + + /// Converts the metadata entry into a pivot table. pub fn into_pivot_table(self) -> PivotTable { let mut data = Vec::new(); let group = match self.visit(&mut data) { @@ -2737,12 +1879,22 @@ impl MetadataEntry { } } +/// A value in a metadata table. pub enum MetadataValue { - Leaf(Value), - Group(Vec), + /// A value. + Leaf( + /// The value. + Value, + ), + /// A nested group of entries. + Group( + /// The entries. + Vec, + ), } impl MetadataValue { + /// Construct a new "leaf" metadata value. pub fn new_leaf(value: impl Into) -> Self { Self::Leaf(value.into()) } @@ -2792,7 +1944,24 @@ impl Serialize for MetadataEntry { #[cfg(test)] mod test { - use crate::output::pivot::{Display26Adic, MetadataEntry, MetadataValue, Value}; + use std::str::FromStr; + + use crate::output::pivot::{ + Display26Adic, MetadataEntry, MetadataValue, + look::Color, + tests::assert_rendering, + value::{TemplateValue, Value, ValueInner}, + }; + + #[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() { @@ -2813,6 +1982,41 @@ mod test { } } + #[test] + fn template() { + for (template, expected) in [ + ( + "1: [:^1,:]1; [:^1,:]2", + "1: First,1.00,Second,2,; Third,3.00,Fourth,4,", + ), + (r#"2: [:^1\n:]1"#, "2: First\n1.00\nSecond\n2\n"), + (r#"3: [:^1 = ^2\n:]1"#, "3: First = 1.00\nSecond = 2\n"), + ("4: [%1:, ^1:]1", "4: First, 1.00, Second, 2"), + ("5: [%1 = %2:, ^1 = ^2:]1", "5: First = 1.00, Second = 2"), + ("6: [%1:, ^1:]1", "6: First, 1.00, Second, 2"), + ] { + let value = Value::new(ValueInner::Template(TemplateValue { + args: vec![ + vec![ + Value::new_user_text("First"), + Value::new_number(Some(1.0)), + Value::new_user_text("Second"), + Value::new_integer(Some(2.0)), + ], + vec![ + Value::new_user_text("Third"), + Value::new_number(Some(3.0)), + Value::new_user_text("Fourth"), + Value::new_integer(Some(4.0)), + ], + ], + localized: String::from(template), + id: None, + })); + assert_eq!(value.display(()).to_string(), expected); + } + } + #[test] fn metadata_entry() { let tree = MetadataEntry { @@ -2858,18 +2062,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.rs b/rust/pspp/src/output/pivot/look.rs new file mode 100644 index 0000000000..4df79828b3 --- /dev/null +++ b/rust/pspp/src/output/pivot/look.rs @@ -0,0 +1,1106 @@ +// 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 . + +//! Pivot table styles (called "TableLooks" by SPSS). +//! +//! Each [PivotTable] is styled with a [PivotTableStyle], which contains a +//! [Look]. This module contains [Look] and its contents. +//! +//! [PivotTable]: super::PivotTable +//! [PivotTableStyle]: super::PivotTableStyle + +// Warn about missing docs, but not for items declared with `#[cfg(test)]`. +#![cfg_attr(not(test), warn(missing_docs))] +#![warn(dead_code)] + +use std::{ + fmt::{Debug, Display}, + io::Read, + ops::{Range, RangeInclusive}, + str::{FromStr, Utf8Error}, + sync::{Arc, OnceLock}, +}; + +use color::{AlphaColor, Rgba8, Srgb, palette::css::TRANSPARENT}; +use enum_map::{Enum, EnumMap, enum_map}; +use quick_xml::{DeError, de::from_str}; +use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeStruct}; + +use crate::{ + output::pivot::{ + Axis2, FootnoteMarkerPosition, FootnoteMarkerType, TableProperties, tlo::parse_tlo, + }, + util::ToSmallString, + variable::VarType, +}; + +/// Areas of a pivot table for styling purposes. +#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)] +pub enum Area { + /// Title. + /// + /// Displayed above the table. If a table is split across multiple pages + /// for printing, the title appears on each page. + Title, + + /// Caption. + /// + /// Displayed below the table. + Caption, + + /// Footnotes. + /// + /// Displayed below the table. + Footer, + + /// Top-left corner. + /// + /// To the left of the column labels, and above the row labels. + Corner, + + /// Labels. + Labels( + /// - [Axis2::X]: Column labels, along the top of the table. + /// - [Axis2::Y]: Row labels, along the left side of the table. + Axis2, + ), + + /// Data cells. + Data( + /// This allows styling for even rows and odd rows to differ + /// arbitrarily, but the SPV file format only distinguishes foreground + /// and background colors, so any other differences will be lost upon + /// save. + RowParity, + ), + + /// Layer indication. + Layers, +} + +impl Default for Area { + fn default() -> Self { + Self::Data(RowParity::default()) + } +} + +impl Display for Area { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Area::Title => write!(f, "title"), + Area::Caption => write!(f, "caption"), + Area::Footer => write!(f, "footer"), + Area::Corner => write!(f, "corner"), + Area::Labels(axis2) => write!(f, "labels({axis2})"), + Area::Data(row) => write!(f, "data({row})"), + Area::Layers => write!(f, "layers"), + } + } +} + +impl Serialize for Area { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_small_string::<16>()) + } +} + +/// Distinguishes [Area::Data] for even-numbered and odd-numbered rows. +#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)] +pub enum RowParity { + /// Even-numbered rows. + /// + /// The first row is row 0, hence even. + #[default] + Even, + /// Odd-numbered rows. + Odd, +} + +impl From for RowParity { + fn from(value: usize) -> Self { + if value % 2 == 1 { + Self::Odd + } else { + Self::Even + } + } +} + +impl Display for RowParity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RowParity::Even => write!(f, "even"), + RowParity::Odd => write!(f, "odd"), + } + } +} + +/// Table borders for styling purposes. +#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)] +pub enum Border { + /// Title. + Title, + + /// Outer frame. + OuterFrame( + /// Which border of the outer frame. + BoxBorder, + ), + /// Inner frame. + InnerFrame( + /// Which border of the inner frame. + BoxBorder, + ), + /// Between dimensions. + Dimension( + /// Which part between dimensions. + RowColBorder, + ), + /// Between categories. + Category( + /// Which part between categories. + RowColBorder, + ), + /// Between the row borders and the data. + DataLeft, + /// Between the column borders and the data. + DataTop, +} + +impl Border { + /// Returns the default [Stroke] for this border. + pub fn default_stroke(self) -> Stroke { + match self { + Self::InnerFrame(_) | Self::DataLeft | Self::DataTop => Stroke::Thick, + Self::Dimension( + RowColBorder(HeadingRegion::Columns, _) | RowColBorder(_, Axis2::X), + ) + | Self::Category(RowColBorder(HeadingRegion::Columns, _)) => Stroke::Solid, + _ => Stroke::None, + } + } + /// Returns the default [BorderStyle] for this border. + pub fn default_border_style(self) -> BorderStyle { + BorderStyle { + stroke: self.default_stroke(), + color: Color::BLACK, + } + } + + /// Returns an alternative border for this one. + pub fn fallback(self) -> Self { + match self { + Self::Title + | Self::OuterFrame(_) + | Self::InnerFrame(_) + | Self::DataLeft + | Self::DataTop + | Self::Category(_) => self, + Self::Dimension(row_col_border) => Self::Category(row_col_border), + } + } + + /// Returns all the default borders. + pub fn default_borders() -> EnumMap { + EnumMap::from_fn(Border::default_border_style) + } +} + +impl Display for Border { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Border::Title => write!(f, "title"), + Border::OuterFrame(box_border) => write!(f, "outer_frame({box_border})"), + Border::InnerFrame(box_border) => write!(f, "inner_frame({box_border})"), + Border::Dimension(row_col_border) => write!(f, "dimension({row_col_border})"), + Border::Category(row_col_border) => write!(f, "category({row_col_border})"), + Border::DataLeft => write!(f, "data(left)"), + Border::DataTop => write!(f, "data(top)"), + } + } +} + +impl Serialize for Border { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_small_string::<32>()) + } +} + +/// The borders on a box. +#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BoxBorder { + /// Left side. + Left, + /// Top. + Top, + /// Right side. + Right, + /// Bottom. + Bottom, +} + +impl BoxBorder { + fn as_str(&self) -> &'static str { + match self { + BoxBorder::Left => "left", + BoxBorder::Top => "top", + BoxBorder::Right => "right", + BoxBorder::Bottom => "bottom", + } + } +} + +impl Display for BoxBorder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Borders between rows and columns. +#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct RowColBorder( + /// Row or column headings. + pub HeadingRegion, + /// Horizontal ([Axis2::X]) or vertical ([Axis2::Y]) borders. + pub Axis2, +); + +impl Display for RowColBorder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.0, self.1) + } +} + +/// Sizing for rows or columns of a rendered table. +/// +/// The comments below talk about columns and their widths but they apply +/// equally to rows and their heights. +#[derive(Default, Clone, Debug, Serialize)] +pub struct Sizing { + /// Specific column widths, in 1/96" units. + 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. + pub breaks: Vec, + + /// Keeps: columns to keep together on a page if possible. + pub keeps: Vec>, +} + +/// Core styling for a pivot table. +/// +/// The division between `Look` and [PivotTableStyle] is fairly arbitrary. The +/// ultimate reason for the division is simply because that's how SPSS +/// documentation and file formats do it. +/// +/// A `Look` can be read from standalone files in [XML] and [binary] formats and +/// extracted from [PivotTable]s, which in turn can be read from [SPV files]. +/// +/// [XML]: Self::from_xml +/// [binary]: Self::from_binary +/// [PivotTable]: super::PivotTable +/// [PivotTableStyle]: super::PivotTableStyle +/// [SPV files]: crate::spv +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Look { + /// Optional name for this `Look`. + /// + /// This allows building a catalog of `Look`s based on more than their + /// filenames. + pub name: Option, + + /// Whether to hide rows or columns whose cells are all empty. + pub hide_empty: bool, + + /// Where to place [Group] labels. + /// + /// [Group]: super::Group + pub row_label_position: LabelPosition, + + /// Ranges of column widths in the two heading regions, in 1/96" units. + pub heading_widths: EnumMap>, + + /// Kind of markers to use for footnotes. + pub footnote_marker_type: FootnoteMarkerType, + + /// Where to put the footnote markers. + pub footnote_marker_position: FootnoteMarkerPosition, + + /// Styles for areas of the pivot table. + pub areas: EnumMap, + + /// Styles for borders in the pivot table. + pub borders: EnumMap, + + /// Whether to print all layers. + /// + /// If true, all table layers are printed sequentially, + /// If false, only the current layer is printed. + /// + /// This affects only printing. On-screen display shows just one layer at a + /// time. + pub print_all_layers: bool, + + /// If true, print each layer on its own page. + pub paginate_layers: bool, + + /// If `shrink_to_fit[Axis2::X]`, then tables wider than the page are scaled + /// to fit horizontally. + /// + /// If `shrink_to_fit[Axis2::Y]`, then tables longer than the page are + /// scaled to fit vertically. + pub shrink_to_fit: EnumMap, + + /// When to show `continuation`: + /// + /// - `show_continuations[0]`: Whether to show `continuation` at the top of + /// a table that is continued from the previous page. + /// + /// - `show_continuations[1]`: Whether to show `continuation` at the bottom + /// of a table that continues onto the next page. + pub show_continuations: [bool; 2], + + /// Text that can be shown at the top or bottom of a table that continues + /// across multiple pages. + pub continuation: Option, + + /// Minimum number of rows or columns to put in one part of a table that is + /// broken across pages. + pub n_orphan_lines: usize, +} + +impl Look { + /// Returns this look with `omit_empty` set as provided. + pub fn with_omit_empty(mut self, omit_empty: bool) -> Self { + self.hide_empty = omit_empty; + self + } + /// Returns this look with `row_label_position` set as provided. + pub fn with_row_label_position(mut self, row_label_position: LabelPosition) -> Self { + self.row_label_position = row_label_position; + self + } + /// Returns this look with `borders` set as provided. + pub fn with_borders(mut self, borders: EnumMap) -> Self { + self.borders = borders; + self + } +} + +impl Default for Look { + fn default() -> Self { + Self { + name: None, + hide_empty: true, + row_label_position: LabelPosition::default(), + heading_widths: EnumMap::from_fn(|region| match region { + HeadingRegion::Rows => 36..=72, + HeadingRegion::Columns => 36..=120, + }), + footnote_marker_type: FootnoteMarkerType::default(), + footnote_marker_position: FootnoteMarkerPosition::default(), + 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), + show_continuations: [false, false], + continuation: None, + n_orphan_lines: 0, + } + } +} + +/// Error type returned by [Look] methods that parse a file. +#[derive(thiserror::Error, Debug)] +pub enum ParseLookError { + /// [quick_xml] deserialization errors. + #[error(transparent)] + XmlError( + /// Inner error. + #[from] + DeError, + ), + + /// UTF-8 decoding error. + #[error(transparent)] + Utf8Error( + /// Inner error. + #[from] + Utf8Error, + ), + + /// [binrw] deserialization error. + #[error(transparent)] + BinError( + /// Inner error. + #[from] + binrw::Error, + ), + + /// I/O error. + #[error(transparent)] + IoError( + /// Inner error. + #[from] + std::io::Error, + ), +} + +impl Look { + /// Returns a globally shared copy of [Look::default]. + pub fn shared_default() -> Arc { + static LOOK: OnceLock> = OnceLock::new(); + LOOK.get_or_init(|| Arc::new(Look::default())).clone() + } + + /// Parses `xml` as an XML-formatted `Look`, as found in [`.stt` files]. + /// + /// [`.stt` files]: https://pspp.benpfaff.org/manual/tablelook.html#the-stt-format + pub fn from_xml(xml: &str) -> Result { + Ok(from_str::(xml) + .map_err(ParseLookError::from)? + .into()) + } + + /// Parses `xml` as a binary-formatted `Look`, as found in [`.tlo` files]. + /// + /// # Obsolescence + /// + /// The `.tlo` format is obsolete. PSPP only supports it as an input + /// format. + /// + /// [`.tlo` files]: https://pspp.benpfaff.org/manual/tablelook.html#the-tlo-format + pub fn from_binary(tlo: &[u8]) -> Result { + parse_tlo(tlo).map_err(ParseLookError::from) + } + + /// Parses `data` as the binary or XML format of a `Look`, automatically + /// detecting which format. + pub fn from_data(data: &[u8]) -> Result { + if data.starts_with(b"\xff\xff\0\0") { + Self::from_binary(data) + } else { + Self::from_xml(str::from_utf8(data).map_err(ParseLookError::from)?) + } + } + + /// Reads a [Look] in binary or XML format from `reader`. + pub fn from_reader(mut reader: R) -> Result + where + R: Read, + { + let mut buffer = Vec::new(); + reader.read_to_end(&mut buffer)?; + Self::from_data(&buffer) + } +} + +/// Position for [Group] labels. +/// +/// [Group]: super::Group +#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub enum LabelPosition { + /// 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. + /// + /// ```text + /// ┌────┬──────────────┐ ┌─────────┬──────────┐ + /// │ │ nested │ │ │ columns │ + /// │ ├────┬────┬────┤ ├──────┬──┼──────────┤ + /// │ │ a1 │ a2 │ a3 │ │ │a1│...data...│ + /// ├────┼────┼────┼────┤ │nested│a2│...data...│ + /// │ │data│data│data│ │ │a3│...data...│ + /// │ │ . │ . │ . │ └──────┴──┴──────────┘ + /// │rows│ . │ . │ . │ + /// │ │ . │ . │ . │ + /// └────┴────┴────┴────┘ + /// ``` + #[serde(rename = "nested")] + Nested, + + /// In the corner (row labels only). + /// + /// ```text + /// ┌──────┬──────────┐ + /// │corner│ columns │ + /// ├──────┼──────────┤ + /// │ a1│...data...│ + /// │ a2│...data...│ + /// │ a3│...data...│ + /// └──────┴──────────┘ + /// ``` + #[default] + #[serde(rename = "inCorner")] + Corner, +} + +/// The heading region of a rendered pivot table: +/// +/// ```text +/// ┌──────────────────┬─────────────────────────────────────────────────┐ +/// │ │ column headings │ +/// │ ├─────────────────────────────────────────────────┤ +/// │ corner │ │ +/// │ and │ │ +/// │ row headings │ data │ +/// │ │ │ +/// │ │ │ +/// └──────────────────┴─────────────────────────────────────────────────┘ +/// ``` +#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HeadingRegion { + /// Headings for labeling rows. + /// + /// These appear on the left side of the pivot table, including the top-left + /// corner area. + Rows, + + /// Headings for labeling columns. + /// + /// These appear along the top of the pivot table, excluding the top-left + /// corner area. + Columns, +} + +impl HeadingRegion { + /// Returns "rows" or "columns". + pub fn as_str(&self) -> &'static str { + match self { + HeadingRegion::Rows => "rows", + HeadingRegion::Columns => "columns", + } + } +} + +impl Display for HeadingRegion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl From for HeadingRegion { + fn from(axis: Axis2) -> Self { + match axis { + Axis2::X => HeadingRegion::Columns, + Axis2::Y => HeadingRegion::Rows, + } + } +} + +/// Default style for cells in an [Area]. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct AreaStyle { + /// Cell style for the area. + pub cell_style: CellStyle, + /// Font style for the area. + pub font_style: FontStyle, +} + +impl AreaStyle { + /// Returns the default style for `area`. + pub fn default_for_area(area: Area) -> Self { + Self { + cell_style: CellStyle::default_for_area(area), + font_style: FontStyle::default_for_area(area), + } + } +} + +/// Style for the cells that contain a [Value]. +/// +/// The division between [CellStyle] and [FontStyle] isn't particularly +/// meaningful but it matches SPSS file formats. +/// +/// [Value]: super::value::Value +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct CellStyle { + /// Horizontal alignment. + /// + /// `None` means "mixed" alignment: align strings to the left, numbers to + /// the right. + pub horz_align: Option, + + /// Vertical alignment. + pub vert_align: VertAlign, + + /// Margins in 1/96" units: + /// + /// - `margins[Axis2::X][0]` is the left margin. + /// - `margins[Axis2::X][1]` is the right margin. + /// - `margins[Axis2::Y][0]` is the top margin. + /// - `margins[Axis2::Y][1]` is the bottom margin. + pub margins: EnumMap, +} + +impl Default for CellStyle { + fn default() -> Self { + Self::default_for_area(Area::default()) + } +} + +impl CellStyle { + /// Returns the default cell style for `area`. + 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 }, + } + } + /// Returns the cell style with `horz_align` set as specified. + pub fn with_horz_align(self, horz_align: Option) -> Self { + Self { horz_align, ..self } + } + /// Returns the cell style with `vert_align` set as specified. + pub fn with_vert_align(self, vert_align: VertAlign) -> Self { + Self { vert_align, ..self } + } + /// Returns the cell style with `margins` set as specified. + pub fn with_margins(self, margins: EnumMap) -> Self { + Self { margins, ..self } + } +} + +/// Horizontal alignment of text. +/// +/// "Mixed" alignment is implemented at a higher level using +/// `Option`. +#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HorzAlign { + /// Right aligned. + Right, + + /// Left aligned. + Left, + + /// Centered. + Center, + + /// Align the decimal point at the specified position. + Decimal { + /// Decimal offset from the right side of the cell, in 1/96" units. + offset: f64, + }, +} + +impl HorzAlign { + /// Returns the [HorzAlign] to use for "mixed alignment" based on the + /// variable type. + pub fn for_mixed(var_type: VarType) -> Self { + match var_type { + VarType::Numeric => Self::Right, + VarType::String => Self::Left, + } + } + + /// Returns this alignment as a static string. + /// + /// Decimal alignment doesn't have a static string representation. + 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) + } + } +} + +/// Vertical alignment. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum VertAlign { + /// Top alignment. + Top, + + /// Centered, + Middle, + + /// Bottom alignment. + Bottom, +} + +/// Style of the font used in a [Value]. +/// +/// The division between [CellStyle] and [FontStyle] isn't particularly +/// meaningful but it matches SPSS file formats. +/// +/// [Value]: super::value::Value +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct FontStyle { + /// **Bold** + pub bold: bool, + + /// *Italic* + pub italic: bool, + + /// Underline + pub underline: bool, + + /// Typeface. + pub font: String, + + /// Foreground color. + pub fg: Color, + + /// Background 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 { + /// Returns the default font style for `area`. + pub fn default_for_area(area: Area) -> Self { + Self::default().with_bold(area == Area::Title) + } + /// Returns the font with its `size` set as specified. + pub fn with_size(self, size: i32) -> Self { + Self { size, ..self } + } + /// Returns the font with `bold` set as specified. + pub fn with_bold(self, bold: bool) -> Self { + Self { bold, ..self } + } + /// Returns the font with `italic` set as specified. + pub fn with_italic(self, italic: bool) -> Self { + Self { italic, ..self } + } + /// Returns the font with `underline` set as specified. + pub fn with_underline(self, underline: bool) -> Self { + Self { underline, ..self } + } + /// Returns the font with `font` set as specified. + pub fn with_font(self, font: impl Into) -> Self { + Self { + font: font.into(), + ..self + } + } + /// Returns the font with `fg` set as specified. + pub fn with_fg(self, fg: Color) -> Self { + Self { fg, ..self } + } + /// Returns the font with `bg` set as specified. + pub fn with_bg(self, fg: Color) -> Self { + Self { fg, ..self } + } +} + +/// Color used in [FontStyle]. +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct Color { + /// Alpha channel. + /// + /// 255 is opaque, 0 is transparent. + pub alpha: u8, + + /// Red. + pub r: u8, + + /// Green. + pub g: u8, + + /// Blue. + pub b: u8, +} + +impl Color { + /// Black. + pub const BLACK: Color = Color::new(0, 0, 0); + /// White. + pub const WHITE: Color = Color::new(255, 255, 255); + /// Red. + pub const RED: Color = Color::new(255, 0, 0); + /// Green. + pub const GREEN: Color = Color::new(0, 255, 0); + /// Blue. + pub const BLUE: Color = Color::new(0, 0, 255); + /// Transparent. + pub const TRANSPARENT: Color = Color::new(0, 0, 0).with_alpha(0); + + /// Returns an opaque color with the given red, green, and blue values. + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { + alpha: 255, + r, + g, + b, + } + } + + /// Returns this color with the alpha channel set as specified. + pub const fn with_alpha(self, alpha: u8) -> Self { + Self { alpha, ..self } + } + + /// Returns an opaque version of this color. + pub const fn without_alpha(self) -> Self { + self.with_alpha(255) + } + + /// Displays opaque colors as `#rrggbb` and others as `rgb(r, g, b, alpha)`. + pub fn display_css(&self) -> impl Display { + ColorDisplayCss(*self) + } + + /// Returns the red, green, and blue channels of this color. + pub fn into_rgb(&self) -> (u8, u8, u8) { + (self.r, self.g, self.b) + } + + /// Returns 16-bit versions of the red, green, and blue channels of this + /// color. + 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 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.display_css()) + } +} + +impl From for Color { + fn from(Rgba8 { r, g, b, a }: Rgba8) -> Self { + Self::new(r, g, b).with_alpha(a) + } +} + +impl FromStr for Color { + type Err = color::ParseError; + + fn from_str(s: &str) -> Result { + fn is_bare_hex(s: &str) -> bool { + let s = s.trim(); + s.chars().count() == 6 && s.chars().all(|c| c.is_ascii_hexdigit()) + } + let color: AlphaColor = match s.parse() { + 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()) + } +} + +impl Serialize for Color { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.display_css().to_small_string::<32>()) + } +} + +impl<'de> Deserialize<'de> for Color { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct ColorVisitor; + + impl<'de> Visitor<'de> for ColorVisitor { + type Value = Color; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("\"#rrggbb\" or \"rrggbb\" or web color name") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + v.parse().map_err(E::custom) + } + } + + deserializer.deserialize_str(ColorVisitor) + } +} + +/// A structure for formatting a [Color] in a CSS-compatible format. +/// +/// See [Color::display_css]. +struct ColorDisplayCss(Color); + +impl Display for ColorDisplayCss { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Color { alpha, r, g, b } = self.0; + match alpha { + 255 => write!(f, "#{r:02x}{g:02x}{b:02x}"), + _ => write!(f, "rgb({r}, {g}, {b}, {:.2})", alpha as f64 / 255.0), + } + } +} + +/// Style for drawing a border in a pivot table. +#[derive(Copy, Clone, Debug, PartialEq, Deserialize)] +pub struct BorderStyle { + /// The kind of line to draw. + #[serde(rename = "@borderStyleType")] + pub stroke: Stroke, + + /// Line color. + #[serde(rename = "@color")] + pub color: Color, +} + +impl Serialize for BorderStyle { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut s = serializer.serialize_struct("BorderStyle", 2)?; + s.serialize_field("stroke", &self.stroke)?; + s.serialize_field("color", &self.color)?; + s.end() + } +} + +impl From for BorderStyle { + fn from(value: Stroke) -> Self { + Self::new(value) + } +} + +impl BorderStyle { + /// Returns a black border style with the given `stroke`. + pub const fn new(stroke: Stroke) -> Self { + Self { + stroke, + color: Color::BLACK, + } + } + + /// Returns a border style with no line. + pub const fn none() -> Self { + Self::new(Stroke::None) + } + + /// Returns whether this border style has no line. + pub fn is_none(&self) -> bool { + self.stroke.is_none() + } + + /// Returns a border style that "combines" the two arguments, that is, that + /// gives a reasonable choice for a rule for different reasons should have + /// both styles. + pub fn combine(self, other: BorderStyle) -> Self { + Self { + stroke: self.stroke.combine(other.stroke), + color: self.color, + } + } +} + +/// A line style for borders in a pivot table. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Stroke { + /// No line. + None, + /// Solid line. + Solid, + /// Dashed line. + Dashed, + /// Thick solid line. + Thick, + /// Thin solid line. + Thin, + /// Two lines. + Double, +} + +impl Stroke { + /// Return whether this stroke is [Stroke::None]. + pub fn is_none(&self) -> bool { + self == &Self::None + } + + /// Returns a stroke that "combines" the two arguments, that is, that gives + /// a reasonable stroke choice for a rule for different reasons should have + /// both styles. + pub fn combine(self, other: Stroke) -> Self { + self.max(other) + } +} diff --git a/rust/pspp/src/output/pivot/look_xml.rs b/rust/pspp/src/output/pivot/look_xml.rs index a9e1264f55..dfbb93e5f4 100644 --- a/rust/pspp/src/output/pivot/look_xml.rs +++ b/rust/pspp/src/output/pivot/look_xml.rs @@ -19,16 +19,17 @@ use std::{fmt::Debug, num::ParseFloatError, str::FromStr}; use enum_map::enum_map; use serde::{Deserialize, de::Visitor}; -use crate::{ - format::Decimal, - output::pivot::{ - Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, Color, FootnoteMarkerPosition, - FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, VertAlign, +use crate::output::pivot::{ + Axis2, FootnoteMarkerPosition, FootnoteMarkerType, + look::{ + self, Area, AreaStyle, Border, BorderStyle, BoxBorder, Color, HeadingRegion, HorzAlign, + LabelPosition, Look, RowColBorder, RowParity, VertAlign, }, }; use thiserror::Error as ThisError; -#[derive(Deserialize, Debug)] +/// An XML TableLook. +#[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, @@ -91,12 +92,12 @@ impl From for Look { Axis2::X => table_properties.printing_properties.rescale_wide_table_to_fit_page, Axis2::Y => table_properties.printing_properties.rescale_long_table_to_fit_page, }, - top_continuation: table_properties + show_continuations: [ table_properties .printing_properties .continuation_text_at_top, - bottom_continuation: table_properties + table_properties .printing_properties - .continuation_text_at_bottom, + .continuation_text_at_bottom], continuation: { let text = table_properties.printing_properties.continuation_text; if text.is_empty() { @@ -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,30 +189,29 @@ 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 { + cell_style: look::CellStyle { horz_align: match self.text_alignment { TextAlignment::Left => Some(HorzAlign::Left), TextAlignment::Right => Some(HorzAlign::Right), TextAlignment::Center => Some(HorzAlign::Center), TextAlignment::Decimal => Some(HorzAlign::Decimal { offset: self.decimal_offset.as_px_f64(), - decimal: Decimal::Dot, }), TextAlignment::Mixed => None, }, @@ -225,27 +225,26 @@ impl CellStyle { Axis2::Y => [self.margin_top.as_px_i32(), self.margin_bottom.as_px_i32()], }, }, - font_style: super::FontStyle { + font_style: look::FontStyle { 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::WHITE), + RowParity::Odd => self.alternating_color.unwrap_or(Color::WHITE), + }, 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 +252,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 +260,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 +268,7 @@ enum FontUnderline { Underline, } -#[derive(Deserialize, Debug, Default)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] enum TextAlignment { Left, @@ -280,7 +279,7 @@ enum TextAlignment { Mixed, } -#[derive(Deserialize, Debug, Default)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] enum LabelLocationVertical { /// Top. @@ -294,7 +293,7 @@ enum LabelLocationVertical { Center, } -#[derive(Deserialize, Debug)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BorderProperties { bottom_inner_frame: BorderStyle, @@ -318,7 +317,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")] @@ -346,42 +345,57 @@ struct PrintingProperties { continuation_text_at_top: bool, } +/// A length. #[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 { + /// Returns the length in 1/96" units. + pub fn as_px_f64(self) -> f64 { self.0 * 96.0 } - fn as_px_i32(self) -> i32 { + /// Returns the length in 1/96" units. + pub fn as_px_i32(self) -> i32 { num::cast(self.as_px_f64() + 0.5).unwrap_or_default() } - fn as_pt_f64(self) -> f64 { + /// Returns the length in 1/72" units. + pub fn as_pt_f64(self) -> f64 { self.0 * 72.0 } - fn as_pt_i32(self) -> i32 { + /// Returns the length in 1/72" units. + 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)?; + let unit = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == ','); + let value = &s[..s.len() - unit.len()]; + let number: f64 = if value.contains(',') { + value.replace(',', ".").parse() + } else { + value.parse() + } + .map_err(LengthParseError::ParseFloatError)?; let divisor = match unit.trim() { // Inches. "in" | "인치" | "pol." | "cala" | "cali" => 1.0, @@ -395,14 +409,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 +426,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 +434,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 +456,52 @@ 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::{ + Axis2, FootnoteMarkerPosition, FootnoteMarkerType, + look::{ + Area, AreaStyle, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle, + 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 +563,408 @@ 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::WHITE, + 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), + show_continuations: [false, 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..17d1de9b2a 100644 --- a/rust/pspp/src/output/pivot/output.rs +++ b/rust/pspp/src/output/pivot/output.rs @@ -20,13 +20,17 @@ use enum_map::{EnumMap, enum_map}; use itertools::Itertools; use crate::output::{ - pivot::{HeadingRegion, LabelPosition, Path}, - table::{CellInner, Table}, + pivot::{ + Footnote, Path, + look::{HeadingRegion, LabelPosition, RowParity}, + }, + table::{CellInner, CellPos, CellRect, Table}, }; -use super::{ - Area, Axis2, Axis3, Border, BorderStyle, BoxBorder, Color, Coord2, Dimension, Footnote, - IntoValueOptions, PivotTable, Rect2, RowColBorder, Stroke, Value, +use crate::output::pivot::{ + Axis2, Axis3, Dimension, PivotTable, + look::{Area, Border, BorderStyle, BoxBorder, Color, RowColBorder, Stroke}, + value::Value, }; /// All of the combinations of dimensions along an axis. @@ -92,7 +96,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; } } @@ -139,15 +143,15 @@ impl PivotTable { I: Iterator> + ExactSizeIterator, { let mut table = Table::new( - Coord2::new(1, rows.len()), - Coord2::new(0, 0), - self.look.areas.clone(), + CellPos::new(1, rows.len()), + CellPos::new(0, 0), + self.style.look.areas.clone(), self.borders(false), - self.into_value_options(), + self, ); for (y, row) in rows.enumerate() { table.put( - Rect2::for_cell(Coord2::new(0, y)), + CellRect::for_cell(CellPos::new(0, y)), CellInner::new(area, row), ); } @@ -166,25 +170,54 @@ impl PivotTable { } fn borders(&self, printing: bool) -> EnumMap { + fn resolve_border_style( + border: Border, + borders: &EnumMap, + show_grid_lines: bool, + ) -> BorderStyle { + let style = borders[border]; + if style.stroke != Stroke::None { + style + } else { + let style = borders[border.fallback()]; + if style.stroke != Stroke::None || !show_grid_lines { + style + } else { + BorderStyle { + stroke: Stroke::Dashed, + color: Color::BLACK, + } + } + } + } + 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, + ) }) } + /// Constructs a [Table] for the body of this `PivotTable` for the layer + /// with the specified indexes. `printing` specifies whether the table is + /// for printing or screen display (grid lines are only enabled for + /// printing). pub fn output_body(&self, layer_indexes: &[usize], printing: bool) -> Table { let headings = EnumMap::from_fn(|axis| Headings::new(self, axis, layer_indexes)); - let data = Coord2::from_fn(|axis| headings[axis].width()); - let mut stub = Coord2::from_fn(|axis| headings[!axis].height()); - if headings[Axis2::Y].row_label_position == LabelPosition::Corner && stub.y() == 0 { + let data = CellPos::from_fn(|axis| headings[axis].width()); + let mut stub = CellPos::from_fn(|axis| headings[!axis].height()); + if headings[Axis2::Y].row_label_position == LabelPosition::Corner && stub.y == 0 { stub[Axis2::Y] = 1; } let mut body = Table::new( - Coord2::from_fn(|axis| data[axis] + stub[axis]), + CellPos::from_fn(|axis| data[axis] + stub[axis]), stub, - self.look.areas.clone(), + self.style.look.areas.clone(), self.borders(printing), - self.into_value_options(), + self, ); for h in [Axis2::X, Axis2::Y] { @@ -199,55 +232,56 @@ 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()), - }, + CellRect::new(x..x + 1, y..y + 1), + 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 - && stub.x() > 0 - && stub.y() > 0 + 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()), + CellRect::new(0..stub.x, 0..stub.y), + CellInner::new( + Area::Corner, + self.metadata.corner_text.clone().unwrap_or_default(), + ), ); } - if body.n.x() > 0 && body.n.y() > 0 { - body.h_line(Border::InnerFrame(BoxBorder::Top), 0..body.n.x(), 0); - body.h_line( - Border::InnerFrame(BoxBorder::Bottom), - 0..body.n.x(), - body.n.y(), - ); - body.v_line(Border::InnerFrame(BoxBorder::Left), 0, 0..body.n.y()); - body.v_line( - Border::InnerFrame(BoxBorder::Right), - body.n.x(), - 0..body.n.y(), - ); + if body.n.x > 0 && body.n.y > 0 { + body.h_line(Border::InnerFrame(BoxBorder::Top), 0..body.n.x, 0); + body.h_line(Border::InnerFrame(BoxBorder::Bottom), 0..body.n.x, body.n.y); + body.v_line(Border::InnerFrame(BoxBorder::Left), 0, 0..body.n.y); + body.v_line(Border::InnerFrame(BoxBorder::Right), body.n.x, 0..body.n.y); - body.h_line(Border::DataTop, 0..body.n.x(), stub.y()); - body.v_line(Border::DataLeft, stub.x(), 0..body.n.y()); + body.h_line(Border::DataTop, 0..body.n.x, stub.y); + body.v_line(Border::DataLeft, stub.x, 0..body.n.y); } body } + /// Constructs a [Table] for this `PivotTable`'s title. Returns `None` if + /// the table doesn't have a title. 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(), + )) } + /// Constructs a [Table] for this `PivotTable`'s layer values. Returns + /// `None` if the table doesn't have layers. pub fn output_layers(&self, layer_indexes: &[usize]) -> Option
{ let mut layers = Vec::new(); for (dimension, &layer_index) in zip( @@ -258,7 +292,14 @@ impl PivotTable { layer_indexes, ) { if !dimension.is_empty() { - layers.push(dimension.nth_leaf(layer_index).unwrap().name.clone()); + // `\u{2001}` is an "em quad" space, which looks to me like the + // space that SPSS uses here. + let s = format!( + "{}:\u{2001}{}", + dimension.root.name().display(self), + dimension.nth_leaf(layer_index).unwrap().0.display(self) + ); + layers.push(Box::new(Value::new_user_text(s))); } } layers.reverse(); @@ -266,10 +307,17 @@ impl PivotTable { self.create_aux_table_if_nonempty(Area::Layers, layers.into_iter()) } + /// Constructs a [Table] for this `PivotTable`'s caption. Returns `None` if + /// the table doesn't have a caption. 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(), + )) } + /// Constructs a [Table] for this `PivotTable`'s footnotes. Returns `None` + /// if the table doesn't have footnotes. pub fn output_footnotes(&self, footnotes: &[Arc]) -> Option
{ self.create_aux_table_if_nonempty( Area::Footer, @@ -283,12 +331,18 @@ impl PivotTable { ) } + /// Constructs [OutputTables] for this `PivotTable`, for the specified + /// layer, formatted for screen display or printing as specified. 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 = [ @@ -343,20 +397,36 @@ impl PivotTable { } } +/// [Table]s for outputting a layer of a [PivotTable]. pub struct OutputTables { + /// Title table, if any. pub title: Option
, + /// Layers table, if any. pub layers: Option
, + /// Table body. pub body: Table, + /// Table caption, if any. pub caption: Option
, + /// Footnotes, if any. pub footnotes: Option
, } impl Path<'_> { - pub fn get(&self, y: usize, height: usize) -> Option<&Value> { - if y + 1 == height { - Some(&self.leaf.name) + /// Gets the label to be displayed for this path to a leaf within a heading + /// block with the given `height`. Returns both the label and the range of + /// rows within the heading block that displays the label. + /// + /// A path to a leaf that contains `n` groups must be displayed in a heading + /// block with at least `n + 1` rows. Within a heading block with `height` + /// rows, the groups are displayed in rows `0..n`, and the leaf is displayed + /// in rows `n..height`. Thus, each group is displayed in exactly one row, + /// but the leaf can span multiple rows. + 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 +492,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()), - }, + CellRect::for_ranges((h, x1 + h_ofs..x2 + h_ofs), y1..y2), + 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 +558,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 // ┌─────────────────────────────────────────────────────┐ @@ -520,14 +597,10 @@ impl<'a> Heading<'a> { } } - if dimension_label_position == LabelPosition::Corner { + if dimension_label_position == LabelPosition::Corner && self.dimension.root.show_label { table.put( - Rect2::new(v_ofs..v_ofs + 1, 0..h_ofs), - CellInner { - rotate: false, - area: Area::Corner, - value: self.dimension.root.name.clone(), - }, + CellRect::new(v_ofs..v_ofs + 1, 0..h_ofs), + CellInner::new(Area::Corner, self.dimension.root.name.clone()), ); } } @@ -542,7 +615,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 +630,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 +694,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 // ┌─────────────────────────────────────────────────────┐ __ @@ -643,49 +722,3 @@ impl<'a> Headings<'a> { } } } - -pub fn try_range(range: R, bounds: std::ops::RangeTo) -> Option> -where - R: std::ops::RangeBounds, -{ - let len = bounds.end; - - let start = match range.start_bound() { - std::ops::Bound::Included(&start) => start, - std::ops::Bound::Excluded(start) => start.checked_add(1)?, - std::ops::Bound::Unbounded => 0, - }; - - let end = match range.end_bound() { - std::ops::Bound::Included(end) => end.checked_add(1)?, - std::ops::Bound::Excluded(&end) => end, - std::ops::Bound::Unbounded => len, - }; - - if start > end || end > len { - None - } else { - Some(std::ops::Range { start, end }) - } -} - -fn resolve_border_style( - border: Border, - borders: &EnumMap, - show_grid_lines: bool, -) -> BorderStyle { - let style = borders[border]; - if style.stroke != Stroke::None { - style - } else { - let style = borders[border.fallback()]; - if style.stroke != Stroke::None || !show_grid_lines { - style - } else { - BorderStyle { - stroke: Stroke::Dashed, - color: Color::BLACK, - } - } - } -} 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..842bf2702a --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cl-all_layers.expected @@ -0,0 +1,23 @@ +Column (All Layers) +b: b1 +╭──┬──┬──╮ +│a1│a2│a3│ +├──┼──┼──┤ +│ 0│ 1│ 2│ +╰──┴──┴──╯ + +Column (All Layers) +b: b2 +╭──┬──┬──╮ +│a1│a2│a3│ +├──┼──┼──┤ +│ 3│ 4│ 5│ +╰──┴──┴──╯ + +Column (All Layers) +b: 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..9a3c09891e --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cl-layer0.expected @@ -0,0 +1,7 @@ +Column x b1 +b: 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..6611a174d5 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_cl-layer1.expected @@ -0,0 +1,7 @@ +Column x b2 +b: 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..2a83533b22 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rl-all_layers.expected @@ -0,0 +1,23 @@ +Row (All Layers) +b: b1 +╭──┬─╮ +│a1│0│ +│a2│1│ +│a3│2│ +╰──┴─╯ + +Row (All Layers) +b: b2 +╭──┬─╮ +│a1│3│ +│a2│4│ +│a3│5│ +╰──┴─╯ + +Row (All Layers) +b: 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..843d15dbbc --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rl-layer0.expected @@ -0,0 +1,7 @@ +Row x b1 +b: 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..53ad394014 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d2_rl-layer1.expected @@ -0,0 +1,7 @@ +Row x b2 +b: 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..3ddf5ab679 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d3-layer0_0.expected @@ -0,0 +1,8 @@ +Column x b1 x a1 +b: b1 +a: 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..8f4dd26678 --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d3-layer0_1.expected @@ -0,0 +1,8 @@ +Column x b2 x a1 +b: b2 +a: 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..bc679a3ebe --- /dev/null +++ b/rust/pspp/src/output/pivot/testdata/d3-layer1_2.expected @@ -0,0 +1,8 @@ +Column x b3 x a2 +b: b3 +a: 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..ad73c77a9c 100644 --- a/rust/pspp/src/output/pivot/tests.rs +++ b/rust/pspp/src/output/pivot/tests.rs @@ -19,19 +19,24 @@ 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, + Axis2, Class, Dimension, Footnote, FootnoteMarkerPosition, FootnoteMarkerType, Footnotes, + Group, PivotTable, + look::{ + Area, Border, BorderStyle, Color, HeadingRegion, HorzAlign, LabelPosition, Look, + RowColBorder, Stroke, + }, }, - spv::SpvDriver, }; -use super::{Axis3, Value}; +use super::{Axis3, value::Value}; #[test] fn color() { @@ -65,43 +70,17 @@ 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 { let mut look = Look::default(); - look.areas[Area::Title].cell_style.horz_align = Some(super::HorzAlign::Left); + look.areas[Area::Title].cell_style.horz_align = Some(HorzAlign::Left); look.areas[Area::Title].font_style.bold = false; look } @@ -164,48 +143,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 +200,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 +217,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 +229,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 +246,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 +258,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 +275,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 +287,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 +408,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 +481,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 +492,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 +503,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 +515,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 +528,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 +539,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 +557,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 +581,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 +653,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 +667,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 +681,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 +695,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 +711,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 +727,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 +751,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 +833,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..28f497232c 100644 --- a/rust/pspp/src/output/pivot/tlo.rs +++ b/rust/pspp/src/output/pivot/tlo.rs @@ -16,15 +16,12 @@ use std::{fmt::Debug, io::Cursor}; -use crate::{ - format::Decimal, - output::pivot::{ - Axis2, Border, BoxBorder, FootnoteMarkerPosition, FootnoteMarkerType, HeadingRegion, - LabelPosition, RowColBorder, - }, +use crate::output::pivot::{ + Axis2, FootnoteMarkerPosition, FootnoteMarkerType, + look::{self, Border, BoxBorder, HeadingRegion, LabelPosition, RowColBorder}, }; -use super::{Area, BorderStyle, Color, HorzAlign, Look, Stroke, VertAlign}; +use crate::output::pivot::look::{Area, BorderStyle, Color, HorzAlign, Look, Stroke, VertAlign}; use binrw::{BinRead, BinResult, Error as BinError, binread}; use enum_map::enum_map; @@ -53,7 +50,7 @@ pub fn parse_tlo(input: &[u8]) -> BinResult { } /// Points (72/inch) to pixels (96/inch). -fn pt_to_px(pt: i32) -> usize { +fn pt_to_px(pt: i32) -> isize { num::cast((pt as f64 * (96.0 / 72.0)).round()).unwrap_or_default() } @@ -63,7 +60,7 @@ fn px_to_pt(px: i32) -> i32 { } /// 20ths of a point to pixels (96/inch). -fn pt20_to_px(pt20: i32) -> usize { +fn pt20_to_px(pt20: i32) -> isize { num::cast((pt20 as f64 * (96.0 / 72.0 / 20.0)).round()).unwrap_or_default() } @@ -97,13 +94,13 @@ impl From for Look { FootnoteMarkerPosition::Superscript }, areas: enum_map! { - Area::Title => super::AreaStyle::from_tlo(look.pv_cell_style.title_color, &look.pv_text_style.title_style), + Area::Title => look::AreaStyle::from_tlo(look.pv_cell_style.title_color, &look.pv_text_style.title_style), Area::Caption => (&look.pv_text_style.caption).into(), Area::Footer => (&look.pv_text_style.footer).into(), 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! { @@ -141,8 +138,7 @@ impl From for Look { Axis2::X => (flags & 0x10) != 0, Axis2::Y => (flags & 0x20) != 0 }, - top_continuation: (flags & 0x80) != 0, - bottom_continuation: (flags & 0x100) != 0, + show_continuations: [(flags & 0x80) != 0, (flags & 0x100) != 0], continuation: { let s = &look.v2_styles.continuation; if s.is_empty() { @@ -218,6 +214,7 @@ enum Separator { None, #[br(magic = 1u16)] Some { + #[br(parse_with(parse_tlo_color))] color: Color, style: u16, width: u16, @@ -249,17 +246,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 +267,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 +281,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 @@ -347,23 +328,22 @@ struct MostAreas { style: AreaStyle, } -impl From<&MostAreas> for super::AreaStyle { +impl From<&MostAreas> for look::AreaStyle { fn from(area: &MostAreas) -> Self { Self::from_tlo(area.color, &area.style) } } -impl super::AreaStyle { +impl look::AreaStyle { fn from_tlo(bg: Color, style: &AreaStyle) -> Self { Self { - cell_style: super::CellStyle { + cell_style: look::CellStyle { horz_align: match style.halign { 0 => Some(HorzAlign::Left), 1 => Some(HorzAlign::Right), 2 => Some(HorzAlign::Center), 4 => Some(HorzAlign::Decimal { offset: style.decimal_offset as f64 / (72.0 * 20.0) * 96.0, - decimal: Decimal::Comma, }), _ => None, }, @@ -382,17 +362,13 @@ impl super::AreaStyle { } }, }, - font_style: super::FontStyle { + font_style: look::FontStyle { 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 +403,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 +467,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/pivot/value.rs b/rust/pspp/src/output/pivot/value.rs new file mode 100644 index 0000000000..3af7749c78 --- /dev/null +++ b/rust/pspp/src/output/pivot/value.rs @@ -0,0 +1,1265 @@ +// 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 . + +//! Data cell contents. +//! +//! This module contains [Value], which is the contents of a single pivot table +//! cell, plus what it in turn contains. + +// Warn about missing docs, but not for items declared with `#[cfg(test)]`. +#![cfg_attr(not(test), warn(missing_docs))] +#![warn(dead_code)] + +use crate::{ + calendar::{date_time_to_pspp, time_to_pspp}, + data::{ByteString, Datum, EncodedString, WithEncoding}, + format::{self, DATETIME40_0, Decimal, F8_2, F40, Format, TIME40_0, Type, UncheckedFormat}, + output::pivot::{ + Footnote, FootnoteMarkerType, + look::{CellStyle, FontStyle}, + }, + settings::{Settings, Show}, + spv::html::Markup, + variable::{VarType, Variable}, +}; +use chrono::{NaiveDateTime, NaiveTime}; +use serde::{ + Serialize, Serializer, + ser::{SerializeMap, SerializeStruct}, +}; +use std::{ + borrow::Borrow, + fmt::{Debug, Display, Write}, + iter::{once, repeat}, + sync::Arc, +}; + +/// The content of a single pivot table cell. +/// +/// A [Value] is also a pivot table's title, caption, footnote marker and +/// contents, and so on. +#[derive(Clone, Default, PartialEq)] +pub struct Value { + /// Content. + pub inner: ValueInner, + + /// Optional styling. + pub styling: Option>, +} + +impl Serialize for Value { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.inner.serialize(serializer) + } +} +/// Wrapper for [Value] that serializes in a plain way: +/// +/// - Numbers: The number. +/// - Strings: The string. +/// - Variables: The variable name. +/// - Text: The localized text string. +/// - Markup: A string containing HTML for the markup. +/// - Template: The formatted template string. +/// - Empty: `()`. +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct BareValue(pub T); +impl Serialize for BareValue +where + T: Borrow, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let value = self.0.borrow(); + match &value.inner { + ValueInner::Datum(datum_value) => datum_value.serialize_bare(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(_) => value.display(()).to_string().serialize(serializer), + ValueInner::Empty => ().serialize(serializer), + } + } +} +impl Value { + /// Constructs a new `Value`, initially with no styling. + /// + /// Usually one of the other constructors is more convenient. + pub fn new(inner: ValueInner) -> Self { + Self { + inner, + styling: None, + } + } + + /// Constructs a new `Value` from `number` with a default [F8_2] format. + /// Some related useful methods are: + /// + /// - [with_source_variable], to add information about the variable that the + /// datum came from (or use [new_datum_from_variable] as a shortcut to + /// combine both). + /// + /// - [with_format] to override the default format. + /// + /// [with_source_variable]: Self::with_source_variable + /// [new_datum_from_variable]: Self::new_datum_from_variable + /// [with_format]: Self::with_format + pub fn new_number(number: Option) -> Self { + Self::new(ValueInner::Datum(DatumValue::new_number(number))) + } + + /// Construct a new `Value` from `number` with format [F8_0]. + /// + /// [F8_0]: crate::format::F8_0 + pub fn new_integer(x: Option) -> Self { + Self::new_number(x).with_format(F40) + } + + /// Constructs a new `Value` as a number whose value is `date_time`, which + /// is converted to the [PSPP date representation](crate::calendar), with + /// format [DATETIME40_0]. + pub fn new_date(date_time: NaiveDateTime) -> Self { + Self::new_number(Some(date_time_to_pspp(date_time))).with_format(DATETIME40_0) + } + + /// Constructs a new `Value` as a number whose value is `time`, which is + /// converted to the [PSPP time representation](crate::calendar), with + /// format [TIME40_0]. + pub fn new_time(time: NaiveTime) -> Self { + Self::new_number(Some(time_to_pspp(time))).with_format(TIME40_0) + } + + /// Constructs a new `Value` from localizable text string `s`. + /// + /// PSPP doesn't support internationalization yet, so this does the same + /// thing as [new_user_text] for now. + /// + /// [new_user_text]: Self::new_user_text + pub fn new_text(s: impl Into) -> Self { + Self::new_user_text(s) + } + + /// Constructs a new `Value` from localizable text string `localized`, + /// English string `c`, and identifier `id`. If the string came from the + /// user, `user_provided` should be true. + 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, + })) + } + + /// Constructs a new `Value` from `markup`. + pub fn new_markup(markup: Markup) -> Self { + Self::new(ValueInner::Markup(markup)) + } + + /// Constructs a new text `Value` from `s`, which should have been provided + /// by the user. + pub fn new_user_text(s: impl Into) -> Self { + let s: String = s.into(); + if s.is_empty() { + Self::default() + } else { + Self::new(ValueInner::Text(TextValue { + user_provided: true, + localized: s, + c: None, + id: None, + })) + } + } + + /// Constructs a new `Value` from `variable`. + pub fn new_variable(variable: &Variable) -> Self { + Self::new(ValueInner::Variable(VariableValue { + show: None, + var_name: String::from(variable.name.as_str()), + variable_label: variable.label.clone(), + })) + } + + /// Constructs a new `Value` from `datum` with a default format. Some + /// related useful methods are: + /// + /// - [with_source_variable], to add information about the variable that the + /// datum came from (or use [new_datum_from_variable] as a shortcut to + /// combine both). + /// + /// - [with_format] to override the default format. + /// + /// [with_source_variable]: Self::with_source_variable + /// [new_datum_from_variable]: Self::new_datum_from_variable + /// [with_format]: Self::with_format + pub fn new_datum(datum: &Datum) -> Self + where + B: EncodedString, + { + Self::new(ValueInner::Datum(DatumValue::new(datum))) + } + + /// Construct a new, empty `Value`. + pub const fn new_empty() -> Self { + // Can't use `Self::default()` because that is non-const. + Value { + inner: ValueInner::Empty, + styling: None, + } + } + + /// Returns a reference to a statically allocated empty `Value`. + pub const fn static_empty() -> &'static Self { + static EMPTY: Value = Value::new_empty(); + &EMPTY + } + + /// Returns true if this `Value` is empty and unstyled. + pub const fn is_empty(&self) -> bool { + self.inner.is_empty() && self.styling.is_none() + } + + /// Returns this value with its value label, format, and variable name from + /// `variable`. + pub fn with_source_variable(self, variable: &Variable) -> Self { + let value_label = self + .datum() + .and_then(|datum| variable.value_labels.get(&datum).map(String::from)); + self.with_value_label(value_label) + .with_format(variable.print_format) + .with_variable_name(Some(variable.name.as_str().into())) + } + + /// Returns this value with its display format set to `format`, if it is a + /// [DatumValue]. + pub fn with_format(self, format: Format) -> Self { + Self { + inner: self.inner.with_format(format), + ..self + } + } + + /// Returns this value with `honor_small` set as specified, if it is a + /// [DatumValue]. + pub fn with_honor_small(self, honor_small: bool) -> Self { + Self { + inner: self.inner.with_honor_small(honor_small), + ..self + } + } + + /// Construct a new `Value` from `datum`, which is a value of `variable`. + pub fn new_datum_from_variable(datum: &Datum, variable: &Variable) -> Self { + Self::new_datum(&datum.as_encoded(variable.encoding())).with_source_variable(variable) + } + + /// Returns the inner [Datum], if this value is a [DatumValue]. + pub fn datum(&self) -> Option<&Datum>> { + self.inner.datum() + } + + /// Returns this `Value` with the added `footnote`. + pub fn with_footnote(mut self, footnote: &Arc) -> Self { + self.add_footnote(footnote); + self + } + + /// Adds `footnote` to this `Value`. + pub fn add_footnote(&mut self, footnote: &Arc) { + let footnotes = &mut self.styling_mut().footnotes; + footnotes.push(footnote.clone()); + footnotes.sort_by_key(|f| f.index); + } + + /// Returns this `Value` with `show` as the [Show] setting for value labels, + /// if this is a [DatumValue]. + pub fn with_show_value_label(mut self, show: Option) -> Self { + if let Some(datum_value) = self.inner.as_datum_value_mut() { + datum_value.show = show; + } + self + } + + /// Returns this `Value` with `value_label` as the value label, if this is a + /// [DatumValue]. + /// + /// Use [with_source_variable], instead, to automatically add a value label + /// and other information from a source variable. + /// + /// [with_source_variable]: Self::with_source_variable + pub fn with_value_label(mut self, value_label: Option) -> Self { + if let Some(datum_value) = self.inner.as_datum_value_mut() { + datum_value.value_label = value_label.clone() + } + self + } + + /// Returns this `Value` with `variable_name` as the variable's name, if + /// this is a [DatumValue]. + /// + /// Use [with_source_variable], instead, to automatically add a variable + /// name and other information from a source variable. + /// + /// [with_source_variable]: Self::with_source_variable + pub fn with_variable_name(mut self, variable_name: Option) -> Self { + if let Some(datum_value) = self.inner.as_datum_value_mut() { + datum_value.variable = variable_name.clone() + } + self + } + + /// Returns this `Value` with `show` as the [Show] setting for variable + /// labels, if this is a [VariableValue]. + pub fn with_show_variable_label(mut self, show: Option) -> Self { + if let ValueInner::Variable(variable_value) = &mut self.inner { + variable_value.show = show; + } + self + } + + /// Returns this `Value` with the specified `font_style`. + pub fn with_font_style(mut self, font_style: FontStyle) -> Self { + self.styling_mut().font_style = Some(font_style); + self + } + + /// Returns this `Value` with the specified `cell_style`. + pub fn with_cell_style(mut self, cell_style: CellStyle) -> Self { + self.styling_mut().cell_style = Some(cell_style); + self + } + + /// Returns this `Value` with the specified `styling`. + pub fn with_styling(self, styling: Option>) -> Self { + Self { styling, ..self } + } + + /// Returns the styling for this `Value` for modification. + /// + /// If this `Value` doesn't have styling yet, this creates it. + pub fn styling_mut(&mut self) -> &mut ValueStyle { + self.styling.get_or_insert_default() + } + + /// Returns this `Value`'s font style, if it has one. + pub fn font_style(&self) -> Option<&FontStyle> { + self.styling + .as_ref() + .map(|styling| styling.font_style.as_ref()) + .flatten() + } + + /// Returns this `Value`'s cell style, if it has one. + pub fn cell_style(&self) -> Option<&CellStyle> { + self.styling + .as_ref() + .map(|styling| styling.cell_style.as_ref()) + .flatten() + } + + /// Returns this `Value`'s subscripts. + pub fn subscripts(&self) -> &[String] { + self.styling + .as_ref() + .map_or(&[], |styling| &styling.subscripts) + } + + /// Returns this `Value`'s footnotes. + pub fn footnotes(&self) -> &[Arc] { + self.styling + .as_ref() + .map_or(&[], |styling| &styling.footnotes) + } + + /// Returns an object that will format this value, including subscripts and + /// superscripts and footnotes. `options` controls whether variable and + /// value labels are included. + pub fn display(&self, options: impl Into) -> DisplayValue<'_> { + let display = self.inner.display(options); + match &self.styling { + Some(styling) => display.with_styling(styling), + None => display, + } + } + + /// Serializes this value in a plain way, like [BareValue]. This function + /// can be used on a field as `#[serde(serialize_with = + /// Value::serialize_bare)]`. + pub fn serialize_bare(&self, serializer: S) -> Result + where + S: Serializer, + { + BareValue(self).serialize(serializer) + } +} + +impl From<&str> for Value { + fn from(value: &str) -> Self { + Self::new_text(value) + } +} + +impl From for Value { + fn from(value: String) -> Self { + Self::new_text(value) + } +} + +impl From<&Variable> for Value { + fn from(variable: &Variable) -> Self { + Self::new_variable(variable) + } +} + +/// Helper struct for printing a [Value] with `format!` and `{}`. +/// +/// Create this struct with [Value::display]. +#[derive(Clone, Debug)] +pub struct DisplayValue<'a> { + inner: &'a ValueInner, + subscripts: &'a [String], + footnotes: &'a [Arc], + options: ValueOptions, + show_value: bool, + show_label: Option<&'a str>, +} + +impl<'a> DisplayValue<'a> { + /// Returns the subscripts to be displayed, as an iterator of `&str`. + pub fn subscripts(&self) -> impl Iterator + ExactSizeIterator + Clone { + self.subscripts.iter().map(String::as_str) + } + + /// Returns true if the value to be displayed includes subscripts. + pub fn has_subscripts(&self) -> bool { + !self.subscripts.is_empty() + } + + /// Returns the footnotes to be displayed, as an iterator. + /// + /// The iterator can have fewer elements than there are footnotes, because + /// footnotes can be hidden. + pub fn footnotes(&self) -> impl Iterator + Clone { + self.footnotes + .iter() + .filter(|f| f.show) + .map(|f| f.display_marker(self.options.clone())) + } + + /// Returns true if there are footnotes to be displayed. + /// + /// Because footnotes can be hidden, this method can return false for values + /// with footnotes. + pub fn has_footnotes(&self) -> bool { + self.footnotes().next().is_some() + } + + /// Returns this [DisplayValue] modified so that it won't show any + /// subscripts or footnotes. + pub fn without_suffixes(self) -> Self { + Self { + subscripts: &[], + footnotes: &[], + ..self + } + } + + /// Returns this [DisplayValue] modified so that it will only show the + /// suffixes and footnotes, not the body. + pub fn without_body(self) -> Self { + Self { + inner: &ValueInner::Empty, + ..self + } + } + + /// Returns the [Markup] to be formatted, if any. + pub fn markup(&self) -> Option<&Markup> { + self.inner.as_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) { + (self.clone().without_suffixes(), self.without_body()) + } + + /// Returns this display with subscripts and footnotes taken from `styling`. + /// + /// (This display can't use the other parts of `styling`, since we're just + /// formatting plain text.) + pub fn with_styling(mut self, styling: &'a ValueStyle) -> Self { + self.subscripts = styling.subscripts.as_slice(); + self.footnotes = styling.footnotes.as_slice(); + self + } + + /// Returns this display with the given `subscripts.` + pub fn with_subscripts(self, subscripts: &'a [String]) -> Self { + Self { subscripts, ..self } + } + + /// Returns this display with the given `footnotes.` + pub fn with_footnotes(self, footnotes: &'a [Arc]) -> Self { + Self { footnotes, ..self } + } + + /// Returns true if this display will format to the empty string. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() && self.subscripts.is_empty() && self.footnotes.is_empty() + } + + /// Returns the character that the formatted value would use for a decimal + /// point if it has one, or `None` if the value isn't a datum value and + /// therefore doesn't have a decimal point. + pub fn decimal(&self) -> Option { + self.inner + .as_datum_value() + .map(|datum_value| datum_value.decimal()) + } + + fn small(&self) -> f64 { + self.options.small + } + + /// Returns a variable type for the value to be displayed. + /// + /// We consider a numeric value displayed by itself to be numeric, but if + /// the value label is displayed then it is considered to be a string. + /// Anything else is also a string. + /// + /// This is useful for passing to [HorzAlign::for_mixed], although maybe + /// this method should just return [HorzAlign] directly. + /// + /// [HorzAlign]: crate::output::pivot::look::HorzAlign + /// [HorzAlign::for_mixed]: crate::output::pivot::look::HorzAlign::for_mixed + pub fn var_type(&self) -> VarType { + if let Some(datum_value) = self.inner.as_datum_value() + && datum_value.datum.is_number() + && self.show_label.is_none() + { + VarType::Numeric + } else { + VarType::String + } + } +} + +impl Display for DisplayValue<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.inner { + ValueInner::Datum(datum_value) => datum_value.display(self, f), + ValueInner::Variable(variable_value) => variable_value.display(self, f), + ValueInner::Markup(markup) => write!(f, "{markup}"), + ValueInner::Text(text_value) => write!(f, "{text_value}"), + ValueInner::Template(template_value) => template_value.display(self, f), + ValueInner::Empty => Ok(()), + }?; + + for (subscript, delimiter) in self.subscripts.iter().zip(once('_').chain(repeat(','))) { + write!(f, "{delimiter}{subscript}")?; + } + + for footnote in self.footnotes { + write!(f, "[{}]", footnote.display_marker(&self.options))?; + } + + Ok(()) + } +} + +impl Debug for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match &self.inner { + ValueInner::Datum(_) => "Datum", + 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.as_markup() { + write!(f, " (markup: {markup:?})")?; + } + if let Some(styling) = &self.styling { + write!(f, " ({styling:?})")?; + } + Ok(()) + } +} + +/// A datum and how to display it. +#[derive(Clone, Debug, PartialEq)] +pub struct DatumValue { + /// The datum. + pub datum: Datum>, + + /// The display format. + pub format: Format, + + /// Whether to show `value` or `value_label` or both. + /// + /// If this is unset, then a higher-level default is used. + pub show: Option, + + /// If true, then numbers smaller than [PivotTableStyle::small] will be + /// displayed in scientific notation. Otherwise, all numbers will be + /// displayed with `format`. + /// + /// [PivotTableStyle::small]: super::PivotTableStyle::small + pub honor_small: bool, + + /// The name of the variable that `value` came from, if any. + pub variable: Option, + + /// The value label associated with `value`, if any. + pub value_label: Option, +} + +impl Serialize for DatumValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if self.format.type_() == Type::F && self.variable.is_none() && self.value_label.is_none() { + self.datum.serialize(serializer) + } else { + let mut s = serializer.serialize_map(None)?; + s.serialize_entry("datum", &self.datum)?; + s.serialize_entry("format", &self.format)?; + if let Some(show) = self.show { + s.serialize_entry("show", &show)?; + } + if self.honor_small { + s.serialize_entry("honor_small", &self.honor_small)?; + } + if let Some(variable) = &self.variable { + s.serialize_entry("variable", variable)?; + } + if let Some(value_label) = &self.value_label { + s.serialize_entry("value_label", value_label)?; + } + s.end() + } + } +} + +impl DatumValue { + /// Constructs a new `DatumValue` for `datum`. + pub fn new(datum: &Datum) -> Self + where + B: EncodedString, + { + Self { + datum: datum.cloned(), + format: F8_2, + show: None, + honor_small: false, + variable: None, + value_label: None, + } + } + + /// Constructs a new `DatumValue` for `number`. + pub fn new_number(number: Option) -> Self { + Self::new(&Datum::<&str>::Number(number)) + } + + /// Returns this `DatumValue` with the given `format`. + pub fn with_format(self, format: Format) -> Self { + Self { format, ..self } + } + + /// Returns this `DatumValue` with the given `honor_small`. + pub fn with_honor_small(self, honor_small: bool) -> Self { + Self { + honor_small, + ..self + } + } + + /// Writes this value to `f` using the settings in `display`. + pub fn display<'a>( + &self, + display: &DisplayValue<'a>, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + if display.show_value { + match &self.datum { + Datum::Number(number) => { + let format = if self.format.type_() == Type::F + && self.honor_small + && let Some(number) = *number + && number != 0.0 + && number.abs() < display.small() + { + UncheckedFormat::new(Type::E, 40, self.format.d() as u8).fix() + } else { + self.format + }; + self.datum + .display(format) + .with_settings(&display.options.settings) + .without_leading_spaces() + .fmt(f)?; + } + Datum::String(s) => { + if self.format.type_() == Type::AHex { + write!(f, "{}", s.inner.display_hex())?; + } else { + f.write_str(&s.as_str())?; + } + } + } + } + if let Some(label) = display.show_label { + if display.show_value { + f.write_char(' ')?; + } + f.write_str(label)?; + } + Ok(()) + } + + /// Returns the decimal point used in the formatted value, if any. + pub fn decimal(&self) -> Decimal { + self.datum.display(self.format).decimal() + } + + /// Serializes this value to `serializer` in the "bare" manner described for + /// [BareValue]. + pub fn serialize_bare(&self, serializer: S) -> Result + where + S: Serializer, + { + if let Datum::Number(Some(number)) = &self.datum + && let number = *number + && number.trunc() == number + && number >= -(1i64 << 53) as f64 + && number <= (1i64 << 53) as f64 + { + Some(number as u64).serialize(serializer) + } else { + self.datum.serialize(serializer) + } + } +} + +/// A variable name. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct VariableValue { + /// Variable name. + pub var_name: String, + + /// Variable label, if any. + pub variable_label: Option, + + /// Whether to show `var_name` or `variable_label` or both. + /// + /// If this is unset, then a higher-level default is used. + pub show: Option, +} + +impl VariableValue { + fn display(&self, display: &DisplayValue<'_>, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if display.show_value { + f.write_str(&self.var_name)?; + } + if let Some(label) = display.show_label { + if display.show_value { + f.write_char(' ')?; + } + f.write_str(label)?; + } + Ok(()) + } +} + +/// A text string. +/// +/// A `TextValue` is used for text within a table, such as a title, a column or +/// row heading, or a footnote. (String data values are better represented as +/// [DatumValue].) +#[derive(Clone, Debug, PartialEq)] +pub struct TextValue { + /// Whether the text came from the user. + /// + /// PSPP can localize text that it writes itself, but not text provided by + /// the user. + pub user_provided: bool, + + /// Localized. + /// + /// This is the main output string. + pub localized: String, + + /// English version of the string. + /// + /// Only for strings that are not user-provided, and only if it is different + /// from `localized`. + pub c: Option, + + /// Identifier. + /// + /// Only for strings that are not user-provided, and only if it is different + /// from `localized`. + pub id: Option, +} + +impl Serialize for TextValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if self.user_provided && self.c.is_none() && self.id.is_none() { + serializer.serialize_str(&self.localized) + } else { + let mut s = serializer.serialize_struct( + "TextValue", + 2 + self.c.is_some() as usize + self.id.is_some() as usize, + )?; + s.serialize_field("user_provided", &self.user_provided)?; + s.serialize_field("localized", &self.localized)?; + if let Some(c) = &self.c { + s.serialize_field("c", &c)?; + } + if let Some(id) = &self.id { + s.serialize_field("id", &id)?; + } + s.end() + } + } +} + +impl Display for TextValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.localized) + } +} + +impl TextValue { + /// Returns the localized version of this `TextValue`. + pub fn localized(&self) -> &str { + self.localized.as_str() + } + + /// Returns the English version of this `TextValue`. + pub fn c(&self) -> &str { + self.c.as_ref().unwrap_or(&self.localized).as_str() + } + + /// Returns an identifier for this `TextValue`. + pub fn id(&self) -> &str { + self.id.as_ref().unwrap_or(&self.localized).as_str() + } +} + +/// A template with substitutions. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct TemplateValue { + /// Template string. + /// + /// The documentation for [Value] in the PSPP manual describes the template + /// syntax. + /// + /// [Value]: https://pspp.benpfaff.org/manual/spv/light-detail.html#value + pub localized: String, + + /// Arguments to the template string. + pub args: Vec>, + + /// Optional identifier for the template. + pub id: Option, +} + +impl TemplateValue { + fn display<'a>( + &self, + display: &DisplayValue<'a>, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + fn extract_inner_template(input: &str) -> (&str, &str) { + let mut prev = None; + for (index, c) in input.char_indices() { + if c == ':' && prev != Some('\\') { + return (&input[..index], &input[index + 1..]); + } + prev = Some(c); + } + (input, "") + } + + let mut iter = self.localized.chars(); + while let Some(c) = iter.next() { + match c { + '\\' => { + let c = match iter.next() { + None => '\\', + Some('n') => '\n', + Some(c) => c, + }; + f.write_char(c)?; + } + '^' => { + let (index, rest) = Self::consume_int(iter.as_str()); + if let Some(index) = index.checked_sub(1) + && let Some(arg) = self.args.get(index) + && let Some(arg) = arg.first() + { + write!(f, "{}", arg.display(&display.options))?; + } + iter = rest.chars(); + } + '[' => { + let (a, rest) = extract_inner_template(iter.as_str()); + let (b, rest) = extract_inner_template(rest); + let rest = rest.strip_prefix("]").unwrap_or(rest); + let (index, rest) = Self::consume_int(rest); + iter = rest.chars(); + + if let Some(index) = index.checked_sub(1) + && let Some(args) = self.args.get(index) + { + let mut args = args.as_slice(); + let (mut template, mut escape) = + if !a.is_empty() { (a, '%') } else { (b, '^') }; + while !args.is_empty() + && let n_consumed = + self.inner_template(display, f, template, escape, args)? + && n_consumed > 0 + { + args = &args[n_consumed..]; + template = b; + escape = '^'; + } + } + } + c => f.write_char(c)?, + } + } + Ok(()) + } + + fn inner_template<'a>( + &self, + display: &DisplayValue<'a>, + f: &mut std::fmt::Formatter<'_>, + template: &str, + escape: char, + args: &[Value], + ) -> Result { + let mut iter = template.chars(); + let mut args_consumed = 0; + while let Some(c) = iter.next() { + match c { + '\\' => { + let c = iter.next().unwrap_or('\\') as char; + let c = if c == 'n' { '\n' } else { c }; + write!(f, "{c}")?; + } + c if c == escape => { + let (index, rest) = Self::consume_int(iter.as_str()); + iter = rest.chars(); + if let Some(index) = index.checked_sub(1) + && let Some(arg) = args.get(index) + { + args_consumed = args_consumed.max(index + 1); + write!(f, "{}", arg.display(&display.options))?; + } + } + c => write!(f, "{c}")?, + } + } + Ok(args_consumed) + } + + fn consume_int(input: &str) -> (usize, &str) { + let mut n = 0; + for (index, c) in input.char_indices() { + match c.to_digit(10) { + Some(digit) => n = n * 10 + digit as usize, + None => return (n, &input[index..]), + } + } + (n, "") + } +} + +/// Possible content for a [Value]. +#[derive(Clone, Debug, Default, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ValueInner { + /// A [Datum] value. + Datum( + /// The datum. + DatumValue, + ), + /// A variable name. + Variable( + /// The variable. + VariableValue, + ), + /// Plain text. + Text( + /// The text. + TextValue, + ), + /// Rich text. + Markup( + /// The rich text. + Markup, + ), + /// A template with substitutions. + Template( + /// The template. + TemplateValue, + ), + /// An empty value. + #[default] + Empty, +} + +impl ValueInner { + /// Returns true if this is a [ValueInner::Empty]. + pub const fn is_empty(&self) -> bool { + matches!(self, Self::Empty) + } + + /// Returns this value with its display format set to `format`, if it is a + /// [DatumValue]. + pub fn with_format(mut self, format: Format) -> Self { + if let Some(datum_value) = self.as_datum_value_mut() { + datum_value.format = format; + } + self + } + + /// Returns this value with `honor_small` set as specified, if it is a + /// [DatumValue]. + pub fn with_honor_small(mut self, honor_small: bool) -> Self { + if let Some(datum_value) = self.as_datum_value_mut() { + datum_value.honor_small = honor_small; + } + self + } + + /// Returns the [Datum] inside this value, if it is a [DatumValue]. + pub fn datum(&self) -> Option<&Datum>> { + self.as_datum_value().map(|d| &d.datum) + } + + /// Returns the [Show] value inside this value, if it has one, or [None] + /// otherwise. + fn show(&self) -> Option { + match self { + ValueInner::Datum(DatumValue { show, .. }) + | ValueInner::Variable(VariableValue { show, .. }) => *show, + _ => None, + } + } + + /// Returns the value label or variable label inside this value, if it has + /// one. + pub fn label(&self) -> Option<&str> { + self.value_label().or_else(|| self.variable_label()) + } + + /// Returns the value label inside this value, if it has one. + fn value_label(&self) -> Option<&str> { + self.as_datum_value() + .and_then(|d| d.value_label.as_ref().map(String::as_str)) + } + + /// Returns the variable label inside this value, if it has one. + fn variable_label(&self) -> Option<&str> { + self.as_variable_value() + .and_then(|d| d.variable_label.as_ref().map(String::as_str)) + } + + /// Returns the [DatumValue] inside this value, if it is + /// [ValueInner::Datum]. + pub fn as_datum_value(&self) -> Option<&DatumValue> { + match self { + ValueInner::Datum(datum) => Some(datum), + _ => None, + } + } + + /// Returns the [DatumValue] inside this value, mutably, if it is + /// [ValueInner::Datum]. + pub fn as_datum_value_mut(&mut self) -> Option<&mut DatumValue> { + match self { + ValueInner::Datum(datum) => Some(datum), + _ => None, + } + } + + /// Returns the [VariableValue] inside this value, if it is + /// [ValueInner::Variable]. + pub fn as_variable_value(&self) -> Option<&VariableValue> { + match self { + ValueInner::Variable(variable) => Some(variable), + _ => None, + } + } + + /// Returns the [VariableValue] inside this value, mutably, if it is + /// [ValueInner::Variable]. + pub fn as_variable_value_mut(&mut self) -> Option<&mut VariableValue> { + match self { + ValueInner::Variable(variable) => Some(variable), + _ => None, + } + } + + /// Returns the [Markup] inside this value, if it is [ValueInner::Markup]. + fn as_markup(&self) -> Option<&Markup> { + match self { + ValueInner::Markup(markup) => Some(markup), + _ => None, + } + } + + /// Returns an object that will format this value. Settings on `options` + /// control whether variable and value labels are included. + pub fn display(&self, options: impl Into) -> DisplayValue<'_> { + fn interpret_show( + global_show: impl Fn() -> Show, + table_show: Option, + value_show: Option, + label: &str, + ) -> (bool, Option<&str>) { + match value_show.or(table_show).unwrap_or_else(global_show) { + Show::Value => (true, None), + Show::Label => (false, Some(label)), + Show::Both => (true, Some(label)), + } + } + + let options = options.into(); + let (show_value, show_label) = if let Some(value_label) = self.value_label() { + interpret_show( + || Settings::global().show_values, + options.show_values, + self.show(), + value_label, + ) + } else if let Some(variable_label) = self.variable_label() { + interpret_show( + || Settings::global().show_variables, + options.show_variables, + self.show(), + variable_label, + ) + } else { + (true, None) + }; + DisplayValue { + inner: self, + subscripts: &[], + footnotes: &[], + options, + show_value, + show_label, + } + } +} + +/// Styling inside a [Value]. +/// +/// Most [Value]s use a default style, so this is a separate [Box]ed structure +/// to save memory. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ValueStyle { + /// Cell style. + pub cell_style: Option, + + /// Font style. + pub font_style: Option, + + /// Subscripts. + pub subscripts: Vec, + + /// Footnotes. + pub footnotes: Vec>, +} + +impl ValueStyle { + /// Returns true if this [ValueStyle] is empty. + /// + /// This will return false if the font style exists but is the default font + /// style, and similarly for the cell style. + pub fn is_empty(&self) -> bool { + self.font_style.is_none() + && self.cell_style.is_none() + && self.subscripts.is_empty() + && self.footnotes.is_empty() + } +} + +/// Options for displaying a [Value]. +#[derive(Clone, Debug)] +pub struct ValueOptions { + /// Whether to show values or value labels, or both. + /// + /// When this is `None`, a global default is used. + pub show_values: Option, + + /// Whether to show variable names or variable labels, or both. + /// + /// When this is `None`, a global default is used. + pub show_variables: Option, + + /// Numbers whose magnitudes are less than this value are displayed in + /// scientific notation. A value of 0 disables this feature. + pub small: f64, + + /// Where to put the footnote markers. + pub footnote_marker_type: FootnoteMarkerType, + + /// Settings for formatting [Datum]s. + pub settings: Arc, +} + +impl Default for ValueOptions { + fn default() -> Self { + Self { + show_values: None, + show_variables: None, + small: 0.0001, + footnote_marker_type: FootnoteMarkerType::default(), + settings: Settings::global().formats.clone(), + } + } +} + +impl From<()> for ValueOptions { + fn from(_: ()) -> Self { + ValueOptions::default() + } +} + +impl From<&ValueOptions> for ValueOptions { + fn from(value: &ValueOptions) -> Self { + value.clone() + } +} diff --git a/rust/pspp/src/output/render.rs b/rust/pspp/src/output/render.rs index 61ac68af47..be194a7dcf 100644 --- a/rust/pspp/src/output/render.rs +++ b/rust/pspp/src/output/render.rs @@ -15,21 +15,22 @@ // this program. If not, see . use std::cmp::{max, min}; -use std::collections::HashMap; -use std::iter::once; +use std::iter::{once, zip}; use std::ops::Range; use std::sync::Arc; use enum_map::{Enum, EnumMap, enum_map}; -use itertools::interleave; +use itertools::{Itertools, interleave}; use num::Integer; use smallvec::SmallVec; -use crate::output::pivot::VertAlign; -use crate::output::table::DrawCell; - -use super::pivot::{Axis2, BorderStyle, Coord2, Look, PivotTable, Rect2, Stroke}; -use super::table::{Content, Table}; +use crate::output::{ + pivot::{ + Axis2, Coord2, PivotTable, Rect2, + look::{BorderStyle, Look, Stroke, VertAlign}, + }, + table::{CellPos, CellRect, Content, DrawCell, Table}, +}; /// Parameters for rendering a table_item to a device. /// @@ -51,20 +52,22 @@ pub struct Params { /// Nominal size of a character in the most common font: /// `font_size[Axis2::X]` is the em width. /// `font_size[Axis2::Y]` is the line leading. - pub font_size: EnumMap, + pub font_size: EnumMap, /// Width of different kinds of lines. - pub line_widths: EnumMap, + pub line_widths: EnumMap, /// 1/96" of an inch (1px) in the rendering unit. Currently used only for - /// column width ranges, as in `width_ranges` in - /// [crate::output::pivot::Look]. Set to `None` to disable this feature. - pub px_size: Option, + /// column width ranges, as in `width_ranges` in [Look]. Set to `None` to + /// disable this feature. + /// + /// [Look]: crate::output::pivot::look::Look + pub px_size: Option, /// Minimum cell width or height before allowing the cell to be broken /// across two pages. (Joined cells may always be broken at join /// points.) - pub min_break: EnumMap, + pub min_break: EnumMap, /// True if the driver supports cell margins. (If false, the rendering /// engine will insert a small space betweeen adjacent cells that don't have @@ -88,7 +91,7 @@ pub struct Params { impl Params { /// Returns a small but visible width. - fn em(&self) -> usize { + fn em(&self) -> isize { self.font_size[Axis2::X] } } @@ -110,10 +113,10 @@ pub trait Device { /// /// - `map[Extreme::Max]` is the minimum width required to avoid line breaks /// other than at new-lines. - fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap; + fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap; /// Returns the height required to render `cell` given a width of `width`. - fn measure_cell_height(&self, cell: &DrawCell, width: usize) -> usize; + fn measure_cell_height(&self, cell: &DrawCell, width: isize) -> isize; /// Given that there is space measuring `size` to render `cell`, where /// `size.y()` is insufficient to render the entire height of the cell, @@ -125,7 +128,7 @@ pub trait Device { /// /// Optional. If [Params::can_adjust_break] is false, the rendering engine /// assumes that all breakpoints are acceptable. - fn adjust_break(&self, cell: &Content, size: Coord2) -> usize; + fn adjust_break(&self, cell: &Content, size: Coord2) -> isize; /// Draws a generalized intersection of lines in `bb`. /// @@ -151,10 +154,9 @@ pub trait Device { fn draw_cell( &mut self, draw_cell: &DrawCell, - alternate_row: bool, bb: Rect2, - valign_offset: usize, - spill: EnumMap, + valign_offset: isize, + spill: EnumMap, clip: &Rect2, ); @@ -167,203 +169,57 @@ pub trait Device { fn scale(&mut self, factor: f64); } -/// A layout for rendering a specific table on a specific device. -/// -/// May represent the layout of an entire table presented to [Pager::new], or a -/// rectangular subregion of a table broken out using [Break::next] to allow a -/// table to be broken across multiple pages. -/// -/// A page's size is not limited to the size passed in as part of [Params]. -/// [Pager] breaks a [Page] into smaller [page]s that will fit in the available -/// space. -/// -/// # Rendered cells -/// -/// The horizontal cells rendered are the leftmost `h[X]`, then `r[X]`. -/// The vertical cells rendered are the topmost `h[Y]`, then `r[Y]`. -/// `n[i]` is the sum of `h[i]` and `r[i].len()`. #[derive(Debug)] -struct Page { - table: Arc
, - - /// Size of the table in cells. - /// - n: Coord2, - - /// Header size. Cells `0..h[X]` are rendered horizontally, and `0..h[Y]` vertically. - h: Coord2, - - /// Main region of cells to render. - r: Rect2, - - /// Mappings from [Page] positions to those in the underlying [Table]. - maps: EnumMap, +struct RenderedTable { + table: Table, /// "Cell positions". /// - /// cp[X] represents x positions within the table. - /// cp[X][0] = 0. - /// cp[X][1] = the width of the leftmost vertical rule. - /// cp[X][2] = cp[X][1] + the width of the leftmost column. - /// cp[X][3] = cp[X][2] + the width of the second-from-left vertical rule. - /// and so on: - /// cp[X][2 * n[X]] = x position of the rightmost vertical rule. - /// cp[X][2 * n[X] + 1] = total table width including all rules. - /// - /// Similarly, cp[Y] represents y positions within the table. - /// cp[Y][0] = 0. - /// cp[Y][1] = the height of the topmost horizontal rule. - /// cp[Y][2] = cp[Y][1] + the height of the topmost row. - /// cp[Y][3] = cp[Y][2] + the height of the second-from-top horizontal rule. - /// and so on: - /// cp[Y][2 * n[Y]] = y position of the bottommost horizontal rule. - /// cp[Y][2 * n[Y] + 1] = total table height including all rules. - /// - /// Rules and columns can have width or height 0, in which case consecutive - /// values in this array are equal. - cp: EnumMap>, - - /// [Break::next] can break a table such that some cells are not fully - /// contained within a render_page. This will happen if a cell is too wide - /// or two tall to fit on a single page, or if a cell spans multiple rows - /// or columns and the page only includes some of those rows or columns. - /// - /// This hash table contains represents each such cell that doesn't - /// completely fit on this page. - /// - /// Each overflow cell borders at least one header edge of the table and may - /// border more. (A single table cell that is so large that it fills the - /// entire page can overflow on all four sides!) - /// - /// # Interpretation - /// - /// overflow[X][0]: space trimmed off its left side. - /// overflow[X][1]: space trimmed off its right side. - /// overflow[Y][0]: space trimmed off its top. - /// overflow[Y][1]: space trimmed off its bottom. - /// - /// During rendering, this information is used to position the rendered - /// portion of the cell within the available space. - /// - /// When a cell is rendered, sometimes it is permitted to spill over into - /// space that is ordinarily reserved for rules. Either way, this space is - /// still included in overflow values. - /// - /// Suppose, for example, that a cell that joins 2 columns has a width of 60 - /// pixels and content "abcdef", that the 2 columns that it joins have - /// widths of 20 and 30 pixels, respectively, and that therefore the rule - /// between the two joined columns has a width of 10 (20 + 10 + 30 = 60). - /// It might render like this, if each character is 10x10, and showing a few - /// extra table cells for context: + /// `cp[X]` represents `x` positions within the table: /// - /// ```text - /// +------+ - /// |abcdef| - /// +--+---+ - /// |gh|ijk| - /// +--+---+ - /// ``` + /// - `cp[X][0]` = 0. + /// - `cp[X][1]` = the width of the leftmost vertical rule. + /// - `cp[X][2]` = `cp[X][1]` + the width of the leftmost column. + /// - `cp[X][3]` = `cp[X][2]` + the width of the second-from-left vertical rule. + /// - ... + /// - `cp[X][2 * n[X]]` = `x` position of the rightmost vertical rule. + /// - `cp[X][2 * n[X] + 1]` = total table width including all rules. /// - /// If this render_page is broken at the rule that separates "gh" from - /// "ijk", then the page that contains the left side of the "abcdef" cell - /// will have overflow[X][1] of 10 + 30 = 40 for its portion of the cell, - /// and the page that contains the right side of the cell will have - /// overflow[X][0] of 20 + 10 = 30. The two resulting pages would look like - /// this: + /// So, for 0-based column `i`: /// - /// ```text - /// +--- - /// |abc - /// +--+ - /// |gh| - /// +--+ - /// ``` + /// * The rule to its left covers `cp[X][i * 2]..cp[X][i * 2 + 1]`. + /// * The column covers `cp[X][i * 2 + 1]..cp[X][i * 2 + 2]`. + /// * The rule to its right covers `cp[X][i * 2 + 2]..cp[X][i * 2 + 3]`. /// - /// and: + /// Similarly, `cp[Y]` represents `y` positions within the table: /// - /// ```text - /// ----+ - /// cdef| - /// +---+ - /// |ijk| - /// +---+ - /// ``` - /// Each entry maps from a cell that overflows to the space that has been - /// trimmed off the cell: - overflows: HashMap>, - - /// If a single column (or row) is too wide (or tall) to fit on a page - /// reasonably, then render_break_next() will split a single row or column - /// across multiple render_pages. This member indicates when this has - /// happened: + /// - `cp[Y][0]` = 0. + /// - `cp[Y][1]` = the height of the topmost horizontal rule. + /// - `cp[Y][2]` = `cp[Y][1]` + the height of the topmost row. + /// - `cp[Y][3]` = `cp[Y][2]` + the height of the second-from-top horizontal rule. + /// - ... + /// - `cp[Y][2 * n[Y]]` = `y` position of the bottommost horizontal rule. + /// - `cp[Y][2 * n[Y] + 1]` = total table height including all rules. /// - /// is_edge_cutoff[X][0] is true if pixels have been cut off the left side - /// of the leftmost column in this page, and false otherwise. + /// So, for 0-based row `i`: /// - /// is_edge_cutoff[X][1] is true if pixels have been cut off the right side - /// of the rightmost column in this page, and false otherwise. + /// * The rule above it covers `cp[Y][i * 2]..cp[Y][i * 2 + 1]`. + /// * The row covers `cp[Y][i * 2 + 1]..cp[Y][i * 2 + 2]`. + /// * The rule below it covers `cp[Y][i * 2 + 2]..cp[Y][i * 2 + 3]`. /// - /// is_edge_cutoff[Y][0] and is_edge_cutoff[Y][1] are similar for the top - /// and bottom of the table. - /// - /// The effect of is_edge_cutoff is to prevent rules along the edge in - /// question from being rendered. - /// - /// When is_edge_cutoff is true for a given edge, the 'overflows' hmap will - /// contain a node for each cell along that edge. - is_edge_cutoff: EnumMap, -} - -/// Returns the width of `extent` along `axis`. -fn axis_width(cp: &[usize], extent: Range) -> usize { - cp[extent.end] - cp[extent.start] -} - -/// Returns the width of cells within `extent` along `axis`. -fn joined_width(cp: &[usize], extent: Range) -> usize { - axis_width(cp, cell_ofs(extent.start)..cell_ofs(extent.end) - 1) -} -/// Returns the offset in [Self::cp] of the cell with index `cell_index`. -/// That is, if `cell_index` is 0, then the offset is 1, that of the leftmost -/// or topmost cell; if `cell_index` is 1, then the offset is 3, that of the -/// next cell to the right (or below); and so on. */ -fn cell_ofs(cell_index: usize) -> usize { - cell_index * 2 + 1 -} - -/// Returns the offset in [Self::cp] of the rule with index `rule_index`. -/// That is, if `rule_index` is 0, then the offset is that of the leftmost -/// or topmost rule; if `rule_index` is 1, then the offset is that of the -/// next rule to the right (or below); and so on. -fn rule_ofs(rule_index: usize) -> usize { - rule_index * 2 -} - -/// Returns the width of cell `z` along `axis`. -fn cell_width(cp: &[usize], z: usize) -> usize { - let ofs = cell_ofs(z); - axis_width(cp, ofs..ofs + 1) -} - -/// Is `ofs` the offset of a rule in `cp`? -fn is_rule(z: usize) -> bool { - z.is_even() -} - -#[derive(Clone)] -pub struct RenderCell<'a> { - rect: Rect2, - content: &'a Content, + /// Rules and columns can have width or height 0, in which case consecutive + /// values in this array are equal. + cp: EnumMap>, } -impl Page { - /// Creates and returns a new [Page] for rendering `table` with the given +impl RenderedTable { + /// Creates and returns a new [RenderedTable] for rendering `table` with the given /// `look` on `device`. /// /// The new [Page] will be suitable for rendering on a device whose page /// size is `params.size`, but the caller is responsible for actually /// breaking it up to fit on such a device, using the [Break] abstraction. - fn new(table: Arc
, device: &dyn Device, min_width: usize, look: &Look) -> Self { + fn new(table: Table, device: &dyn Device, min_width: isize, look: &Look) -> Self { use Axis2::*; use Extreme::*; @@ -389,11 +245,11 @@ impl Page { // Calculate minimum and maximum widths of cells that do not span // multiple columns. - let mut unspanned_columns = EnumMap::from_fn(|_| vec![0; n.x()]); + let mut unspanned_columns = EnumMap::from_fn(|_| vec![0; n.x]); for cell in table.cells().filter(|cell| cell.col_span() == 1) { let mut w = device.measure_cell_width(&DrawCell::new(cell.inner(), &table)); if device.params().px_size.is_some() { - if let Some(region) = table.heading_region(cell.coord) { + if let Some(region) = table.heading_region(cell.pos) { let wr = &heading_widths[region]; if w[Min] < wr[Min] { w[Min] = wr[Min]; @@ -409,7 +265,7 @@ impl Page { } } - let x = cell.coord[X]; + let x = cell.pos[X]; for ext in [Min, Max] { if unspanned_columns[ext][x] < w[ext] { unspanned_columns[ext][x] = w[ext]; @@ -426,9 +282,9 @@ impl Page { for ext in [Min, Max] { distribute_spanned_width( w[ext], - &unspanned_columns[ext][rect[X].clone()], - &mut columns[ext][rect[X].clone()], - &rules[X][rect[X].start..rect[X].end + 1], + &unspanned_columns[ext][rect.x.clone()], + &mut columns[ext][rect.x.clone()], + &rules[X][rect.x.start..rect.x.end + 1], ); } } @@ -446,28 +302,25 @@ impl Page { // In pathological cases, spans can cause the minimum width of a column // to exceed the maximum width. This bollixes our interpolation // algorithm later, so fix it up. - for i in 0..n.x() { + for i in 0..n.x { if columns[Min][i] > columns[Max][i] { columns[Max][i] = columns[Min][i]; } } // Decide final column widths. - let rule_widths = rules[X].iter().copied().sum::(); - let table_widths = EnumMap::from_fn(|ext| columns[ext].iter().sum::() + rule_widths); + let rule_widths = rules[X].iter().copied().sum::(); + let table_widths = EnumMap::from_fn(|ext| columns[ext].iter().sum::() + rule_widths); let cp_x = if table_widths[Max] <= device.params().size[X] { // Fits even with maximum widths. Use them. Self::use_row_widths(&columns[Max], &rules[X]) - } else if table_widths[Min] <= device.params().size[X] { + } else if device.params().size[X] > table_widths[Min] { // Fits with minimum widths, so distribute the leftover space. - //Self::new_with_interpolated_widths() - Self::interpolate_row_widths( - device.params(), - &columns[Min], - &columns[Max], - table_widths[Min], - table_widths[Max], + Self::interpolate_column_widths( + device.params().size[Axis2::X], + &columns, + &table_widths, &rules[X], ) } else { @@ -481,10 +334,10 @@ impl Page { for cell in table.cells().filter(|cell| cell.row_span() == 1) { let rect = cell.rect(); - let w = joined_width(&cp_x, rect[X].clone()); + let w = joined_width(&cp_x, rect.x.clone()); let h = device.measure_cell_height(&DrawCell::new(cell.inner(), &table), w); - let row = &mut unspanned_rows[cell.coord.y()]; + let row = &mut unspanned_rows[cell.pos.y]; if h > *row { *row = h; } @@ -494,13 +347,13 @@ impl Page { let mut rows = unspanned_rows.clone(); for cell in table.cells().filter(|cell| cell.row_span() > 1) { let rect = cell.rect(); - let w = joined_width(&cp_x, rect[X].clone()); + let w = joined_width(&cp_x, rect.x.clone()); let h = device.measure_cell_height(&DrawCell::new(cell.inner(), &table), w); distribute_spanned_width( h, - &unspanned_rows[rect[Y].clone()], - &mut rows[rect[Y].clone()], - &rules[Y][rect[Y].start..rect[Y].end + 1], + &unspanned_rows[rect.y.clone()], + &mut rows[rect.y.clone()], + &rules[Y][rect.y.start..rect.y.end + 1], ); } @@ -520,21 +373,22 @@ impl Page { h[axis] = 0; } } - let r = Rect2::new(h[X]..n[X], h[Y]..n[Y]); - let maps = Self::new_mappings(h, &r); Self { table, - n, - h, - r, cp: Axis2::new_enum(cp_x, cp_y), - overflows: HashMap::new(), - is_edge_cutoff: EnumMap::default(), - maps, } } - fn use_row_widths(rows: &[usize], rules: &[usize]) -> Vec { + /// A [Page] always has the same headers as its underlying [Table]. + fn h(&self) -> CellPos { + self.table.h + } + + fn n(&self) -> CellPos { + self.table.n + } + + fn use_row_widths(rows: &[isize], rules: &[isize]) -> Vec { let mut vec = once(0) .chain(interleave(rules, rows).copied()) .collect::>(); @@ -544,21 +398,18 @@ impl Page { vec } - fn interpolate_row_widths( - params: &Params, - rows_min: &[usize], - rows_max: &[usize], - w_min: usize, - w_max: usize, - rules: &[usize], - ) -> Vec { - let avail = params.size[Axis2::X] - w_min; - let wanted = w_max - w_min; + fn interpolate_column_widths( + target: isize, + columns: &EnumMap>, + widths: &EnumMap, + rules: &[isize], + ) -> Vec { + use Extreme::*; + + let avail = target - widths[Min]; + let wanted = widths[Max] - widths[Min]; let mut w = wanted / 2; - let rows_mid = rows_min - .iter() - .copied() - .zip(rows_max.iter().copied()) + let rows_mid = zip(columns[Min].iter().copied(), columns[Max].iter().copied()) .map(|(min, max)| { w += avail * (max - min); let extra = w / wanted; @@ -570,28 +421,32 @@ impl Page { } /// Returns the width of `extent` along `axis`. - fn axis_width(&self, axis: Axis2, extent: Range) -> usize { + fn axis_width(&self, axis: Axis2, extent: Range) -> isize { axis_width(&self.cp[axis], extent) } /// Returns the width of cells within `extent` along `axis`. - fn joined_width(&self, axis: Axis2, extent: Range) -> usize { + fn joined_width(&self, axis: Axis2, extent: Range) -> isize { joined_width(&self.cp[axis], extent) } /// Returns the width of the headers along `axis`. - fn headers_width(&self, axis: Axis2) -> usize { - self.axis_width(axis, rule_ofs(0)..cell_ofs(self.h[axis])) + /// + /// The headers do not include the rule along the right or bottom edge of + /// the headers; that rule is considered to be part of the top or left body + /// cell. + fn headers_width(&self, axis: Axis2) -> isize { + self.axis_width(axis, rule_ofs(0)..cell_ofs(self.h()[axis])) } /// Returns the width of rule `z` along `axis`. - fn rule_width(&self, axis: Axis2, z: usize) -> usize { + fn rule_width(&self, axis: Axis2, z: usize) -> isize { let ofs = rule_ofs(z); self.axis_width(axis, ofs..ofs + 1) } /// Returns the width of rule `z` along `axis`, counting in reverse order. - fn rule_width_r(&self, axis: Axis2, z: usize) -> usize { + fn rule_width_r(&self, axis: Axis2, z: usize) -> isize { let ofs = self.rule_ofs_r(axis, z); self.axis_width(axis, ofs..ofs + 1) } @@ -603,280 +458,203 @@ impl Page { /// rule; if `rule_index_r` is 1, then the offset is that of the next rule to the left /// (or above); and so on. fn rule_ofs_r(&self, axis: Axis2, rule_index_r: usize) -> usize { - (self.n[axis] - rule_index_r) * 2 + (self.table.n[axis] - rule_index_r) * 2 } /// Returns the width of cell `z` along `axis`. - fn cell_width(&self, axis: Axis2, z: usize) -> usize { + fn cell_width(&self, axis: Axis2, z: usize) -> isize { let ofs = cell_ofs(z); self.axis_width(axis, ofs..ofs + 1) } /// Returns the width of the widest cell, excluding headers, along `axis`. - fn max_cell_width(&self, axis: Axis2) -> usize { - (self.h[axis]..self.n[axis]) + fn max_cell_width(&self, axis: Axis2) -> isize { + (self.h()[axis]..self.n()[axis]) .map(|z| self.cell_width(axis, z)) .max() .unwrap_or(0) } +} - fn width(&self, axis: Axis2) -> usize { - *self.cp[axis].last().unwrap() - } - - fn new_mappings(h: Coord2, r: &Rect2) -> EnumMap { - EnumMap::from_fn(|axis| { - [ - Map { - p0: 0, - t0: 0, - ofs: 0, - n: h[axis], - }, - Map { - p0: h[axis], - t0: r[axis].start, - ofs: r[axis].start - h[axis], - n: r[axis].len(), - }, - ] - }) - } - - fn get_map(&self, axis: Axis2, z: usize) -> &Map { - if z < self.h[axis] { - &self.maps[axis][0] - } else { - &self.maps[axis][1] - } - } +/// A layout for rendering a specific table on a specific device. +/// +/// May represent the layout of an entire table presented to [Pager::new], or a +/// rectangular subregion of a table broken out using [Break::next] to allow a +/// table to be broken across multiple pages. +/// +/// A page's size is not limited to the size passed in as part of [Params]. +/// [Pager] breaks a [Page] into smaller [page]s that will fit in the available +/// space. +/// +/// A [Page] always has the same headers as its [Table]. +/// +/// # Rendered cells +/// +/// - The columns rendered are the leftmost `self.table.h[X]`, then `r[X]`. +/// - The rows rendered are the topmost `self.table.h[Y]`, then `r[Y]`. +#[derive(Clone, Debug)] +struct Page { + /// Rendered table. + table: Arc, + ranges: EnumMap>, +} - fn map_z(&self, axis: Axis2, z: usize) -> usize { - z + self.get_map(axis, z).ofs +impl Page { + /// Creates and returns a new [RenderedTable] for rendering `table` with the given + /// `look` on `device`. + /// + /// The new [Page] will be suitable for rendering on a device whose page + /// size is `params.size`, but the caller is responsible for actually + /// breaking it up to fit on such a device, using the [Break] abstraction. + pub fn new(table: Table, device: &dyn Device, min_width: isize, look: &Look) -> Self { + let table = Arc::new(RenderedTable::new(table, device, min_width, look)); + let ranges = EnumMap::from_fn(|axis| { + table.cp[axis][1 + table.h()[axis] * 2]..table.cp[axis].last().copied().unwrap() + }); + Self { table, ranges } } - fn map_coord(&self, coord: Coord2) -> Coord2 { - Coord2::from_fn(|a| self.map_z(a, coord[a])) + pub fn split(&self, axis: Axis2) -> Break { + Break::new(self.clone(), axis) } - fn get_cell(&self, coord: Coord2) -> RenderCell<'_> { - let maps = EnumMap::from_fn(|axis| self.get_map(axis, coord[axis])); - let cell = self.table.get(self.map_coord(coord)); - RenderCell { - rect: Rect2(cell.rect().0.map(|axis, Range { start, end }| { - let m = maps[axis]; - max(m.p0, start - m.ofs)..min(m.p0 + m.n, end - m.ofs) - })), - content: cell.content, - } + fn width(&self, axis: Axis2) -> isize { + self.table.cp[axis].last().copied().unwrap() } - /// Creates and returns a new [Page] whose contents are a subregion of this - /// page's contents. The new page includes cells `extent` (exclusive) along - /// `axis`, plus any headers on `axis`. - /// - /// If `pixel0` is nonzero, then it is a number of pixels to exclude from - /// the left or top (according to `axis`) of cell `extent.start`. - /// Similarly, `pixel1` is a number of pixels to exclude from the right or - /// bottom of cell `extent.end - 1`. (`pixel0` and `pixel1` are used to - /// render cells that are too large to fit on a single page.) - /// - /// The whole of axis `!axis` is included. (The caller may follow up with - /// another call to select on `!axis`.) - fn select( - self: &Arc, - a: Axis2, - extent: Range, - pixel0: usize, - pixel1: usize, - ) -> Arc { - let b = !a; - let z0 = extent.start; - let z1 = extent.end; - - // If all of the page is selected, just make a copy. - if z0 == self.h[a] && z1 == self.n[a] && pixel0 == 0 && pixel1 == 0 { - return self.clone(); + fn draw(&self, device: &mut dyn Device, ofs: Coord2) { + fn overlap(a: &Range, b: &Range) -> bool { + a.contains(&b.start) || b.contains(&a.start) } - // Figure out `n`, `h`, `r` for the subpage. - let trim = [z0 - self.h[a], self.n[a] - z1]; - let mut n = self.n; - n[a] -= trim[0] + trim[1]; - let h = self.h; - let mut r = self.r.clone(); - r[a].start += trim[0]; - r[a].end -= trim[1]; - - // An edge is cut off if it was cut off in `self` or if we're trimming - // pixels off that side of the page and there are no headers. - let mut is_edge_cutoff = self.is_edge_cutoff; - is_edge_cutoff[a][0] = h[a] == 0 && (pixel0 > 0 || (z0 == 0 && self.is_edge_cutoff[a][0])); - is_edge_cutoff[a][1] = pixel1 > 0 || (z1 == self.n[a] && self.is_edge_cutoff[a][1]); - - // Select widths from `self` into subpage. - let scp = self.cp[a].as_slice(); - let mut dcp = Vec::with_capacity(2 * n[a] + 1); - dcp.push(0); - let mut total = 0; - for z in 0..=rule_ofs(h[a]) { - total += if z == 0 && is_edge_cutoff[a][0] { - 0 - } else { - scp[z + 1] - scp[z] - }; - dcp.push(total); - } - for z in cell_ofs(z0)..=cell_ofs(z1 - 1) { - total += scp[z + 1] - scp[z]; - if z == cell_ofs(z0) { - total -= pixel0; - } - if z == cell_ofs(z1 - 1) { - total -= pixel1; + use Axis2::*; + let cp = &self.table.cp; + let headers = Coord2::from_fn(|a| self.table.headers_width(a)); + for (y, yr) in self.table.cp[Y] + .iter() + .copied() + .tuple_windows() + .map(|(y0, y1)| y0..y1) + .enumerate() + .filter_map(|(y, yr)| if y % 2 == 1 { Some((y / 2, yr)) } else { None }) + { + if yr.start >= headers[Y] && !overlap(&yr, &self.ranges[Y]) { + continue; } - dcp.push(total); - } - let z = self.rule_ofs_r(a, 0); - if !is_edge_cutoff[a][1] { - total += scp[z + 1] - scp[z]; - } - dcp.push(total); - debug_assert_eq!(dcp.len(), 1 + 2 * n[a] + 1); - - let mut cp = EnumMap::default(); - cp[a] = dcp; - cp[!a] = self.cp[!a].clone(); - - let mut overflows = HashMap::new(); - - // Add new overflows. - let s = Selection { - a, - b, - h, - z0, - z1, - p0: pixel0, - p1: pixel1, - }; - if self.h[a] == 0 || z0 > self.h[a] || pixel0 > 0 { - let mut z = 0; - while z < self.n[b] { - let d = Coord2::for_axis((a, z0), z); - let cell = self.get_cell(d); - let overflow0 = pixel0 > 0 || cell.rect[a].start < z0; - let overflow1 = cell.rect[a].end > z1 || (cell.rect[a].end == z1 && pixel1 > 0); - if overflow0 || overflow1 { - let mut overflow = self.overflows.get(&d).cloned().unwrap_or_default(); - if overflow0 { - overflow[a][0] += - pixel0 + self.axis_width(a, cell_ofs(cell.rect[a].start)..cell_ofs(z0)); + for (x, xr) in self.table.cp[X] + .iter() + .copied() + .tuple_windows() + .map(|(x0, x1)| x0..x1) + .enumerate() + .filter_map(|(x, xr)| if x % 2 == 1 { Some((x / 2, xr)) } else { None }) + { + if xr.start >= headers[X] && !overlap(&xr, &self.ranges[X]) { + continue; + } + let cell = self.table.table.get(CellPos { x, y }); + // XXX skip if not top-left cell + let rect = cell.rect(); + let mut bb = Rect2::from_fn(|a| { + cp[a][rect[a].start * 2 + 1]..cp[a][(rect[a].end - 1) * 2 + 2] + }); + let mut clip = if y < self.table.h().y { + if x < self.table.h().x { + // Corner + bb.clone() + } else { + // Top stub + Rect2::new( + max(bb[X].start, self.ranges[X].start) + ..min(bb[X].end, self.ranges[X].end), + bb[Y].clone(), + ) } - if overflow1 { - overflow[a][1] += - pixel1 + self.axis_width(a, cell_ofs(z1)..cell_ofs(cell.rect[a].end)); + } else if x < self.table.h().x { + // Left stub + Rect2::new( + bb[X].clone(), + max(bb[Y].start, self.ranges[Y].start)..min(bb[Y].end, self.ranges[Y].end), + ) + } else { + // Body + Rect2::from_fn(|a| { + max(bb[a].start, self.ranges[a].start)..min(bb[a].end, self.ranges[a].end) + }) + }; + if clip[X].start >= clip[X].end || clip[Y].start >= clip[Y].end { + continue; + } + for a in [X, Y] { + if bb[a].start >= self.ranges[a].start { + let h = self.ranges[a].start - self.table.headers_width(a); + bb[a].start -= h; + bb[a].end -= h; + clip[a].start -= h; + clip[a].end -= h; } - assert!( - overflows - .insert(s.coord_to_subpage(cell.rect.top_left()), overflow) - .is_none() - ); } - z += cell.rect[b].len(); + let draw_cell = DrawCell::new(cell.content.inner(), &self.table.table); + let valign_offset = match draw_cell.cell_style.vert_align { + VertAlign::Top => 0, + VertAlign::Middle => self.extra_height(device, &bb, &draw_cell) / 2, + VertAlign::Bottom => self.extra_height(device, &bb, &draw_cell), + }; + device.draw_cell( + &draw_cell, + bb.translate(ofs), + valign_offset, + EnumMap::from_fn(|_| [0, 0]), + &clip.translate(ofs), + ) } } - let mut z = 0; - while z < self.n[b] { - let d = Coord2::for_axis((a, z1 - 1), z); - let cell = self.get_cell(d); - if cell.rect[a].end > z1 - || (cell.rect[a].end == z1 && pixel1 > 0) - && overflows.contains_key(&s.coord_to_subpage(cell.rect.top_left())) + for (y, yr) in self.table.cp[Y] + .iter() + .copied() + .tuple_windows() + .map(|(y0, y1)| y0..y1) + .enumerate() + { + for (x, xr) in self.table.cp[X] + .iter() + .copied() + .tuple_windows() + .map(|(x0, x1)| x0..x1) + .enumerate() + .filter(|(x, _)| *x % 2 == 0 || y % 2 == 0) { - let mut overflow = self.overflows.get(&d).cloned().unwrap_or_default(); - overflow[a][1] += - pixel1 + self.axis_width(a, cell_ofs(z1)..cell_ofs(cell.rect[a].end)); - assert!( - overflows - .insert(s.coord_to_subpage(cell.rect.top_left()), overflow) - .is_none() - ); - } - z += cell.rect[b].len(); - } - - // Copy overflows from `self` into the subpage. - // XXX this could be done at the start, which would simplify the while loops above - for (coord, overflow) in self.overflows.iter() { - let cell = self.table.get(*coord); - let rect = cell.rect(); - if rect[a].end > z0 && rect[a].start < z1 { - overflows - .entry(s.coord_to_subpage(rect.top_left())) - .or_insert(*overflow); - } - } - - let maps = Self::new_mappings(h, &r); - Arc::new(Self { - table: self.table.clone(), - n, - h, - r, - maps, - cp, - overflows, - is_edge_cutoff, - }) - } - - fn total_size(&self, axis: Axis2) -> usize { - self.cp[axis].last().copied().unwrap() - } - - fn draw(&self, device: &mut dyn Device, ofs: Coord2) { - use Axis2::*; - self.draw_cells( - device, - ofs, - Rect2::new(0..self.n[X] * 2 + 1, 0..self.n[Y] * 2 + 1), - ); - } + let mut bb = Rect2::new(xr.clone(), yr.clone()); - fn draw_cells(&self, device: &mut dyn Device, ofs: Coord2, cells: Rect2) { - use Axis2::*; - for y in cells[Y].clone() { - let mut x = cells[X].start; - while x < cells[X].end { - if !is_rule(x) && !is_rule(y) { - let cell = self.get_cell(Coord2::new(x / 2, y / 2)); - self.draw_cell(device, ofs, &cell); - x = rule_ofs(cell.rect[X].end); + let h = self.table.headers_width(X); + if xr.start < h { + } else if self.ranges[X].contains(&xr.start) { + bb[X].start -= self.ranges[X].start - h; + bb[X].end -= self.ranges[X].start - h; } else { - x += 1; + continue; } - } - } - for y in cells[Y].clone() { - for x in cells[X].clone() { - if is_rule(x) || is_rule(y) { - self.draw_rule(device, ofs, Coord2::new(x, y)); + let h = self.table.headers_width(Y); + if yr.start < h { + } else if self.ranges[Y].contains(&yr.start) { + bb[Y].start -= self.ranges[Y].start - h; + bb[Y].end -= self.ranges[Y].start - h; + } else { + continue; } + + self.draw_rule(device, ofs, CellPos { x, y }, bb); } } } - fn draw_rule(&self, device: &mut dyn Device, ofs: Coord2, coord: Coord2) { + fn draw_rule(&self, device: &mut dyn Device, ofs: Coord2, coord: CellPos, bb: Rect2) { const NO_BORDER: BorderStyle = BorderStyle::none(); let styles = EnumMap::from_fn(|a: Axis2| { let b = !a; - if !is_rule(coord[a]) - || (self.is_edge_cutoff[a][0] && coord[a] == 0) - || (self.is_edge_cutoff[a][1] && coord[a] == self.n[a] * 2) - { + if !is_rule(coord[a]) { [NO_BORDER, NO_BORDER] } else if is_rule(coord[b]) { let first = if coord[b] > 0 { @@ -887,7 +665,7 @@ impl Page { NO_BORDER }; - let second = if coord[b] / 2 < self.n[b] { + let second = if coord[b] / 2 < self.table.n()[b] { self.get_rule(a, coord) } else { NO_BORDER @@ -904,83 +682,62 @@ impl Page { .values() .all(|border| border.iter().all(BorderStyle::is_none)) { - let bb = - Rect2::from_fn(|a| self.cp[a][coord[a]]..self.cp[a][coord[a] + 1]).translate(ofs); - device.draw_line(bb, styles); + device.draw_line(bb.translate(ofs), styles); } } - fn get_rule(&self, a: Axis2, coord: Coord2) -> BorderStyle { - let coord = Coord2::from_fn(|a| coord[a] / 2); - let coord = self.map_coord(coord); - - let border = self.table.get_rule(a, coord); - if self.h[a] > 0 && coord[a] == self.h[a] { - let border2 = self - .table - .get_rule(a, Coord2::for_axis((a, self.h[a]), coord[!a])); - border.combine(border2) - } else { - border - } + fn get_rule(&self, a: Axis2, coord: CellPos) -> BorderStyle { + let coord = CellPos::from_fn(|a| coord[a] / 2); + self.table.table.get_rule(a, coord) } - fn extra_height(&self, device: &dyn Device, bb: &Rect2, cell: &DrawCell) -> usize { + fn extra_height(&self, device: &dyn Device, bb: &Rect2, cell: &DrawCell) -> isize { use Axis2::*; - let height = device.measure_cell_height(cell, bb[X].len()); - usize::saturating_sub(bb[Y].len(), height) + let height = device.measure_cell_height(cell, bb[X].len() as isize); + bb[Y].len() as isize - 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] - }) - .translate(ofs); - /* - let spill = EnumMap::from_fn(|a| { - [ - self.rule_width(a, cell.rect[a].start) / 2, - self.rule_width(a, cell.rect[a].end) / 2, - ] - });*/ - let spill = EnumMap::from_fn(|_| [0, 0]); - - let clip = if let Some(overflow) = self.overflows.get(&cell.rect.top_left()) { - Rect2::from_fn(|a| { - let mut clip = bb[a].clone(); - if overflow[a][0] > 0 { - bb[a].start -= overflow[a][0]; - if cell.rect[a].start == 0 && !self.is_edge_cutoff[a][0] { - clip.start = ofs[a] + self.cp[a][cell.rect[a].start * 2]; - } - } +/// Returns the width of `extent` along `axis`. +fn axis_width(cp: &[isize], extent: Range) -> isize { + cp[extent.end] - cp[extent.start] +} - if overflow[a][1] > 0 { - bb[a].end += overflow[a][1]; - if cell.rect[a].end == self.n[a] && !self.is_edge_cutoff[a][1] { - clip.end = ofs[a] + self.cp[a][cell.rect[a].end * 2 + 1]; - } - } +/// Returns the width of cells within `extent` along `axis`. +fn joined_width(cp: &[isize], extent: Range) -> isize { + axis_width(cp, cell_ofs(extent.start)..cell_ofs(extent.end) - 1) +} +/// Returns the offset in [Self::cp] of the cell with index `cell_index`. +/// That is, if `cell_index` is 0, then the offset is 1, that of the leftmost +/// or topmost cell; if `cell_index` is 1, then the offset is 3, that of the +/// next cell to the right (or below); and so on. */ +fn cell_ofs(cell_index: usize) -> usize { + cell_index * 2 + 1 +} - clip - }) - } else { - bb.clone() - }; +/// Returns the offset in [Self::cp] of the rule with index `rule_index`. +/// That is, if `rule_index` is 0, then the offset is that of the leftmost +/// or topmost rule; if `rule_index` is 1, then the offset is that of the +/// next rule to the right (or below); and so on. +fn rule_ofs(rule_index: usize) -> usize { + rule_index * 2 +} - // 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); +/// Returns the width of cell `z` along `axis`. +fn cell_width(cp: &[isize], z: usize) -> isize { + let ofs = cell_ofs(z); + axis_width(cp, ofs..ofs + 1) +} - let draw_cell = DrawCell::new(cell.content.inner(), &self.table); - let valign_offset = match draw_cell.style.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) - } +/// Is `ofs` the offset of a rule in `cp`? +fn is_rule(z: usize) -> bool { + z.is_even() +} + +#[derive(Clone)] +pub struct RenderCell<'a> { + rect: CellRect, + content: &'a Content, } struct Selection { @@ -988,21 +745,29 @@ struct Selection { b: Axis2, z0: usize, z1: usize, - p0: usize, - p1: usize, - h: Coord2, + p0: isize, + p1: isize, + h: CellPos, } impl Selection { /// Returns the coordinates of `coord` as it will appear in this subpage. /// /// `coord` must be in the selected region or the results will not make - /// sense. - fn coord_to_subpage(&self, coord: Coord2) -> Coord2 { + /// sense (or will panic due to overflow). + fn coord_to_subpage(&self, coord: CellPos) -> CellPos { let a = self.a; let b = self.b; let ha0 = self.h[a]; - Coord2::for_axis((a, max(coord[a] + ha0 - self.z0, ha0)), coord[b]) + let z = coord[a]; + let z_subpage = if (0..ha0).contains(&z) { + z + } else if (self.z0..self.z1).contains(&z) { + z - self.z0 + ha0 + } else { + unreachable!("{z} is not in {:?} or {:?}", 0..ha0, self.z0..self.z1); + }; + CellPos::for_axis((a, z_subpage), coord[b]) } } @@ -1059,10 +824,10 @@ struct Map { /// the right. That way each rule contributes to both the cell on its left and /// on its right.) fn distribute_spanned_width( - width: usize, - unspanned: &[usize], - spanned: &mut [usize], - rules: &[usize], + width: isize, + unspanned: &[isize], + spanned: &mut [isize], + rules: &[isize], ) { let n = unspanned.len(); if n == 0 { @@ -1072,15 +837,15 @@ fn distribute_spanned_width( debug_assert_eq!(spanned.len(), n); debug_assert_eq!(rules.len(), n + 1); - let total_unspanned = unspanned.iter().sum::() + let total_unspanned = unspanned.iter().sum::() + rules .get(1..n) - .map_or(0, |rules| rules.iter().copied().sum::()); + .map_or(0, |rules| rules.iter().copied().sum::()); if total_unspanned >= width { return; } - let d0 = n; + let d0 = n as isize; let d1 = 2 * total_unspanned.max(1); let d = if total_unspanned > 0 { d0 * d1 * 2 @@ -1107,13 +872,13 @@ fn distribute_spanned_width( /// Returns the width of the rule in `table` that is at offset `z` along axis /// `a`, if rendered on `device`. -fn measure_rule(device: &dyn Device, table: &Table, a: Axis2, z: usize) -> usize { +fn measure_rule(device: &dyn Device, table: &Table, a: Axis2, z: usize) -> isize { let b = !a; // Determine the types of rules that are present. let mut rules = EnumMap::default(); for w in 0..table.n[b] { - let stroke = table.get_rule(a, Coord2::for_axis((a, z), w)).stroke; + let stroke = table.get_rule(a, CellPos::for_axis((a, z), w)).stroke; rules[stroke] = true; } @@ -1142,77 +907,20 @@ fn measure_rule(device: &dyn Device, table: &Table, a: Axis2, z: usize) -> usize } #[derive(Debug)] -struct Break { - page: Arc, +pub struct Break { + page: Page, /// Axis along which `page` is being broken. axis: Axis2, - - /// Next cell along `axis`. - z: usize, - - /// Pixel offset within cell `z` (usually 0). - pixel: usize, - - /// Width of headers of `page` along `axis`. - hw: usize, } impl Break { - fn new(page: Arc, axis: Axis2) -> Self { - let z = page.h[axis]; - let hw = page.headers_width(axis); - Self { - page, - axis, - z, - pixel: 0, - hw, - } + fn new(page: Page, axis: Axis2) -> Self { + Self { page, axis } } fn has_next(&self) -> bool { - self.z < self.page.n[self.axis] - } - - /// Returns the width that would be required along this breaker's axis to - /// render a page from the current position up to but not including `cell`. - fn needed_size(&self, cell: usize) -> usize { - // Width of header not including its rightmost rule. - let mut size = self - .page - .axis_width(self.axis, 0..rule_ofs(self.page.h[self.axis])); - - // If we have a pixel offset and there is no header, then we omit - // the leftmost rule of the body. Otherwise the rendering is deceptive - // because it looks like the whole cell is present instead of a partial - // cell. - // - // Otherwise (if there is a header) we will be merging two rules: the - // rightmost rule in the header and the leftmost rule in the body. We - // assume that the width of a merged rule is the larger of the widths of - // either rule individually. - if self.pixel == 0 || self.page.h[self.axis] > 0 { - size += max( - self.page.rule_width(self.axis, self.page.h[self.axis]), - self.page.rule_width(self.axis, self.z), - ); - } - - // Width of body, minus any pixel offset in the leftmost cell. - size += self - .page - .joined_width(self.axis, self.z..cell) - .checked_sub(self.pixel) - .unwrap(); - - // Width of rightmost rule in body merged with leftmost rule in headers. - size += max( - self.page.rule_width_r(self.axis, 0), - self.page.rule_width(self.axis, cell), - ); - - size + !self.page.ranges[self.axis].is_empty() } /// Returns a new [Page] that is up to `size` pixels wide along the axis @@ -1220,128 +928,55 @@ impl Break { /// completely broken up, or if `size` is too small to reasonably render any /// cells. The latter will never happen if `size` is at least as large as /// the page size passed to [Page::new] along the axis using for breaking. - fn next(&mut self, device: &dyn Device, size: usize) -> Option> { + fn next(&mut self, device: &dyn Device, size: isize) -> Result, ()> { if !self.has_next() { - return None; + return Ok(None); } - - self.find_breakpoint(device, size).map(|(z, pixel)| { - let page = match pixel { - 0 => self.page.select(self.axis, self.z..z, self.pixel, 0), - pixel => self.page.select( - self.axis, - self.z..z + 1, - pixel, - self.page.cell_width(self.axis, z) - pixel, - ), - }; - self.z = z; - self.pixel = pixel; - page - }) + let target = size - self.page.table.headers_width(self.axis); + if target <= 0 { + return Err(()); + } + let start = self.page.ranges[self.axis].start; + let (end, next_start) = self.find_breakpoint(start..start + target, device); + let result = Page { + table: self.page.table.clone(), + ranges: EnumMap::from_fn(|axis| { + if axis == self.axis { + start..end + } else { + self.page.ranges[axis].clone() + } + }), + }; + self.page.ranges[self.axis].start = next_start; + Ok(Some(result)) } - fn break_cell(&self, device: &dyn Device, z: usize, overflow: usize) -> usize { - if self.cell_is_breakable(device, z) { - // If there is no right header and we render a partial cell - // on the right side of the body, then we omit the rightmost - // rule of the body. Otherwise the rendering is deceptive - // because it looks like the whole cell is present instead - // of a partial cell. - // - // This is similar to code for the left side in - // [Self::needed_size]. - let rule_allowance = self.page.rule_width(self.axis, z); - - // The amount that, if we added cell `z`, the rendering - // would overfill the allocated `size`. - let overhang = overflow - rule_allowance; // XXX could go negative - - // The width of cell `z`. - let cell_size = self.page.cell_width(self.axis, z); - - // The amount trimmed off the left side of `z`, and the - // amount left to render. - let cell_ofs = if z == self.z { self.pixel } else { 0 }; - let cell_left = cell_size - cell_ofs; - - // If some of the cell remains to render, and there would - // still be some of the cell left afterward, then partially - // render that much of the cell. - let mut pixel = if cell_left > 0 && cell_left > overhang { - cell_left - overhang + cell_ofs - } else { - 0 - }; - - // If there would be only a tiny amount of the cell left - // after rendering it partially, reduce the amount rendered - // slightly to make the output look a little better. - let em = device.params().em(); - if pixel + em > cell_size { - pixel = pixel.saturating_sub(em); - } + fn find_breakpoint(&self, range: Range, device: &dyn Device) -> (isize, isize) { + let cp = &self.page.table.cp[self.axis]; - // If we're breaking vertically, then consider whether the - // cells being broken have a better internal breakpoint than - // the exact number of pixels available, which might look - // bad e.g. because it breaks in the middle of a line of - // text. - if self.axis == Axis2::Y && device.params().can_adjust_break { - let mut x = 0; - while x < self.page.n[Axis2::X] { - let cell = self.page.get_cell(Coord2::new(x, z)); - let better_pixel = device.adjust_break( - cell.content, - Coord2::new( - self.page - .joined_width(Axis2::X, cell.rect[Axis2::X].clone()), - pixel, - ), - ); - x += cell.rect[Axis2::X].len(); - - if better_pixel < pixel { - let start_pixel = if z > self.z { self.pixel } else { 0 }; - if better_pixel > start_pixel { - pixel = better_pixel; - break; - } else if better_pixel == 0 && z != self.z { - pixel = 0; - break; - } - } - } - } - - pixel - } else { - 0 + // If everything remaining fits, then take it all. + let max = cp.last().copied().unwrap(); + if range.end >= max { + return (max, max); } - } - fn find_breakpoint(&mut self, device: &dyn Device, size: usize) -> Option<(usize, usize)> { - for z in self.z..self.page.n[self.axis] { - let needed = self.needed_size(z + 1); - if needed > size { - let pixel = self.break_cell(device, z, needed - size); - if z == self.z && pixel == 0 { - return None; + // Otherwise, take as much as fits. + for c in 0..self.page.table.n()[self.axis] { + let position = cp[c * 2 + 3]; + if position > range.end { + if c == 0 + || self.page.table.cell_width(self.axis, c) + >= device.params().min_break[self.axis] + { + // XXX various way to choose a better breakpoint + return (range.end, range.end); } else { - return Some((z, pixel)); + return (cp[(c - 1) * 2 + 3], cp[(c - 1) * 2 + 2]); } } } - Some((self.page.n[self.axis], 0)) - } - - /// Returns true if `cell` along this breaker's axis may be broken across a - /// page boundary. - /// - /// This is just a heuristic. Breaking cells across page boundaries can - /// save space, but it looks ugly. - fn cell_is_breakable(&self, device: &dyn Device, cell: usize) -> bool { - self.page.cell_width(self.axis, cell) >= device.params().min_break[self.axis] + unreachable!() } } @@ -1351,7 +986,7 @@ pub struct Pager { /// [Page]s to be rendered, in order, vertically. There may be up to 5 /// pages, for the pivot table's title, layers, body, captions, and /// footnotes. - pages: SmallVec<[Arc; 5]>, + pages: SmallVec<[Page; 5]>, x_break: Option, y_break: Option, @@ -1364,16 +999,16 @@ impl Pager { layer_indexes: Option<&[usize]>, ) -> Self { let output = pivot_table.output( - layer_indexes.unwrap_or(&pivot_table.current_layer), + layer_indexes.unwrap_or(pivot_table.layer()), device.params().printing, ); // 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_width = body_page.width(Axis2::X); + let body_page = Page::new(output.body, device, 0, &pivot_table.style.look); + let body_width = body_page.width(Axis2::X).min(device.params().size.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 @@ -1383,21 +1018,16 @@ impl Pager { let mut pages = SmallVec::new(); for table in [output.title, output.layers].into_iter().flatten() { - pages.push(Arc::new(Page::new( - Arc::new(table), + pages.push(Page::new( + table, device, body_width, - &pivot_table.look, - ))); + &pivot_table.style.look, + )); } - pages.push(Arc::new(body_page)); + pages.push(body_page); for table in [output.caption, output.footnotes].into_iter().flatten() { - pages.push(Arc::new(Page::new( - Arc::new(table), - device, - 0, - &pivot_table.look, - ))); + pages.push(Page::new(table, device, 0, &pivot_table.style.look)); } pages.reverse(); @@ -1410,11 +1040,11 @@ 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)) - .sum::() as f64; + .map(|page: &Page| page.width(Axis2::Y)) + .sum::() as f64; let max_height = device.params().size[Axis2::Y] as f64; if total_height * scale >= max_height { scale *= max_height / total_height; @@ -1430,30 +1060,30 @@ impl Pager { } /// True if there's content left to render. - pub fn has_next(&mut self, device: &dyn Device) -> bool { - while self - .y_break - .as_mut() - .is_none_or(|y_break| !y_break.has_next()) + pub fn has_next(&mut self, device: &dyn Device) -> Option<&mut Break> { + // If there's a nonempty y_break, return it. + if let Some(y_break) = self.y_break.as_mut() + && y_break.has_next() { - self.y_break = self - .x_break - .as_mut() - .and_then(|x_break| { - x_break.next( + return self.y_break.as_mut(); + } + + loop { + // Get a new y_break from the x_break. + if let Some(x_break) = &mut self.x_break + && let Some(page) = x_break + .next( device, - (device.params().size[Axis2::X] as f64 / self.scale) as usize, + (device.params().size[Axis2::X] as f64 / self.scale) as isize, ) - }) - .map(|page| Break::new(page, Axis2::Y)); - if self.y_break.is_none() { - match self.pages.pop() { - Some(page) => self.x_break = Some(Break::new(page, Axis2::X)), - _ => return false, - } + .unwrap() + { + self.y_break = Some(page.split(Axis2::Y)); + return self.y_break.as_mut(); } + + self.x_break = Some(self.pages.pop()?.split(Axis2::X)); } - true } /// Draws a chunk of content to fit in a space that has vertical size @@ -1461,29 +1091,25 @@ impl Pager { /// Returns the amount of vertical space actually used by the rendered /// chunk, which will be 0 if `space` is too small to render anything or if /// no content remains (use [Self::has_next] to distinguish these cases). - pub fn draw_next(&mut self, device: &mut dyn Device, mut space: usize) -> usize { + pub fn draw_next(&mut self, device: &mut dyn Device, mut space: isize) -> isize { use Axis2::*; if self.scale != 1.0 { device.scale(self.scale); - space = (space as f64 / self.scale) as usize; + space = (space as f64 / self.scale) as isize; } let mut ofs = Coord2::new(0, 0); - while self.has_next(device) { - let Some(page) = self - .y_break - .as_mut() - .and_then(|y_break| y_break.next(device, space - ofs[Y])) - else { + while let Some(y_break) = self.has_next(device) { + let Some(page) = y_break.next(device, space - ofs[Y]).unwrap_or_default() else { break; }; page.draw(device, ofs); - ofs[Y] += page.total_size(Y); + ofs[Y] += page.width(Y); } if self.scale != 1.0 { - ofs[Y] = (ofs[Y] as f64 * self.scale) as usize; + ofs[Y] = (ofs[Y] as f64 * self.scale) as isize; } ofs[Y] } diff --git a/rust/pspp/src/output/spv.rs b/rust/pspp/src/output/spv.rs deleted file mode 100644 index 9f7290f1b3..0000000000 --- a/rust/pspp/src/output/spv.rs +++ /dev/null @@ -1,1373 +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 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 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::{ - 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, - }, - }, - 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(( - "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 { - 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) - } - 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(); - } -} - -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), - )) - } - - // 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, -{ - 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.as_group().is_some()), - SimpleFileOptions::default(), - ) - .unwrap(); // XXX - self.writer.write_all(&headings).unwrap(); // XXX - } -} - -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")) - } - - 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(()) -} - -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(()) -} - -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, - 0u8, - 1u8, - 0u32, // x23 - -1i32, - ) - .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.0.len() as u32).write_options(writer, endian, args)?; - for footnote in &self.0 { - footnote.write_options(writer, endian, args)?; - } - Ok(()) - } -} - -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, ()) - } -} - -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[0], - self.bg[0], - 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)?; - 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) - } - } -} - -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::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/table.rs b/rust/pspp/src/output/table.rs index 98d5a0b16f..5d63ba8a7c 100644 --- a/rust/pspp/src/output/table.rs +++ b/rust/pspp/src/output/table.rs @@ -26,20 +26,136 @@ //! Some drivers use tables as an implementation detail of rendering pivot //! tables. -use std::{ops::Range, sync::Arc}; +use std::{ + borrow::Cow, + ops::{Index, IndexMut, Range}, + sync::Arc, +}; use enum_map::{EnumMap, enum_map}; use ndarray::{Array, Array2}; -use crate::output::pivot::{Coord2, DisplayValue, Footnote, HorzAlign, ValueInner}; - -use super::pivot::{ - Area, AreaStyle, Axis2, Border, BorderStyle, HeadingRegion, Rect2, Value, ValueOptions, +use crate::{ + output::pivot::{ + Axis2, Footnote, + look::{ + Area, AreaStyle, Border, BorderStyle, CellStyle, FontStyle, HeadingRegion, HorzAlign, + }, + value::{DisplayValue, Value, ValueInner, ValueOptions}, + }, + spv::html, }; +/// The `(x,y)` position of a cell in a [Table]. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct CellPos { + /// X + pub x: usize, + /// Y + pub y: usize, +} + +impl CellPos { + pub fn new(x: usize, y: usize) -> Self { + Self { x, y } + } + pub fn for_axis((a, az): (Axis2, usize), bz: usize) -> Self { + match a { + Axis2::X => Self::new(az, bz), + Axis2::Y => Self::new(bz, az), + } + } + + pub fn from_fn(mut f: F) -> Self + where + F: FnMut(Axis2) -> usize, + { + Self::new(f(Axis2::X), f(Axis2::Y)) + } +} + +impl Index for CellPos { + type Output = usize; + + fn index(&self, index: Axis2) -> &Self::Output { + match index { + Axis2::X => &self.x, + Axis2::Y => &self.y, + } + } +} + +impl IndexMut for CellPos { + fn index_mut(&mut self, index: Axis2) -> &mut Self::Output { + match index { + Axis2::X => &mut self.x, + Axis2::Y => &mut self.y, + } + } +} + +/// A rectangular group of cells in a [Table]. +#[derive(Clone, Debug, Default)] +pub struct CellRect { + /// X range. + pub x: Range, + /// Y range. + pub y: Range, +} + +impl CellRect { + pub fn new(x: Range, y: Range) -> Self { + Self { x, y } + } + pub fn for_cell(cell: CellPos) -> Self { + Self::new(cell.x..cell.x + 1, cell.y..cell.y + 1) + } + pub fn for_ranges((a_axis, a): (Axis2, Range), b: Range) -> Self { + match a_axis { + Axis2::X => Self { x: a, y: b }, + Axis2::Y => Self { x: b, y: a }, + } + } + pub fn top_left(&self) -> CellPos { + CellPos::new(self.x.start, self.y.start) + } + pub fn is_empty(&self) -> bool { + self.x.is_empty() || self.y.is_empty() + } + pub fn map(&self, mut f: F) -> Self + where + F: FnMut(Axis2, Range) -> Range, + { + Self { + x: f(Axis2::X, self.x.clone()), + y: f(Axis2::Y, self.y.clone()), + } + } +} + +impl Index for CellRect { + type Output = Range; + + fn index(&self, index: Axis2) -> &Self::Output { + match index { + Axis2::X => &self.x, + Axis2::Y => &self.y, + } + } +} + +impl IndexMut for CellRect { + fn index_mut(&mut self, index: Axis2) -> &mut Self::Output { + match index { + Axis2::X => &mut self.x, + Axis2::Y => &mut self.y, + } + } +} + #[derive(Clone, Debug)] pub struct CellRef<'a> { - pub coord: Coord2, + pub pos: CellPos, pub content: &'a Content, } @@ -52,16 +168,16 @@ impl CellRef<'_> { self.content.is_empty() } - pub fn rect(&self) -> Rect2 { - self.content.rect(self.coord) + pub fn rect(&self) -> CellRect { + self.content.rect(self.pos) } pub fn next_x(&self) -> usize { - self.content.next_x(self.coord.x()) + self.content.next_x(self.pos.x) } pub fn is_top_left(&self) -> bool { - self.content.is_top_left(self.coord) + self.content.is_top_left(self.pos) } pub fn span(&self, axis: Axis2) -> usize { @@ -96,7 +212,7 @@ impl Content { /// Returns the rectangle that this cell covers, only if the cell contains /// that information. (Joined cells always do, and other cells usually /// don't.) - pub fn joined_rect(&self) -> Option<&Rect2> { + pub fn joined_rect(&self) -> Option<&CellRect> { match self { Content::Join(cell) => Some(&cell.region), _ => None, @@ -104,26 +220,25 @@ impl Content { } /// Returns the rectangle that this cell covers. If the cell doesn't contain - /// that information, returns a rectangle containing `coord`. - pub fn rect(&self, coord: Coord2) -> Rect2 { + /// that information, returns a rectangle containing `pos`. + pub fn rect(&self, pos: CellPos) -> CellRect { match self { Content::Join(cell) => cell.region.clone(), - _ => Rect2::for_cell(coord), + _ => CellRect::for_cell(pos), } } pub fn next_x(&self, x: usize) -> usize { - self.joined_rect() - .map_or(x + 1, |region| region[Axis2::X].end) + self.joined_rect().map_or(x + 1, |region| region.x.end) } - pub fn is_top_left(&self, coord: Coord2) -> bool { - self.joined_rect().is_none_or(|r| coord == r.top_left()) + pub fn is_top_left(&self, pos: CellPos) -> bool { + self.joined_rect().is_none_or(|r| pos == r.top_left()) } pub fn span(&self, axis: Axis2) -> usize { self.joined_rect().map_or(1, |r| { - let range = &r.0[axis]; + let range = &r[axis]; range.end - range.start }) } @@ -147,11 +262,11 @@ pub struct Cell { inner: CellInner, /// Occupied table region. - region: Rect2, + region: CellRect, } impl Cell { - fn new(inner: CellInner, region: Rect2) -> Self { + fn new(inner: CellInner, region: CellRect) -> Self { Self { inner, region } } } @@ -176,6 +291,10 @@ impl CellInner { } } + pub fn with_rotate(self, rotate: bool) -> Self { + Self { rotate, ..self } + } + pub fn is_empty(&self) -> bool { self.value.inner.is_empty() } @@ -185,11 +304,17 @@ impl CellInner { #[derive(derive_more::Debug)] pub struct Table { /// Number of rows and columns. - pub n: Coord2, + pub n: CellPos, /// Table header rows and columns. - pub h: Coord2, - + /// + /// This is a subset of `n`, so `h.x <= n.x` and + /// `h.y <= n.y`. + pub h: CellPos, + + /// Table contents. + /// + /// The array has `n.x` columns and `n.y` rows. pub contents: Array2, /// Styles for areas of the table. @@ -201,7 +326,7 @@ pub struct Table { pub borders: EnumMap, /// Horizontal ([Axis2::Y]) and vertical ([Axis2::X]) rules. - pub rules: EnumMap>, + pub rules: EnumMap>>, /// How to present values. #[debug(skip)] @@ -210,45 +335,44 @@ pub struct Table { impl Table { pub fn new( - n: Coord2, - headers: Coord2, + n: CellPos, + headers: CellPos, areas: EnumMap, borders: EnumMap, - value_options: ValueOptions, + value_options: impl Into, ) -> Self { Self { n, h: headers, - contents: Array::default((n.x(), n.y())), + contents: Array::default((n.x, n.y)), areas, borders, rules: enum_map! { - Axis2::X => Array::from_elem((n.x() + 1, n.y()), Border::Title), - Axis2::Y => Array::from_elem((n.x(), n.y() + 1), Border::Title), + Axis2::X => Array::default((n.x + 1, n.y)), + Axis2::Y => Array::default((n.x, n.y + 1)), }, - value_options, + value_options: value_options.into(), } } - pub fn get(&self, coord: Coord2) -> CellRef<'_> { + pub fn get(&self, coord: CellPos) -> CellRef<'_> { CellRef { - coord, - content: &self.contents[[coord.x(), coord.y()]], + pos: coord, + content: &self.contents[[coord.x, coord.y]], } } - pub fn get_rule(&self, axis: Axis2, pos: Coord2) -> BorderStyle { - self.borders[self.rules[axis][[pos.x(), pos.y()]]] + pub fn get_rule(&self, axis: Axis2, pos: CellPos) -> BorderStyle { + self.rules[axis][[pos.x, pos.y]].map_or(BorderStyle::none(), |b| self.borders[b]) } - pub fn put(&mut self, region: Rect2, inner: CellInner) { - use Axis2::*; - if region[X].len() == 1 && region[Y].len() == 1 { - self.contents[[region[X].start, region[Y].start]] = Content::Value(inner); + pub fn put(&mut self, region: CellRect, inner: CellInner) { + if region.x.len() == 1 && region.y.len() == 1 { + self.contents[[region.x.start, region.y.start]] = Content::Value(inner); } else { let cell = Arc::new(Cell::new(inner, region.clone())); - for y in region[Y].clone() { - for x in region[X].clone() { + for y in region.y.clone() { + for x in region.x.clone() { self.contents[[x, y]] = Content::Join(cell.clone()) } } @@ -257,13 +381,13 @@ impl Table { pub fn h_line(&mut self, border: Border, x: Range, y: usize) { for x in x { - self.rules[Axis2::Y][[x, y]] = border; + self.rules[Axis2::Y][[x, y]] = Some(border); } } pub fn v_line(&mut self, border: Border, x: usize, y: Range) { for y in y { - self.rules[Axis2::X][[x, y]] = border; + self.rules[Axis2::X][[x, y]] = Some(border); } } @@ -288,10 +412,10 @@ impl Table { } /// The heading region that `pos` is part of, if any. - pub fn heading_region(&self, pos: Coord2) -> Option { - if pos.x() < self.h.x() { + pub fn heading_region(&self, pos: CellPos) -> Option { + if pos.x < self.h.x { Some(HeadingRegion::Rows) - } else if pos.y() < self.h.y() { + } else if pos.y < self.h.y { Some(HeadingRegion::Columns) } else { None @@ -323,8 +447,8 @@ impl Iterator for XIter<'_> { fn next(&mut self) -> Option { let next_x = self .x - .map_or(0, |x| self.table.get(Coord2::new(x, self.y)).next_x()); - if next_x >= self.table.n.x() { + .map_or(0, |x| self.table.get(CellPos::new(x, self.y)).next_x()); + if next_x >= self.table.n.x { None } else { self.x = Some(next_x); @@ -346,7 +470,7 @@ impl<'a> Cells<'a> { next: if table.is_empty() { None } else { - Some(table.get(Coord2::new(0, 0))) + Some(table.get(CellPos::new(0, 0))) }, } } @@ -363,9 +487,9 @@ impl<'a> Iterator for Cells<'a> { self.next = loop { let next_x = next.next_x(); let coord = if next_x < self.table.n[X] { - Coord2::new(next_x, next.coord.y()) - } else if next.coord.y() + 1 < self.table.n[Y] { - Coord2::new(0, next.coord.y() + 1) + CellPos::new(next_x, next.pos.y) + } else if next.pos.y + 1 < self.table.n[Y] { + CellPos::new(0, next.pos.y + 1) } else { break None; }; @@ -378,48 +502,46 @@ impl<'a> Iterator for Cells<'a> { } } -pub struct DrawCell<'a> { +pub struct DrawCell<'a, 'b> { pub rotate: bool, pub inner: &'a ValueInner, - pub style: &'a AreaStyle, + pub cell_style: &'a CellStyle, + pub font_style: &'a FontStyle, pub subscripts: &'a [String], pub footnotes: &'a [Arc], pub value_options: &'a ValueOptions, + pub substitutions: &'b dyn Fn(html::Variable) -> Option>, } -impl<'a> DrawCell<'a> { +impl<'a, 'b> DrawCell<'a, 'b> { pub fn new(inner: &'a CellInner, table: &'a Table) -> Self { - let default_area_style = &table.areas[inner.area]; - let (style, subscripts, footnotes) = if let Some(styling) = &inner.value.styling { - ( - styling.style.as_ref().unwrap_or(default_area_style), - styling.subscripts.as_slice(), - styling.footnotes.as_slice(), - ) - } else { - (default_area_style, [].as_slice(), [].as_slice()) - }; Self { rotate: inner.rotate, inner: &inner.value.inner, - style, - subscripts, - footnotes, + font_style: inner + .value + .font_style() + .unwrap_or(&table.areas[inner.area].font_style), + cell_style: inner + .value + .cell_style() + .unwrap_or(&table.areas[inner.area].cell_style), + subscripts: inner.value.subscripts(), + footnotes: inner.value.footnotes(), value_options: &table.value_options, + substitutions: &|_| None, } } pub fn display(&self) -> DisplayValue<'a> { self.inner .display(self.value_options) - .with_font_style(&self.style.font_style) .with_subscripts(self.subscripts) .with_footnotes(self.footnotes) } pub fn horz_align(&self, display: &DisplayValue) -> HorzAlign { - self.style - .cell_style + self.cell_style .horz_align .unwrap_or_else(|| HorzAlign::for_mixed(display.var_type())) } diff --git a/rust/pspp/src/output/text.rs b/rust/pspp/src/output/text.rs deleted file mode 100644 index 990f1fae7a..0000000000 --- a/rust/pspp/src/output/text.rs +++ /dev/null @@ -1,707 +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, Error as FmtError, Result as FmtResult, Write as FmtWrite}, - fs::File, - io::{BufWriter, Write as IoWrite}, - 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, text_line::Emphasis}; - -use super::{ - Details, Item, - driver::Driver, - pivot::{Axis2, BorderStyle, Coord2, HorzAlign, PivotTable, Rect2, Stroke}, - render::{Device, Pager, Params}, - table::Content, - text_line::{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: BufWriter, - renderer: TextRenderer, -} - -impl TextDriver { - pub fn new(config: &TextConfig) -> std::io::Result { - Ok(Self { - file: BufWriter::new(match &config.file { - Some(file) => File::create(file)?, - None => File::options().write(true).open("/dev/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 => todo!(), - Details::Image => todo!(), - Details::Group(children) => { - for (index, child) in children.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, - _alternate_row: bool, - 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.style.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::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/text_line.rs b/rust/pspp/src/output/text_line.rs deleted file mode 100644 index e4d7c5c370..0000000000 --- a/rust/pspp/src/output/text_line.rs +++ /dev/null @@ -1,610 +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 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/pc.rs b/rust/pspp/src/pc.rs index ce484496a7..0298cd9f12 100644 --- a/rust/pspp/src/pc.rs +++ b/rust/pspp/src/pc.rs @@ -45,7 +45,7 @@ use crate::{ dictionary::Dictionary, format::{Error as FormatError, Format, UncheckedFormat}, identifier::{Error as IdError, Identifier}, - output::pivot::{MetadataEntry, MetadataValue, PivotTable, Value}, + output::pivot::{MetadataEntry, MetadataValue, PivotTable, value::Value}, sys::raw::{self, CaseDetails, CaseVar, CompressionAction, records::RawFormat}, variable::{MissingValues, MissingValuesError, VarWidth, Variable}, }; @@ -137,7 +137,7 @@ impl From<&Metadata> for PivotTable { value: MetadataValue::Group(vec![ MetadataEntry { name: Value::new_user_text("Created"), - value: MetadataValue::new_leaf(Value::new_date_time(value.creation)), + value: MetadataValue::new_leaf(Value::new_date(value.creation)), }, maybe_string("Product", &value.product), maybe_string("File Name", &value.filename), @@ -211,9 +211,7 @@ impl Cases { match result { Ok(Some(mut raw_case)) => { for datum in &mut raw_case.0 { - if let Datum::Number(Some(number)) = datum - && *number == self.sysmis - { + if datum.is_number_and(|number| number == Some(self.sysmis)) { *datum = Datum::Number(None); } } diff --git a/rust/pspp/src/pc/tests.rs b/rust/pspp/src/pc/tests.rs index 25dab4f513..1a573f448d 100644 --- a/rust/pspp/src/pc/tests.rs +++ b/rust/pspp/src/pc/tests.rs @@ -5,7 +5,7 @@ use itertools::Itertools; use crate::{ data::cases_to_output, output::{ - Details, Item, Text, + Item, Text, pivot::{PivotTable, tests::assert_lines_eq}, }, pc::PcFile, @@ -30,9 +30,9 @@ fn test_pcfile(name: &str) { output.push(PivotTable::from(&metadata).into()); output.extend(dictionary.all_pivot_tables().into_iter().map_into()); output.extend(cases_to_output(&dictionary, cases)); - Item::new(Details::Group(output.into_iter().map_into().collect())) + output.into_iter().collect() } - Err(error) => Item::new(Details::Text(Box::new(Text::new_log(error.to_string())))), + Err(error) => Text::new_log(error.to_string()).into_item(), }; let actual = output.to_string(); diff --git a/rust/pspp/src/por/read.rs b/rust/pspp/src/por/read.rs index 51ccfbef8e..221b6b41c4 100644 --- a/rust/pspp/src/por/read.rs +++ b/rust/pspp/src/por/read.rs @@ -35,7 +35,7 @@ use crate::{ dictionary::{DictIndex, Dictionary}, format::{Error as FormatError, Format, Type, UncheckedFormat}, identifier::{Error as IdError, Identifier}, - output::pivot::{MetadataEntry, MetadataValue, PivotTable, Value}, + output::pivot::{MetadataEntry, MetadataValue, PivotTable, value::Value}, por::portable_to_windows_1252, variable::{MissingValueRange, MissingValues, MissingValuesError, VarType, VarWidth, Variable}, }; @@ -122,7 +122,7 @@ impl From<&Metadata> for PivotTable { MetadataEntry { name: Value::new_user_text("Created"), value: MetadataValue::Leaf( - value.creation.map(Value::new_date_time).unwrap_or_default(), + value.creation.map(Value::new_date).unwrap_or_default(), ), }, maybe_string("Product", &value.product), @@ -1158,7 +1158,7 @@ mod tests { use crate::{ data::cases_to_output, output::{ - Details, Item, Text, + Item, Text, pivot::{PivotTable, tests::assert_lines_eq}, }, por::{PortableFile, ReadPad}, @@ -1196,9 +1196,9 @@ mod tests { output.push(PivotTable::from(&metadata).into()); output.extend(dictionary.all_pivot_tables().into_iter().map_into()); output.extend(cases_to_output(&dictionary, cases)); - Item::new(Details::Group(output.into_iter().map_into().collect())) + output.into_iter().collect() } - Err(error) => Item::new(Details::Text(Box::new(Text::new_log(error.to_string())))), + Err(error) => Text::new_log(error.to_string()).into_item(), }; let actual = output.to_string(); diff --git a/rust/pspp/src/settings.rs b/rust/pspp/src/settings.rs index aac4a4c8a8..cc424a5783 100644 --- a/rust/pspp/src/settings.rs +++ b/rust/pspp/src/settings.rs @@ -21,9 +21,9 @@ use enum_map::EnumMap; use serde::Serialize; use crate::{ - format::{Format, Settings as FormatSettings}, + format::{F8_2, Format, Settings as FormatSettings}, message::Severity, - output::pivot::Look, + output::pivot::look::Look, }; /// Whether to show variable or value labels or the underlying value or variable @@ -110,7 +110,7 @@ pub struct Settings { pub commands: Compatibility, pub global: Compatibility, pub syntax: Compatibility, - pub formats: FormatSettings, + pub formats: Arc, pub endian: EndianSettings, pub small: f64, pub show_values: Show, @@ -136,14 +136,14 @@ impl Default for Settings { macros: MacroSettings::default(), max_loops: 40, workspace: 64 * 1024 * 1024, - default_format: Format::F8_2, + default_format: F8_2, testing: false, fuzz_bits: 6, scale_min: 24, commands: Compatibility::default(), global: Compatibility::default(), syntax: Compatibility::default(), - formats: FormatSettings::default(), + formats: Default::default(), endian: EndianSettings::default(), small: 0.0001, show_values: Show::default(), diff --git a/rust/pspp/src/show.rs b/rust/pspp/src/show.rs deleted file mode 100644 index 2a866c5d47..0000000000 --- a/rust/pspp/src/show.rs +++ /dev/null @@ -1,383 +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 crate::parse_encoding; -use anyhow::{Result, anyhow}; -use clap::{Args, ValueEnum}; -use encoding_rs::Encoding; -use pspp::{ - data::cases_to_output, - output::{ - Details, Item, Text, - driver::{Config, Driver}, - pivot::PivotTable, - }, - sys::{ - Records, - raw::{Decoder, EncodingReport, Magic, Reader, Record, infer_encoding}, - }, -}; -use serde::Serialize; -use std::{ - cell::RefCell, - ffi::OsStr, - fmt::{Display, Write as _}, - fs::File, - io::{BufReader, Write, stdout}, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, -}; - -/// Show information about SPSS system files. -#[derive(Args, Clone, Debug)] -pub struct Show { - /// What to show. - #[arg(value_enum)] - mode: Mode, - - /// File to show. - #[arg(required = true)] - input: PathBuf, - - /// Output file name. If omitted, output is written to stdout. - output: Option, - - /// The encoding to use. - #[arg(long, value_parser = parse_encoding, help_heading = "Input file options")] - encoding: Option<&'static Encoding>, - - /// Maximum number of cases to read. - /// - /// If specified without an argument, all cases will be read. - #[arg( - long = "data", - num_args = 0..=1, - default_missing_value = "18446744073709551615", - default_value_t = 0, - help_heading = "Input file options" - )] - max_cases: u64, - - /// Output driver configuration options. - #[arg(short = 'o', help_heading = "Output options")] - output_options: Vec, - - /// Output format. - #[arg(long, short = 'f', help_heading = "Output options")] - format: Option, -} - -enum Output { - Driver { - driver: Rc>>, - mode: Mode, - }, - Json { - writer: Rc>>, - pretty: bool, - }, - Discard, -} - -impl Output { - /* - fn show_metadata(&self, metadata: MetadataEntry) -> Result<()> { - match self { - Self::Driver { driver, .. } => { - driver - .borrow_mut() - .write(&Arc::new(Item::new(metadata.into_pivot_table()))); - Ok(()) - } - Self::Json { .. } => self.show_json(&metadata), - Self::Discard => Ok(()), - } - }*/ - - fn show(&self, value: &T) -> Result<()> - where - T: Serialize, - for<'a> &'a T: Into
, - { - match self { - Self::Driver { driver, .. } => { - driver - .borrow_mut() - .write(&Arc::new(Item::new(value.into()))); - Ok(()) - } - Self::Json { .. } => self.show_json(value), - Self::Discard => Ok(()), - } - } - - fn show_json(&self, value: &T) -> Result<()> - where - T: Serialize, - { - match self { - Self::Driver { mode, driver: _ } => { - Err(anyhow!("Mode '{mode}' only supports output as JSON.")) - } - Self::Json { writer, pretty } => { - let mut writer = writer.borrow_mut(); - match pretty { - true => serde_json::to_writer_pretty(&mut *writer, value)?, - false => serde_json::to_writer(&mut *writer, value)?, - }; - writeln!(writer)?; - Ok(()) - } - Self::Discard => Ok(()), - } - } - - fn warn(&self, warning: &impl Display) { - match self { - Output::Driver { driver, .. } => { - driver - .borrow_mut() - .write(&Arc::new(Item::from(Text::new_log(warning.to_string())))); - } - Output::Json { .. } => { - #[derive(Serialize)] - struct Warning { - warning: String, - } - let warning = Warning { - warning: warning.to_string(), - }; - let _ = self.show_json(&warning); - } - Self::Discard => (), - } - } -} - -impl Show { - pub fn run(self) -> Result<()> { - let format = if let Some(format) = self.format { - format - } else if let Some(output_file) = &self.output { - match output_file - .extension() - .unwrap_or(OsStr::new("")) - .to_str() - .unwrap_or("") - { - "json" => ShowFormat::Json, - "ndjson" => ShowFormat::Ndjson, - _ => ShowFormat::Output, - } - } else { - ShowFormat::Json - }; - - let output = match format { - ShowFormat::Output => { - let mut config = String::new(); - - if let Some(file) = &self.output { - #[derive(Serialize)] - struct File<'a> { - file: &'a Path, - } - let file = File { - file: file.as_path(), - }; - let toml_file = toml::to_string_pretty(&file).unwrap(); - config.push_str(&toml_file); - } - for option in &self.output_options { - writeln!(&mut config, "{option}").unwrap(); - } - - let table: toml::Table = toml::from_str(&config)?; - if !table.contains_key("driver") { - let driver = if let Some(file) = &self.output { - ::driver_type_from_filename(file).ok_or_else(|| { - anyhow!("{}: no default output format for file name", file.display()) - })? - } else { - "text" - }; - - #[derive(Serialize)] - struct DriverConfig { - driver: &'static str, - } - config.insert_str( - 0, - &toml::to_string_pretty(&DriverConfig { driver }).unwrap(), - ); - } - - let config: Config = toml::from_str(&config)?; - Output::Driver { - mode: self.mode, - driver: Rc::new(RefCell::new(Box::new(::new(&config)?))), - } - } - ShowFormat::Json | ShowFormat::Ndjson => Output::Json { - pretty: format == ShowFormat::Json, - writer: if let Some(output_file) = &self.output { - Rc::new(RefCell::new(Box::new(File::create(output_file)?))) - } else { - Rc::new(RefCell::new(Box::new(stdout()))) - }, - }, - ShowFormat::Discard => Output::Discard, - }; - - let reader = File::open(&self.input)?; - let reader = BufReader::new(reader); - let mut reader = Reader::new(reader, Box::new(|warning| output.warn(&warning)))?; - - match self.mode { - Mode::Identity => { - match reader.header().magic { - Magic::Sav => println!("SPSS System File"), - Magic::Zsav => println!("SPSS System File with Zlib compression"), - Magic::Ebcdic => println!("EBCDIC-encoded SPSS System File"), - } - return Ok(()); - } - Mode::Raw => { - output.show_json(reader.header())?; - for record in reader.records() { - output.show_json(&record?)?; - } - for (_index, case) in (0..self.max_cases).zip(reader.cases()) { - output.show_json(&case?)?; - } - } - Mode::Decoded => { - let records: Vec = reader.records().collect::, _>>()?; - let encoding = match self.encoding { - Some(encoding) => encoding, - None => infer_encoding(&records, &mut |e| output.warn(&e))?, - }; - let mut decoder = Decoder::new(encoding, |e| output.warn(&e)); - for record in records { - output.show_json(&record.decode(&mut decoder))?; - } - } - Mode::Dictionary => { - let records: Vec = reader.records().collect::, _>>()?; - let encoding = match self.encoding { - Some(encoding) => encoding, - None => infer_encoding(&records, &mut |e| output.warn(&e))?, - }; - let mut decoder = Decoder::new(encoding, |e| output.warn(&e)); - let records = Records::from_raw(records, &mut decoder); - let (dictionary, metadata, cases) = records - .decode( - reader.header().clone().decode(&mut decoder), - reader.cases(), - encoding, - |e| output.warn(&e), - ) - .into_parts(); - match &output { - Output::Driver { driver, mode: _ } => { - let mut output = Vec::new(); - output.push(Item::new(PivotTable::from(&metadata))); - output.extend( - dictionary - .all_pivot_tables() - .into_iter() - .map(|pivot_table| Item::new(pivot_table)), - ); - output.extend(cases_to_output(&dictionary, cases)); - driver - .borrow_mut() - .write(&Arc::new(Item::new(Details::Group( - output.into_iter().map(Arc::new).collect(), - )))); - } - Output::Json { .. } => { - output.show_json(&dictionary)?; - output.show_json(&metadata)?; - for (_index, case) in (0..self.max_cases).zip(cases) { - output.show_json(&case?)?; - } - } - Output::Discard => (), - } - } - Mode::Encodings => { - let encoding_report = EncodingReport::new(reader, self.max_cases)?; - output.show(&encoding_report)?; - } - } - - Ok(()) - } -} - -/// What to show in a system file. -#[derive(Clone, Copy, Debug, Default, PartialEq, ValueEnum)] -enum Mode { - /// The kind of file. - Identity, - - /// File dictionary, with variables, value labels, attributes, ... - #[default] - #[value(alias = "dict")] - Dictionary, - - /// Possible encodings of text in file dictionary and (with `--data`) cases. - Encodings, - - /// Raw file records, without assuming a particular character encoding. - Raw, - - /// Raw file records decoded with a particular character encoding. - Decoded, -} - -impl Mode { - fn as_str(&self) -> &'static str { - match self { - Mode::Dictionary => "dictionary", - Mode::Identity => "identity", - Mode::Raw => "raw", - Mode::Decoded => "decoded", - Mode::Encodings => "encodings", - } - } -} - -impl Display for Mode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -enum ShowFormat { - /// Pretty-printed JSON. - #[default] - Json, - /// Newline-delimited JSON. - Ndjson, - /// Pivot tables. - Output, - /// No output. - Discard, -} diff --git a/rust/pspp/src/show_pc.rs b/rust/pspp/src/show_pc.rs deleted file mode 100644 index 385f8770be..0000000000 --- a/rust/pspp/src/show_pc.rs +++ /dev/null @@ -1,300 +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 anyhow::{Result, anyhow}; -use clap::{Args, ValueEnum}; -use pspp::{ - data::cases_to_output, - output::{ - Details, Item, Text, - driver::{Config, Driver}, - pivot::PivotTable, - }, - pc::PcFile, -}; -use serde::Serialize; -use std::{ - cell::RefCell, - ffi::OsStr, - fmt::{Display, Write as _}, - fs::File, - io::{BufReader, Write, stdout}, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, -}; - -/// Show information about SPSS/PC+ data files. -#[derive(Args, Clone, Debug)] -pub struct ShowPc { - /// What to show. - #[arg(value_enum)] - mode: Mode, - - /// File to show. - #[arg(required = true)] - input: PathBuf, - - /// Output file name. If omitted, output is written to stdout. - output: Option, - - /// Maximum number of cases to read. - /// - /// If specified without an argument, all cases will be read. - #[arg( - long = "data", - num_args = 0..=1, - default_missing_value = "18446744073709551615", - default_value_t = 0, - help_heading = "Input file options" - )] - max_cases: usize, - - /// Output driver configuration options. - #[arg(short = 'o', help_heading = "Output options")] - output_options: Vec, - - /// Output format. - #[arg(long, short = 'f', help_heading = "Output options")] - format: Option, -} - -enum Output { - Driver { - driver: Rc>>, - mode: Mode, - }, - Json { - writer: Rc>>, - pretty: bool, - }, - Discard, -} - -impl Output { - fn show_json(&self, value: &T) -> Result<()> - where - T: Serialize, - { - match self { - Self::Driver { mode, driver: _ } => { - Err(anyhow!("Mode '{mode}' only supports output as JSON.")) - } - Self::Json { writer, pretty } => { - let mut writer = writer.borrow_mut(); - match pretty { - true => serde_json::to_writer_pretty(&mut *writer, value)?, - false => serde_json::to_writer(&mut *writer, value)?, - }; - writeln!(writer)?; - Ok(()) - } - Self::Discard => Ok(()), - } - } - - fn warn(&self, warning: &impl Display) { - match self { - Output::Driver { driver, .. } => { - driver - .borrow_mut() - .write(&Arc::new(Item::from(Text::new_log(warning.to_string())))); - } - Output::Json { .. } => { - #[derive(Serialize)] - struct Warning { - warning: String, - } - let warning = Warning { - warning: warning.to_string(), - }; - let _ = self.show_json(&warning); - } - Self::Discard => (), - } - } -} - -impl ShowPc { - pub fn run(self) -> Result<()> { - let format = if let Some(format) = self.format { - format - } else if let Some(output_file) = &self.output { - match output_file - .extension() - .unwrap_or(OsStr::new("")) - .to_str() - .unwrap_or("") - { - "json" => ShowFormat::Json, - "ndjson" => ShowFormat::Ndjson, - _ => ShowFormat::Output, - } - } else { - ShowFormat::Json - }; - - let output = match format { - ShowFormat::Output => { - let mut config = String::new(); - - if let Some(file) = &self.output { - #[derive(Serialize)] - struct File<'a> { - file: &'a Path, - } - let file = File { - file: file.as_path(), - }; - let toml_file = toml::to_string_pretty(&file).unwrap(); - config.push_str(&toml_file); - } - for option in &self.output_options { - writeln!(&mut config, "{option}").unwrap(); - } - - let table: toml::Table = toml::from_str(&config)?; - if !table.contains_key("driver") { - let driver = if let Some(file) = &self.output { - ::driver_type_from_filename(file).ok_or_else(|| { - anyhow!("{}: no default output format for file name", file.display()) - })? - } else { - "text" - }; - - #[derive(Serialize)] - struct DriverConfig { - driver: &'static str, - } - config.insert_str( - 0, - &toml::to_string_pretty(&DriverConfig { driver }).unwrap(), - ); - } - - let config: Config = toml::from_str(&config)?; - Output::Driver { - mode: self.mode, - driver: Rc::new(RefCell::new(Box::new(::new(&config)?))), - } - } - ShowFormat::Json | ShowFormat::Ndjson => Output::Json { - pretty: format == ShowFormat::Json, - writer: if let Some(output_file) = &self.output { - Rc::new(RefCell::new(Box::new(File::create(output_file)?))) - } else { - Rc::new(RefCell::new(Box::new(stdout()))) - }, - }, - ShowFormat::Discard => Output::Discard, - }; - - let reader = BufReader::new(File::open(&self.input)?); - match self.mode { - Mode::Dictionary => { - let PcFile { - dictionary, - metadata: _, - cases, - } = PcFile::open(reader, |warning| output.warn(&warning))?; - let cases = cases.take(self.max_cases); - - match &output { - Output::Driver { driver, mode: _ } => { - let mut output = Vec::new(); - output.extend( - dictionary - .all_pivot_tables() - .into_iter() - .map(|pivot_table| Item::new(pivot_table)), - ); - output.extend(cases_to_output(&dictionary, cases)); - driver - .borrow_mut() - .write(&Arc::new(Item::new(Details::Group( - output.into_iter().map(Arc::new).collect(), - )))); - } - Output::Json { .. } => { - output.show_json(&dictionary)?; - for (_index, case) in (0..self.max_cases).zip(cases) { - output.show_json(&case?)?; - } - } - Output::Discard => (), - } - } - Mode::Metadata => { - let metadata = PcFile::open(reader, |warning| output.warn(&warning))?.metadata; - - match &output { - Output::Driver { driver, mode: _ } => { - driver - .borrow_mut() - .write(&Arc::new(Item::new(PivotTable::from(&metadata)))); - } - Output::Json { .. } => { - output.show_json(&metadata)?; - } - Output::Discard => (), - } - } - } - Ok(()) - } -} - -/// What to show in a system file. -#[derive(Clone, Copy, Debug, Default, PartialEq, ValueEnum)] -enum Mode { - /// File dictionary, with variables, value labels, ... - #[default] - #[value(alias = "dict")] - Dictionary, - - /// File metadata not included in the dictionary. - Metadata, -} - -impl Mode { - fn as_str(&self) -> &'static str { - match self { - Mode::Dictionary => "dictionary", - Mode::Metadata => "metadata", - } - } -} - -impl Display for Mode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -enum ShowFormat { - /// Pretty-printed JSON. - #[default] - Json, - /// Newline-delimited JSON. - Ndjson, - /// Pivot tables. - Output, - /// No output. - Discard, -} diff --git a/rust/pspp/src/show_por.rs b/rust/pspp/src/show_por.rs deleted file mode 100644 index d0ba365eca..0000000000 --- a/rust/pspp/src/show_por.rs +++ /dev/null @@ -1,327 +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 anyhow::{Result, anyhow}; -use clap::{Args, ValueEnum}; -use pspp::{ - data::cases_to_output, - output::{ - Details, Item, Text, - driver::{Config, Driver}, - pivot::PivotTable, - }, - por::PortableFile, -}; -use serde::Serialize; -use std::{ - cell::RefCell, - ffi::OsStr, - fmt::{Display, Write as _}, - fs::File, - io::{BufReader, Write, stdout}, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, -}; - -/// Show information about SPSS portable files. -#[derive(Args, Clone, Debug)] -pub struct ShowPor { - /// What to show. - #[arg(value_enum)] - mode: Mode, - - /// File to show. - #[arg(required = true)] - input: PathBuf, - - /// Output file name. If omitted, output is written to stdout. - output: Option, - - /// Maximum number of cases to read. - /// - /// If specified without an argument, all cases will be read. - #[arg( - long = "data", - num_args = 0..=1, - default_missing_value = "18446744073709551615", - default_value_t = 0, - help_heading = "Input file options" - )] - max_cases: usize, - - /// Output driver configuration options. - #[arg(short = 'o', help_heading = "Output options")] - output_options: Vec, - - /// Output format. - #[arg(long, short = 'f', help_heading = "Output options")] - format: Option, -} - -enum Output { - Driver { - driver: Rc>>, - mode: Mode, - }, - Json { - writer: Rc>>, - pretty: bool, - }, - Discard, -} - -impl Output { - fn show_json(&self, value: &T) -> Result<()> - where - T: Serialize, - { - match self { - Self::Driver { mode, driver: _ } => { - Err(anyhow!("Mode '{mode}' only supports output as JSON.")) - } - Self::Json { writer, pretty } => { - let mut writer = writer.borrow_mut(); - match pretty { - true => serde_json::to_writer_pretty(&mut *writer, value)?, - false => serde_json::to_writer(&mut *writer, value)?, - }; - writeln!(writer)?; - Ok(()) - } - Self::Discard => Ok(()), - } - } - - fn warn(&self, warning: &impl Display) { - match self { - Output::Driver { driver, .. } => { - driver - .borrow_mut() - .write(&Arc::new(Item::from(Text::new_log(warning.to_string())))); - } - Output::Json { .. } => { - #[derive(Serialize)] - struct Warning { - warning: String, - } - let warning = Warning { - warning: warning.to_string(), - }; - let _ = self.show_json(&warning); - } - Self::Discard => (), - } - } -} - -impl ShowPor { - pub fn run(self) -> Result<()> { - let format = if let Some(format) = self.format { - format - } else if let Some(output_file) = &self.output { - match output_file - .extension() - .unwrap_or(OsStr::new("")) - .to_str() - .unwrap_or("") - { - "json" => ShowFormat::Json, - "ndjson" => ShowFormat::Ndjson, - _ => ShowFormat::Output, - } - } else { - ShowFormat::Json - }; - - let output = match format { - ShowFormat::Output => { - let mut config = String::new(); - - if let Some(file) = &self.output { - #[derive(Serialize)] - struct File<'a> { - file: &'a Path, - } - let file = File { - file: file.as_path(), - }; - let toml_file = toml::to_string_pretty(&file).unwrap(); - config.push_str(&toml_file); - } - for option in &self.output_options { - writeln!(&mut config, "{option}").unwrap(); - } - - let table: toml::Table = toml::from_str(&config)?; - if !table.contains_key("driver") { - let driver = if let Some(file) = &self.output { - ::driver_type_from_filename(file).ok_or_else(|| { - anyhow!("{}: no default output format for file name", file.display()) - })? - } else { - "text" - }; - - #[derive(Serialize)] - struct DriverConfig { - driver: &'static str, - } - config.insert_str( - 0, - &toml::to_string_pretty(&DriverConfig { driver }).unwrap(), - ); - } - - let config: Config = toml::from_str(&config)?; - Output::Driver { - mode: self.mode, - driver: Rc::new(RefCell::new(Box::new(::new(&config)?))), - } - } - ShowFormat::Json | ShowFormat::Ndjson => Output::Json { - pretty: format == ShowFormat::Json, - writer: if let Some(output_file) = &self.output { - Rc::new(RefCell::new(Box::new(File::create(output_file)?))) - } else { - Rc::new(RefCell::new(Box::new(stdout()))) - }, - }, - ShowFormat::Discard => Output::Discard, - }; - - let reader = BufReader::new(File::open(&self.input)?); - match self.mode { - Mode::Dictionary => { - let PortableFile { - dictionary, - metadata: _, - cases, - } = PortableFile::open(reader, |warning| output.warn(&warning))?; - let cases = cases.take(self.max_cases); - - match &output { - Output::Driver { driver, mode: _ } => { - let mut output = Vec::new(); - output.extend( - dictionary - .all_pivot_tables() - .into_iter() - .map(|pivot_table| Item::new(pivot_table)), - ); - output.extend(cases_to_output(&dictionary, cases)); - driver - .borrow_mut() - .write(&Arc::new(Item::new(Details::Group( - output.into_iter().map(Arc::new).collect(), - )))); - } - Output::Json { .. } => { - output.show_json(&dictionary)?; - for (_index, case) in (0..self.max_cases).zip(cases) { - output.show_json(&case?)?; - } - } - Output::Discard => (), - } - } - Mode::Metadata => { - let metadata = - PortableFile::open(reader, |warning| output.warn(&warning))?.metadata; - - match &output { - Output::Driver { driver, mode: _ } => { - driver - .borrow_mut() - .write(&Arc::new(Item::new(PivotTable::from(&metadata)))); - } - Output::Json { .. } => { - output.show_json(&metadata)?; - } - Output::Discard => (), - } - } - Mode::Histogram => { - let (histogram, translations) = PortableFile::read_histogram(reader)?; - let h = histogram - .into_iter() - .enumerate() - .filter_map(|(index, count)| { - if count > 0 - && index != translations[index as u8] as usize - && translations[index as u8] != 0 - { - Some(( - format!("{index:02x}"), - translations[index as u8] as char, - count, - )) - } else { - None - } - }) - .collect::>(); - output.show_json(&h)?; - } - } - Ok(()) - } -} - -/// What to show in a system file. -#[derive(Clone, Copy, Debug, Default, PartialEq, ValueEnum)] -enum Mode { - /// File dictionary, with variables, value labels, ... - #[default] - #[value(alias = "dict")] - Dictionary, - - /// File metadata not included in the dictionary. - Metadata, - - /// Histogram of character incidence in the file. - Histogram, -} - -impl Mode { - fn as_str(&self) -> &'static str { - match self { - Mode::Dictionary => "dictionary", - Mode::Metadata => "metadata", - Mode::Histogram => "histogram", - } - } -} - -impl Display for Mode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -enum ShowFormat { - /// Pretty-printed JSON. - #[default] - Json, - /// Newline-delimited JSON. - Ndjson, - /// Pivot tables. - Output, - /// No output. - Discard, -} diff --git a/rust/pspp/src/spv.rs b/rust/pspp/src/spv.rs new file mode 100644 index 0000000000..a9868f7041 --- /dev/null +++ b/rust/pspp/src/spv.rs @@ -0,0 +1,34 @@ +// 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 . + +//! Reading and writing SPV files. +//! +//! This module enables reading and writing SPSS Viewer or `.spv` files, which +//! SPSS 16 and later uses to represent the contents of its output editor. See also +//! [SPV file format documentation]. +//! +//! Use [ReadOptions] to read an SPV file. Use [Writer] to write an SPV file. +//! +//! [SPV file format documentation]: https://pspp.benpfaff.org/manual/spv/index.html + +// Warn about missing docs, but not for items declared with `#[cfg(test)]`. +#![cfg_attr(not(test), warn(missing_docs))] + +mod read; +mod write; + +pub use read::{Error, ReadOptions, SpvFile, html, legacy_bin}; +pub use write::Writer; diff --git a/rust/pspp/src/spv/read.rs b/rust/pspp/src/spv/read.rs new file mode 100644 index 0000000000..cf088c4fb5 --- /dev/null +++ b/rust/pspp/src/spv/read.rs @@ -0,0 +1,846 @@ +// 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 . +#![allow(dead_code)] +use std::{ + cell::RefCell, + fmt::Display, + fs::File, + io::{BufReader, Cursor, Read, Seek}, + path::Path, + rc::Rc, +}; + +use anyhow::Context; +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::{ + crypto::EncryptedReader, + output::{ + Details, Item, SpvInfo, SpvMembers, Text, + page::{self, Orientation}, + pivot::{Axis2, Length, TableProperties, look::Look, value::Value}, + }, + spv::read::{ + html::Document, + legacy_bin::LegacyBin, + legacy_xml::Visualization, + light::{LightTable, LightWarning}, + }, +}; + +mod css; +pub mod html; +pub mod legacy_bin; +mod legacy_xml; +mod light; +#[cfg(test)] +mod tests; + +/// A warning encountered reading an SPV file. +#[derive(Clone, Debug)] +pub struct Warning { + /// The name of the .zip file member inside the system file. + pub member: String, + /// Detailed warning message. + pub details: WarningDetails, +} + +impl Display for Warning { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Warning reading member {:?}: {}", + &self.member, &self.details + ) + } +} + +/// Details of a [Warning]. +#[derive(Clone, Debug, thiserror::Error, Display)] +pub enum WarningDetails { + /// {0} + LightWarning(LightWarning), + + /// Unknown page orientation {0:?}. + UnknownOrientation(String), +} + +/// Options for reading an SPV file. +#[derive(Clone, Debug)] +pub struct ReadOptions { + /// Function called to report warnings. + pub warn: F, + + /// 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 ReadOptions { + /// Construct a new [ReadOptions] without a password. + pub fn new(warn: F) -> Self { + Self { + warn, + password: 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, ..self } + } +} + +pub trait ReadSeek: Read + Seek {} +impl ReadSeek for T where T: Read + Seek {} + +impl ReadOptions +where + F: FnMut(Warning) + 'static, +{ + /// Opens the file at `path`. + pub fn open_file

(self, path: P) -> Result + where + P: AsRef, + { + self.open_reader(File::open(path)?) + } + + /// Opens the file read from `reader`. + pub fn open_reader(self, reader: R) -> Result + where + R: Read + Seek + 'static, + { + let reader = match &self.password { + None => Box::new(reader) as Box, + Some(password) => Box::new(EncryptedReader::open(reader, password)?), + }; + self.open_reader_inner(Box::new(reader)) + } + + fn open_reader_inner(self, reader: Box) -> Result { + let archive = ZipArchive::new(reader).map_err(|error| match error { + ZipError::InvalidArchive(_) => Error::NotSpv, + other => other.into(), + })?; + Ok(self.open_zip_archive(archive)?) + } + + /// Opens the provided Zip `archive`. + /// + /// Any password provided for reading the file is unused, because if one was + /// needed then it must have already been used to open the archive. + pub fn open_zip_archive( + self, + mut archive: ZipArchive>, + ) -> Result { + // 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); + + // Read all the items. + let warn = Rc::new(RefCell::new(Box::new(self.warn) as Box)); + 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(&mut archive, i, &name, &warn)?; + items.append(&mut new_items); + page_setup = page_setup.or(ps); + } + } + + Ok(SpvFile { + items: items.into_iter().collect(), + page_setup, + archive, + }) + } +} + +/// A SPSS viewer (SPV) file read with [ReadOptions]. +pub struct SpvFile { + /// SPV file contents. + pub items: Vec, + + /// The page setup in the SPV file, if any. + pub page_setup: Option, + + /// The Zip archive that the file was read from. + pub archive: ZipArchive>, +} + +impl SpvFile { + /// Returns the contents of the `SpvFile`. + pub fn into_contents(self) -> (Vec, Option) { + (self.items, self.page_setup) + } + + /// Returns just the [Item]s. + pub fn into_items(self) -> Vec { + self.items + } +} + +/// An error reading an SPV file. +/// +/// Returned by [ReadOptions::open_file] and [ReadOptions::open_reader]. +#[derive(Debug, Display, thiserror::Error)] +pub enum Error { + /// Not an SPV file. + NotSpv, + + /// {0} + EncryptionError(#[from] crate::crypto::Error), + + /// {0} + ZipError(#[from] ZipError), + + /// {0} + IoError(#[from] std::io::Error), + + /// {0} + DeError(#[from] quick_xml::DeError), + + /// {0} + BinrwError(#[from] binrw::Error), + + /// {0} + CairoError(#[from] cairo::IoError), +} + +fn new_error_item(message: impl Into) -> Item { + Text::new_log(message).into_item().with_label("Error") +} + +fn read_heading( + archive: &mut ZipArchive, + file_number: usize, + structure_member: &str, + warn: &Rc>>, +) -> 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}")) + { + Ok(result) => result, + Err(error) => panic!("{error:?}"), + }; + let page_setup = heading + .page_setup + .take() + .map(|ps| ps.decode(warn, structure_member)); + Ok((heading.decode(archive, structure_member, warn)?, page_setup)) +} + +#[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, + + #[serde(rename = "$value")] + #[serde(default)] + children: Vec, +} + +impl Heading { + fn decode( + self, + archive: &mut ZipArchive, + structure_member: &str, + warn: &Rc>>, + ) -> Result, Error> + where + R: Read + Seek, + { + 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, warn) + .unwrap_or_else(|error| { + new_error_item(format!("Error reading table: {error}")) + .with_spv_info(SpvInfo::new(structure_member).with_error()) + }), + 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_or_else(|error| { + new_error_item(format!("Error reading image: {error}")) + .with_spv_info(SpvInfo::new(structure_member).with_error()) + }), + ContainerContent::Object(object) => object + .decode(archive, structure_member) + .unwrap_or_else(|error| { + new_error_item(format!("Error reading object: {error}")) + .with_spv_info(SpvInfo::new(structure_member).with_error()) + }), + 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.with_show(container.visibility == Visibility::Visible)); + } + 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, warn)? + .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, + warn: &Rc>>, + structure_member: &str, + ) -> 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 { + if reference_orientation.starts_with("0") { + setup.orientation = Orientation::Portrait; + } else if reference_orientation.starts_with("90") { + setup.orientation = Orientation::Landscape; + } else { + (warn.borrow_mut())(Warning { + member: structure_member.into(), + details: WarningDetails::UnknownOrientation(reference_orientation.clone()), + }); + } + } + 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(); + } + if let Some(PageParagraph { text }) = &self.page_footer.page_paragraph { + setup.footer = text.decode(); + } + setup + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageHeader { + page_paragraph: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageFooter { + page_paragraph: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageParagraph { + text: PageParagraphText, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageParagraphText { + #[serde(default, rename = "$text")] + text: String, +} + +impl PageParagraphText { + fn decode(&self) -> Document { + Document::from_html(&self.text) + } +} + +#[derive(Copy, Clone, Debug, Default, Deserialize)] +#[serde(rename = "snake_case")] +enum ReferenceOrientation { + #[serde(alias = "0")] + #[serde(alias = "0deg")] + #[serde(alias = "inherit")] + #[default] + Portrait, + + #[serde(alias = "90")] + #[serde(alias = "90deg")] + #[serde(alias = "-270")] + #[serde(alias = "-270deg")] + Landscape, + + #[serde(alias = "180")] + #[serde(alias = "180deg")] + #[serde(alias = "-1280")] + #[serde(alias = "-180deg")] + ReversePortrait, + + #[serde(alias = "270")] + #[serde(alias = "270deg")] + #[serde(alias = "-90")] + #[serde(alias = "-90deg")] + Seascape, +} + +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 + } + } + } +} + +/// Chart size. +#[derive(Copy, Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum ChartSize { + FullHeight, + HalfHeight, + QuarterHeight, + #[default] + #[serde(other)] + AsIs, +} + +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, PartialEq, Eq)] +#[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(), + }), + ) + } +} + +fn decode_image( + archive: &mut ZipArchive, + structure_member: &str, + command_name: &Option, + image_name: &str, +) -> Result +where + R: Read + Seek, +{ + 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())), + )) +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Image { + #[serde(rename = "@commandName")] + command_name: Option, + data_path: String, +} + +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, + ) + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct Object { + #[serde(rename = "@commandName")] + command_name: Option, + #[serde(rename = "@uri")] + uri: String, +} + +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) + } +} + +#[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, +} + +impl Table { + fn decode( + &self, + archive: &mut ZipArchive, + structure_member: &str, + warn: &Rc>>, + ) -> Result + where + R: Read + Seek, + { + match &self.table_structure.path { + None => { + let member_name = self.table_structure.data_path.clone(); + 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 warn = warn.clone(); + let warning = Rc::new(RefCell::new(Box::new({ + let warn = warn.clone(); + let member_name = member_name.clone(); + move |w| { + (warn.borrow_mut())(Warning { + member: member_name.clone(), + details: WarningDetails::LightWarning(w), + }) + } + }) + as Box)); + let table = + LightTable::read_args(&mut cursor, (warning.clone(),)).map_err(|e| { + e.with_message(format!( + "While parsing {member_name:?} as light binary SPV member" + )) + })?; + let pivot_table = table.decode(&mut *warning.borrow_mut()); + Ok(pivot_table.into_item().with_spv_info( + SpvInfo::new(structure_member).with_members(SpvMembers::LightTable( + 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); + + 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::LegacyTable { + xml: xml_member_name.clone(), + binary: bin_member_name.clone(), + }), + )) + } + } + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +enum TableType { + Table, + Note, + Warning, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct ContainerText { + #[serde(rename = "@type")] + text_type: TextType, + #[serde(rename = "@commandName")] + command_name: Option, + html: String, +} + +impl ContainerText { + fn decode(&self) -> Value { + html::Document::from_html(&self.html).into_value() + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +enum TextType { + Title, + Log, + Text, + #[serde(rename = "page-title")] + PageTitle, +} + +#[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. + #[serde(rename = "csvPath")] + _csv_path: Option, +} diff --git a/rust/pspp/src/spv/read/css.rs b/rust/pspp/src/spv/read/css.rs new file mode 100644 index 0000000000..abd7712d19 --- /dev/null +++ b/rust/pspp/src/spv/read/css.rs @@ -0,0 +1,396 @@ +use std::{ + borrow::Cow, + fmt::{Display, Write}, + mem::discriminant, + ops::Not, +}; + +use itertools::Itertools; + +use crate::{ + output::pivot::look::{FontStyle, HorzAlign}, + spv::read::html::Style, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Token<'a> { + Id(Cow<'a, str>), + LeftCurly, + RightCurly, + Colon, + Semicolon, +} + +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 { + /// Parses `s` as CSS and returns the value of `text-align` found in it, if + /// any. + /// + /// This is only good enough to handle the simple CSS found in SPV files. + pub fn from_css(css: &str) -> Option { + let mut lexer = Lexer(css); + 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 { + /// Parses the CSS found in `css` and returns the corresponding [Style]s. + /// + /// This is only good enough to parse the simple CSS found in SPV files. + pub fn parse_css(css: &str) -> Vec