"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"
"memchr",
]
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.101",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.101",
+]
+
[[package]]
name = "dashmap"
version = "5.5.3"
"syn 2.0.101",
]
+[[package]]
+name = "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"
"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"
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"
"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"
"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"
"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"
"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"
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"
"aes",
"anyhow",
"binrw",
- "bitflags 2.9.1",
+ "bit-vec",
"cairo-rs",
"chardetng",
"chrono",
"encoding_rs",
"enum-iterator",
"enum-map",
- "flagset",
+ "enumset",
+ "erased-serde",
"flate2",
"hashbrown 0.15.5",
"hexplay",
+ "html_parser",
"indexmap",
"itertools 0.14.0",
"libc",
"ordered-float",
"pango",
"pangocairo",
+ "paper-sizes",
"pspp-derive",
"quick-xml",
"rand",
"readpass",
"serde",
"serde_json",
+ "serde_path_to_error",
"smallstr",
"smallvec",
"thiserror",
"unicode-segmentation",
"unicode-width",
"windows-sys 0.48.0",
- "xmlwriter",
"zeroize",
"zip",
]
[[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",
"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"
"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"
"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"
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"
[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
/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}
/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'
/MITERATE=NUMBER
/MNEST=NUMBER
-(settings not yet implemented, but accepted and ignored)
+(not yet implemented)
/BASETEXTDIRECTION={AUTOMATIC,RIGHTTOLEFT,LEFTTORIGHT}
/BLOCK='C'
/BOX={'XXX','XXXXXXXXXXX'}
For subcommands that take boolean values, `ON` and `YES` are
synonymous, as are `OFF` and `NO`, when used as subcommand values.
+<!-- toc -->
+
+# 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
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
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
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
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:
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:
`.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`
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
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
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).
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:
--- /dev/null
+# Output Drivers
+
+PSPP has output drivers for several formats. This section documents
+the supported formats and how they can be configured:
+
+<!-- toc -->
+
+# 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 = <columns>`
+ 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 = <bool>`
+ 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:
+
+* <a name="page_setup">`page_setup = <PageSetup>`</a>
+ Sets the page size, margins, and other parameters. The following
+ sub-options are available:
+
+ - `initial_page_number = <number>`
+ The page number to use for the first page of output. The default
+ is 1.
+
+ - `paper = "<size>"`
+ Sets the page size. `<size>` takes the form `<w>x<h><unit>`,
+ 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 = "<trbl>"`
+ `margins = ["<tb>", "<lr>"]`
+ `margins = ["<t>", "<rl>", "<b>"]`
+ `margins = ["<t>", "<r>", "<b>", "<l>"]`
+ 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 = "<length>"`
+ Sets the vertical spacing between output objects, such as tables
+ or text. `<length>` 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 = "<heading>"`
+ `footer = "<heading>"`
+ 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:
+
+ * `<html>`, optionally, as a top-level element.
+
+ * `<head>`, optionally, as a first element enclosing a `<style>`
+ element with simple CSS. Only the following CSS properties,
+ for `p` elements only, are supported:
+
+ - `color`: an RGB value, e.g. `#RRGGBB`.
+
+ - `font-weight`: either `bold` or `normal`.
+
+ - `font-style`: either `italic` or `normal`.
+
+ - `text-decoration`: either `underline` or `normal`.
+
+ - `font-family`: a font name.
+
+ - `font-size`: a font size[^1].
+
+ * `<p>`, optionally, for dividing text into multiple paragraphs.
+ The `align` attribute is honored, as is the `text_align`
+ property on the `style` attribute.
+
+ * `<br>` for breaking a paragraph into lines. Line breaks inside
+ HTML are also treated as breaks.
+
+ * `<b>`, `<i>`, `<u>`, `<strike>`, `<strong>`, and `<em>` for
+ styling text.
+
+ * `<font>`, with attributes `face`, `color`, and `size`[^1].
+
+ * Other elements are accepted but ignored.
+
+ Text in a header or footer may include any of the following
+ variable references, which are expanded at output time:
+
+ * `&[Date]`
+ `&[Time]`
+ The current date or time in the preferred format for the locale.
+
+ * `&[Head1]`
+ `&[Head2]`
+ `&[Head3]`
+ `&[Head4]`
+ The name of the first-, second-, third-, or fourth-level
+ heading corresponding to the first output item that appears on
+ the page with the header or footer.
+
+ * `&[PageTitle]`
+ The title set with the `TITLE` command.
+
+ * `&[Filename]`
+ Name of the output file.
+
+ * `&[Page]`
+ The page number.
+
+ The following example shows how to put a centered title at the top
+ of each page and a right-justified page number at the bottom:
+
+ ```
+ header = '<p align="center">&[PageTitle]</p>'
+ footer = '<p align="right">Page &[Page]</p>'
+ ```
+
+[^1]: Font sizes on `<font>` and in CSS may be given as points,
+ e.g. `10pt`, or as a bare number interpreted as follows:
+
+ | Number | Size |
+ |-------:|--------:|
+ | 1 | 6 pt |
+ | 2 | 7.5 pt |
+ | 3 | 9 pt |
+ | 4 | 10.5 pt |
+ | 5 | 13.5 pt |
+ | 6 | 18 pt |
+ | 7 | 27 pt |
+
+# HTML Output (`.htm` and `.html`)
+
+PSPP can produce HTML output.
+
+HTML output is encoded in UTF-8.
+
+This driver currently has no options.
+
+# Comma-Separated Value Output (`.csv`)
+
+PSPP can produce output in [comma-separated value] format.
+
+CSV output is encoded in UTF-8.
+
+This driver has the following general options:
+
+* `quote = "<char>"`
+ A single character for quoting multi-line fields and fields that
+ contain the delimiter. The default is `"`.
+
+* `delimiter = "<char>"`
+ A single character to separate fields. The default is `,`.
+
+The following additional options only affect output written by [`pspp
+convert`](pspp-convert.md):
+
+* `var_names = false`
+ By default, `pspp convert` writes the variable names as the first
+ line of output. With this option, `pspp convert` omits this line.
+
+* `recode = true`
+ By default, `pspp convert` writes user-missing values to CSV output
+ files as their regular values. This options makes `pspp convert`
+ write them the same way as system-missing values (as a single
+ space).
+
+* `labels = true`
+ By default, `pspp convert` writes variables' values to CSV output
+ files. With this option, `pspp convert` writes value labels.
+
+* `print_formats = true`
+ By default, `pspp convert` writes numeric variables as plain
+ numbers. This option makes `pspp convert` honor variables' print
+ formats.
+
+* `decimal = "<char>"`
+ This option sets the character used as a decimal point in output.
+ The default is `.`.
+
+[comma-separated value]: https://en.wikipedia.org/wiki/Comma-separated_values
+
+# JSON Output (`.json`)
+
+PSPP can produce output in [JSON] format.
+
+CSV output is encoded in UTF-8.
+
+This driver currently has no options.
+
+[JSON]: https://www.json.org/json-en.html
+
+# SPSS Viewer Output (`.spv`)
+
+PSPP can produce output in SPSS Viewer format.
+
+This driver has the following options:
+
+* `page_setup = <PageSetup>`
+ Sets the page size, margins, and other parameters. `<PageSetup>`
+ has the same form documented [for PDF format](#page_setup).
+
+# System File Output (`.sav`)
+
+PSPP can produce output in the form of SPSS system files, which
+usually have a `.sav` extension.
+
+This driver has the following options:
+
+* `compression = "<compression>"`
+ Sets the kind of compression used for writing data in the system
+ file. `<compression>` must be one of the following:
+
+ - `simple`: Uses a simple form of compression that saves space
+ writing small integer values and string segments that are all
+ spaces. All versions of SPSS support simple compression.
+
+ - `zlib`: Uses more advanced compression that saves space in more
+ general cases. Only SPSS 21 and later can read files written with
+ `zlib` compression.
+
+# Portable File Output (`.por`)
+
+PSPP can produce output in the form of SPSS portable files, which
+usually have a `.por` extension.
+
+> The portable file format is mostly obsolete. The "system file"
+> or .sav format should be used for writing new data files.
+
+This driver has no options.
-# Converting data files with `pspp convert`
+# Converting Files with `pspp convert`
-The `pspp convert` command reads data from one file and writes it to
-another. The basic syntax is:
+The `pspp convert` command reads a file and writes it to another,
+usually in a different format. The basic syntax is:
```
pspp convert <INPUT> [OUTPUT]
```
-which reads an SPSS system file or portable file or SPSS/PC+ system
-file from `<INPUT>` and writes a copy of it to `[OUTPUT]`. If
-`[OUTPUT]` is omitted, output is written to the terminal.
+which reads `<INPUT>` and writes a copy of it to `[OUTPUT]`, or to the
+terminal if `[OUTPUT]` is omitted.
-If `[OUTPUT]` is specified, then `pspp convert` tries to guess the
-output format based on its extension:
+`pspp convert` can convert following kinds of files:
-* `csv`
- `txt`
- Comma-separated value. Each value is formatted according to its
- variable's print format. The first line in the file contains
- variable names.
+* Data files:
-* `sav`
- `sys`
- SPSS system file.
+ When `<INPUT>` is an SPSS system file or portable file or an SPSS/PC+
+ system file, then `[OUTPUT]` can be a data file format. The
+ currently supported data file formats are:
-Without an output file name, the default output format is CSV. Use
-`-O <output_format>` to override the default or to specify the format
-for unrecognized extensions.
+ - [Comma-separated value (CSV) files]
+ - [SPSS system files]
+ - [SPSS portable files]
-## Options
+ If `[OUTPUT]` is omitted, the default is to write the data to stdout
+ in CSV format.
+
+* Viewer files:
+
+ When `<INPUT>` is an SPSS viewer (SPV) file, then `[OUTPUT]` may be
+ any [PSPP output format], including:
+
+ - [plain text]
+ - [PDF]
+ - [HTML]
+
+ If `[OUTPUT]` is omitted, the default is to write the viewer file to
+ stdout in plain text format.
+
+[Converting SPSS Viewer Files]: pspp-convert-spv.md
+[Comma-separated value (CSV) files]: output.md#comma-separated-value-output-csv
+[SPSS system files]: output.md#system-file-output-sav
+[SPSS portable files]: output.md#portable-file-output-por
+[PSPP output format]: output.md
+[plain text]: output.md#text-output-txt-and-text
+[PDF]: output.md#pdf-output-pdf
+[HTML]: output.md#html-output-htm-and-html
-`pspp convert` accepts the following general options:
+## Options
-* `-O csv`
- `-O sys`
- Sets the output format.
+`pspp convert` accepts the following options:
* `-e <ENCODING>`
`--encoding=<ENCODING>`
[Encoding Standard]: https://encoding.spec.whatwg.org/#names-and-labels
+* `--unicode`
+ For input from a system file, converts from the file's encoding to
+ Unicode (UTF-8) encoding before writing the output. If the input
+ was not already in Unicode, then this causes string variables to be
+ tripled in width.
+
* `-c <MAX_CASES>`
`--cases=<MAX_CASES>`
By default, all cases in the input are copied to the output.
`--password=<PASSWORD>`
Specifies the password for reading an encrypted SPSS system file.
+ In addition to file encryption, SPSS supports a feature called
+ "password encryption". The password specified can be specified with
+ or without "password encryption".
+
`pspp convert` reads, but does not write, encrypted system files.
> ⚠️ The password (and other command-line options) may be visible to
- other users on multiuser systems.
-
-## System File Output Options
-
-These options only affect output to SPSS system files.
-
-* `--unicode`
- Writes system file output with Unicode (UTF-8) encoding. If the
- input was not already in Unicode, then this causes string variables
- to be tripled in width.
-
-* `--compression <COMPRESSION>`
- Writes data in the system file with the specified format of
- compression:
-
- - `simple`: A simple form of compression that saves space writing
- small integer values and string segments that are all spaces. All
- versions of SPSS support simple compression.
-
- - `zlib`: More advanced compression that saves space in more general
- cases. Only SPSS 21 and later can read files written with `zlib`
- compression.
-
-## CSV Output Options
-
-These options only affect output to CSV files.
-
-* `--no-var-names`
- By default, `pspp convert` writes the variable names as the first
- line of output. With this option, `pspp convert` omits this line.
-
-* `--recode`
- By default, `pspp convert` writes user-missing values to CSV output
- files as their regular values. With this option, `pspp convert`
- recodes them to system-missing values (which are written as a
- single space).
-
-* `--labels`
- By default, `pspp convert` writes variables' values to CSV output
- files. With this option, `pspp convert` writes value labels.
-
-* `--print-formats`
- By default, `pspp convert` writes numeric variables as plain
- numbers. This option makes `pspp convert` honor variables' print
- formats.
+ > other users on multiuser systems.
-* `--decimal=DECIMAL`
- This option sets the character used as a decimal point in output.
- The default is `.`. Only ASCII characters may be used.
+* `-o <OUTPUT_OPTIONS>`
+ Adds `<OUTPUT_OPTIONS>` to the output engine configuration. See
+ [Output Drivers](output.md) for information on how to configure
+ output.
-* `--delimiter=DELIMITER`
- This option sets the character used to separate fields in output.
- The default is `,`, unless the decimal point is `,`, in which case
- `;` is used. Only ASCII characters may be used.
+ If no output driver is specified, the default output format is
+ chosen based on `[OUTPUT]`'s extension. If `[OUTPUT]` is omitted,
+ output is written to stdout, either in [CSV] format if `<INPUT>` is
+ a data file, or in [plain text] format if `<INPUT>` is an SPV file.
-* `--qualifier=QUALIFIER`
- The option sets the character used to quote fields that contain the
- delimiter. The default is `"`. Only ASCII characters may be used.
+[CSV]: output.md#comma-separated-value-output-csv
--- /dev/null
+# Identifying Files
+
+The `pspp identify` command identifies file types. The syntax is:
+
+```
+pspp identify <FILE>
+```
+
+which reads `<FILE>` and identifies it based on its contents. The
+command prints one of the following on the terminal:
+
+* `sav`, for an SPSS system file, `sav (encrypted)` if the file is
+ encrypted.
+
+* `por`, for an SPSS portable file.
+
+* `pc+`, for an SPSS/PC+ data file.
+
+* `spv`, for an SPSS Viewer file, or `spv (encrypted)` if the file is
+ encrypted.
+
+* `sps`, for an SPSS syntax file, `sps (encrypted)` if the file is
+ encrypted, or `sps (low confidence)` if the file could be a syntax
+ file or another kind of text file.
+
+* `unknown`, for other kinds of files.
## Options
-The following options affect how `pspp show-pc` reads `<INPUT>`:
+`pspp show-pc` accepts the following options:
* `--data [<MAX_CASES>]`
For mode `dictionary`, and `encodings`, this instructs `pspp
then that sets a limit on the number of cases to read. Without this
option, PSPP will not read any cases.
-The following options affect how `pspp show-pc` writes its output:
-
-* `-f <FORMAT>`
- `--format <FORMAT>`
- Specifies the format to use for output. `<FORMAT>` may be one of
- the following:
-
- - `json`: JSON using indentation and spaces for easy human
- consumption.
- - `ndjson`: [Newline-delimited JSON].
- - `output`: Pivot tables with the PSPP output engine. Use `-o` for
- additional configuration.
- - `discard`: Do not produce any output.
-
- When these options are not used, the default output format is chosen
- based on the `[OUTPUT]` extension. If `[OUTPUT]` is not specified,
- then output defaults to JSON.
-
- [Newline-delimited JSON]: https://github.com/ndjson/ndjson-spec
-
* `-o <OUTPUT_OPTIONS>`
- Adds `<OUTPUT_OPTIONS>` to the output engine configuration.
+ Adds `<OUTPUT_OPTIONS>` to the output engine configuration. See
+ [Output Drivers](output.md) for information on how to configure
+ output.
+
+ If no output driver is specified, the default output format is
+ chosen based on `[OUTPUT]`'s extension. If `[OUTPUT]` is omitted,
+ output is written to stdout in [JSON](output.md#json-output-json)
+ format.
## Options
-The following options affect how `pspp show-por` reads `<INPUT>`:
+`pspp show-por` accepts the following options:
* `--data [<MAX_CASES>]`
For mode `dictionary`, and `encodings`, this instructs `pspp
then that sets a limit on the number of cases to read. Without this
option, PSPP will not read any cases.
-The following options affect how `pspp show-por` writes its output:
-
-* `-f <FORMAT>`
- `--format <FORMAT>`
- Specifies the format to use for output. `<FORMAT>` may be one of
- the following:
-
- - `json`: JSON using indentation and spaces for easy human
- consumption.
- - `ndjson`: [Newline-delimited JSON].
- - `output`: Pivot tables with the PSPP output engine. Use `-o` for
- additional configuration.
- - `discard`: Do not produce any output.
-
- When these options are not used, the default output format is chosen
- based on the `[OUTPUT]` extension. If `[OUTPUT]` is not specified,
- then output defaults to JSON.
-
- [Newline-delimited JSON]: https://github.com/ndjson/ndjson-spec
-
* `-o <OUTPUT_OPTIONS>`
- Adds `<OUTPUT_OPTIONS>` to the output engine configuration.
+ Adds `<OUTPUT_OPTIONS>` to the output engine configuration. See
+ [Output Drivers](output.md) for information on how to configure
+ output.
+
+ If no output driver is specified, the default output format is
+ chosen based on `[OUTPUT]`'s extension. If `[OUTPUT]` is omitted,
+ output is written to stdout in [JSON](output.md#json-output-json)
+ format.
--- /dev/null
+# Inspecting SPSS Viewer Files
+
+The `pspp show-spv` command reads SPSS Viewer (SPV) files, whose names
+usually have an `.spv` extension, and produces a report. The basic
+syntax is:
+
+```
+pspp show-spv <MODE> <INPUT> [OUTPUT]
+```
+
+where `<MODE>` is a mode of operation (see below) and `<INPUT>` is the
+SPV file to read, and `[OUTPUT]` is the output file name. If
+`[OUTPUT]` is omitted, output is written to the terminal.
+
+The following `<MODE>`s are accepted:
+
+* `dir`: Outputs a table of contents for the SPV file, listing every
+ selected object, which by default is every object except for hidden
+ ones.
+
+ The following additional option for `dir` is intended mainly for use
+ by PSPP developers:
+
+ - `--member-names`: Also show the names of the ZIP file members
+ associated with each object.
+
+* `get-table-look`: Extracts the TableLook from the first table in the
+ selected objects and outputs it in TableLook XML format. The output
+ file should have an `.stt` extension.
+
+ Use `-` for `<INPUT>` to instead write the default TableLook.
+
+* `convert-table-look`: Reads an `.stt` or `.tlo` TableLook file as
+ `<INPUT>` and outputs it in TableLook XML format. The output file
+ should have an `.stt` extension.
+
+ This is useful for converting a TableLook `.tlo` file from SPSS 15
+ or earlier into the newer `.stt` format.
+
+## Options
+
+These options apply to any `<MODE>` that reads an SPV file:
+
+* `-p <PASSWORD>`
+ `--password=<PASSWORD>`
+ Specifies the password for reading an encrypted SPV file.
+
+ PSPP reads, but does not write, encrypted SPV files.
+
+ > ⚠️ The password (and other command-line options) may be visible to
+ other users on multiuser systems.
+
+## Input Selection Options
+
+Commands that read an SPV file operate, by default, on all of the
+objects in the file, except for objects that are not visible in the
+output viewer window. The user may specify these options to select a
+subset of the input objects. When multiple options are used, only
+objects that satisfy all of them are selected:
+
+* `--select=[^]CLASS...`
+ Include only objects of the given `CLASS`; with leading `^`, include
+ only objects not in the class. Use commas to separate multiple
+ classes. The supported classes are:
+
+ * `charts`
+ * `headings`
+ * `logs`
+ * `models`
+ * `tables`
+ * `texts`
+ * `trees`
+ * `warnings`
+ * `outlineheaders`
+ * `pagetitle`
+ * `notes`
+ * `unknown`
+ * `other`
+
+* `--commands=[^]COMMAND...`
+ `--subtypes=[^]SUBTYPE...`
+ `--labels=[^]LABEL...`
+ Include only objects with the specified `COMMAND`, `SUBTYPE`, or
+ `LABEL`. With a leading `^`, include only the objects that do not
+ match. Multiple values may be specified separated by commas. An
+ asterisk at the end of a value acts as a wildcard.
+
+ The `--command` option matches command identifiers, case
+ insensitively. All of the objects produced by a single command use
+ the same, unique command identifier. Command identifiers are
+ always in English regardless of the language used for output. They
+ often differ from the command name in PSPP syntax. Use the
+ `pspp-output` program's `dir` command to print command identifiers
+ in particular output.
+
+ The `--subtypes` option matches particular tables within a command,
+ case insensitively. Subtypes are not necessarily unique: two
+ commands that produce similar output tables may use the same
+ subtype. Only tables have subtypes, so specifying `--subtypes` will
+ exclude other kinds of objects. Subtypes are always in English and
+ `dir` will print them.
+
+ The `--labels` option matches the labels in table output (that is,
+ the table titles). Labels are affected by the output language,
+ variable names and labels, split file settings, and other factors.
+
+* `--nth-commands=N...`
+ Include only objects from the `N`th command that matches `--command`
+ (or the `N`th command overall if `--command` is not specified),
+ where `N` is 1 for the first command, 2 for the second, and so on.
+
+* `--instances=INSTANCE...`
+ Include the specified `INSTANCE` of an object that matches the other
+ criteria within a single command. `INSTANCE` may be a number (1 for
+ the first instance, 2 for the second, and so on) or `last` for the
+ last instance.
+
+* `--show-hidden`
+ Include hidden output objects in the output. By default, they are
+ excluded.
+
+* `--or`
+ Separates two sets of selection options. Objects selected by
+ either set of options are included in the output.
+
+The following additional input selection options are intended mainly
+for use by PSPP developers:
+
+* `--errors`
+ Include only objects that cause an error when read. With the
+ `convert` command, this is most useful in conjunction with the
+ `--force` option.
+
+* `--members=MEMBER...`
+ Include only the objects that include a listed Zip file `MEMBER`.
+ More than one name may be included, comma-separated. The members in
+ an SPV file may be listed with the `dir` command by adding the
+ `--member-names` option or with `zipinfo` or another program to view
+ Zip files. Error messages that `pspp-output` prints when it reads
+ SPV files also often include member names.
## Options
-The following options affect how `pspp show` reads `<INPUT>`:
+`pspp show` accepts the following options:
* `--encoding <ENCODING>`
For modes `decoded` and `dictionary`, this reads the input file
that sets a limit on the number of cases to read. Without this
option, PSPP will not read any cases.
-The following options affect how `pspp show` writes its output:
-
-* `-f <FORMAT>`
- `--format <FORMAT>`
- Specifies the format to use for output. `<FORMAT>` may be one of
- the following:
-
- - `json`: JSON using indentation and spaces for easy human
- consumption.
- - `ndjson`: [Newline-delimited JSON].
- - `output`: Pivot tables with the PSPP output engine. Use `-o` for
- additional configuration.
- - `discard`: Do not produce any output.
-
- When these options are not used, the default output format is chosen
- based on the `[OUTPUT]` extension. If `[OUTPUT]` is not specified,
- then output defaults to JSON.
-
- [Newline-delimited JSON]: https://github.com/ndjson/ndjson-spec
-
* `-o <OUTPUT_OPTIONS>`
- Adds `<OUTPUT_OPTIONS>` to the output engine configuration.
+ Adds `<OUTPUT_OPTIONS>` to the output engine configuration. See
+ [Output Drivers](output.md) for information on how to configure
+ output.
+
+ If no output driver is specified, the default output format is
+ chosen based on `[OUTPUT]`'s extension. If `[OUTPUT]` is omitted,
+ output is written to stdout in [JSON](output.md#json-output-json)
+ format.
new-line. PSPP uses this string to identify an SPV file; it is
invariant across the corpus.
-> SPV files always begin with the 7-byte sequence 50 4b 03 04 14 00
-> 08, but this is not a useful magic number because most Zip archives
-> start the same way.
+> SPV files always begin with the 2-byte sequence 50 4b (`PK`), but
+> this is not a useful magic number because all Zip archives start the
+> same way.
>
> Checking only for the presence of `META-INF/MANIFEST.MF` is also not
> a useful magic number because this file name also appears in every
member whose output goes at the beginning of the document is numbered
0, the next member in the output is numbered 1, and so on.
-Structure members contain XML. This XML is sometimes self-contained,
+[Structure members] contain XML. This XML is sometimes self-contained,
but it often references detail members in the Zip archive, which are
named as follows:
* `PREFIX_table.xml` and `PREFIX_tableData.bin`
`PREFIX_lightTableData.bin`
The structure of a table plus its data. Older SPV files pair a
- `PREFIX_table.xml` file that describes the table's structure with a
- binary `PREFIX_tableData.bin` file that gives its data. Newer SPV
- files (the majority of those in the corpus) instead include a
- single `PREFIX_lightTableData.bin` file that incorporates both into
- a single binary format.
+ `PREFIX_table.xml` [legacy detail XML member] that describes the
+ table's structure with a `PREFIX_tableData.bin` [legacy detail
+ binary member] that gives its data. Newer SPV files (the majority
+ of those in the corpus) instead include a single
+ `PREFIX_lightTableData.bin` [light detail binary member] that
+ incorporates both into a single binary format.
* `PREFIX_warning.xml` and `PREFIX_warningData.bin`
`PREFIX_lightWarningData.bin`
SPSS tolerates corrupted Zip archives that Zip reader libraries tend
to reject. These can be fixed up with `zip -FF`.
+
+[Structure members]: structure.md
+[legacy detail XML member]: legacy-detail-xml.md
+[legacy detail binary member]: legacy-detail-binary.md
+[light detail binary member]: light-detail.md
will refer to "version 0xaf" and "version 0xb0" members later on.
A legacy member consists of `n-sources` data sources, each of which
-has Metadata and Data.
+has `Metadata` and `Data`.
`member-size` is the size of the legacy binary member, in bytes.
-The Data and Strings above are commented out because the Metadata has
-some oddities that mean that the Data sometimes seems to start at an
-unexpected place. The following section goes into detail.
+The `Data` and `Strings` above are commented out because the
+`Metadata` has some oddities that mean that the `Data` sometimes seems
+to start at an unexpected place. The following section goes into
+detail.
<!-- toc -->
values.
`source-name` is a 28- or 64-byte string padded on the right with
-0-bytes. The names that appear in the corpus are very generic: usually
-`tableData` for pivot table data or `source0` for chart data.
-
-A given Metadata's `data-offset` is the offset, in bytes, from the
-beginning of the member to the start of the corresponding Data. This
-allows programs to skip to the beginning of the data for a particular
-source. In every case in the corpus, the Data follow the Metadata in
-the same order, but it is important to use `data-offset` instead of
-reading sequentially through the file because of the exception described
-below.
-
-One SPV file in the corpus has legacy binary members with version
-0xb0 but a 28-byte `source-name` field (and only a single source). In
-practice, this means that the 64-byte `source-name` used in version 0xb0
-has a lot of 0-bytes in the middle followed by the `variable-name` of
-the following Data. As long as a reader treats the first 0-byte in the
-`source-name` as terminating the string, it can properly interpret these
-members.
+0-bytes. The names that appear in the corpus are very generic:
+usually `tableData` for pivot table data or `source0` for chart data.
+They are encoded in ASCII.
+
+A given `Metadata`'s `data-offset` is the offset, in bytes, from the
+beginning of the member to the start of the corresponding `Data`.
+This allows programs to skip to the beginning of the data for a
+particular source. In every case in the corpus, the `Data` follow the
+`Metadata` in the same order, but it is important to use `data-offset`
+instead of reading sequentially through the file because of the
+exception described below.
+
+One SPV file in the corpus has legacy binary members with version 0xb0
+but a 28-byte `source-name` field (and only a single source). In
+practice, this means that the 64-byte `source-name` used in version
+0xb0 has a lot of 0-bytes in the middle followed by the
+`variable-name` of the following `Data`. As long as a reader treats
+the first 0-byte in the `source-name` as terminating the string, it
+can properly interpret these members.
The meaning of `x` in version 0xb0 is unknown.
Variable => byte*288[variable-name] double*[n-values]
```
-Data follow the `Metadata` in the legacy binary format, with sources
+`Data` follow the `Metadata` in the legacy binary format, with sources
in the same order (but readers should use the `data-offset` in
-`Metadata` records, rather than reading sequentially). Each Variable
-begins with a `variable-name` that generally indicates its role in the
-pivot table, e.g. "cell", "cellFormat", "dimension0categories",
-"dimension0group0", followed by the numeric data, one double per
-datum. A double with the maximum negative double `-DBL_MAX`
-represents the system-missing value `SYSMIS`.
+`Metadata` records, rather than reading sequentially). Each
+`Variable` begins with a `variable-name` that generally indicates its
+role in the pivot table, e.g. `cell`, `cellFormat`,
+`dimension0categories`, `dimension0group0`, followed by the numeric
+data, one double per datum. A double with the maximum negative double
+`-DBL_MAX` represents the system-missing value `SYSMIS`.
## String Data
Each variable may include a mix of numeric and string data values.
If a legacy binary member contains any string data, `Strings` is present;
-otherwise, it ends just after the last Data element.
+otherwise, it ends just after the last `Data` element.
The string data overlays the numeric data. When a variable includes
-any string data, its Variable represents the string values with a
+any string data, its `Variable` represents the string values with a
`SYSMIS` or NaN placeholder. (Not all such values need be
placeholders.)
(sourceVariable | derivedVariable)+
categoricalDomain?
graph
- labelFrame[lf1]*
- container?
- labelFrame[lf2]*
+ (labelFrame | container)*
style+
layerController?
simpleSort :method[sort_method]=(custom) => categoryOrder
-container :style=ref style => container_extension? location+ labelFrame*
+categoryOrder => TEXT
+
+container :style=ref style => container_extension? location* labelFrame*
extension[container_extension] :combinedFootnotes=(true) => EMPTY
* `lang`
The locale used for output, in Windows format, which is similar to
the format used in Unix with the underscore replaced by a hyphen,
- e.g. `en-US`, `en-GB`, `el-GR`, `sr-Cryl-RS`.
+ e.g. `en-US`, `en-GB`, `el-GR`. This locale must be used to
+ determine the default decimal point (used for formats other than
+ `COMMA`). PSPP uses decimal point information from the [Unicode
+ CLDR database].
+
+ [Unicode CLDR database]: https://github.com/unicode-org/cldr/
* `name`
The title of the pivot table, localized to the output language.
The `userSource` element has no visible effect.
+The `labelFrame` elements that are direct children of `visualization`
+seem to have the same effect as those that are children of the
+`container` element.
+
The `extension` element as a child of `visualization` has the
following attributes.
Always set to `true`.
* `source`
- Always set to `tableData`, the `source-name` in the corresponding
- `tableData.bin` member (see
+ A `source-name` in the corresponding `tableData.bin` member (see
[Metadata](legacy-detail-binary.md#metadata)).
* `sourceName`
* `value`
An expression that defines the variable's value. In theory this
could be an arbitrary expression in terms of constants, functions,
- and other variables, e.g. (VAR1 + VAR2) / 2. In practice, the
+ and other variables, e.g. `(VAR1 + VAR2) / 2`. In practice, the
corpus contains only the following forms of expressions:
- `constant(0)`
### `sourceVariable` and `derivedVariable` Parent Element
```
-extension[variable_extension] :from :helpId => EMPTY
+extension[variable_extension] :from? :helpId? :layerValue? => EMPTY
```
With `sourceVariable` or `derivedVariable` as its parent element,
-`extension` has the following attributes. A given parent element
-often contains several `extension` elements that specify the meaning
-of the source data's variables or sources, e.g.
-
-```
-<extension from="0" helpId="corrected_model"/>
-<extension from="3" helpId="error"/>
-<extension from="4" helpId="total_9"/>
-<extension from="5" helpId="corrected_total"/>
-```
-
-More commonly they are less helpful, e.g.
-
-```
-<extension from="0" helpId="notes"/>
-<extension from="1" helpId="notes"/>
-<extension from="2" helpId="notes"/>
-<extension from="5" helpId="notes"/>
-<extension from="6" helpId="notes"/>
-<extension from="7" helpId="notes"/>
-<extension from="8" helpId="notes"/>
-<extension from="12" helpId="notes"/>
-<extension from="13" helpId="no_help"/>
-<extension from="14" helpId="notes"/>
-```
-
-* `from`
- An integer or a name like "dimension0".
-
-* `helpId`
- An identifier.
+`extension` has a few combinations of attributes, described below.
+
+* `from` and `helpId`
+ These specify identifiers for variable values. A given parent
+ element often contains several `extension` elements that specify the
+ meaning of the source data's variables or sources, e.g.
+
+ ```
+ <extension from="0" helpId="corrected_model"/>
+ <extension from="3" helpId="error"/>
+ <extension from="4" helpId="total_9"/>
+ <extension from="5" helpId="corrected_total"/>
+ ```
+
+ More commonly they are less helpful, e.g.
+
+ ```
+ <extension from="0" helpId="notes"/>
+ <extension from="1" helpId="notes"/>
+ <extension from="2" helpId="notes"/>
+ <extension from="5" helpId="notes"/>
+ <extension from="6" helpId="notes"/>
+ <extension from="7" helpId="notes"/>
+ <extension from="8" helpId="notes"/>
+ <extension from="12" helpId="notes"/>
+ <extension from="13" helpId="no_help"/>
+ <extension from="14" helpId="notes"/>
+ ```
+
+ * `from`
+ An integer or a name like "dimension0".
+
+ * `helpId`
+ An identifier.
+
+* `layerValue`
+ For layer variables, this specifies the value selected to be
+ displayed. It duplicates [the `layer` element's `value`
+ attribute](#layer-value), but unlike that attribute, it isn't always
+ present.
## The `graph` Element
element if the table has any dimensions along the axis in question,
otherwise a `unity` element.
+### The `nest` element
+
A `nest` element contains of one or more dimensions listed from
innermost to outermost, each represented by `variableReference` child
elements. Each variable in a dimension is listed in order. See
A `variableReference` element refers to a variable through its `ref`
attribute.
-Each `layer` element represents a dimension, e.g.:
+### The `layer` element
+
+A sequence of `layer` elements represents a dimension, e.g.:
```
<layer value="0" variable="dimension0categories" visible="true"/>
* `variable`
Refers to a `sourceVariable` or `derivedVariable` element.
-* `value`
- The value to select. For a category variable, this is always `0`;
- for a data variable, it is the same as the `variable` attribute.
+* <a name="layer-value">`value`</a>
+ The value to select. For a `category` variable, this is the value
+ of the category variable to use as the layer value; for a `group`
+ variable, this is the value of the group variable (this is redundant
+ since the category implies all of its enclosing groups); for a
+ `dimension` variable, it is the same as the `variable` attribute.
* `visible`
- Whether the layer is visible. Generally, category layers are
- visible and data layers are not, but sometimes this attribute is
- omitted.
+ For `category` layers, this controls whether the layer name and its
+ value are shown in the output. If it is set to `false`, they are
+ hidden.
+
+ > The "light" form of tables does not have a way to hide layer names
+ and values. PSPP ignores this attribute.
+
+ This has no effect on other layers, which aren't ever shown.
+
+* `titleVisible`
+ This has no noticeable effect.
* `method`
When present, this is always `nest`.
## The `facetLayout` Element
```
-facetLayout => tableLayout setCellProperties[scp1]*
- facetLevel+ setCellProperties[scp2]*
+facetLayout => tableLayout (setCellProperties | facetLevel)+
tableLayout
:verticalTitlesInCorner=bool
:usesReference=int?
:definesReference=int?
:position=(subscript | superscript)?
- :style=ref style
+ :style=ref style?
=> TEXT
```
The `name` attribute appears only in [standalone `.stt`
files](../tablelook.md#the-tlo-format).
+## Cell Data
+
+A variable named `cell` always exists. This variable holds the data
+displayed in the table.
+
+`cell` is taken along with the `categories` variables from [`nest` and
+`layer`], which specify the values of the dimensions.
+
+XXX example
+
+[`nest` and `layer`]: #the-faceting-element
`bestring`
A 32-bit unsigned integer, in little-endian or big-endian byte
order, respectively, followed by the specified number of bytes of
- character data. (The encoding is indicated by the Formats
- nonterminal.)
+ character data. (The encoding is indicated by the
+ [`Formats`](#formats) nonterminal.)
* `X?`
- X is optional, e.g. 00? is an optional zero byte.
+ X is optional, e.g. `00?` is an optional zero byte.
* `X*N`
- X is repeated N times, e.g. byte*10 for ten arbitrary bytes.
+ X is repeated N times, e.g. `byte*10` for ten arbitrary bytes.
* `X[NAME]`
Gives X the specified NAME. Names are used in textual
* `(X)`
Parentheses are used for grouping to make precedence clear,
- especially in the presence of |, e.g. in 00 (01 | 02 | 03) 00.
+ especially in the presence of `|`, e.g. in `00 (01 | 02 | 03) 00`.
* `count(X)`
`becount(X)`
convert from pt to px, multiply by 1.33 and round up. To convert from
px to pt, divide by 1.33 and round down.
+## Top-Level Structure
+
A "light" detail member `.bin` consists of a number of sections
-concatenated together, terminated by an optional byte 01:
+concatenated together, terminated by an optional byte `01`:
```
Table =>
these values, a table is divided into the three regions shown below:
```
-+------------------+-------------------------------------------------+
-| | column headings |
-| +-------------------------------------------------+
-| corner | |
-| and | |
-| row headings | data |
-| | |
-| | |
-+------------------+-------------------------------------------------+
+┌──────────────────┬─────────────────────────────────────────────────┐
+│ │ column headings │
+│ ├─────────────────────────────────────────────────┤
+│ corner │ │
+│ and │ │
+│ row headings │ data │
+│ │ │
+│ │ │
+└──────────────────┴─────────────────────────────────────────────────┘
```
`min-col-heading-width` and `max-col-heading-width` apply to the
Each footnote has `text` and an optional custom `marker` (such as
`*`).
-The syntax for Value would allow footnotes (and their markers) to
+The syntax for `Value` would allow footnotes (and their markers) to
reference other footnotes, but in practice this doesn't work.
`show` is a 32-bit signed integer. It is positive to show the
v3(int32[left-margin] int32[right-margin] int32[top-margin] int32[bottom-margin])
```
-Each `Area` represents the style for a different area of the table, in
-the following order: title, caption, footer, corner, column labels,
-row labels, data, and layers.
-
-`index` is the 1-based index of the Area, i.e. 1 for the first `Area`,
-through 8 for the final `Area`.
+Each `Area` represents the style for a different area of the table.
+`index` is the 1-based index of the `Area`, i.e. 1 for the first
+`Area`, through 8 for the final `Area`. The following table shows the
+`index` values and the areas that they represent:
+
+| `index` | Area |
+|--------:|:--------------|
+| 1 | Title |
+| 2 | Caption |
+| 3 | Footer |
+| 4 | Corner |
+| 5 | Column labels |
+| 6 | Row labels |
+| 7 | Data |
+| 8 | Layers |
`typeface` is the string name of the font used in the area. In the
corpus, this is `SansSerif` in over 99% of instances and `Times New
`underline` is 1 if the font is underlined, 0 otherwise.
-`halign` specifies horizontal alignment: 0 for center, 2 for left, 4
-for right, 61453 for decimal, 64173 for mixed. Mixed alignment varies
-according to type: string data is left-justified, numbers and most other
-formats are right-justified.
+`halign` specifies horizontal alignment:
+
+| `halign` | Alignment |
+|---------:|:----------|
+| 0 | Center |
+| 2 | Left |
+| 4 | Right |
+| 64173 | Mixed |
+
+Mixed alignment varies according to type: string data is
+left-justified, numbers and most other formats are right-justified.
-`valign` specifies vertical alignment: 0 for center, 1 for top, 3 for
-bottom.
+`valign` specifies vertical alignment:
+
+| `valign` | Alignment |
+|---------:|:----------|
+| 0 | Center |
+| 1 | Top |
+| 3 | Bottom |
`fg-color` and `bg-color` are the foreground color and background
color, respectively. In the corpus, these are always `#000000` and
are empty strings.
`left-margin`, `right-margin`, `top-margin`, and `bottom-margin` are
-measured in px.
+measured in [px](#px).
## Borders
00 00 00)
Border =>
- be32[border-type]
+ be32[index]
be32[stroke-type]
be32[color]
```
`show-grid-lines` is 1 to draw grid lines, otherwise 0.
Each `Border` describes one kind of border. `n-borders` seems to
-always be 19. Each `border-type` appears once (although in an
+always be 19. Each `index` appears once (although in an
unpredictable order) and correspond to the following borders:
-* 0: Title.
-* 1...4: Left, top, right, and bottom outer frame.
-* 5...8: Left, top, right, and bottom inner frame.
-* 9, 10: Left and top of data area.
-* 11, 12: Horizontal and vertical dimension rows.
-* 13, 14: Horizontal and vertical dimension columns.
-* 15, 16: Horizontal and vertical category rows.
-* 17, 18: Horizontal and vertical category columns.
+| `index` | Borders |
+|--------:|:-------------------------------------------|
+| 0 | Title. |
+| 1...4 | Left, top, right, and bottom outer frame. |
+| 5...8 | Left, top, right, and bottom inner frame. |
+| 9, 10 | Left and top of data area. |
+| 11, 12 | Horizontal and vertical dimension rows. |
+| 13, 14 | Horizontal and vertical dimension columns. |
+| 15, 16 | Horizontal and vertical category rows. |
+| 17, 18 | Horizontal and vertical category columns. |
`stroke-type` describes how a border is drawn, as one of:
-* 0: No line.
-* 1: Solid line.
-* 2: Dashed line.
-* 3: Thick line.
-* 4: Thin line.
-* 5: Double line.
+| `stroke-type` | Border style |
+|--------------:|:-------------|
+| 0 | No line. |
+| 1 | Solid line. |
+| 2 | Dashed line. |
+| 3 | Thick line. |
+| 4 | Thin line. |
+| 5 | Double line. |
`color` is an RGB color. Bits 24-31 are alpha, bits 16-23 are red,
8-15 are green, 0-7 are blue. An alpha of 255 indicates an opaque
user-specified Keeps. They seems to indicate a conversion from rows or
columns to pixel or point offsets.
-`notes` is a text string that contains user-specified notes. It is
-displayed when the user hovers the cursor over the table, like text in
-the `title` attribute in HTML. It is not printed. It is usually empty.
+<a name="notes">`notes`</a> is a text string that contains
+user-specified notes. It is displayed when the user hovers the cursor
+over the table, like text in the `title` attribute in HTML. It is not
+printed. It is usually empty. See also
+[`notes-unexpanded`](#notes-unexpanded).
`table-look` is the name of a SPSS "TableLook" table style, such as
"Default" or "Academic"; it is often empty.
Y0
CustomCurrency
count(
- v1(X0?)
- v3(count(X1 count(X2)) count(X3)))
+ v1(N0?)
+ v3(count(N1 count(N2)) count(N3)))
Y0 => int32[epoch] byte[decimal] byte[grouping]
CustomCurrency => int32[n-ccs] string*[n-ccs]
```
`locale` is a locale including an encoding, such as
`en_US.windows-1252` or `it_IT.windows-1252`. (`locale` is often
-duplicated in Y1, described below).
+duplicated in `Y1`, described below).
`epoch` is the year that starts the epoch. A 2-digit year is
interpreted as belonging to the 100 years beginning at the epoch. The
1943 to 1948, plus some contain -1.
`decimal` is the decimal point character. The observed values are
-`.` and `,`.
+`.`, `,`, and 0.
`grouping` is the grouping character. Usually, it is `,` if
`decimal` is `.`, and vice versa. Other observed values are `'`
`n-ccs` is observed as either 0 or 5. When it is 5, the following
strings are [CCA through
CCE](../language/datasets/formats/custom-currency.md) format strings.
-Most commonly these are all `-,,,` but other strings occur.
+Most commonly these are all empty or `-,,,`, but other strings occur.
A writer may safely use false for `x7`, `x8`, and `x9`.
-### X0
+### N0
-X0 only appears, optionally, in version 1 members.
+`N0` only appears, optionally, in version 1 members.
```
-X0 => byte*14 Y1 Y2
+N0 => byte*14 Y1 Y2
Y1 =>
string[command] string[command-local]
string[language] string[charset] string[locale]
A writer may safely use false for `x10` and `x17` and true for `x12`
and `x13`.
-### X1
+### N1
-`X1` only appears in version 3 members.
+`N1` only appears in version 3 members.
```
-X1 =>
+N1 =>
bool[x14]
byte[show-title]
bool[x16]
bool[show-caption]
```
-`lang` may indicate the language in use. Some values seem to be 0:
-en, 1: de, 2: es, 3: it, 5: ko, 6: pl, 8: zh-tw, 10: pt_BR, 11: fr.
-
-`show-variables` determines how variables are displayed by default.
-A value of 1 means to display variable names, 2 to display variable
-labels when available, 3 to display both (name followed by label,
-separated by a space). The most common value is 0, which probably means
-to use a global default.
-
-`show-values` is a similar setting for values. A value of 1 means to
-display the value, 2 to display the value label when available, 3 to
-display both. Again, the most common value is 0, which probably means
-to use a global default.
+`lang` may indicate the language in use. Some values and their
+apparent meanings are:
+
+| Value | Language |
+|------:|---------:|
+| 0 | `en` |
+| 1 | `de` |
+| 2 | `es` |
+| 3 | `it` |
+| 5 | `ko` |
+| 6 | `pl` |
+| 8 | `zh-tw` |
+| 10 | `pt_BR` |
+| 11 | `fr` |
+
+`show-variables` determines how variables are displayed by default:
+
+| Value | Meaning |
+|------:|:----------------------------------------------------|
+| 0 | Use global default (the most common value) |
+| 1 | Variable name only |
+| 2 | Variable label only (when available) |
+| 3 | Both (name followed by label, separated by a space) |
+
+`show-values` is a similar setting for values:
+
+| Value | Meaning |
+|------:|:-------------------------------------------|
+| 0 | Use global default (the most common value) |
+| 1 | Value only |
+| 2 | Value label only (when available) |
+| 3 | Both |
`show-title` is 1 to show the caption, 10 to hide it.
A writer may safely use false for `x14`, false for `x16`, 0 for
`lang`, -1 for `x18` and `x19`, and false for `x20`.
-### X2
+### N2
-`X2` only appears in version 3 members.
+`N2` only appears in version 3 members.
```
-X2 =>
+N2 =>
int32[n-row-heights] int32*[n-row-heights]
int32[n-style-map] StyleMap*[n-style-map]
int32[n-styles] StylePair*[n-styles]
If present, `n-row-heights` and the accompanying integers are row
heights as manually adjusted by the user.
-The rest of `X2` specifies styles for data cells. At first glance
+The rest of `N2` specifies styles for data cells. At first glance
this is odd, because each data cell can have its own style embedded as
-part of the data, but in practice `X2` specifies a style for a cell
+part of the data, but in practice `N2` specifies a style for a cell
only if that cell is empty (and thus does not appear in the data at
-all). Each StyleMap specifies the index of a blank cell, calculated
+all). Each `StyleMap` specifies the index of a blank cell, calculated
the same was as in the [Cells](#cells), along with a 0-based index
into the accompanying StylePair array.
A writer may safely omit the optional `i0 i0` inside the
`count(...)`.
-### X3
+### N3
-`X3` only appears in version 3 members.
+`N3` only appears in version 3 members.
```
-X3 =>
+N3 =>
01 00 byte[x21] 00 00 00
Y1
double[small] 01
- (string[dataset] string[datafile] i0 int32[date] i0)?
+ (string[dataset] string[datafile] string[notes-unexpanded] int32[date] i0)?
Y2
- (int32[x22] i0 01?)?
+ (int32[x22] i0 bool[x25]?)?
```
`small` is a small real number. In the corpus, it overwhelmingly
e.g. `DataSet1`, and `datafile` the name of the file it was read from,
e.g. `C:\Users\foo\bar.sav`. The latter is sometimes the empty string.
-`date` is a date, as seconds since the epoch, i.e. since January 1,
-1970. Pivot tables within an SPV file often have dates a few minutes
-apart, so this is probably a creation date for the table rather than for
-the file.
+<a name="notes-unexpanded">`notes-unexpanded`</a> is a text string
+that contains user-specified notes. It may contain special variables
+such as `)TITLE` that are expanded before the notes are displayed.
+See [`notes`](#notes) for the expanded version. This text string is
+often empty even if [`notes`](#notes) is nonempty; `notes-unexpanded`
+has only been observed to be nonempty when it contains variables.
+
+> `notes-unexpanded` could be used to allow the user to edit the notes
+in their original form, but to otherwise display them as expanded at
+the time the file was written. Note that the expansion might change
+if redone since variables can include the date and time, although the
+pivot table also includes [`date`](#date) for anchoring the date.
+
+<a name="date">`date`</a> is a date, as seconds since the epoch,
+i.e. since January 1, 1970. Pivot tables within an SPV file often
+have dates a few minutes apart, so this is probably a creation date
+for the table rather than for the file.
Sometimes `dataset`, `datafile`, and `date` are present and other
times they are absent. The reader can distinguish by assuming that they
`x22` is usually 0 or 2000000.
-A writer may safely use 4 for `x21` and omit `x22` and the other
-optional bytes at the end.
+`x25` is usually `01`.
+
+A writer may safely use 4 for `x21` and omit `x22`, `x25`, and the
+other optional bytes at the end.
### Encoding
-Formats contains several indications of character encoding:
+`Formats` contains several indications of character encoding:
-- `locale` in Formats itself.
+- `locale` in `Formats` itself.
-- `locale` in Y1 (in version 1, Y1 is optionally nested inside X0; in
-version 3, Y1 is nested inside X3).
+- `locale` in `Y1` (in version 1, `Y1` is optionally nested inside
+`N0`; in version 3, `Y1` is nested inside `N3`).
-- `charset` in version 3, in Y1.
+- `charset` in version 3, in `Y1`.
-- `lang` in X1, in version 3.
+- `lang` in `N1`, in version 3.
-`charset`, if present, is a good indication of character encoding,
-and in its absence the encoding suffix on `locale` in Formats will work.
+`charset`, if present, is a good indication of character encoding, and
+in its absence the encoding suffix on `locale` in `Formats` will work.
-`locale` in Y1 can be disregarded: it is normally the same as
-`locale` in Formats, and it is only present if `charset` is also.
+A reader may disregard `locale` in `Y1`, because it is normally the
+same as `locale` in `Formats`, and it is only present if `charset` is
+also.
`lang` is not helpful and should be ignored for character encoding
purposes.
dimensions, and C column dimensions, `x2` is 2 for the first L
dimensions, 0 for the next R dimensions, and 1 for the remaining C
dimensions. This does not mean that the layer dimensions must be
-presented first, followed by the row dimensions, followed by the column
-dimensions--on the contrary, they are frequently in a different
-order--but `x2` must follow this pattern to prevent the pivot table from
-being misinterpreted.
+presented first, followed by the row dimensions, followed by the
+column dimensions--on the contrary, they are frequently in a different
+order—but `x2` must follow this pattern to prevent the pivot table
+from being misinterpreted.
-If `hide-dim-label` is 00, the pivot table displays a label for the
+If `hide-dim-label` is `00`, the pivot table displays a label for the
dimension itself. Because usually the group and category labels are
-enough explanation, it is usually 01.
+enough explanation, it is usually `01`.
-If `hide-all-labels` is 01, the pivot table omits all labels for the
-dimension, including group and category labels. It is usually 00. When
-`hide-all-labels` is 01, `hide-dim-label` is ignored.
+If `hide-all-labels` is `01`, the pivot table omits all labels for the
+dimension, including group and category labels. It is usually `00`.
+When `hide-all-labels` is `01`, `hide-dim-label` is ignored.
`dim-index` is usually the 0-based index of the dimension, e.g. 0 for
the first dimension, 1 for the second, and so on. Sometimes it is -1.
There is no visible difference. A writer may safely use the 0-based
index.
-## Categories
+### Categories
Categories are arranged in a tree. Only the leaf nodes in the tree are
really categories; the others just serve as grouping constructs.
```
Category => Value[name] (Leaf | Group)
-Leaf => 00 00 00 i2 int32[leaf-index] i0
+Leaf => 00 00 bool[x24] i2 int32[leaf-index] i0
Group =>
bool[merge] 00 01 int32[x23]
i-1 int32[n-subcategories] Category*[n-subcategories]
`name` is the name of the category (or group).
-A Leaf represents a leaf category. The Leaf's `leaf-index` is a
-nonnegative integer unique within the Dimension and less than
-`n-categories` in the Dimension. If the user does not sort or rearrange
-the categories, then `leaf-index` starts at 0 for the first Leaf in the
-dimension and increments by 1 with each successive Leaf. If the user
-does sorts or rearrange the categories, then the order of categories in
-the file reflects that change and `leaf-index` reflects the original
-order.
+A `Leaf` represents a leaf category. The `Leaf`'s `leaf-index` is a
+nonnegative integer unique within the `Dimension` and less than
+`n-categories` in the Dimension. If the user does not sort or
+rearrange the categories, then `leaf-index` starts at 0 for the first
+`Leaf` in the dimension and increments by 1 with each successive
+`Leaf`. If the user does sort or rearrange the categories, then the
+order of categories in the file reflects that change and `leaf-index`
+reflects the original order.
A dimension can have no leaf categories at all. A table that
contains such a dimension necessarily has no data at all.
-A Group is a group of nested categories. Usually a Group contains at
-least one Category, so that `n-subcategories` is positive, but Groups
-with zero subcategories have been observed.
+A `Group` is a group of nested categories. Usually a `Group` contains
+at least one `Category`, so that `n-subcategories` is positive, but
+`Group`s with zero subcategories have been observed.
-If a Group's `merge` is 00, the most common value, then the group is
+If a Group's `merge` is `00`, the most common value, then the group is
really a distinct group that should be represented as such in the visual
-representation and user interface. If `merge` is 01, the categories in
+representation and user interface. If `merge` is `01`, the categories in
this group should be shown and treated as if they were direct children
of the group's containing group (or if it has no parent group, then
direct children of the dimension), and this group's name is irrelevant
Writers need not use merged groups.
-A Group's `x23` appears to be `i2` when all of the categories within a
-group are leaf categories that directly represent data values for a
+A `Group`'s `x23` appears to be `i2` when all of the categories within
+a group are leaf categories that directly represent data values for a
variable (e.g. in a frequency table or crosstabulation, a group of
values in a variable being tabulated) and i0 otherwise. A writer may
safely write a constant 0 in this field.
+`x24` is usually `00`. Its meaning is unexplored.
+
## Axes
After the dimensions come assignment of each dimension to one of the
Cell => int64[index] v1(00?) Value
```
-A Cell consists of an `index` and a Value. Suppose there are \\(d\\)
-dimensions, numbered 1 through \\(d\\) in the order given in the [`Dimensions`](#dimensions)
-previously, and that dimension \\(i\\) has \\(n_i\\) categories. Consider the cell
-at coordinates \\(x_i, 1 \le i \le d\\), and note that \\(0 \le x_i < n_i\\). Then
-the index \\(k\\) is calculated by the following algorithm:
+A `Cell` consists of an `index` and a Value. Suppose there are
+\\(d\\) dimensions, numbered 1 through \\(d\\) in the order given in
+the [`Dimension`s](#dimensions) previously, and that dimension \\(i\\)
+has \\(n_i\\) categories. Consider the cell at coordinates \\(x_i, 1
+\le i \le d\\), and note that \\(0 \le x_i < n_i\\). Then the index
+\\(k\\) is calculated by the following algorithm:
> let \\(k = 0\\).
For example, suppose there are 3 dimensions with 3, 4, and 5
categories, respectively. The cell at coordinates (1, 2, 3) has index
\\(k = 5 \times (4 \times (3 \times 0 + 1) + 2) + 3 = 33\\). Within a
-given dimension, the index is the `leaf-index` in a Leaf.
+given dimension, the index is the `leaf-index` in a `Leaf`.
## Value
40 is shown in scientific notation if and only if it is nonzero and
its magnitude is less than [`small`](#formats).
+ Values of 0 or 1 or 0x10000 are sometimes seen as `format`. PSPP
+ interprets these as F40.2.
+
Most commonly, `format` has width 40 (the maximum).
An `x` with the maximum negative double value `-DBL_MAX` represents
latter very commonly.
`show` determines whether to show the numeric value or the value
- label. A value of 1 means to show the value, 2 to show the label,
- 3 to show both, and 0 means to use the default specified in
- [`show-values`](#formats).
+ label:
+
+ | `show` | Meaning |
+ |-------:|:---------------------------------------------------|
+ | 0 | Use default specified in [`show-values`](#formats) |
+ | 1 | Value only |
+ | 2 | Label only |
+ | 3 | Both value and label |
* `03`
A text string, in two forms: `c` is in English, and sometimes
* `^I`
Expands to a formatted version of argument `I`, which must have
only a single value. For example, `^1` expands to the first
- argument's `value`.
+ argument.
* `[:A:]I`
Expands `A` for each of the values in `I`. `A` should contain one
new-line.
* `[:^1 = ^2:]2`
- Expands to `X = Y` where X is the second argument's first alue
- and Y is its second value. (This would be used only if the
- argument has two values. If there were more values, the second
- and third values would be directly concatenated, which would
- look funny.)
+ Expands to `X = Y` where `X` is the second argument's first
+ value and `Y` is its second value. (This would be used only if
+ the argument has two values. If there were more values, the
+ second and third values would be directly concatenated, which
+ would look funny.)
* `[A:B:]I`
This extends the previous form so that the first values are
The template string is localized to the user's locale.
-A writer may safely omit all of the optional 00 bytes at the
-beginning of a Value, except that it should write a single 00 byte
+A writer may safely omit all of the optional `00` bytes at the
+beginning of a Value, except that it should write a single `00` byte
before a templated Value.
## ValueMod
the optional fixed data in `TemplateString`.
`FontStyle` and `CellStyle`, if present, change the style for this
-individual Value. In `FontStyle`, `bold`, `italic`, and `underline`
-control the particular style. `show` is ordinarily 1; if it is 0, then
-the cell data is not shown. `fg-color` and `bg-color` are strings in
-the format `#rrggbb`, e.g. `#ff0000` for red or `#ffffff` for white.
-The empty string is occasionally observed also. The `size` is a font
-size in units of 1/128 inch.
-
-In `CellStyle`, `halign` is 0 for center, 2 for left, 4 for right, 6
-for decimal, 0xffffffad for mixed. For decimal alignment,
-`decimal-offset` is the decimal point's offset from the right side of
-the cell, in [pt](#pt). `valign` specifies vertical alignment: 0 for
-center, 1 for top, 3 for bottom. `left-margin`, `right-margin`,
-`top-margin`, and `bottom-margin` are in pt.
+individual `Value`. In `FontStyle`, `bold`, `italic`, and `underline`
+control the particular style. `show` is ordinarily 1; if it is 0,
+then the cell data is not shown. `fg-color` and `bg-color` are
+strings in the format `#rrggbb`, e.g. `#ff0000` for red or `#ffffff`
+for white. The empty string is occasionally observed also. The
+`size` is a font size in units of 1/128 inch.
+
+In `CellStyle`, `halign` specified horizontal alignment:
+
+| `halign` | Meaning |
+|-----------:|:--------|
+| 0 | Center |
+| 2 | Left |
+| 4 | Right |
+| 6 | Decimal |
+| 0xffffffad | Mixed |
+
+For decimal alignment, `decimal-offset` is the decimal point's offset
+from the right side of the cell, in [pt](#pt).
+
+`valign` specifies vertical alignment:
+
+| `valign` | Meaning |
+|---------:|:--------|
+| 0 | Center |
+| 1 | Top |
+| 3 | Bottom |
+`left-margin`, `right-margin`, `top-margin`, and `bottom-margin` are
+in [pt](#pt).
`container` holds a `label` and one more child, usually `text` or
`table`.
+<!-- toc -->
+
+## Grammar
+
The following sections document the elements found in structure
members in a context-free grammar-like fashion. Consider the following
example, which specifies the attributes and content for the `container`
```
container
- :visibility=(visible | hidden)
+ :visibility=(visible | hidden)?
:page-break-before=(always)?
:text-align=(left | center)?
:width=dimension
Either `true` or `false`.
* `dimension`
- A floating-point number followed by a unit, e.g. `10pt`. Units in
- the corpus include `in` (inch), `pt` (points, 72/inch), `px`
- ("device-independent pixels", 96/inch), and `cm`. If the unit is
- omitted then points should be assumed. The number and unit may be
- separated by white space.
-
- The corpus also includes localized names for units. A reader must
- understand these to properly interpret the dimension:
-
- * inch: `인치`, `pol.`, `cala`, `cali`
- * point: `пт`
- * centimeter: `см`
+ A floating-point number followed by a unit, e.g. `10pt`. If the
+ unit is omitted then points should be assumed. The number and unit
+ may be separated by white space. The number might use `,` as the
+ decimal point.
+
+ The corpus includes the following units, which includes localized
+ names for units. A reader must understand these to properly
+ interpret the dimensions:
+
+ | Unit | Units per Inch | Names |
+ |:-------------------------|---------------:|:----------------------------------------------------------------------------|
+ | Inch | 1 | `in`<BR>`인치`<BR>`pol.`<BR>`pulg.`<BR>`cala`<BR>`cali`<BR>`英吋`<BR>`英寸` |
+ | Centimeter | 2.54 | `cm`<BR>`см` |
+ | Point | 72 | `pt`<BR>`пт`<BR>(empty string) |
+ | Device-independent pixel | 96 | `px` |
* `real`
A floating-point number.
</heading>
```
-<!-- toc -->
-
## The `heading` Element
```
```
container
- :visibility=(visible | hidden)
- :page-break-before=(always)?
+ :visibility=(visible | hidden)?
+ :page-break-before=(always | auto | avoid | left | right | inherit)?
:text-align=(left | center)?
:width=dimension
=> label (table | container_text | graph | model | object | image | tree)
* `visibility`
Whether the container's content is displayed. "Notes" tables are
- often hidden; other data is usually visible.
+ often hidden; other data is usually visible. The default is
+ `visible`.
+
+* `page-break-before`
+ Whether to start the element at the beginning of a new page. This
+ attribute is usually not present. The only value seen in the corpus
+ is `always`.
* `text-align`
Alignment of text within the container. Observed with nested
:commandName?
:creator-version?
=> html
+
+html :lang=(en) => TEXT
```
This `text` element is nested inside a `container`. There is a
-different `text` element that is nested inside a `pageParagraph`.
+[different `text` element that is nested inside a `pageParagraph`](#the-text-element-inside-pageparagraph).
This element has the following attributes.
* `type`
The semantics of the text.
+ Text with types `title`, `log`, and `text` appears directly in the
+ output. Text with type `page-title` sets the title that appears in
+ page headers or footers when [`&[PageTitle]`](#pagetitle) is used.
+
* `creator-version`
As on the `heading` element.
-## The `html` Element
-
-```
-html :lang=(en) => TEXT
-```
-
-The element contains an HTML document as text (or, in practice, as
-CDATA). In some cases, the document starts with `<html>` and ends with
-`</html>`; in others the `html` element is implied. Generally the HTML
-includes a `head` element with a CSS stylesheet. The HTML body often
-begins with `<BR>`.
-
-The HTML document uses only the following elements:
-
-* `html`
- Sometimes, the document is enclosed with `<html>`...`</html>`.
-
-* `br`
- The HTML body often begins with `<BR>` and may contain it as well.
-
-* `b`
- `i`
- `u`
- Styling.
-
-* `font`
- The attributes `face`, `color`, and `size` are observed. The value
- of `color` takes one of the forms `#RRGGBB` or `rgb (R, G, B)`.
- The value of `size` is a number between 1 and 7, inclusive.
-
-The CSS in the corpus is simple. To understand it, a parser only
-needs to be able to skip white space, `<!--`, and `-->`, and parse style
-only for `p` elements. Only the following properties matter:
-
-* `color`
- In the form `RRGGBB`, e.g. `000000`, with no leading `#`.
-
-* `font-weight`
- Either `bold` or `normal`.
-
-* `font-style`
- Either `italic` or `normal`.
-
-* `text-decoration`
- Either `underline` or `normal`.
-
-* `font-family`
- A font name, commonly `Monospaced` or `SansSerif`.
+### The `html` element
-* `font-size`
- Values claim to be in points, e.g. `14pt`, but the values are
- actually in "device-independent pixels" (px), at 96/inch.
+The `html` element inside `text` contains an HTML document as text
+(or, in practice, as CDATA). In some cases, the document starts with
+`<html>` and ends with `</html>`, and in others the `html` element is
+implied. Generally the HTML includes a `head` element with a CSS
+stylesheet. The HTML body often begins with `<BR>`. See [Embedded
+HTML](#embedded-html) for details.
-This element has the following attributes.
+The `html` element has the following attributes:
* `lang`
This always contains `en` in the corpus.
+> A few examples of typical text in the corpus:
+>
+> ```
+> <html xmlns="http://www.w3.org/1999/xhtml" lang="en"><head><style type="text/css">p{color:0;font-family:Monospaced;font-size:14pt;font-style:normal;font-weight:normal;text-decoration:none}</style></head><BR>REGRESSION
+> /MISSING LISTWISE
+> /STATISTICS COEFF OUTS R ANOVA
+> /CRITERIA=PIN(.05) POUT(.10)
+> /NOORIGIN
+> /DEPENDENT Pvalues
+> /METHOD=ENTER MMN.</html>
+> ```
+>
+> ```
+> <html xmlns="http://www.w3.org/1999/xhtml" lang="en"><head><style type="text/css">p{color:0;font-family:Monospaced;font-size:13pt;font-style:normal;font-weight:normal;text-decoration:none}</style></head><BR>CROSSTABS<BR>&nbsp;&nbsp;/TABLES=facrec&nbsp;BY&nbsp;nq1e<BR>&nbsp;&nbsp;/FORMAT=AVALUE&nbsp;TABLES<BR>&nbsp;&nbsp;/CELLS=COUNT&nbsp;ROW<BR>&nbsp;&nbsp;/COUNT&nbsp;ROUND&nbsp;CELL.</html>
+> ```
+>
+> ```
+> <html xmlns="http://www.w3.org/1999/xhtml" lang="en"><html>
+> <head>
+> <style type="text/css">
+> <!--
+> p { font-style: normal; text-decoration: none; font-weight: bold; color: 000000; font-size: 14pt; font-family: Trebuchet MS }
+> -->
+> </style>
+>
+> </head>
+> <body>
+> <b><font size="5" face="Times New Roman"> <u>H</u></font><u><font size="5" color="#000000" face="Times New Roman">ousehold
+> Income (In Thousands)</font></u><font size="5" color="#000000" face="Times New Roman">
+> </font></b>
+> </body>
+> </html>
+> </html>
+> ```
+
## The `table` Element
```
:orphanTolerance=int?
:rowBreakNumber=int?
:subType
- :tableId
+ :tableId?
:tableLookId?
:type[table_type]=(table | note | warning)
=> tableProperties? tableStructure
* `tableId`
A number that uniquely identifies the table within the SPV file,
typically a large negative number such as `-4147135649387905023`.
+ It is usually present. For light binary members, this is the same
+ as `table-id` in the [light detail member
+ header](light-detail.md#header).
* `creator-version`
As on the `heading` element. In the corpus, this is only present
for version 21 and up and always includes all 8 digits.
-See [Legacy Properties](legacy-detail-xml.md#legacy-properties), for
-details on the `tableProperties` element.
+This element contains the following:
+
+* `tableProperties`
+ See [Legacy Properties](legacy-detail-xml.md#legacy-properties), for
+ details.
+
+* `tableStructure`
+ This eleemnt in turn contains:
+
+ - Both `path` and `dataPath` for legacy members.
+
+ - `dataPath` but not `path` for light detail binary members.
+
+ - The usage of `csvPath` is rare and not yet understood.
+
+ See [SPSS Viewer File Format](index.md) for more information on how
+ structure members refer to tables.
## The `graph` Element
The `pageSetup` element has the following attributes.
* `initial-page-number`
- The page number to put on the first page of printed output.
- Usually `1`.
+ The page number to put on the first page of printed output.
+ Usually `1`.
* `chart-size`
- One of the listed, self-explanatory chart sizes, `quarter-height`,
- or a localization (!) of one of these (e.g. `dimensione attuale`,
- `Wie vorgegeben`).
+ One of the listed chart sizes, or some localization of `as-is`.
+
+ > No localizations of other sizes have been observed, so PSPP
+ > interprets any unknown value like `as-is`.
* `margin-left`
-* `margin-right`
-* `margin-top`
-* `margin-bottom`
- Margin sizes, e.g. `0.25in`.
+ `margin-right`
+ `margin-top`
+ `margin-bottom`
+ Margin sizes, e.g. `0.25in`.
* `paper-height`
-* `paper-width`
- Paper sizes.
+ `paper-width`
+ Paper sizes.
* `reference-orientation`
- Indicates the orientation of the output page. Either `0deg`
- (portrait) or `90deg` (landscape),
+ Indicates the orientation of the output page. This is most commonly
+ `0deg` (portrait) or `90deg` (landscape). Various localized
+ versions also exist, such as `0grau` and `90 度`.
+
+ > PSPP just looks at whether the value starts with `0` or `90`
+ > because all of the localized versions begin that way.
* `space-after`
- The amount of space between printed objects, typically `12pt`.
+ The amount of space between printed objects, typically `12pt`.
-## The `text` Element (Inside `pageParagraph`)
+### The `text` Element (Inside `pageParagraph`)
```
text[pageParagraph_text] :type=(title | text) => TEXT
```
This `text` element is nested inside a `pageParagraph`. There is a
-different `text` element that is nested inside a `container`.
+[different `text` element that is nested inside a
+`container`](#the-text-element-inside-container).
+
+This element has the following attributes:
+
+* `type`
+ Always `text`.
-The element is either empty, or contains CDATA that holds almost-XHTML
-text: in the corpus, either an `html` or `p` element. It is
-_almost_-XHTML because the `html` element designates the default
-namespace as `http://xml.spss.com/spss/viewer/viewer-tree` instead of
-an XHTML namespace, and because the CDATA can contain substitution
-variables. The following variables are supported:
+The element is either empty, or contains CDATA that holds XHTML text
+with a root element of either `html` or `p`. Text in the XHTML can
+contain substitution variables. The following variables are
+supported:[^1]
+
+[^1]: The `&` characters are escaped as `&`, that is, these are
+ not XML entities, since XML entity names can't begin with `[`.
* `&[Date]`
`&[Time]`
`&[Head2]`
`&[Head3]`
`&[Head4]`
- First-, second-, third-, or fourth-level heading.
+ First-, second-, third-, or fourth-level heading, respectively.
-* `&[PageTitle]`
+* <a name="pagetitle">`&[PageTitle]`</a>
+ `&[Заголовок страницы]`
+ `&[頁面標題]`
The page title.
* `&[Filename]`
Name of the output file.
* `&[Page]`
+ `&[Страница]`
+ `&[頁]`
The page number.
-Typical contents (indented for clarity):
+See [Embedded HTML](#embedded-html) for more information.
+
+> The 23,000 SPV files in the corpus have only 17 unique instances of
+`text` inside `pageParagraph`. Most of them look similar to this for
+page headers:
+>
+> ```
+> <html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+> <head>
+>
+> </head>
+> <body>
+> <p style="text-align:center; margin-top: 0">
+> &[PageTitle]
+> </p>
+> </body>
+> </html>
+> ```
+>
+> and footers:
+>
+> ```
+> <html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+> <head>
+>
+> </head>
+> <body>
+> <p style="text-align:right; margin-top: 0">
+> Page &[Page]
+> </p>
+> </body>
+> </html>
+> ```
+>
+> Sometimes CSS is present (the original was indented much deeper), with
+> header:
+>
+> ```
+> <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+> <head>
+> <style type="text/css">
+> p { font-family: sans-serif;
+> font-size: 10pt; text-align: center;
+> font-weight: normal;
+> color: #000000;
+> }
+> </style>
+> </head>
+> <body>
+> <p>&amp;[PageTitle]</p>
+> </body>
+> </html>
+> ```
+>
+> and footer:
+>
+> ```
+> <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+> <head>
+> <style type="text/css">
+> p { font-family: sans-serif;
+> font-size: 10pt; text-align: right;
+> font-weight: normal;
+> color: #000000;
+> }
+> </style>
+> </head>
+> <body>
+> <p>Page &amp;[Page]</p>
+> </body>
+> </html>
+> ```
+>
+> No files in the corpus show any more sophisticated use of features
+> than these examples.
+
+## Embedded HTML
+
+Structure XML contains embedded HTML in two contexts:
+
+- The [`text` element inside `container`](#the-text-element-inside-container).
+
+- The [`text` element inside
+ `pageParagraph`](#the-text-element-inside-pageparagraph).
+
+The use of HTML in both cases is similar. These HTML documents use
+only the following elements:
+
+* `html`
+ Sometimes, the document is enclosed with `<html>`...`</html>`.
+
+* `head`
+ The document often contains a `head` element. It can be
+ empty or it can contain a `style` element, in turn enclosing CSS
+ within `<!--` and `-->`. See [embedded CSS](#embedded-ccs), below,
+ for details.
+
+* `body`
+ The document often contains a `body` element that contains the
+ content.
+
+* `p`
+ The document often contains a `p` element that contains the content.
+ [Inside `pageParagraph`](#the-text-element-inside-pageparagraph)
+ only, the document can contain multiple paragraphs.
+
+ The following attributes are observed:
+
+ - `align`
+ With value `left`, `center`, or `right`.
+
+ - `style`
+ With value `text-align:<align>; margin-top: 0`, where `<align>` is
+ one of `left`, `center`, or `right`, or simply `margin-top: 0`.
+
+* `br`
+ The HTML body often begins with a "break" tag and may contain them
+ as well.
+
+ Embedded HTML writes most tag names in lowercase but this one is
+ usually in uppercase, as `<BR>`.
+
+* `b`
+ `i`
+ `u`
+ `strike`
+ Styling.
+
+* `font`
+ The following attributes are observed:
+
+ - `face`
+ A typeface, most often `Monospaced` or `SansSerif`.
+
+ - `color`
+ One of the forms `#RRGGBB` or `rgb (R, G, B)`.
+
+ - `size`
+ A number between 1 and 7 with the following meanings:
+
+ | `size` | Size |
+ |-------:|--------:|
+ | 1[^2] | 6 pt |
+ | 2 | 7.5 pt |
+ | 3 | 9 pt |
+ | 4 | 10.5 pt |
+ | 5 | 13.5 pt |
+ | 6 | 18 pt |
+ | 7 | 27 pt |
+
+ [^2]: This `size` doesn't appear in the corpus. The size listed
+ is an extrapolation based on what browsers usually do.
+
+> It appears that pasting HTML into the SPSS viewer can cause more
+> general HTML to be included. The following elements in the corpus,
+> each of these is observed in only a few files, appear to be added by
+> pasting HTML from another application:
+>
+> * `strong`
+> `em`
+> Styling.
+>
+> * `span`
+> The `style` attribute is used a bit, but not for CSS properties
+> that PSPP supports.
+>
+> * `li`
+> `ul`
+> Seen in only one file in the corpus.
+>
+> * `a`
+> Seen in only two files in the corpus. SPSS doesn't allow the link
+> to be seen or visited.
+>
+> * `table`
+> `td`
+> `tr`
+> Seen in only one file in the corpus. SPSS doesn't render the
+> table properly.
+>
+> * `img`
+> Seen in only one file in the corpus. In this file, the `src`
+> attribute was an invalid `jar:` URL.
+
+Text in embedded HTML often uses non-breaking spaces (U+00A0
+NON-BREAKING SPACE), often written as ` ` or ` `. In
+embedded HTML, newlines must be treated as line breaks.
+
+### Embedded CSS
+
+The CSS in the corpus is simple. To understand it, a parser only
+needs to be able to skip white space, `<!--`, and `-->`, and parse style
+only for `p` elements. Only the following properties matter:
+* `color`
+ In the form `RRGGBB`, e.g. `000000`, with no leading `#`.
+
+* `font-weight`
+ Either `bold` or `normal`.
+
+* `font-style`
+ Either `italic` or `normal`.
+
+* `text-decoration`
+ Either `underline` or `normal`.
+
+* `font-family`
+ A font name, commonly `Monospaced` or `SansSerif`.
+
+* `font-size`
+ Values claim to be in points, e.g. `14pt`, but the values are
+ actually in "device-independent pixels" (px), at 96/inch.
+
+### Examples
+
+Text that looks like "plain **bold** *italic* ~~strikeout~~", for use
+[inside `pageParagraph`]:
+
+```
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+ <head>
+
+ </head>
+ <body>
+ <p>
+ plain&#160;<font color="#000000" size="3" face="Monospaced"><b>bold</b></font>&#160;<font color="#000000" size="3" face="Monospaced"><i>italic</i>&#160;<strike>strikeout</strike></font>
+ </p>
+ </body>
+</html>
```
-<html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
- <head></head>
- <body>
- <p style="text-align:right; margin-top: 0">Page &[Page]</p>
- </body>
-</html>
+
+Another example, also for use [inside `pageParagraph`], of three
+paragraphs, the first left justified, the second center justified with
+a large font, and the third right justified:
+
```
+<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+ <head>
-This element has the following attributes.
+ </head>
+ <body>
+ <p>left</p>
+ <p align="center"><font color="#000000" size="5" face="Monospaced">center&#160;large</font></p>
+ <p align="right"><font color="#000000" size="3" face="Monospaced"><b><i>right</i></b></font></p>
+ </body>
+</html>
+```
-* `type`
- Always `text`.
+[inside `pageParagraph`]: #the-text-element-inside-pageparagraph
+[inside `container`]: #the-text-element-inside-container
`AreaStyle` represents style properties of an area.
-`valign` is 0 for top alignment, 1 for bottom alginment, 2 for
-center.
-
-`halign` is 0 for left alignment, 1 for right, 2 for center, 3 for
-mixed, 4 for decimal. For decimal alignment, `decimal-offset` is the
-offset of the decimal point in 20ths of a point.
+`valign` has the following values:
+
+| `valign` | Vertical Alignment |
+|---------:|:-------------------|
+| 0 | Top |
+| 1 | Bottom |
+| 2 | Center |
+
+`halign` has the following values:
+
+| `halign` | Horizontal Alignment |
+|---------:|:---------------------|
+| 0 | Left |
+| 1 | Right |
+| 2 | Center |
+| 3 | Mixed |
+| 4 | Decimal |
+
+For decimal alignment, `decimal-offset` is the offset of the decimal
+point, in 20ths of a point.
`left-margin`, `right-margin`, `top-margin`, and `bottom-margin` are
also measured in 20ths of a point.
unicase = "2.6.0"
libc = "0.2.147"
indexmap = { version = "2.1.0", features = ["serde"] }
-bitflags = "2.5.0"
unicode-width = "0.2.0"
chardetng = "0.1.17"
enum-map = { version = "2.7.3", features = ["serde"] }
-flagset = "0.4.6"
pspp-derive = { version = "0.1.0", path = "../pspp-derive" }
either = "1.13.0"
enum-iterator = "2.1.0"
smallstr = "0.3.0"
itertools = "0.14.0"
unicode-linebreak = "0.1.5"
-quick-xml = { version = "0.37.2", features = ["serialize"] }
+quick-xml = { version = "0.38.4", features = ["serialize", "escape-html"] }
serde = { version = "1.0.218", features = ["derive", "rc"] }
color = { version = "0.2.3", features = ["serde"] }
binrw = "0.14.1"
pango = "0.20.9"
pangocairo = "0.20.7"
zip = "4.0.0"
-xmlwriter = "0.1.0"
csv = "1.3.1"
cmac = "0.7.2"
aes = "0.8.4"
hashbrown = { version = "0.15.5", features = ["serde"] }
displaydoc = "0.2.5"
codepage-437 = "0.1.0"
+serde_path_to_error = "0.1.20"
+html_parser = "0.7.0"
+paper-sizes = { path = "../paper-sizes", features = ["serde"] }
+enumset = "1.1.10"
+bit-vec = "0.8.0"
+erased-serde = "0.4.9"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.48.0", features = ["Win32_Globalization"] }
[build-dependencies]
anyhow = "1.0.69"
+zip = "4.0.0"
+serde_json = "1.0.141"
[[bin]]
name = "pspp"
// this program. If not, see <http://www.gnu.org/licenses/>.
use anyhow::{Result as AnyResult, anyhow};
+use serde_json::Value;
use std::{
collections::{BTreeMap, HashSet, VecDeque},
env::var_os,
+ ffi::OsStr,
+ fmt::Write as _,
fs::{File, read_to_string},
io::{Error as IoError, Write},
path::{Path, PathBuf},
};
+use zip::ZipArchive;
#[derive(Copy, Clone, PartialEq, Eq, Ord, PartialOrd)]
enum Source {
for (&cpnumber, value) in codepages.iter() {
let source = value.keys().max().unwrap();
let name = value[source][0];
- writeln!(file, " map.insert({cpnumber}, \"{name}\");")?;
+ writeln!(file, " map.insert({cpnumber}, \"{name}\");")?;
}
file.write_all(
" map
Ok(())
}
-fn main() -> AnyResult<()> {
- println!("cargo:rerun-if-changed=build.rs");
-
+fn build_encodings() -> AnyResult<()> {
let input_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("convrtrs.txt");
- println!("cargo:rerun-if-changed={}", input_file.to_string_lossy());
+ println!("cargo:rerun-if-changed={}", input_file.display());
let input = read_to_string(&input_file)
- .map_err(|e| anyhow!("{}: read failed ({e})", input_file.to_string_lossy()))?;
+ .map_err(|e| anyhow!("{}: read failed ({e})", input_file.display()))?;
let mut codepages: BTreeMap<CodepageNumber, BTreeMap<Source, Vec<&str>>> = BTreeMap::new();
let mut converter: Vec<&str> = Vec::new();
let output_file_name = Path::new(&var_os("OUT_DIR").unwrap()).join("encodings.rs");
write_output(&codepages, &output_file_name)
- .map_err(|e| anyhow!("{}: write failed ({e})", output_file_name.to_string_lossy()))?;
+ .map_err(|e| anyhow!("{}: write failed ({e})", output_file_name.display()))?;
+ Ok(())
+}
+
+/// Read `cldr-json-full.zip` and write `src/format/decimals.rs`.
+fn builld_decimals() -> AnyResult<()> {
+ let cldr_path = Path::new("cldr-json-full.zip");
+ if !cldr_path.try_exists()? {
+ // Don't try to regenerate `decimals.rs`, since we don't have the CLDR.
+ return Ok(());
+ }
+ println!("cargo:rerun-if-changed={}", cldr_path.display());
+
+ let mut archive = ZipArchive::new(File::open(cldr_path)?)?;
+ let mut decimals = BTreeMap::new();
+ for i in 0..archive.len() {
+ let name = Path::new(archive.name_for_index(i).unwrap());
+ if name.file_name() == Some(OsStr::new("numbers.json")) {
+ let file = archive.by_index(i)?;
+ let json: Value = serde_json::from_reader(file).expect("should be JSON");
+ if let Some(main) = json.get("main")
+ && let Some(langs) = main.as_object()
+ && let Some((lang, details)) = langs.iter().next()
+ && let Some(numbers) = details.get("numbers")
+ && let Some(latin) = numbers.get("symbols-numberSystem-latn")
+ && let Some(decimal) = latin.get("decimal")
+ && let Some(decimal) = decimal.as_str()
+ && let Some(decimal) = decimal.chars().next()
+ && (decimal == '.' || decimal == ',')
+ {
+ decimals.insert(lang.clone(), decimal);
+ }
+ }
+ }
+ let decimals = decimals.iter().filter(|(name, decimal)| {
+ if let Some((stem, _)) = name.split_once('-')
+ && decimals.get(stem) == Some(*decimal)
+ {
+ false
+ } else {
+ true
+ }
+ });
+
+ let mut contents = String::new();
+ write!(
+ &mut contents,
+ "//! Autogenerated, do not edit by hand!
+//!
+//! The code in this file is generated by `build.rs`. It is unconventional
+//! to check generated files into a repository, but it is done in this case
+//! because the source data is large and the generated code rarely needs to
+//! change.
+//!
+//! To regenerate this code, download a CLDR JSON release from
+//! <https://cldr.unicode.org/index/downloads>, rename it as
+//! `cldr-json-full.zip` in the same directory as `build.rs`,
+//! and touch `build.rs` to force a rebuild.
+use std::{{collections::HashMap, sync::LazyLock}};
+use crate::format::Decimal;
+
+/// Map from language to decimal point.
+pub static LANG_TO_DECIMAL: LazyLock<HashMap<&'static str, Decimal>> = LazyLock::new(|| {{
+ let mut map = HashMap::new();
+"
+ )
+ .unwrap();
+ for (lang, decimal) in decimals {
+ let decimal = if *decimal == ',' { "Comma" } else { "Dot" };
+ writeln!(
+ &mut contents,
+ " map.insert({lang:?}, Decimal::{decimal});"
+ )
+ .unwrap();
+ }
+ write!(
+ &mut contents,
+ " map
+}});
+"
+ )
+ .unwrap();
+
+ let output_file_name = Path::new("src/format/decimals.rs");
+ if !output_file_name.try_exists()? || contents.as_bytes() != &std::fs::read(&output_file_name)?
+ {
+ std::fs::write(&output_file_name, &contents)?;
+ }
+
+ Ok(())
+}
+
+fn main() -> AnyResult<()> {
+ println!("cargo:rerun-if-changed=build.rs");
+
+ build_encodings()?;
+ builld_decimals()?;
Ok(())
}
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <http://www.gnu.org/licenses/>.
+/// Dates and times in PSPP.
+///
+/// PSPP represents dates as the number of seconds since [EPOCH], and times as
+/// the number of seconds since midnight.
use chrono::{Datelike, Days, Month, NaiveDate, NaiveDateTime, NaiveTime};
use num::FromPrimitive;
use thiserror::Error as ThisError;
use crate::format::Settings;
+/// The PSPP epoch, 14 Oct 1582, as a [NaiveDate].
const EPOCH: NaiveDate = NaiveDate::from_ymd_opt(1582, 10, 14).unwrap();
+
+/// The PSPP epoch, 14 Oct 1582, as a [NaiveDateTime].
const EPOCH_DATETIME: NaiveDateTime = EPOCH.and_time(NaiveTime::MIN);
pub fn date_time_to_pspp(date_time: NaiveDateTime) -> f64 {
(date_time - EPOCH_DATETIME).as_seconds_f64()
}
+pub fn time_to_pspp(time: NaiveTime) -> f64 {
+ (time - NaiveTime::MIN).as_seconds_f64()
+}
+
/// Takes a count of days from 14 Oct 1582 and translates it into a Gregorian
/// calendar date, if possible. Positive and negative offsets are supported.
pub fn calendar_offset_to_gregorian(offset: f64) -> Option<NaiveDate> {
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+use encoding_rs::Encoding;
+use thiserror::Error as ThisError;
+
+use convert::Convert;
+use decrypt::Decrypt;
+use identify::Identify;
+use show::Show;
+use show_pc::ShowPc;
+use show_por::ShowPor;
+use show_spv::ShowSpv;
+
+mod convert;
+mod decrypt;
+mod identify;
+mod show;
+mod show_pc;
+mod show_por;
+mod show_spv;
+
+/// PSPP, a program for statistical analysis of sampled data.
+#[derive(Parser, Debug)]
+#[command(author, version, about, long_about = None)]
+pub struct Cli {
+ #[command(subcommand)]
+ pub command: Command,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum Command {
+ Convert(Convert),
+ Decrypt(Decrypt),
+ Identify(Identify),
+ Show(Show),
+ ShowPor(ShowPor),
+ ShowPc(ShowPc),
+ ShowSpv(ShowSpv),
+}
+
+impl Command {
+ pub fn run(self) -> Result<()> {
+ match self {
+ Command::Convert(convert) => convert.run(),
+ Command::Decrypt(decrypt) => decrypt.run(),
+ Command::Identify(identify) => identify.run(),
+ Command::Show(show) => show.run(),
+ Command::ShowPor(show_por) => show_por.run(),
+ Command::ShowPc(show_pc) => show_pc.run(),
+ Command::ShowSpv(show_spv) => show_spv.run(),
+ }
+ }
+}
+
+#[derive(ThisError, Debug)]
+#[error("{0}: unknown encoding")]
+struct UnknownEncodingError(String);
+
+fn parse_encoding(arg: &str) -> Result<&'static Encoding, UnknownEncodingError> {
+ match Encoding::for_label_no_replacement(arg.as_bytes()) {
+ Some(encoding) => Ok(encoding),
+ None => Err(UnknownEncodingError(arg.to_string())),
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2023 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use std::{path::PathBuf, sync::Arc};
+
+use anyhow::{Error as AnyError, Result, bail};
+use clap::Args;
+use encoding_rs::Encoding;
+use pspp::{
+ data::{ByteString, Case, Datum},
+ dictionary::Dictionary,
+ file::FileType,
+ output::{Criteria, drivers::Driver},
+ pc::PcFile,
+ por::PortableFile,
+ sys::ReadOptions,
+};
+
+use super::parse_encoding;
+
+/// Convert SPSS data and viewer files into other formats.
+#[derive(Args, Clone, Debug)]
+pub struct Convert {
+ /// Input file name.
+ input: PathBuf,
+
+ /// Output file name (if omitted, output is written to stdout).
+ output: Option<PathBuf>,
+
+ /// The encoding to use for reading the input file.
+ #[arg(short = 'e', long, value_parser = parse_encoding)]
+ encoding: Option<&'static Encoding>,
+
+ /// Password for decryption.
+ ///
+ /// In addition to file encryption, SPSS supports a feature called "password
+ /// encryption". The password specified can be specified with or without
+ /// "password encryption".
+ ///
+ /// Specify only for an encrypted system file.
+ #[clap(short, long)]
+ password: Option<String>,
+
+ /// Maximum number of cases to print.
+ #[arg(short = 'c', long = "cases")]
+ max_cases: Option<usize>,
+
+ /// Write the output file with Unicode (UTF-8) encoding.
+ ///
+ /// If the input was not already encoded in Unicode, this triples the width
+ /// of string variables.
+ #[arg(long = "unicode")]
+ to_unicode: bool,
+
+ /// Output driver configuration options.
+ #[arg(short = 'o')]
+ output_options: Vec<String>,
+
+ /// Selection options for SPV input files.
+ #[command(flatten)]
+ criteria: Criteria,
+}
+
+impl Convert {
+ fn open_driver(&self, default_driver: &str) -> Result<Box<dyn Driver>> {
+ <dyn Driver>::from_options(self.output.as_ref(), &self.output_options, default_driver)
+ }
+ fn write_data(
+ self,
+ dictionary: Dictionary,
+ cases: Box<dyn Iterator<Item = Result<Case<Vec<Datum<ByteString>>>, AnyError>>>,
+ ) -> Result<()> {
+ // Take only the first `self.max_cases` cases.
+ let cases = cases.take(self.max_cases.unwrap_or(usize::MAX));
+
+ let mut output = self.open_driver("csv")?;
+ if !output.can_write_data_file() {
+ bail!("Can't write data output to {} driver.", output.name());
+ }
+
+ let mut writer = output.write_data_file(&dictionary)?.unwrap();
+ for case in cases {
+ writer.write_case(case?)?;
+ }
+ Ok(())
+ }
+
+ pub fn run(self) -> Result<()> {
+ match FileType::from_file(&self.input)? {
+ Some(FileType::System { .. }) => {
+ fn warn(warning: anyhow::Error) {
+ eprintln!("warning: {warning}");
+ }
+
+ let mut system_file = ReadOptions::new(warn)
+ .with_encoding(self.encoding)
+ .with_password(self.password.clone())
+ .open_file(&self.input)?;
+ if self.to_unicode {
+ system_file = system_file.into_unicode();
+ }
+ let (dictionary, _, cases) = system_file.into_parts();
+ let cases = cases.map(|result| result.map_err(AnyError::from));
+ let cases = Box::new(cases)
+ as Box<dyn Iterator<Item = Result<Case<Vec<Datum<ByteString>>>, AnyError>>>;
+ self.write_data(dictionary, cases)
+ }
+ Some(FileType::Portable) => {
+ fn warn_portable(warning: pspp::por::Warning) {
+ eprintln!("warning: {warning}");
+ }
+
+ let portable_file = PortableFile::open_file(&self.input, warn_portable)?;
+ let (dictionary, _, cases) = portable_file.into_parts();
+ let cases = cases.map(|result| result.map_err(AnyError::from));
+ let cases = Box::new(cases)
+ as Box<dyn Iterator<Item = Result<Case<Vec<Datum<ByteString>>>, AnyError>>>;
+ self.write_data(dictionary, cases)
+ }
+ Some(FileType::Pc) => {
+ fn warn_pc(warning: pspp::pc::Warning) {
+ eprintln!("warning: {warning}");
+ }
+
+ let pc_file = PcFile::open_file(&self.input, warn_pc)?;
+ let (dictionary, _, cases) = pc_file.into_parts();
+ let cases = cases.map(|result| result.map_err(AnyError::from));
+ let cases = Box::new(cases)
+ as Box<dyn Iterator<Item = Result<Case<Vec<Datum<ByteString>>>, AnyError>>>;
+ self.write_data(dictionary, cases)
+ }
+ Some(FileType::Viewer { .. }) => {
+ let (items, page_setup) = pspp::spv::ReadOptions::new(|e| eprintln!("{e}"))
+ .with_password(self.password.clone())
+ .open_file(&self.input)?
+ .into_contents();
+ let mut output = self.open_driver("text")?;
+ if let Some(page_setup) = &page_setup {
+ output.setup(page_setup);
+ }
+ for item in items {
+ output.write(&Arc::new(item));
+ }
+ Ok(())
+ }
+ _ => bail!(
+ "{}: not a system, portable, or SPSS/PC+ file",
+ self.input.display()
+ ),
+ }
+ }
+}
--- /dev/null
+/* PSPP - a program for statistical analysis.
+ * Copyright (C) 2023 Free Software Foundation, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>. */
+
+use anyhow::{Result, anyhow};
+use clap::Args;
+use pspp::crypto::EncryptedFile;
+use std::{fs::File, path::PathBuf};
+use zeroize::Zeroizing;
+
+/// Decrypts an encrypted SPSS data, output, or syntax file.
+#[derive(Args, Clone, Debug)]
+pub struct Decrypt {
+ /// Input file name.
+ input: PathBuf,
+
+ /// Output file name.
+ output: PathBuf,
+
+ /// Password for decryption, with or without what SPSS calls "password encryption".
+ ///
+ /// If omitted, PSPP will prompt interactively for the password.
+ #[clap(short, long)]
+ password: Option<String>,
+}
+
+impl Decrypt {
+ pub fn run(self) -> Result<()> {
+ let input = EncryptedFile::new(File::open(&self.input)?)?;
+ let password = match self.password {
+ Some(password) => Zeroizing::new(password),
+ None => {
+ eprintln!("Please enter the password for {}:", self.input.display());
+ readpass::from_tty().unwrap()
+ }
+ };
+ let mut reader = match input.unlock(password) {
+ Ok(reader) => reader,
+ Err(_) => return Err(anyhow!("Incorrect password.")),
+ };
+ let mut writer = File::create(self.output)?;
+ std::io::copy(&mut reader, &mut writer)?;
+ Ok(())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use anyhow::Result;
+use clap::Args;
+use pspp::file::FileType;
+use std::path::PathBuf;
+
+/// Identify the type of a file.
+#[derive(Args, Clone, Debug)]
+pub struct Identify {
+ /// File to identify.
+ file: PathBuf,
+}
+
+impl Identify {
+ pub fn run(self) -> Result<()> {
+ match FileType::from_file(&self.file)? {
+ None => println!("unknown"),
+ Some(file_type) => {
+ print!("{}", file_type.as_extension());
+ if file_type.is_encrypted() {
+ print!(" (encrypted)");
+ }
+ if !file_type.is_confident() {
+ print!(" (low confidence)");
+ }
+ println!();
+ }
+ }
+ Ok(())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use super::parse_encoding;
+use anyhow::{Result, anyhow};
+use clap::{Args, ValueEnum};
+use encoding_rs::Encoding;
+use itertools::Itertools;
+use pspp::{
+ data::cases_to_output,
+ output::{Details, Item, Text, drivers::Driver, pivot::PivotTable},
+ sys::{
+ Records,
+ raw::{Decoder, EncodingReport, Magic, Reader, Record, infer_encoding},
+ },
+};
+use serde::Serialize;
+use std::{cell::RefCell, fmt::Display, fs::File, io::BufReader, 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<PathBuf>,
+
+ /// 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<String>,
+}
+
+struct Output {
+ driver: Rc<RefCell<Box<dyn Driver>>>,
+ mode: Mode,
+}
+
+impl Output {
+ fn show<T>(&self, value: &T) -> Result<()>
+ where
+ T: Serialize,
+ for<'a> &'a T: Into<Details>,
+ {
+ let mut driver = self.driver.borrow_mut();
+ if driver.can_serialize() {
+ driver.serialize(value);
+ } else {
+ driver.write(&Arc::new(value.into().into_item()));
+ }
+ Ok(())
+ }
+
+ fn can_show_json(&self) -> bool {
+ self.driver.borrow().can_serialize()
+ }
+
+ fn show_json<T>(&self, value: &T) -> Result<()>
+ where
+ T: Serialize,
+ {
+ let mut driver = self.driver.borrow_mut();
+ if driver.can_serialize() {
+ driver.serialize(value);
+ Ok(())
+ } else {
+ Err(anyhow!(
+ "Mode '{}' only supports output as JSON.",
+ self.mode
+ ))
+ }
+ }
+
+ fn warn(&self, warning: &impl Display) {
+ let mut driver = self.driver.borrow_mut();
+ #[derive(Serialize)]
+ struct Warning {
+ warning: String,
+ }
+ let w = Warning {
+ warning: warning.to_string(),
+ };
+ if driver.can_serialize() {
+ driver.serialize(&w);
+ } else {
+ driver.write(&Arc::new(Item::from(Text::new_log(warning.to_string()))));
+ }
+ }
+}
+
+impl Show {
+ pub fn run(self) -> Result<()> {
+ let output = Output {
+ mode: self.mode,
+ driver: Rc::new(RefCell::new(Box::new(<dyn Driver>::from_options(
+ self.output.as_ref(),
+ &self.output_options,
+ "json",
+ )?))),
+ };
+
+ 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<Record> = reader.records().collect::<Result<Vec<_>, _>>()?;
+ 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<Record> = reader.records().collect::<Result<Vec<_>, _>>()?;
+ 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();
+
+ if output.can_show_json() {
+ output.show_json(&dictionary)?;
+ output.show_json(&metadata)?;
+ for (_index, case) in (0..self.max_cases).zip(cases) {
+ output.show_json(&case?)?;
+ }
+ } else {
+ let mut items = Vec::new();
+ items.push(PivotTable::from(&metadata).into());
+ items.extend(dictionary.all_pivot_tables().into_iter().map_into());
+ items.extend(cases_to_output(&dictionary, cases));
+ output
+ .driver
+ .borrow_mut()
+ .write(&Arc::new(items.into_iter().collect()));
+ }
+ }
+ 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())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use anyhow::{Result, anyhow};
+use clap::{Args, ValueEnum};
+use itertools::Itertools;
+use pspp::{
+ data::cases_to_output,
+ output::{Item, Text, drivers::Driver, pivot::PivotTable},
+ pc::PcFile,
+};
+use serde::Serialize;
+use std::{cell::RefCell, fmt::Display, fs::File, io::BufReader, 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<PathBuf>,
+
+ /// 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,
+ )]
+ max_cases: usize,
+
+ /// Output driver configuration options.
+ #[arg(short = 'o')]
+ output_options: Vec<String>,
+}
+
+struct Output {
+ driver: Rc<RefCell<Box<dyn Driver>>>,
+ mode: Mode,
+}
+
+impl Output {
+ fn can_show_json(&self) -> bool {
+ self.driver.borrow().can_serialize()
+ }
+
+ fn show_json<T>(&self, value: &T) -> Result<()>
+ where
+ T: Serialize,
+ {
+ let mut driver = self.driver.borrow_mut();
+ if driver.can_serialize() {
+ driver.serialize(value);
+ Ok(())
+ } else {
+ Err(anyhow!(
+ "Mode '{}' only supports output as JSON.",
+ self.mode
+ ))
+ }
+ }
+
+ fn warn(&self, warning: &impl Display) {
+ let mut driver = self.driver.borrow_mut();
+ #[derive(Serialize)]
+ struct Warning {
+ warning: String,
+ }
+ let w = Warning {
+ warning: warning.to_string(),
+ };
+ if driver.can_serialize() {
+ driver.serialize(&w);
+ } else {
+ driver.write(&Arc::new(Item::from(Text::new_log(warning.to_string()))));
+ }
+ }
+}
+
+impl ShowPc {
+ pub fn run(self) -> Result<()> {
+ let output = Output {
+ mode: self.mode,
+ driver: Rc::new(RefCell::new(Box::new(<dyn Driver>::from_options(
+ self.output.as_ref(),
+ &self.output_options,
+ "json",
+ )?))),
+ };
+
+ 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);
+
+ if output.can_show_json() {
+ output.show_json(&dictionary)?;
+ for (_index, case) in (0..self.max_cases).zip(cases) {
+ output.show_json(&case?)?;
+ }
+ } else {
+ let mut items = Vec::new();
+ items.extend(dictionary.all_pivot_tables().into_iter().map_into());
+ items.extend(cases_to_output(&dictionary, cases));
+ output
+ .driver
+ .borrow_mut()
+ .write(&Arc::new(items.into_iter().collect()));
+ }
+ }
+ Mode::Metadata => {
+ let metadata = PcFile::open(reader, |warning| output.warn(&warning))?.metadata;
+
+ if output.can_show_json() {
+ output.show_json(&metadata)?;
+ } else {
+ output
+ .driver
+ .borrow_mut()
+ .write(&Arc::new(PivotTable::from(&metadata).into()));
+ }
+ }
+ }
+ 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())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use anyhow::{Result, anyhow};
+use clap::{Args, ValueEnum};
+use itertools::Itertools;
+use pspp::{
+ data::cases_to_output,
+ output::{Item, Text, drivers::Driver, pivot::PivotTable},
+ por::PortableFile,
+};
+use serde::Serialize;
+use std::{cell::RefCell, fmt::Display, fs::File, io::BufReader, 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<PathBuf>,
+
+ /// 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<String>,
+}
+
+struct Output {
+ driver: Rc<RefCell<Box<dyn Driver>>>,
+ mode: Mode,
+}
+
+impl Output {
+ fn can_show_json(&self) -> bool {
+ self.driver.borrow().can_serialize()
+ }
+
+ fn show_json<T>(&self, value: &T) -> Result<()>
+ where
+ T: Serialize,
+ {
+ let mut driver = self.driver.borrow_mut();
+ if driver.can_serialize() {
+ driver.serialize(value);
+ Ok(())
+ } else {
+ Err(anyhow!(
+ "Mode '{}' only supports output as JSON.",
+ self.mode
+ ))
+ }
+ }
+
+ fn warn(&self, warning: &impl Display) {
+ let mut driver = self.driver.borrow_mut();
+ #[derive(Serialize)]
+ struct Warning {
+ warning: String,
+ }
+ let w = Warning {
+ warning: warning.to_string(),
+ };
+ if driver.can_serialize() {
+ driver.serialize(&w);
+ } else {
+ driver.write(&Arc::new(Item::from(Text::new_log(warning.to_string()))));
+ }
+ }
+}
+
+impl ShowPor {
+ pub fn run(self) -> Result<()> {
+ let output = Output {
+ mode: self.mode,
+ driver: Rc::new(RefCell::new(Box::new(<dyn Driver>::from_options(
+ self.output.as_ref(),
+ &self.output_options,
+ "json",
+ )?))),
+ };
+
+ 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);
+
+ if output.can_show_json() {
+ output.show_json(&dictionary)?;
+ for (_index, case) in (0..self.max_cases).zip(cases) {
+ output.show_json(&case?)?;
+ }
+ } else {
+ let mut items = Vec::new();
+ items.extend(dictionary.all_pivot_tables().into_iter().map_into());
+ items.extend(cases_to_output(&dictionary, cases));
+ output
+ .driver
+ .borrow_mut()
+ .write(&Arc::new(items.into_iter().collect()));
+ }
+ }
+ Mode::Metadata => {
+ let metadata =
+ PortableFile::open(reader, |warning| output.warn(&warning))?.metadata;
+
+ if output.can_show_json() {
+ output.show_json(&metadata)?;
+ } else {
+ output
+ .driver
+ .borrow_mut()
+ .write(&Arc::new(PivotTable::from(&metadata).into()));
+ }
+ }
+ 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::<Vec<_>>();
+ 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())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use anyhow::Result;
+use binrw::{BinRead, error::ContextExt};
+use clap::{Args, ValueEnum};
+use pspp::{
+ output::{
+ Criteria, Item, ItemRefIterator, SpvMembers,
+ pivot::{Axis3, Dimension, Group, Leaf, PivotTable, value::Value},
+ },
+ spv::legacy_bin::LegacyBin,
+};
+use std::{
+ collections::HashMap,
+ fmt::Display,
+ io::{Cursor, Read},
+ path::PathBuf,
+};
+
+/// Show information about SPSS viewer files (SPV files).
+#[derive(Args, Clone, Debug)]
+pub struct ShowSpv {
+ /// What to show.
+ #[arg(value_enum)]
+ mode: Mode,
+
+ /// File to show.
+ ///
+ /// For most modes, this should be a `.spv` file. For `convert-table-look`,
+ /// this should be a `.tlo` or `.stt` file.
+ #[arg(required = true)]
+ input: PathBuf,
+
+ /// Password for decryption.
+ ///
+ /// In addition to file encryption, SPSS supports a feature called "password
+ /// encryption". The password specified can be specified with or without
+ /// "password encryption".
+ ///
+ /// Specify only for an encrypted SPV file.
+ #[clap(short, long)]
+ password: Option<String>,
+
+ /// Input selection options.
+ #[command(flatten)]
+ criteria: Criteria,
+
+ /// Include ZIP member names in `dir` output.
+ #[arg(long = "member-names")]
+ show_member_names: bool,
+}
+
+/// What to show in a viewer file.
+#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
+enum Mode {
+ /// List tables and other items.
+ #[value(alias = "dir")]
+ Directory,
+
+ /// Copies first selected TableLook into output in `.stt` format.
+ GetTableLook,
+
+ /// Reads `.tlo` or `.stt` TableLook and outputs as `.stt` format.
+ ConvertTableLook,
+
+ /// Print data values in legacy tables.
+ LegacyData,
+
+ /// Prints contents.
+ View,
+}
+
+impl Mode {
+ fn as_str(&self) -> &'static str {
+ match self {
+ Mode::Directory => "directory",
+ Mode::GetTableLook => "get-table-look",
+ Mode::ConvertTableLook => "convert-table-look",
+ Mode::LegacyData => "legacy-data",
+ Mode::View => "view",
+ }
+ }
+}
+
+impl Display for Mode {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+impl ShowSpv {
+ pub fn run(self) -> Result<()> {
+ match self.mode {
+ Mode::Directory => {
+ let item = pspp::spv::ReadOptions::new(|e| eprintln!("{e}"))
+ .with_password(self.password)
+ .open_file(&self.input)?
+ .into_items();
+ let items = self.criteria.apply(item);
+ for child in items {
+ print_item_directory(&child, 0, self.show_member_names);
+ }
+ Ok(())
+ }
+ Mode::View => {
+ let item = pspp::spv::ReadOptions::new(|e| eprintln!("{e}"))
+ .with_password(self.password)
+ .open_file(&self.input)?
+ .into_items();
+ let items = self.criteria.apply(item);
+ for child in items {
+ println!("{child}");
+ }
+ Ok(())
+ }
+ Mode::LegacyData => {
+ let mut spv_file = pspp::spv::ReadOptions::new(|e| eprintln!("{e}"))
+ .with_password(self.password)
+ .open_file(&self.input)?;
+
+ let items = self.criteria.apply(spv_file.items);
+ for item in items {
+ for item in ItemRefIterator::with_hidden(&item) {
+ if let Some(spv_info) = dbg!(&item.spv_info)
+ && let Some(members) = &spv_info.members
+ && let SpvMembers::LegacyTable { xml: _, binary } = &members
+ {
+ let mut bin_member = spv_file.archive.by_name(&binary)?;
+ 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 {binary:?} as legacy binary SPV member"
+ ))
+ })?;
+ let data = legacy_bin.decode();
+ let n_values = data
+ .values()
+ .flat_map(|map| map.values())
+ .map(|values| values.len())
+ .max()
+ .unwrap_or(0);
+ let index = Dimension::new(
+ Group::new("Index")
+ .with_multiple(Leaf::numbers(0..n_values))
+ .with_label_shown(),
+ );
+ let variables = Dimension::new(Group::new("Variables").with_multiple(
+ data.iter().map(|(name, contents)| {
+ Group::new(name.as_str()).with_multiple(contents.keys())
+ }),
+ ));
+ let mut pivot_table =
+ PivotTable::new([(Axis3::Y, index), (Axis3::X, variables)]);
+ let formats = HashMap::new();
+ for (variable_index, (variable_name, values)) in
+ data.values().flat_map(|map| map.iter()).enumerate()
+ {
+ for (value_index, data_value) in values.iter().enumerate() {
+ let value = Value::new_datum(&data_value.value)
+ .with_value_label(
+ (variable_name == "cellFormat").then(|| {
+ data_value.as_format(&formats).to_string()
+ }),
+ );
+ pivot_table.insert([value_index, variable_index], value);
+ }
+ }
+ println!("{pivot_table}");
+ }
+ }
+ }
+ Ok(())
+ }
+ Mode::GetTableLook => todo!(),
+ Mode::ConvertTableLook => todo!(),
+ }
+ }
+}
+
+fn print_item_directory(item: &Item, level: usize, show_member_names: bool) {
+ for _ in 0..level {
+ print!(" ");
+ }
+ print!("- {} {:?}", item.details.kind(), item.label());
+ if let Some(table) = item.details.as_table() {
+ let title = table.title().display(table).to_string();
+ if item.label.as_ref().is_none_or(|label| label != &title) {
+ print!(" title {title:?}");
+ }
+ }
+ if let Some(command_name) = &item.command_name {
+ print!(" command {command_name:?}");
+ }
+ if let Some(subtype) = item.subtype()
+ && item.label.as_ref().is_none_or(|label| label != &subtype)
+ {
+ print!(" subtype {subtype:?}");
+ }
+ if !item.show {
+ if item.details.is_heading() {
+ print!(" (collapsed)");
+ } else {
+ print!(" (hidden)");
+ }
+ }
+ if show_member_names && let Some(spv_info) = &item.spv_info {
+ for (index, name) in spv_info.member_names().into_iter().enumerate() {
+ print!(" {} {name:?}", if index == 0 { "in" } else { "and" });
+ }
+ }
+ println!();
+ for child in item.details.children() {
+ print_item_directory(&child, level + 1, show_member_names);
+ }
+}
use data_list::data_list_command;
use descriptives::descriptives_command;
use either::Either;
-use flagset::{FlagSet, flags};
+use enumset::{EnumSet, EnumSetType};
use pspp_derive::FromTokens;
use crate::{
pub mod data_list;
pub mod descriptives;
-flags! {
- enum State: u8 {
- /// No active dataset yet defined.
- Initial,
+#[derive(Debug, EnumSetType)]
+enum State {
+ /// No active dataset yet defined.
+ Initial,
- /// Active dataset has been defined.
- Data,
+ /// Active dataset has been defined.
+ Data,
- /// Inside `INPUT PROGRAM`.
- InputProgram,
+ /// Inside `INPUT PROGRAM`.
+ InputProgram,
- /// Inside `FILE TYPE`.
- FileType,
+ /// Inside `FILE TYPE`.
+ FileType,
- /// State nested inside `LOOP` or `DO IF`, inside [State::Data].
- NestedData,
+ /// State nested inside `LOOP` or `DO IF`, inside [State::Data].
+ NestedData,
- /// State nested inside `LOOP` or `DO IF`, inside [State::InputProgram].
- NestedInputProgram,
- }
+ /// State nested inside `LOOP` or `DO IF`, inside [State::InputProgram].
+ NestedInputProgram,
}
struct Command {
- allowed_states: FlagSet<State>,
+ allowed_states: EnumSet<State>,
enhanced_only: bool,
testing_only: bool,
no_abbrev: bool,
ctables_command(),
data_list_command(),
Command {
- allowed_states: FlagSet::full(),
+ allowed_states: EnumSet::all(),
enhanced_only: false,
testing_only: false,
no_abbrev: false,
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <http://www.gnu.org/licenses/>.
-use flagset::FlagSet;
+use enumset::EnumSet;
use super::{By, Comma, Command, Equals, Integer, Number, Punctuated, Subcommands, VarList};
use crate::command::{
pub(super) fn crosstabs_command() -> Command {
Command {
- allowed_states: FlagSet::full(),
+ allowed_states: EnumSet::all(),
enhanced_only: false,
testing_only: false,
no_abbrev: false,
use std::fmt::Debug;
use either::Either;
-use flagset::FlagSet;
+use enumset::EnumSet;
use super::{
And, Asterisk, By, Command, Dash, Equals, Exp, Gt, InSquares, Integer, Number, Plus,
pub(super) fn ctables_command() -> Command {
Command {
- allowed_states: FlagSet::full(),
+ allowed_states: EnumSet::all(),
enhanced_only: false,
testing_only: false,
no_abbrev: false,
// this program. If not, see <http://www.gnu.org/licenses/>.
use either::Either;
-use flagset::FlagSet;
+use enumset::EnumSet;
use super::{Comma, Command, Equals, Integer, Punctuated, Seq0, Seq1, Slash};
use crate::{
pub(super) fn data_list_command() -> Command {
Command {
- allowed_states: FlagSet::full(),
+ allowed_states: EnumSet::all(),
enhanced_only: false,
testing_only: false,
no_abbrev: false,
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <http://www.gnu.org/licenses/>.
-use flagset::FlagSet;
+use enumset::EnumSet;
use super::{Comma, Command, Equals, Punctuated, Seq1, Subcommands};
use crate::command::{
pub(super) fn descriptives_command() -> Command {
Command {
- allowed_states: FlagSet::full(),
+ allowed_states: EnumSet::all(),
enhanced_only: false,
testing_only: false,
no_abbrev: false,
+++ /dev/null
-/* PSPP - a program for statistical analysis.
- * Copyright (C) 2023 Free Software Foundation, Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>. */
-
-use std::{
- fs::File,
- io::{Write, stdout},
- path::{Path, PathBuf},
-};
-
-use anyhow::{Error as AnyError, Result, anyhow, bail};
-use chrono::{Datelike, NaiveTime, Timelike};
-use clap::{Args, ValueEnum};
-use csv::Writer;
-use encoding_rs::Encoding;
-use pspp::{
- calendar::calendar_offset_to_gregorian,
- data::{ByteString, Case, Datum, WithEncoding},
- file::FileType,
- format::{DisplayPlain, Type},
- pc::PcFile,
- por::PortableFile,
- sys::{ReadOptions, WriteOptions, raw::records::Compression},
- util::ToSmallString,
- variable::Variable,
-};
-
-use crate::parse_encoding;
-
-/// Convert SPSS data files into other formats.
-#[derive(Args, Clone, Debug)]
-pub struct Convert {
- /// Input file name.
- input: PathBuf,
-
- /// Output file name (if omitted, output is written to stdout).
- output: Option<PathBuf>,
-
- /// Format for output file (if omitted, the intended format is inferred
- /// based on file extension).
- #[arg(short = 'O')]
- output_format: Option<OutputFormat>,
-
- /// The encoding to use for reading the input file.
- #[arg(short = 'e', long, value_parser = parse_encoding)]
- encoding: Option<&'static Encoding>,
-
- /// Password for decryption, with or without what SPSS calls "password encryption".
- ///
- /// Specify only for an encrypted system file.
- #[clap(short, long)]
- password: Option<String>,
-
- /// Maximum number of cases to print.
- #[arg(short = 'c', long = "cases")]
- max_cases: Option<usize>,
-
- #[command(flatten, next_help_heading = "Options for CSV output")]
- csv_options: CsvOptions,
-
- #[command(flatten, next_help_heading = "Options for system file output")]
- sys_options: SysOptions,
-}
-
-#[derive(Args, Clone, Debug)]
-struct CsvOptions {
- /// Omit writing variable names as the first line of output.
- #[arg(long)]
- no_var_names: bool,
-
- /// Writes user-missing values like system-missing values. Otherwise,
- /// user-missing values are written the same way as non-missing values.
- #[arg(long)]
- recode: bool,
-
- /// Write value labels instead of values.
- #[arg(long)]
- labels: bool,
-
- /// Use print formats for numeric variables.
- #[arg(long)]
- print_formats: bool,
-
- /// Decimal point.
- #[arg(long, default_value_t = '.')]
- decimal: char,
-
- /// Delimiter.
- ///
- /// The default is `,` unless that would be the same as the decimal point,
- /// in which case `;` is the default.
- #[arg(long)]
- delimiter: Option<char>,
-
- /// Character used to quote the delimiter.
- #[arg(long, default_value_t = '"')]
- qualifier: char,
-}
-
-impl CsvOptions {
- fn write_field<W>(
- &self,
- datum: &Datum<WithEncoding<ByteString>>,
- variable: &Variable,
- writer: &mut Writer<W>,
- ) -> csv::Result<()>
- where
- W: Write,
- {
- if self.labels
- && let Some(label) = variable.value_labels.get(datum)
- {
- writer.write_field(label)
- } else if datum.is_sysmis() {
- writer.write_field(" ")
- } else if self.print_formats || datum.is_string() {
- writer.write_field(
- datum
- .display(variable.print_format)
- .with_trimming()
- .to_small_string::<64>(),
- )
- } else {
- let number = datum.as_number().unwrap().unwrap();
- match variable.print_format.type_() {
- Type::F
- | Type::Comma
- | Type::Dot
- | Type::Dollar
- | Type::Pct
- | Type::E
- | Type::CC(_)
- | Type::N
- | Type::Z
- | Type::P
- | Type::PK
- | Type::IB
- | Type::PIB
- | Type::PIBHex
- | Type::RB
- | Type::RBHex
- | Type::WkDay
- | Type::Month => writer.write_field(
- number
- .display_plain()
- .with_decimal(self.decimal)
- .to_small_string::<64>(),
- ),
-
- Type::Date
- | Type::ADate
- | Type::EDate
- | Type::JDate
- | Type::SDate
- | Type::QYr
- | Type::MoYr
- | Type::WkYr => {
- if number >= 0.0
- && let Some(date) =
- calendar_offset_to_gregorian(number / 60.0 / 60.0 / 24.0)
- {
- writer.write_field(
- format_args!(
- "{:02}/{:02}/{:04}",
- date.month(),
- date.day(),
- date.year()
- )
- .to_small_string::<64>(),
- )
- } else {
- writer.write_field(" ")
- }
- }
-
- Type::DateTime | Type::YmdHms => {
- if number >= 0.0
- && let Some(date) =
- calendar_offset_to_gregorian(number / 60.0 / 60.0 / 24.0)
- && let Some(time) = NaiveTime::from_num_seconds_from_midnight_opt(
- (number % (60.0 * 60.0 * 24.0)) as u32,
- 0,
- )
- {
- writer.write_field(
- format_args!(
- "{:02}/{:02}/{:04} {:02}:{:02}:{:02}",
- date.month(),
- date.day(),
- date.year(),
- time.hour(),
- time.minute(),
- time.second()
- )
- .to_small_string::<64>(),
- )
- } else {
- writer.write_field(" ")
- }
- }
-
- Type::MTime | Type::Time | Type::DTime => {
- if let Some(time) =
- NaiveTime::from_num_seconds_from_midnight_opt(number.abs() as u32, 0)
- {
- writer.write_field(
- format_args!(
- "{}{:02}:{:02}:{:02}",
- if number.is_sign_negative() { "-" } else { "" },
- time.hour(),
- time.minute(),
- time.second()
- )
- .to_small_string::<64>(),
- )
- } else {
- writer.write_field(" ")
- }
- }
-
- Type::A | Type::AHex => unreachable!(),
- }
- }
- }
-}
-
-#[derive(Args, Clone, Debug)]
-struct SysOptions {
- /// Write the output file with Unicode (UTF-8) encoding.
- ///
- /// If the input was not already encoded in Unicode, this triples the width
- /// of string variables.
- #[arg(long = "unicode")]
- to_unicode: bool,
-
- /// How to compress data in the system file.
- #[arg(long, default_value = "simple")]
- compression: Option<Compression>,
-}
-
-/// Output file format.
-#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
-enum OutputFormat {
- /// Comma-separated values using each variable's print format (variable
- /// names are written as the first line)
- Csv,
-
- /// System file
- Sys,
-
- /// Portable file
- Por,
-}
-
-impl TryFrom<&Path> for OutputFormat {
- type Error = AnyError;
-
- fn try_from(value: &Path) -> std::result::Result<Self, Self::Error> {
- let extension = value.extension().unwrap_or_default();
- if extension.eq_ignore_ascii_case("csv") || extension.eq_ignore_ascii_case("txt") {
- Ok(OutputFormat::Csv)
- } else if extension.eq_ignore_ascii_case("sav") || extension.eq_ignore_ascii_case("sys") {
- Ok(OutputFormat::Sys)
- } else if extension.eq_ignore_ascii_case("por") {
- Ok(OutputFormat::Por)
- } else {
- Err(anyhow!(
- "Unknown output file extension '{}'",
- extension.display()
- ))
- }
- }
-}
-
-impl Convert {
- pub fn run(self) -> Result<()> {
- let output_format = match self.output_format {
- Some(format) => format,
- None => match &self.output {
- Some(output) => output.as_path().try_into()?,
- _ => OutputFormat::Csv,
- },
- };
-
- let (dictionary, cases) = match FileType::from_file(&self.input)? {
- Some(FileType::System { .. }) => {
- fn warn(warning: anyhow::Error) {
- eprintln!("warning: {warning}");
- }
-
- let mut system_file = ReadOptions::new(warn)
- .with_encoding(self.encoding)
- .with_password(self.password.clone())
- .open_file(&self.input)?;
- if output_format == OutputFormat::Sys && self.sys_options.to_unicode {
- system_file = system_file.into_unicode();
- }
- let (dictionary, _, cases) = system_file.into_parts();
- let cases = cases.map(|result| result.map_err(AnyError::from));
- let cases = Box::new(cases)
- as Box<dyn Iterator<Item = Result<Case<Vec<Datum<ByteString>>>, AnyError>>>;
- (dictionary, cases)
- }
- Some(FileType::Portable) => {
- fn warn_portable(warning: pspp::por::Warning) {
- eprintln!("warning: {warning}");
- }
-
- let portable_file = PortableFile::open_file(&self.input, warn_portable)?;
- let (dictionary, _, cases) = portable_file.into_parts();
- let cases = cases.map(|result| result.map_err(AnyError::from));
- let cases = Box::new(cases)
- as Box<dyn Iterator<Item = Result<Case<Vec<Datum<ByteString>>>, AnyError>>>;
- (dictionary, cases)
- }
- Some(FileType::Pc) => {
- fn warn_pc(warning: pspp::pc::Warning) {
- eprintln!("warning: {warning}");
- }
-
- let pc_file = PcFile::open_file(&self.input, warn_pc)?;
- let (dictionary, _, cases) = pc_file.into_parts();
- let cases = cases.map(|result| result.map_err(AnyError::from));
- let cases = Box::new(cases)
- as Box<dyn Iterator<Item = Result<Case<Vec<Datum<ByteString>>>, AnyError>>>;
- (dictionary, cases)
- }
- _ => bail!(
- "{}: not a system, portable, or SPSS/PC+ file",
- self.input.display()
- ),
- };
-
- // Take only the first `self.max_cases` cases.
- let cases = cases.take(self.max_cases.unwrap_or(usize::MAX));
-
- match output_format {
- OutputFormat::Csv => {
- let writer = match self.output {
- Some(path) => Box::new(File::create(path)?) as Box<dyn Write>,
- None => Box::new(stdout()),
- };
- let decimal: u8 = self.csv_options.decimal.try_into()?;
- let delimiter: u8 = match self.csv_options.delimiter {
- Some(delimiter) => delimiter.try_into()?,
- None if decimal != b',' => b',',
- None => b';',
- };
- let qualifier: u8 = self.csv_options.qualifier.try_into()?;
- let mut output = csv::WriterBuilder::new()
- .delimiter(delimiter)
- .quote(qualifier)
- .from_writer(writer);
- if !self.csv_options.no_var_names {
- output
- .write_record(dictionary.variables.iter().map(|var| var.name.as_str()))?;
- }
-
- for case in cases {
- for (datum, variable) in case?.into_iter().zip(dictionary.variables.iter()) {
- self.csv_options
- .write_field(&datum, variable, &mut output)?;
- }
- output.write_record(None::<&[u8]>)?;
- }
- }
- OutputFormat::Sys => {
- let Some(output) = &self.output else {
- bail!("output file name must be specified for output to a system file")
- };
- let mut output = WriteOptions::new()
- .with_compression(self.sys_options.compression)
- .write_file(&dictionary, output)?;
- for case in cases {
- output.write_case(case?)?;
- }
- }
- OutputFormat::Por => {
- let Some(output) = &self.output else {
- bail!("output file name must be specified for output to a portable file")
- };
- let mut output = pspp::por::WriteOptions::new().write_file(&dictionary, output)?;
- for case in cases {
- output.write_case(case?)?;
- }
- }
- }
- Ok(())
- }
-}
cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray},
};
use cmac::{Cmac, Mac};
+use displaydoc::Display;
use smallvec::SmallVec;
use std::{
fmt::Debug,
io::{BufRead, Error as IoError, ErrorKind, Read, Seek, SeekFrom},
};
-use thiserror::Error as ThisError;
use binrw::{BinRead, io::NoSeek};
/// Error reading an encrypted file.
-#[derive(Clone, Debug, ThisError)]
+#[derive(Clone, Debug, thiserror::Error, Display)]
pub enum Error {
- /// I/O error.
- #[error("I/O error reading encrypted file wrapper ({0})")]
+ /// I/O error reading encrypted file wrapper ({0}).
IoError(ErrorKind),
/// Invalid padding in final encrypted data block.
- #[error("Invalid padding in final encrypted data block")]
InvalidPadding,
/// Not an encrypted file.
- #[error("Not an encrypted file")]
NotEncrypted,
- /// Encrypted file has invalid length.
- #[error("Encrypted file has invalid length {0} (expected 4 more than a multiple of 16).")]
+ /// Encrypted file has invalid length {0} (expected 4 more than a multiple of 16).
InvalidLength(u64),
- /// Unknown file type.
- #[error("Unknown file type {0:?}.")]
+ /// Unknown file type {0:?}.
UnknownFileType(String),
+
+ /// Incorrect password.
+ WrongPassword,
}
impl From<std::io::Error> for Error {
}
/// An encrypted file.
+#[derive(Clone)]
pub struct EncryptedFile<R> {
reader: R,
file_type: FileType,
/// `password` decoded with [EncodedPassword::decode]. If successful,
/// returns an [EncryptedReader] for the file; on failure, returns the
/// [EncryptedFile] again for another try.
- pub fn unlock(self, password: &[u8]) -> Result<EncryptedReader<R>, Self> {
+ pub fn unlock<P>(self, password: P) -> Result<EncryptedReader<R>, Self>
+ where
+ P: AsRef<[u8]>,
+ {
+ let password = password.as_ref();
self.unlock_literal(password).or_else(|this| {
match EncodedPassword::from_encoded(password) {
Some(encoded) => this.unlock_literal(&encoded.decode()),
///
/// If the password itself might be encoded ("encrypted"), instead use
/// [Self::unlock] to try it both ways.
- pub fn unlock_literal(self, password: &[u8]) -> Result<EncryptedReader<R>, Self> {
+ pub fn unlock_literal<P>(self, password: P) -> Result<EncryptedReader<R>, Self>
+ where
+ P: AsRef<[u8]>,
+ {
// NIST SP 800-108 fixed data.
#[rustfmt::skip]
static FIXED: &[u8] = &[
];
// Truncate password to at most 10 bytes.
+ let password = password.as_ref();
let password = password.get(..10).unwrap_or(password);
let n = password.len();
}
}
-impl<R> Debug for EncryptedFile<R>
-where
- R: Read,
-{
+impl<R> Debug for EncryptedFile<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EncryptedFile({:?})", &self.file_type)
}
/// Encrypted file reader.
///
-/// This implements [Read] and [Seek] for SPSS encrypted files. To construct an
-/// [EncryptedReader], call [EncryptedFile::new], then [EncryptedFile::unlock].
+/// This implements [Read] and [Seek] for SPSS encrypted files.
pub struct EncryptedReader<R> {
/// Underlying reader.
reader: R,
tail: usize,
}
+/// The [Read] and [Seek] traits together, for use as `dyn ReadSeek`.
+pub trait ReadSeek: Read + Seek {}
+impl<T> ReadSeek for T where T: Read + Seek {}
+
impl<R> EncryptedReader<R> {
+ /// Opens `reader` and unlocks it with the given password in one step.
+ ///
+ /// This fails if the password is wrong. To allow for multiple password
+ /// tries, use [EncryptedFile::new] followed by [EncryptedFile::unlock]
+ /// instead.
+ pub fn open<P>(reader: R, password: P) -> Result<Self, Error>
+ where
+ R: Read + Seek,
+ P: AsRef<[u8]>,
+ {
+ EncryptedFile::new(reader)?
+ .unlock(password)
+ .map_err(|_| Error::WrongPassword)
+ }
+
fn new(reader: R, aes: Aes256Dec, file_type: FileType, length: u64) -> Self {
Self {
reader,
impl EncodedPassword {
/// Creates an [EncodedPassword] from an already-encoded password `encoded`.
/// Returns `None` if `encoded` is not a valid encoded password.
- pub fn from_encoded(encoded: &[u8]) -> Option<Self> {
+ pub fn from_encoded<P>(encoded: P) -> Option<Self>
+ where
+ P: AsRef<[u8]>,
+ {
+ let encoded = encoded.as_ref();
if encoded.len() > 20
|| encoded.len() % 2 != 0
|| !encoded.iter().all(|byte| (32..=127).contains(byte))
/// Returns an [EncodedPassword] as an encoded version of the given
/// `plaintext` password. Only the first 10 bytes, at most, of the
/// plaintext password is used.
- pub fn from_plaintext(plaintext: &[u8]) -> EncodedPassword {
+ pub fn from_plaintext<P: AsRef<[u8]>>(plaintext: P) -> EncodedPassword {
+ let plaintext = plaintext.as_ref();
let input = plaintext.get(..10).unwrap_or(plaintext);
EncodedPassword(
input
let mut cursor = Cursor::new(&input);
let file = EncryptedFile::new(&mut cursor).unwrap();
assert_eq!(file.file_type(), file_type);
- let mut reader = file.unlock_literal(password.as_bytes()).unwrap();
+ let mut reader = file.unlock_literal(password).unwrap();
assert_eq!(reader.file_type(), file_type);
let mut actual = Vec::new();
std::io::copy(&mut reader, &mut actual).unwrap();
let encoded = EncodedPassword::from_plaintext(&[plaintext]);
for variant in 0..encoded.n_variants() {
let encoded_variant = encoded.variant(variant);
- let decoded = EncodedPassword::from_encoded(encoded_variant.as_bytes())
+ let decoded = EncodedPassword::from_encoded(encoded_variant)
.unwrap()
.decode();
assert_eq!(&[plaintext], decoded.as_slice());
format::DisplayPlain,
output::{
Item, Text,
- pivot::{Axis3, Dimension, Group, PivotTable, Value},
+ pivot::{Axis3, Dimension, Group, PivotTable, value::Value},
},
variable::{VarType, VarWidth},
};
pub fn spaces(n: usize) -> Self {
Self(std::iter::repeat_n(b' ', n).collect())
}
+
+ pub fn display_hex(&self) -> HexBytes<'_> {
+ HexBytes(self.0.as_slice())
+ }
+ pub fn to_hex(&self) -> String {
+ self.display_hex().to_string()
+ }
+}
+
+pub struct HexBytes<'a>(&'a [u8]);
+
+impl<'a> Display for HexBytes<'a> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ for byte in self.0 {
+ write!(f, "{:02X}", *byte)?;
+ }
+ Ok(())
+ }
}
impl Borrow<ByteStr> for ByteString {
matches!(self, Self::String(_))
}
+ pub fn is_number_and<F>(&self, f: F) -> bool
+ where
+ F: FnOnce(Option<f64>) -> bool,
+ {
+ if let Self::Number(number) = self {
+ f(*number)
+ } else {
+ false
+ }
+ }
+
+ pub fn is_string_or<F>(&self, f: F) -> bool
+ where
+ F: FnOnce(Option<f64>) -> bool,
+ {
+ if let Self::Number(number) = self {
+ f(*number)
+ } else {
+ true
+ }
+ }
+
+ pub fn is_string_and<F>(&self, f: F) -> bool
+ where
+ F: FnOnce(&B) -> bool,
+ {
+ if let Self::String(string) = self {
+ f(string)
+ } else {
+ false
+ }
+ }
+
+ pub fn is_number_or<F>(&self, f: F) -> bool
+ where
+ F: FnOnce(&B) -> bool,
+ {
+ if let Self::String(string) = self {
+ f(string)
+ } else {
+ true
+ }
+ }
+
/// Returns the number inside this datum, or `None` if this is a string
/// datum.
pub fn as_number(&self) -> Option<Option<f64>> {
+++ /dev/null
-/* PSPP - a program for statistical analysis.
- * Copyright (C) 2023 Free Software Foundation, Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>. */
-
-use anyhow::{Result, anyhow};
-use clap::Args;
-use pspp::crypto::EncryptedFile;
-use std::{fs::File, path::PathBuf};
-use zeroize::Zeroizing;
-
-/// Decrypts an encrypted SPSS data, output, or syntax file.
-#[derive(Args, Clone, Debug)]
-pub struct Decrypt {
- /// Input file name.
- input: PathBuf,
-
- /// Output file name.
- output: PathBuf,
-
- /// Password for decryption, with or without what SPSS calls "password encryption".
- ///
- /// If omitted, PSPP will prompt interactively for the password.
- #[clap(short, long)]
- password: Option<String>,
-}
-
-impl Decrypt {
- pub fn run(self) -> Result<()> {
- let input = EncryptedFile::new(File::open(&self.input)?)?;
- let password = match self.password {
- Some(password) => Zeroizing::new(password),
- None => {
- eprintln!("Please enter the password for {}:", self.input.display());
- readpass::from_tty().unwrap()
- }
- };
- let mut reader = match input.unlock(password.as_bytes()) {
- Ok(reader) => reader,
- Err(_) => return Err(anyhow!("Incorrect password.")),
- };
- let mut writer = File::create(self.output)?;
- std::io::copy(&mut reader, &mut writer)?;
- Ok(())
- }
-}
data::{ByteString, Datum, RawString},
identifier::{ByIdentifier, HasIdentifier, Identifier},
output::pivot::{
- Axis3, Dimension, Display26Adic, Footnote, Footnotes, Group, PivotTable, Value,
+ Axis3, Dimension, Display26Adic, Footnote, Footnotes, Group, PivotTable, value::Value,
},
settings::Show,
variable::{Attributes, VarWidth, Variable},
group.push("Weight");
match self.weight_var() {
Some(variable) => values.push(Value::new_variable(variable)),
- None => values.push(Value::empty()),
+ None => values.push(Value::new_empty()),
}
group.push("Documents");
let mut sorted_value_labels = variable.value_labels.0.iter().collect::<Vec<_>>();
sorted_value_labels.sort();
for (datum, label) in sorted_value_labels {
- let mut value = Value::new_variable_value(variable, datum)
+ let mut value = Value::new_datum_from_variable(datum, &variable)
.with_show_value_label(Some(Show::Value));
if variable
.missing_values()
group.push(value);
data.push(
- Value::new_variable_value(variable, datum)
+ Value::new_datum_from_variable(datum, &variable)
.with_show_value_label(Some(Show::Label))
.with_value_label(Some(escape_value_label(label.as_str()).into())),
);
/// An SPSS PC+ data file.
Pc,
- /// An [SPSS Viewer file](crate::output::spv).
+ /// An [SPSS Viewer file](crate::output::drivers::spv).
Viewer {
/// Whether the file is encrypted.
encrypted: bool,
}
let mut string = String::new();
- if buf.get(..7) == Some(&[0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x08])
+ if buf.get(..2) == Some(b"PK")
&& let Ok(mut archive) = ZipArchive::new(reader)
&& let Ok(mut file) = archive.by_name("META-INF/MANIFEST.MF")
&& let Ok(_) = file.read_to_string(&mut string)
Ok(None)
}
+
+ /// Returns a string for the typical extension associated with this kind of
+ /// file, without the leading `.`.
+ ///
+ /// Returns `pc+` for [FileType::Pc] files, even though that is not typical,
+ /// since these files are so unusual.
+ pub fn as_extension(&self) -> &'static str {
+ match self {
+ FileType::System { .. } => "sav",
+ FileType::Portable => "por",
+ FileType::Pc => "pc+",
+ FileType::Viewer { .. } => "spv",
+ FileType::Syntax { .. } => "sps",
+ }
+ }
}
#[cfg(test)]
use crate::{
data::{ByteString, Datum},
+ format::decimals::LANG_TO_DECIMAL,
sys::raw,
util::ToSmallString,
variable::{VarType, VarWidth},
};
+mod decimals;
mod display;
mod parse;
pub use display::{DisplayDatum, DisplayPlain, DisplayPlainF64};
}
}
-impl Format {
- pub const F40: Format = Format {
- type_: Type::F,
- w: 40,
- d: 0,
- };
-
- pub const F40_1: Format = Format {
- type_: Type::F,
- w: 40,
- d: 1,
- };
-
- pub const F40_2: Format = Format {
- type_: Type::F,
- w: 40,
- d: 2,
- };
-
- pub const F40_3: Format = Format {
- type_: Type::F,
- w: 40,
- d: 3,
- };
-
- pub const PCT40_1: Format = Format {
- type_: Type::Pct,
- w: 40,
- d: 1,
- };
-
- pub const F8_2: Format = Format {
- type_: Type::F,
- w: 8,
- d: 2,
- };
-
- pub const DATETIME40_0: Format = Format {
- type_: Type::DateTime,
- w: 40,
- d: 0,
- };
+pub const F40: Format = Format {
+ type_: Type::F,
+ w: 40,
+ d: 0,
+};
+
+pub const F40_1: Format = Format {
+ type_: Type::F,
+ w: 40,
+ d: 1,
+};
+
+pub const F40_2: Format = Format {
+ type_: Type::F,
+ w: 40,
+ d: 2,
+};
+
+pub const F40_3: Format = Format {
+ type_: Type::F,
+ w: 40,
+ d: 3,
+};
+
+pub const PCT40_1: Format = Format {
+ type_: Type::Pct,
+ w: 40,
+ d: 1,
+};
+
+pub const F8_0: Format = Format {
+ type_: Type::F,
+ w: 8,
+ d: 0,
+};
+
+pub const F8_2: Format = Format {
+ type_: Type::F,
+ w: 8,
+ d: 2,
+};
+
+pub const DATETIME40_0: Format = Format {
+ type_: Type::DateTime,
+ w: 40,
+ d: 0,
+};
+pub const TIME40_0: Format = Format {
+ type_: Type::Time,
+ w: 40,
+ d: 0,
+};
+
+impl Format {
pub fn type_(self) -> Type {
self.type_
}
}
}
+ pub fn with_max_width(self) -> Self {
+ if self.var_type().is_numeric() {
+ Self { w: 40, ..self }
+ } else {
+ self
+ }
+ }
+
pub fn fixed_from(source: &UncheckedFormat) -> Self {
let UncheckedFormat {
type_: format,
Decimal::Comma => ",",
}
}
+
+ /// Returns the decimal point to use for the given `lang`, which should be a
+ /// language identifier like `en`, `de-DE`, `fr-FR`, etc.
+ pub fn for_lang(mut lang: &str) -> Self {
+ // Repeatedly strip a hyphenated suffix until we find something.
+ loop {
+ if let Some(decimal) = LANG_TO_DECIMAL.get(lang) {
+ return *decimal;
+ }
+ let Some((prefix, _suffix)) = lang.rsplit_once('-') else {
+ return Self::default();
+ };
+ lang = prefix;
+ }
+ }
}
impl From<Decimal> for char {
#[derive(Clone, Debug, Default, Serialize)]
pub struct Settings {
+ /// Epoch for 2-digit years.
pub epoch: Epoch,
- /// Either `'.'` or `','`.
+ /// Decimal point character.
pub decimal: Decimal,
/// Format `F`, `E`, `COMMA`, and `DOT` with leading zero (e.g. `0.5`
self.s.len().checked_sub(self.width).unwrap()
}
- fn display(&self, escape: char) -> DisplayAffix<'_> {
+ fn display(&self, escape: char) -> impl Display {
DisplayAffix {
affix: self.s.as_str(),
escape,
#[cfg(test)]
mod tests {
- use crate::format::{Format, Type, Width};
+ use crate::format::{Decimal, Format, Type, Width};
#[test]
fn codepage_to_unicode() {
check_format(Format::new(Type::F, 40, 0).unwrap(), 40);
}
+
+ #[test]
+ fn decimal() {
+ assert_eq!(Decimal::for_lang("en"), Decimal::Dot);
+ assert_eq!(Decimal::for_lang("en-US"), Decimal::Dot);
+ assert_eq!(Decimal::for_lang("en-ES"), Decimal::Comma);
+ assert_eq!(Decimal::for_lang("fr-FR"), Decimal::Comma);
+ assert_eq!(Decimal::for_lang("ar"), Decimal::Dot);
+ assert_eq!(Decimal::for_lang("ar-LY"), Decimal::Comma);
+ }
}
--- /dev/null
+//! Autogenerated, do not edit by hand!
+//!
+//! The code in this file is generated by `build.rs`. It is unconventional
+//! to check generated files into a repository, but it is done in this case
+//! because the source data is large and the generated code rarely needs to
+//! change.
+//!
+//! To regenerate this code, download a CLDR JSON release from
+//! <https://cldr.unicode.org/index/downloads>, rename it as
+//! `cldr-json-full.zip` in the same directory as `build.rs`,
+//! and touch `build.rs` to force a rebuild.
+use crate::format::Decimal;
+use std::{collections::HashMap, sync::LazyLock};
+
+/// Map from language to decimal point.
+pub static LANG_TO_DECIMAL: LazyLock<HashMap<&'static str, Decimal>> = LazyLock::new(|| {
+ let mut map = HashMap::new();
+ map.insert("aa", Decimal::Dot);
+ map.insert("ab", Decimal::Dot);
+ map.insert("af", Decimal::Comma);
+ map.insert("agq", Decimal::Comma);
+ map.insert("ak", Decimal::Dot);
+ map.insert("am", Decimal::Dot);
+ map.insert("an", Decimal::Dot);
+ map.insert("ann", Decimal::Dot);
+ map.insert("apc", Decimal::Dot);
+ map.insert("ar", Decimal::Dot);
+ map.insert("ar-DZ", Decimal::Comma);
+ map.insert("ar-LB", Decimal::Comma);
+ map.insert("ar-LY", Decimal::Comma);
+ map.insert("ar-MA", Decimal::Comma);
+ map.insert("ar-MR", Decimal::Comma);
+ map.insert("ar-TN", Decimal::Comma);
+ map.insert("arn", Decimal::Dot);
+ map.insert("as", Decimal::Dot);
+ map.insert("asa", Decimal::Dot);
+ map.insert("ast", Decimal::Comma);
+ map.insert("az", Decimal::Comma);
+ map.insert("az-Arab", Decimal::Dot);
+ map.insert("az-Arab-IQ", Decimal::Dot);
+ map.insert("az-Arab-TR", Decimal::Dot);
+ map.insert("ba", Decimal::Dot);
+ map.insert("bal", Decimal::Dot);
+ map.insert("bas", Decimal::Comma);
+ map.insert("be", Decimal::Comma);
+ map.insert("bem", Decimal::Dot);
+ map.insert("bew", Decimal::Dot);
+ map.insert("bez", Decimal::Dot);
+ map.insert("bg", Decimal::Comma);
+ map.insert("bgc", Decimal::Dot);
+ map.insert("bho", Decimal::Dot);
+ map.insert("blo", Decimal::Comma);
+ map.insert("blt", Decimal::Dot);
+ map.insert("bm", Decimal::Dot);
+ map.insert("bn", Decimal::Dot);
+ map.insert("bo", Decimal::Dot);
+ map.insert("bqi", Decimal::Dot);
+ map.insert("br", Decimal::Comma);
+ map.insert("brx", Decimal::Dot);
+ map.insert("bs", Decimal::Comma);
+ map.insert("bss", Decimal::Dot);
+ map.insert("bua", Decimal::Comma);
+ map.insert("byn", Decimal::Dot);
+ map.insert("ca", Decimal::Comma);
+ map.insert("cad", Decimal::Dot);
+ map.insert("cch", Decimal::Dot);
+ map.insert("ccp", Decimal::Dot);
+ map.insert("ce", Decimal::Dot);
+ map.insert("ceb", Decimal::Dot);
+ map.insert("cgg", Decimal::Dot);
+ map.insert("cho", Decimal::Dot);
+ map.insert("chr", Decimal::Dot);
+ map.insert("cic", Decimal::Dot);
+ map.insert("ckb", Decimal::Dot);
+ map.insert("co", Decimal::Dot);
+ map.insert("cop", Decimal::Dot);
+ map.insert("cs", Decimal::Comma);
+ map.insert("csw", Decimal::Dot);
+ map.insert("cu", Decimal::Dot);
+ map.insert("cv", Decimal::Comma);
+ map.insert("cy", Decimal::Dot);
+ map.insert("da", Decimal::Comma);
+ map.insert("dav", Decimal::Dot);
+ map.insert("de", Decimal::Comma);
+ map.insert("de-CH", Decimal::Dot);
+ map.insert("de-LI", Decimal::Dot);
+ map.insert("dje", Decimal::Dot);
+ map.insert("doi", Decimal::Dot);
+ map.insert("dsb", Decimal::Comma);
+ map.insert("dua", Decimal::Comma);
+ map.insert("dv", Decimal::Dot);
+ map.insert("dyo", Decimal::Comma);
+ map.insert("dz", Decimal::Dot);
+ map.insert("ebu", Decimal::Dot);
+ map.insert("ee", Decimal::Dot);
+ map.insert("el", Decimal::Comma);
+ map.insert("en", Decimal::Dot);
+ map.insert("en-AT", Decimal::Comma);
+ map.insert("en-BE", Decimal::Comma);
+ map.insert("en-CZ", Decimal::Comma);
+ map.insert("en-DE", Decimal::Comma);
+ map.insert("en-DK", Decimal::Comma);
+ map.insert("en-EE", Decimal::Comma);
+ map.insert("en-ES", Decimal::Comma);
+ map.insert("en-FI", Decimal::Comma);
+ map.insert("en-FR", Decimal::Comma);
+ map.insert("en-GE", Decimal::Comma);
+ map.insert("en-HU", Decimal::Comma);
+ map.insert("en-ID", Decimal::Comma);
+ map.insert("en-IT", Decimal::Comma);
+ map.insert("en-LT", Decimal::Comma);
+ map.insert("en-LV", Decimal::Comma);
+ map.insert("en-NL", Decimal::Comma);
+ map.insert("en-NO", Decimal::Comma);
+ map.insert("en-PL", Decimal::Comma);
+ map.insert("en-PT", Decimal::Comma);
+ map.insert("en-RO", Decimal::Comma);
+ map.insert("en-SE", Decimal::Comma);
+ map.insert("en-SI", Decimal::Comma);
+ map.insert("en-SK", Decimal::Comma);
+ map.insert("en-UA", Decimal::Comma);
+ map.insert("en-ZA", Decimal::Comma);
+ map.insert("eo", Decimal::Comma);
+ map.insert("es", Decimal::Comma);
+ map.insert("es-419", Decimal::Dot);
+ map.insert("es-BR", Decimal::Dot);
+ map.insert("es-BZ", Decimal::Dot);
+ map.insert("es-CU", Decimal::Dot);
+ map.insert("es-DO", Decimal::Dot);
+ map.insert("es-GT", Decimal::Dot);
+ map.insert("es-HN", Decimal::Dot);
+ map.insert("es-MX", Decimal::Dot);
+ map.insert("es-NI", Decimal::Dot);
+ map.insert("es-PA", Decimal::Dot);
+ map.insert("es-PE", Decimal::Dot);
+ map.insert("es-PR", Decimal::Dot);
+ map.insert("es-SV", Decimal::Dot);
+ map.insert("es-US", Decimal::Dot);
+ map.insert("et", Decimal::Comma);
+ map.insert("eu", Decimal::Comma);
+ map.insert("ewo", Decimal::Comma);
+ map.insert("fa", Decimal::Dot);
+ map.insert("ff", Decimal::Comma);
+ map.insert("ff-Adlm", Decimal::Dot);
+ map.insert("ff-Adlm-BF", Decimal::Dot);
+ map.insert("ff-Adlm-CM", Decimal::Dot);
+ map.insert("ff-Adlm-GH", Decimal::Dot);
+ map.insert("ff-Adlm-GM", Decimal::Dot);
+ map.insert("ff-Adlm-GW", Decimal::Dot);
+ map.insert("ff-Adlm-LR", Decimal::Dot);
+ map.insert("ff-Adlm-MR", Decimal::Dot);
+ map.insert("ff-Adlm-NE", Decimal::Dot);
+ map.insert("ff-Adlm-NG", Decimal::Dot);
+ map.insert("ff-Adlm-SL", Decimal::Dot);
+ map.insert("ff-Adlm-SN", Decimal::Dot);
+ map.insert("fi", Decimal::Comma);
+ map.insert("fil", Decimal::Dot);
+ map.insert("fo", Decimal::Comma);
+ map.insert("fr", Decimal::Comma);
+ map.insert("frr", Decimal::Dot);
+ map.insert("fur", Decimal::Comma);
+ map.insert("fy", Decimal::Comma);
+ map.insert("ga", Decimal::Dot);
+ map.insert("gaa", Decimal::Dot);
+ map.insert("gd", Decimal::Dot);
+ map.insert("gez", Decimal::Dot);
+ map.insert("gl", Decimal::Comma);
+ map.insert("gn", Decimal::Dot);
+ map.insert("gsw", Decimal::Dot);
+ map.insert("gu", Decimal::Dot);
+ map.insert("guz", Decimal::Dot);
+ map.insert("gv", Decimal::Dot);
+ map.insert("ha", Decimal::Dot);
+ map.insert("haw", Decimal::Dot);
+ map.insert("he", Decimal::Dot);
+ map.insert("hi", Decimal::Dot);
+ map.insert("hnj", Decimal::Dot);
+ map.insert("hr", Decimal::Comma);
+ map.insert("hsb", Decimal::Comma);
+ map.insert("ht", Decimal::Comma);
+ map.insert("hu", Decimal::Comma);
+ map.insert("hy", Decimal::Comma);
+ map.insert("ia", Decimal::Comma);
+ map.insert("id", Decimal::Comma);
+ map.insert("ie", Decimal::Comma);
+ map.insert("ig", Decimal::Dot);
+ map.insert("ii", Decimal::Dot);
+ map.insert("io", Decimal::Dot);
+ map.insert("is", Decimal::Comma);
+ map.insert("it", Decimal::Comma);
+ map.insert("it-CH", Decimal::Dot);
+ map.insert("iu", Decimal::Dot);
+ map.insert("ja", Decimal::Dot);
+ map.insert("jbo", Decimal::Dot);
+ map.insert("jgo", Decimal::Comma);
+ map.insert("jmc", Decimal::Dot);
+ map.insert("jv", Decimal::Comma);
+ map.insert("ka", Decimal::Comma);
+ map.insert("kaa", Decimal::Dot);
+ map.insert("kab", Decimal::Comma);
+ map.insert("kaj", Decimal::Dot);
+ map.insert("kam", Decimal::Dot);
+ map.insert("kcg", Decimal::Dot);
+ map.insert("kde", Decimal::Dot);
+ map.insert("kea", Decimal::Comma);
+ map.insert("kek", Decimal::Dot);
+ map.insert("ken", Decimal::Dot);
+ map.insert("kgp", Decimal::Comma);
+ map.insert("khq", Decimal::Dot);
+ map.insert("ki", Decimal::Dot);
+ map.insert("kk", Decimal::Comma);
+ map.insert("kk-Arab", Decimal::Dot);
+ map.insert("kkj", Decimal::Comma);
+ map.insert("kl", Decimal::Comma);
+ map.insert("kln", Decimal::Dot);
+ map.insert("km", Decimal::Dot);
+ map.insert("kn", Decimal::Dot);
+ map.insert("ko", Decimal::Dot);
+ map.insert("kok", Decimal::Dot);
+ map.insert("kpe", Decimal::Dot);
+ map.insert("ks", Decimal::Dot);
+ map.insert("ksb", Decimal::Dot);
+ map.insert("ksf", Decimal::Comma);
+ map.insert("ksh", Decimal::Comma);
+ map.insert("ku", Decimal::Comma);
+ map.insert("ku-Arab", Decimal::Dot);
+ map.insert("ku-Arab-IR", Decimal::Dot);
+ map.insert("kw", Decimal::Dot);
+ map.insert("kxv", Decimal::Dot);
+ map.insert("ky", Decimal::Comma);
+ map.insert("la", Decimal::Dot);
+ map.insert("lag", Decimal::Dot);
+ map.insert("lb", Decimal::Comma);
+ map.insert("lg", Decimal::Dot);
+ map.insert("lij", Decimal::Comma);
+ map.insert("lkt", Decimal::Dot);
+ map.insert("lld", Decimal::Comma);
+ map.insert("lmo", Decimal::Comma);
+ map.insert("ln", Decimal::Comma);
+ map.insert("lo", Decimal::Comma);
+ map.insert("lrc", Decimal::Dot);
+ map.insert("lt", Decimal::Comma);
+ map.insert("ltg", Decimal::Dot);
+ map.insert("lu", Decimal::Comma);
+ map.insert("luo", Decimal::Dot);
+ map.insert("luy", Decimal::Dot);
+ map.insert("lv", Decimal::Comma);
+ map.insert("lzz", Decimal::Dot);
+ map.insert("mai", Decimal::Dot);
+ map.insert("mas", Decimal::Dot);
+ map.insert("mdf", Decimal::Dot);
+ map.insert("mer", Decimal::Dot);
+ map.insert("mfe", Decimal::Dot);
+ map.insert("mg", Decimal::Dot);
+ map.insert("mgh", Decimal::Comma);
+ map.insert("mgo", Decimal::Dot);
+ map.insert("mhn", Decimal::Dot);
+ map.insert("mi", Decimal::Dot);
+ map.insert("mic", Decimal::Dot);
+ map.insert("mk", Decimal::Comma);
+ map.insert("ml", Decimal::Dot);
+ map.insert("mn", Decimal::Dot);
+ map.insert("mni", Decimal::Dot);
+ map.insert("moh", Decimal::Dot);
+ map.insert("mr", Decimal::Dot);
+ map.insert("ms", Decimal::Dot);
+ map.insert("ms-Arab-BN", Decimal::Comma);
+ map.insert("ms-BN", Decimal::Comma);
+ map.insert("ms-ID", Decimal::Comma);
+ map.insert("mt", Decimal::Dot);
+ map.insert("mua", Decimal::Comma);
+ map.insert("mus", Decimal::Dot);
+ map.insert("mww", Decimal::Dot);
+ map.insert("my", Decimal::Dot);
+ map.insert("myv", Decimal::Dot);
+ map.insert("mzn", Decimal::Dot);
+ map.insert("naq", Decimal::Dot);
+ map.insert("nb", Decimal::Comma);
+ map.insert("nd", Decimal::Dot);
+ map.insert("nds", Decimal::Comma);
+ map.insert("ne", Decimal::Dot);
+ map.insert("nl", Decimal::Comma);
+ map.insert("nmg", Decimal::Comma);
+ map.insert("nn", Decimal::Comma);
+ map.insert("nnh", Decimal::Comma);
+ map.insert("no", Decimal::Comma);
+ map.insert("nqo", Decimal::Dot);
+ map.insert("nr", Decimal::Comma);
+ map.insert("nso", Decimal::Dot);
+ map.insert("nus", Decimal::Dot);
+ map.insert("nv", Decimal::Dot);
+ map.insert("ny", Decimal::Dot);
+ map.insert("nyn", Decimal::Dot);
+ map.insert("oc", Decimal::Comma);
+ map.insert("oka", Decimal::Dot);
+ map.insert("om", Decimal::Dot);
+ map.insert("or", Decimal::Dot);
+ map.insert("os", Decimal::Comma);
+ map.insert("osa", Decimal::Dot);
+ map.insert("pa", Decimal::Dot);
+ map.insert("pap", Decimal::Dot);
+ map.insert("pcm", Decimal::Dot);
+ map.insert("pi", Decimal::Dot);
+ map.insert("pis", Decimal::Dot);
+ map.insert("pl", Decimal::Comma);
+ map.insert("pms", Decimal::Comma);
+ map.insert("prg", Decimal::Comma);
+ map.insert("ps", Decimal::Comma);
+ map.insert("pt", Decimal::Comma);
+ map.insert("qu", Decimal::Dot);
+ map.insert("qu-BO", Decimal::Comma);
+ map.insert("quc", Decimal::Dot);
+ map.insert("raj", Decimal::Dot);
+ map.insert("rhg", Decimal::Dot);
+ map.insert("rif", Decimal::Dot);
+ map.insert("rm", Decimal::Comma);
+ map.insert("rn", Decimal::Comma);
+ map.insert("ro", Decimal::Comma);
+ map.insert("rof", Decimal::Dot);
+ map.insert("ru", Decimal::Comma);
+ map.insert("rw", Decimal::Comma);
+ map.insert("rwk", Decimal::Dot);
+ map.insert("sa", Decimal::Dot);
+ map.insert("sah", Decimal::Comma);
+ map.insert("saq", Decimal::Dot);
+ map.insert("sat", Decimal::Dot);
+ map.insert("sbp", Decimal::Dot);
+ map.insert("sc", Decimal::Comma);
+ map.insert("scn", Decimal::Comma);
+ map.insert("sd", Decimal::Dot);
+ map.insert("sdh", Decimal::Dot);
+ map.insert("se", Decimal::Comma);
+ map.insert("seh", Decimal::Comma);
+ map.insert("ses", Decimal::Dot);
+ map.insert("sg", Decimal::Comma);
+ map.insert("sgs", Decimal::Dot);
+ map.insert("shi", Decimal::Comma);
+ map.insert("shn", Decimal::Dot);
+ map.insert("si", Decimal::Dot);
+ map.insert("sid", Decimal::Dot);
+ map.insert("sk", Decimal::Comma);
+ map.insert("skr", Decimal::Dot);
+ map.insert("sl", Decimal::Comma);
+ map.insert("sma", Decimal::Dot);
+ map.insert("smj", Decimal::Dot);
+ map.insert("smn", Decimal::Comma);
+ map.insert("sms", Decimal::Dot);
+ map.insert("sn", Decimal::Dot);
+ map.insert("so", Decimal::Dot);
+ map.insert("sq", Decimal::Comma);
+ map.insert("sr", Decimal::Comma);
+ map.insert("ss", Decimal::Comma);
+ map.insert("ssy", Decimal::Dot);
+ map.insert("st", Decimal::Dot);
+ map.insert("su", Decimal::Comma);
+ map.insert("suz", Decimal::Dot);
+ map.insert("sv", Decimal::Comma);
+ map.insert("sw", Decimal::Dot);
+ map.insert("sw-CD", Decimal::Comma);
+ map.insert("syr", Decimal::Dot);
+ map.insert("szl", Decimal::Comma);
+ map.insert("ta", Decimal::Dot);
+ map.insert("te", Decimal::Dot);
+ map.insert("teo", Decimal::Dot);
+ map.insert("tg", Decimal::Comma);
+ map.insert("th", Decimal::Dot);
+ map.insert("ti", Decimal::Dot);
+ map.insert("tig", Decimal::Dot);
+ map.insert("tk", Decimal::Comma);
+ map.insert("tn", Decimal::Dot);
+ map.insert("to", Decimal::Dot);
+ map.insert("tok", Decimal::Comma);
+ map.insert("tpi", Decimal::Dot);
+ map.insert("tr", Decimal::Comma);
+ map.insert("trv", Decimal::Dot);
+ map.insert("trw", Decimal::Dot);
+ map.insert("ts", Decimal::Comma);
+ map.insert("tt", Decimal::Comma);
+ map.insert("twq", Decimal::Dot);
+ map.insert("tyv", Decimal::Dot);
+ map.insert("tzm", Decimal::Comma);
+ map.insert("ug", Decimal::Dot);
+ map.insert("uk", Decimal::Comma);
+ map.insert("und", Decimal::Dot);
+ map.insert("ur", Decimal::Dot);
+ map.insert("uz", Decimal::Comma);
+ map.insert("vai", Decimal::Dot);
+ map.insert("ve", Decimal::Comma);
+ map.insert("vec", Decimal::Comma);
+ map.insert("vi", Decimal::Comma);
+ map.insert("vmw", Decimal::Comma);
+ map.insert("vo", Decimal::Dot);
+ map.insert("vun", Decimal::Dot);
+ map.insert("wa", Decimal::Dot);
+ map.insert("wae", Decimal::Comma);
+ map.insert("wal", Decimal::Dot);
+ map.insert("wbp", Decimal::Dot);
+ map.insert("wo", Decimal::Comma);
+ map.insert("xh", Decimal::Dot);
+ map.insert("xnr", Decimal::Dot);
+ map.insert("xog", Decimal::Dot);
+ map.insert("yav", Decimal::Comma);
+ map.insert("yi", Decimal::Dot);
+ map.insert("yo", Decimal::Dot);
+ map.insert("yrl", Decimal::Comma);
+ map.insert("yue", Decimal::Dot);
+ map.insert("za", Decimal::Dot);
+ map.insert("zgh", Decimal::Comma);
+ map.insert("zh", Decimal::Dot);
+ map.insert("zu", Decimal::Dot);
+ map
+});
endian: EndianSettings,
datum: Datum<B>,
- /// If true, the output will remove leading and trailing spaces from numeric
- /// values, and trailing spaces from string values. (This might make the
- /// output narrower than the requested width.)
- trim_spaces: bool,
+ /// If false, the output will omit leading spaces in output, except for
+ /// string values.
+ ///
+ /// Omitting trailing spaces also causes the overflow indication to be
+ /// output as just `*` instead of enough to fill the output width.
+ ///
+ /// Omitting leading spaces can make the output narrower than the requested
+ /// width.
+ leading_spaces: bool,
+
+ /// If false, the output will omit trailing spaces in output. For numeric
+ /// values, in practice this only affects output of missing values.
+ ///
+ /// Omitting trailing spaces also causes the overflow indication to be
+ /// output as just `*` instead of enough to fill the output width.
+ ///
+ /// Omitting trailing spaces can make the output narrower than the requested
+ /// width.
+ trailing_spaces: bool,
/// If true, the output will include a double quote before and after string
/// values.
quote_strings: bool,
}
+impl<'b, B> DisplayDatum<'b, B> {
+ /// For basic numeric formats, displays the datum wide enough to fully
+ /// display the selected number of decimal places, and trims off spaces in
+ /// the output.
+ pub fn with_stretch(self) -> Self {
+ match self.format.type_.category() {
+ Category::Basic | Category::Custom => Self {
+ format: self.format.with_max_width(),
+ leading_spaces: false,
+ trailing_spaces: false,
+ ..self
+ },
+ _ => self,
+ }
+ }
+}
+
#[cfg(test)]
mod tests;
} else {
let quote = if self.quote_strings { "\"" } else { "" };
let s = string.as_str();
- let s = if self.trim_spaces {
+ let s = if !self.trailing_spaces {
s.trim_end_matches(' ')
} else {
&s
datum,
settings: &settings.formats,
endian: settings.endian,
- trim_spaces: false,
+ leading_spaces: true,
+ trailing_spaces: true,
quote_strings: false,
}
}
pub fn with_endian(self, endian: EndianSettings) -> Self {
Self { endian, ..self }
}
- pub fn with_trimming(self) -> Self {
+ pub fn without_spaces(self) -> Self {
+ Self {
+ leading_spaces: false,
+ trailing_spaces: false,
+ ..self
+ }
+ }
+ pub fn without_leading_spaces(self) -> Self {
Self {
- trim_spaces: true,
+ leading_spaces: false,
+ ..self
+ }
+ }
+ pub fn without_trailing_spaces(self) -> Self {
+ Self {
+ trailing_spaces: false,
..self
}
}
..self
}
}
+
+ pub fn decimal(&self) -> Decimal {
+ self.settings.number_style(self.format.type_).decimal
+ }
fn fmt_binary(&self, f: &mut Formatter) -> FmtResult {
let output = self.to_binary().unwrap();
for b in output {
let style = self.settings.number_style(self.format.type_);
if self.format.type_ != Type::E && number.abs() < 1.5 * power10(self.format.w()) {
let rounder = Rounder::new(style, number, self.format.d);
- if self.decimal(f, &rounder, style, true)?
- || self.scientific(f, number, style, true)?
- || self.decimal(f, &rounder, style, false)?
+ if self.number_decimal(f, &rounder, style, true)?
+ || self.number_scientific(f, number, style, true)?
+ || self.number_decimal(f, &rounder, style, false)?
{
return Ok(());
}
}
- if !self.scientific(f, number, style, false)? {
+ if !self.number_scientific(f, number, style, false)? {
self.overflow(f)?;
}
Ok(())
} else {
"Unknown"
};
- let w = if self.trim_spaces { 0 } else { self.format.w() };
+ // XXX does this width trick really work?
+ let w = if self.leading_spaces {
+ self.format.w()
+ } else {
+ 0
+ };
write!(f, "{s:>w$.w$}")
} else {
self.overflow(f)
_ => (),
}
- if self.trim_spaces {
- return write!(f, ".");
- }
-
let w = self.format.w() as isize;
let d = self.format.d() as isize;
let dot_position = match self.format.type_ {
};
let dot_position = dot_position.max(0) as u16;
- for i in 0..self.format.w {
- if i == dot_position {
- write!(f, ".")?;
- } else {
- write!(f, " ")?;
+ if self.leading_spaces {
+ for _ in 0..dot_position {
+ f.write_char(' ')?;
+ }
+ }
+ f.write_char('.')?;
+ if self.trailing_spaces {
+ for _ in dot_position + 1..self.format.w {
+ f.write_char(' ')?;
}
}
Ok(())
}
fn overflow(&self, f: &mut Formatter<'_>) -> FmtResult {
- if self.trim_spaces {
- write!(f, "*")?;
+ if !self.leading_spaces || !self.trailing_spaces {
+ f.write_char('*')?;
} else {
for _ in 0..self.format.w {
- write!(f, "*")?;
+ f.write_char('*')?;
}
}
Ok(())
}
- fn decimal(
+ fn number_decimal(
&self,
f: &mut Formatter<'_>,
rounder: &Rounder,
// Assemble number.
let magnitude = rounder.format(decimals as usize);
let mut output = SmallString::<[u8; 40]>::new();
- if !self.trim_spaces {
+ if self.leading_spaces {
for _ in width..self.format.w() {
output.push(' ');
}
}
}
- debug_assert!(self.trim_spaces || output.len() >= self.format.w());
+ debug_assert!(!self.leading_spaces || output.len() >= self.format.w());
debug_assert!(output.len() <= self.format.w() + style.extra_bytes);
f.write_str(&output)?;
return Ok(true);
Ok(false)
}
- fn scientific(
+ fn number_scientific(
&self,
f: &mut Formatter<'_>,
number: f64,
width += fraction_width;
let mut output = SmallString::<[u8; 40]>::new();
- if !self.trim_spaces {
+ if self.leading_spaces {
for _ in width..self.format.w() {
output.push(' ');
}
}
}
- println!(
- "{} for {number} width={width} fraction_width={fraction_width}: {output:?}",
- self.format
- );
- debug_assert!(self.trim_spaces || output.len() >= self.format.w());
- debug_assert!(output.len() <= self.format.w() + style.extra_bytes);
f.write_str(&output)?;
Ok(true)
}
_ => unreachable!(),
}
}
- if !self.trim_spaces {
+ if self.leading_spaces {
write!(f, "{:>1$}", &output, self.format.w())
} else {
f.write_str(&output)
fn month(&self, f: &mut Formatter<'_>, number: f64) -> FmtResult {
if let Some(month) = month_name(number as u32) {
- if !self.trim_spaces {
+ if self.leading_spaces {
write!(f, "{month:.*}", self.format.w())
} else {
f.write_str(month)
identifier::{IdentifierChar, id_match, id_match_n},
prompt::PromptStyle,
};
-use bitflags::bitflags;
+use enumset::{EnumSet, EnumSetType};
use super::command_name::{COMMAND_NAMES, command_match};
UnexpectedChar,
}
-bitflags! {
- #[derive(Copy, Clone, Debug)]
- struct Substate: u8 {
- const START_OF_LINE = 1;
- const START_OF_COMMAND = 2;
- }
+#[derive(Debug, EnumSetType)]
+enum Substate {
+ StartOfLine,
+ StartOfCommand,
}
/// Used by [Segmenter] to indicate that more input is needed.
/// Labels syntax input with [Segment]s.
#[derive(Copy, Clone)]
pub struct Segmenter {
- state: (State, Substate),
+ state: (State, EnumSet<Substate>),
nest: u8,
syntax: Syntax,
}
pub fn new(syntax: Syntax, is_snippet: bool) -> Self {
Self {
state: if is_snippet {
- (State::General, Substate::empty())
+ (State::General, EnumSet::empty())
} else {
- (State::Shbang, Substate::empty())
+ (State::Shbang, EnumSet::empty())
},
syntax,
nest: 0,
}
fn start_of_line(&self) -> bool {
- self.state.1.contains(Substate::START_OF_LINE)
+ self.state.1.contains(Substate::StartOfLine)
}
fn start_of_command(&self) -> bool {
- self.state.1.contains(Substate::START_OF_COMMAND)
+ self.state.1.contains(Substate::StartOfCommand)
}
/// Returns the style of command prompt to display to an interactive user
if let (Some('#'), rest) = take(input, eof)? {
if let (Some('!'), rest) = take(rest, eof)? {
let rest = self.parse_full_line(rest, eof)?;
- self.state = (State::General, Substate::START_OF_COMMAND);
+ self.state = (State::General, EnumSet::only(Substate::StartOfCommand));
return Ok(Some((rest, Segment::Shbang)));
}
}
self.state = (
State::General,
- Substate::START_OF_COMMAND | Substate::START_OF_LINE,
+ EnumSet::only(Substate::StartOfCommand) | EnumSet::only(Substate::StartOfLine),
);
self.push_rest(input, eof)
}
match c {
'+' if is_start_of_string(skip_spaces_and_comments(rest, eof)?, eof)? => {
// This `+` is punctuation that may separate pieces of a string.
- self.state = (State::General, Substate::empty());
+ self.state = (State::General, EnumSet::empty());
return Ok(Some((rest, Segment::Punct)));
}
'+' | '-' | '.' => {
- self.state = (State::General, Substate::START_OF_COMMAND);
+ self.state = (State::General, EnumSet::only(Substate::StartOfCommand));
return Ok(Some((rest, Segment::StartCommand)));
}
_ if c.is_whitespace() => {
if at_end_of_line(input, eof)? {
- self.state = (State::General, Substate::START_OF_COMMAND);
+ self.state = (State::General, EnumSet::only(Substate::StartOfCommand));
return Ok(Some((input, Segment::SeparateCommands)));
}
}
_ => {
if self.at_command_start(input, eof)?
- && !self.state.1.contains(Substate::START_OF_COMMAND)
+ && !self.state.1.contains(Substate::StartOfCommand)
{
- self.state = (State::General, Substate::START_OF_COMMAND);
+ self.state = (State::General, EnumSet::only(Substate::StartOfCommand));
return Ok(Some((input, Segment::StartCommand)));
}
}
}
- self.state.1 = Substate::START_OF_COMMAND;
+ self.state.1 = EnumSet::only(Substate::StartOfCommand);
self.parse_mid_line(input, eof)
}
fn parse_mid_line<'a>(
eof: bool,
) -> Result<Option<(&'a str, Segment)>, Incomplete> {
debug_assert!(self.state.0 == State::General);
- debug_assert!(!self.state.1.contains(Substate::START_OF_LINE));
+ debug_assert!(!self.state.1.contains(Substate::StartOfLine));
let (Some(c), rest) = take(input, eof)? else {
unreachable!()
};
match c {
'\r' | '\n' if is_end_of_line(input, eof)? => {
- self.state.1 |= Substate::START_OF_LINE;
+ self.state.1 |= EnumSet::only(Substate::StartOfLine);
Ok(Some((
self.parse_newline(input, eof).unwrap().unwrap(),
Segment::Newline,
let rest = skip_comment(rest, eof)?;
Ok(Some((rest, Segment::Comment)))
} else {
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((rest, Segment::Punct)))
}
}
}
None | Some(_) => (),
}
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((rest, Segment::Punct)))
}
'(' | ')' | '[' | ']' | '{' | '}' | ',' | '=' | ';' | ':' | '&' | '|' | '+' => {
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((rest, Segment::Punct)))
}
'*' => {
- if self.state.1.contains(Substate::START_OF_COMMAND) {
- self.state = (State::Comment1, Substate::empty());
+ if self.state.1.contains(Substate::StartOfCommand) {
+ self.state = (State::Comment1, EnumSet::empty());
self.parse_comment_1(input, eof)
} else {
self.parse_digraph(&['*'], rest, eof)
'>' => self.parse_digraph(&['='], rest, eof),
'~' => self.parse_digraph(&['='], rest, eof),
'.' if at_end_of_line(rest, eof)? => {
- self.state.1 = Substate::START_OF_COMMAND;
+ self.state.1 = EnumSet::only(Substate::StartOfCommand);
Ok(Some((rest, Segment::EndCommand)))
}
'.' => match take(rest, eof)? {
c if c.is_whitespace() => Ok(Some((skip_spaces(rest, eof)?, Segment::Spaces))),
c if c.may_start_id() => self.parse_id(input, eof),
'#'..='~' if c != '\\' && c != '^' => {
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((rest, Segment::Punct)))
}
_ => {
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((rest, Segment::UnexpectedChar)))
}
}
_ if c == quote => {
let (c, rest2) = take(rest, eof)?;
if c != Some(quote) {
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
return Ok(Some((rest, segment)));
}
input = rest2;
_ => input = rest,
}
}
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((input, Segment::ExpectedQuote)))
}
fn maybe_parse_string<'a>(
};
let rest = &input[identifier.len()..];
- if self.state.1.contains(Substate::START_OF_COMMAND) {
+ if self.state.1.contains(Substate::StartOfCommand) {
if id_match_n("COMMENT", identifier, 4) {
- self.state = (State::Comment1, Substate::empty());
+ self.state = (State::Comment1, EnumSet::empty());
return self.parse_comment_1(input, eof);
} else if id_match("DOCUMENT", identifier) {
- self.state = (State::Document1, Substate::empty());
+ self.state = (State::Document1, EnumSet::empty());
return Ok(Some((input, Segment::StartDocument)));
} else if id_match_n("DEFINE", identifier, 6) {
- self.state = (State::Define1, Substate::empty());
+ self.state = (State::Define1, EnumSet::empty());
} else if id_match("FILE", identifier) {
if id_match("LABEL", self.next_id_in_command(rest, eof)?.0) {
- self.state = (State::FileLabel1, Substate::empty());
+ self.state = (State::FileLabel1, EnumSet::empty());
return Ok(Some((rest, Segment::Identifier)));
}
} else if id_match("DO", identifier) {
if id_match("REPEAT", self.next_id_in_command(rest, eof)?.0) {
- self.state = (State::DoRepeat1, Substate::empty());
+ self.state = (State::DoRepeat1, EnumSet::empty());
return Ok(Some((rest, Segment::Identifier)));
}
} else if id_match("BEGIN", identifier) {
} else {
State::BeginData2
},
- Substate::empty(),
+ EnumSet::empty(),
);
return Ok(Some((rest, Segment::Identifier)));
}
}
}
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((
rest,
if identifier != "!" {
eof: bool,
) -> Result<Option<(&'a str, Segment)>, Incomplete> {
let (c, rest) = take(input, eof)?;
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((
match c {
Some(c) if seconds.contains(&c) => rest,
let rest = match_char(|c| c == '+' || c == '-', rest, eof)?.unwrap_or(rest);
let rest2 = skip_digits(rest, eof)?;
if rest2.len() == rest.len() {
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
return Ok(Some((rest, Segment::ExpectedExponent)));
}
input = rest2;
}
- self.state.1 = Substate::empty();
+ self.state.1 = EnumSet::empty();
Ok(Some((input, Segment::Number)))
}
fn parse_comment_1<'a>(
loop {
let (Some(c), rest) = take(input, eof)? else {
// End of file.
- self.state = (State::General, Substate::START_OF_COMMAND);
+ self.state = (State::General, EnumSet::only(Substate::StartOfCommand));
return Ok(Some((input, Segment::SeparateCommands)));
};
match c {
match state {
CommentState::Blank => {
// Blank line ends comment command.
- self.state = (State::General, Substate::START_OF_COMMAND);
+ self.state = (State::General, EnumSet::only(Substate::StartOfCommand));
return Ok(Some((input, Segment::SeparateCommands)));
}
CommentState::Period(period) => {
// '.' at end of line ends comment command.
- self.state = (State::General, Substate::empty());
+ self.state = (State::General, EnumSet::empty());
return Ok(Some((period, Segment::CommentCommand)));
}
CommentState::NotBlank => {
// Comment continues onto next line.
- self.state = (State::Comment2, Substate::empty());
+ self.state = (State::Comment2, EnumSet::empty());
return Ok(Some((input, Segment::CommentCommand)));
}
}
if new_command {
self.state = (
State::General,
- Substate::START_OF_LINE | Substate::START_OF_COMMAND,
+ EnumSet::only(Substate::StartOfLine) | EnumSet::only(Substate::StartOfCommand),
);
} else {
- self.state = (State::Comment1, Substate::empty());
+ self.state = (State::Comment1, EnumSet::empty());
}
Ok(Some((rest, Segment::Newline)))
}
let mut end_cmd = false;
loop {
let (Some(c), rest) = take(input, eof)? else {
- self.state = (State::Document3, Substate::empty());
+ self.state = (State::Document3, EnumSet::empty());
return Ok(Some((input, Segment::Document)));
};
match c {
eof: bool,
) -> Result<Option<(&'a str, Segment)>, Incomplete> {
let rest = self.parse_newline(input, eof)?.unwrap();
- self.state = (State::Document1, Substate::empty());
+ self.state = (State::Document1, EnumSet::empty());
Ok(Some((rest, Segment::Newline)))
}
fn parse_document_3<'a>(
) -> Result<Option<(&'a str, Segment)>, Incomplete> {
self.state = (
State::General,
- Substate::START_OF_COMMAND | Substate::START_OF_LINE,
+ EnumSet::only(Substate::StartOfCommand) | EnumSet::only(Substate::StartOfLine),
);
Ok(Some((input, Segment::EndCommand)))
}
eof: bool,
) -> Result<Option<(&'a str, Segment)>, Incomplete> {
let input = skip_spaces(input, eof)?;
- self.state = (State::FileLabel3, Substate::empty());
+ self.state = (State::FileLabel3, EnumSet::empty());
Ok(Some((input, Segment::Spaces)))
}
fn parse_file_label_3<'a>(
let (c, rest) = take(input, eof)?;
match c {
None | Some('\n') | Some('\r') if is_end_of_line(input, eof)? => {
- self.state = (State::General, Substate::empty());
+ self.state = (State::General, EnumSet::empty());
return Ok(Some((end_cmd.unwrap_or(input), Segment::UnquotedString)));
}
None => unreachable!(),
// REPEAT` body.
self.state = (
State::General,
- Substate::START_OF_COMMAND | Substate::START_OF_LINE,
+ EnumSet::only(Substate::StartOfCommand)
+ | EnumSet::only(Substate::StartOfLine),
);
return self.push_rest(input, eof);
}
Segment::Punct if input.starts_with(')') => {
self.nest -= 1;
if self.nest == 0 {
- self.state = (State::Define4, Substate::empty());
+ self.state = (State::Define4, EnumSet::empty());
}
}
_ => (),
let line = &input[..input.len() - rest.len()];
if let Some(end) = Self::find_enddefine(line) {
// Macro ends at the !ENDDEFINE on this line.
- self.state = (State::General, Substate::empty());
+ self.state = (State::General, EnumSet::empty());
let (prefix, rest) = input.split_at(line.len() - end.len());
if prefix.is_empty() {
// Line starts with `!ENDDEFINE`.
if Self::is_end_data(line) {
self.state = (
State::General,
- Substate::START_OF_COMMAND | Substate::START_OF_LINE,
+ EnumSet::only(Substate::StartOfCommand) | EnumSet::only(Substate::StartOfLine),
);
self.push_rest(input, eof)
} else {
pub mod por;
pub mod prompt;
pub mod settings;
+pub mod spv;
pub mod sys;
pub mod util;
pub mod variable;
None
}
}
+
+/// This is [slice::range] copied out from the standard library so that we can
+/// use it while it is still experimental.
+#[allow(dead_code)]
+pub(crate) fn range<R>(range: R, bounds: std::ops::RangeTo<usize>) -> std::ops::Range<usize>
+where
+ R: std::ops::RangeBounds<usize>,
+{
+ try_range(range, bounds).unwrap()
+}
+
+/// This is [slice::try_range] copied out from the standard library so that we
+/// can use it while it is still experimental.
+#[allow(dead_code)]
+pub(crate) fn try_range<R>(
+ range: R,
+ bounds: std::ops::RangeTo<usize>,
+) -> Option<std::ops::Range<usize>>
+where
+ R: std::ops::RangeBounds<usize>,
+{
+ 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 })
+ }
+}
-/* PSPP - a program for statistical analysis.
- * Copyright (C) 2023 Free Software Foundation, Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>. */
-
-use anyhow::Result;
-use clap::{Parser, Subcommand};
-use encoding_rs::Encoding;
-use thiserror::Error as ThisError;
-
-use crate::{convert::Convert, decrypt::Decrypt, show::Show, show_pc::ShowPc, show_por::ShowPor};
-
-mod convert;
-mod decrypt;
-mod show;
-mod show_pc;
-mod show_por;
-
-/// PSPP, a program for statistical analysis of sampled data.
-#[derive(Parser, Debug)]
-#[command(author, version, about, long_about = None)]
-struct Cli {
- #[command(subcommand)]
- command: Command,
-}
-
-#[derive(Subcommand, Clone, Debug)]
-enum Command {
- Convert(Convert),
- Decrypt(Decrypt),
- Show(Show),
- ShowPor(ShowPor),
- ShowPc(ShowPc),
-}
-
-impl Command {
- fn run(self) -> Result<()> {
- match self {
- Command::Convert(convert) => convert.run(),
- Command::Decrypt(decrypt) => decrypt.run(),
- Command::Show(show) => show.run(),
- Command::ShowPor(show_por) => show_por.run(),
- Command::ShowPc(show_pc) => show_pc.run(),
- }
- }
-}
-
-#[derive(ThisError, Debug)]
-#[error("{0}: unknown encoding")]
-struct UnknownEncodingError(String);
-
-fn parse_encoding(arg: &str) -> Result<&'static Encoding, UnknownEncodingError> {
- match Encoding::for_label_no_replacement(arg.as_bytes()) {
- Some(encoding) => Ok(encoding),
- None => Err(UnknownEncodingError(arg.to_string())),
- }
-}
-
-fn main() -> Result<()> {
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use clap::Parser;
+
+use crate::cli::Cli;
+
+mod cli;
+
+fn main() -> anyhow::Result<()> {
Cli::parse().command.run()
}
Data,
}
-#[derive(Serialize)]
+#[derive(Clone, Serialize)]
pub struct Stack {
location: Location,
description: String,
}
-#[derive(Debug, Default)]
+#[derive(Clone, Debug, Default)]
pub struct Diagnostics(pub Vec<Diagnostic>);
impl From<Diagnostic> for Diagnostics {
}
}
-#[derive(Serialize)]
+#[derive(Clone, Serialize)]
pub struct Diagnostic {
pub severity: Severity,
pub category: Category,
#![allow(dead_code)]
use std::{
borrow::Cow,
+ collections::BTreeMap,
+ fmt::Display,
+ iter::once,
+ mem::take,
+ str::FromStr,
sync::{Arc, OnceLock},
};
+use anyhow::anyhow;
+use bit_vec::BitVec;
+use cairo::ImageSurface;
+use clap::{ArgAction, ArgMatches, Args, FromArgMatches, value_parser};
use enum_map::EnumMap;
+use enumset::{EnumSet, EnumSetType};
+use itertools::Itertools;
use pivot::PivotTable;
use serde::Serialize;
use crate::{
- message::Diagnostic,
- output::pivot::{Axis3, BorderStyle, Dimension, Group, Look},
+ message::{Diagnostic, Severity},
+ output::pivot::{
+ Axis3, Dimension, Group,
+ look::{BorderStyle, Look},
+ value::Value,
+ },
};
-use self::pivot::Value;
-
-pub mod cairo;
-pub mod csv;
-pub mod driver;
-pub mod html;
-pub mod json;
+pub mod drivers;
pub mod page;
pub mod pivot;
pub mod render;
-pub mod spv;
pub mod table;
-pub mod text;
-pub mod text_line;
/// A single output item.
-#[derive(Serialize)]
+#[derive(Clone, Debug, Serialize)]
pub struct Item {
/// The localized label for the item that appears in the outline pane in the
/// output viewer and in PDF outlines. This is `None` if no label has been
/// explicitly set.
- label: Option<String>,
+ pub label: Option<String>,
/// A locale-invariant identifier for the command that produced the output,
/// which may be `None` if unknown or if a command did not produce this
/// output.
- command_name: Option<String>,
+ pub command_name: Option<String>,
- /// For a group item, this is true if the group's subtree should
+ /// For a heading item, this is true if the heading's subtree should
/// be expanded in an outline view, false otherwise.
///
/// For other kinds of output items, this is true to show the item's
/// content, false to hide it. The item's label is always shown in an
/// outline view.
- show: bool,
+ pub show: bool,
/// Item details.
- details: Details,
+ pub details: Details,
+
+ /// If the item was read from an SPV file, this is additional information
+ /// related to that file.
+ #[serde(skip_serializing)]
+ pub spv_info: Option<Box<SpvInfo>>,
}
impl Item {
command_name: details.command_name().cloned(),
show: true,
details,
+ spv_info: None,
}
}
None => self.details.label(),
}
}
+
+ pub fn subtype(&self) -> Option<String> {
+ self.details
+ .as_table()
+ .map(|table| table.subtype().display(table).to_string())
+ }
+
+ pub fn with_show(self, show: bool) -> Self {
+ Self { show, ..self }
+ }
+
+ pub fn with_label(self, label: impl Into<String>) -> Self {
+ Self {
+ label: Some(label.into()),
+ ..self
+ }
+ }
+
+ pub fn with_command_name(self, command_name: Option<String>) -> Self {
+ Self {
+ command_name,
+ ..self
+ }
+ }
+
+ pub fn with_some_command_name(self, command_name: impl Into<String>) -> Self {
+ self.with_command_name(Some(command_name.into()))
+ }
+
+ pub fn with_spv_info(self, spv_info: SpvInfo) -> Self {
+ Self {
+ spv_info: Some(Box::new(spv_info)),
+ ..self
+ }
+ }
+
+ /// Should the item be shown?
+ ///
+ /// This always returns true for headings because their contents are always
+ /// shown (although headings can be collapsed in an outline view).
+ pub fn is_shown(&self) -> bool {
+ self.details.is_heading() || self.show
+ }
}
impl<T> From<T> for Item
}
}
-#[derive(Serialize)]
-pub enum Details {
+impl<A> FromIterator<A> for Item
+where
+ A: Into<Arc<Item>>,
+{
+ fn from_iter<T>(iter: T) -> Self
+ where
+ T: IntoIterator<Item = A>,
+ {
+ iter.into_iter().collect::<Details>().into_item()
+ }
+}
+
+impl PivotTable {
+ pub fn into_item(self) -> Item {
+ Details::Table(Box::new(self)).into_item()
+ }
+}
+
+/// A group of output items.
+///
+/// The name "heading" is used in SPV files. There is only a visible
+/// heading if the output items inside the heading include a [Text] item.
+/// The grouping itself is only visible in the outline pane in a viewer
+/// window.
+#[derive(Clone, Debug, Default, Serialize)]
+pub struct Heading(pub Vec<Arc<Item>>);
+
+impl Heading {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn with(mut self, item: impl Into<Arc<Item>>) -> Self {
+ self.0.push(item.into());
+ self
+ }
+
+ pub fn into_item(self) -> Item {
+ Details::Heading(self).into_item()
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum ItemKind {
Chart,
Image,
- Group(Vec<Arc<Item>>),
+ Heading,
+ Message,
+ PageBreak,
+ Table,
+ Text,
+}
+
+impl ItemKind {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ ItemKind::Chart => "chart",
+ ItemKind::Image => "image",
+ ItemKind::Heading => "heading",
+ ItemKind::Message => "message",
+ ItemKind::PageBreak => "page break",
+ ItemKind::Table => "table",
+ ItemKind::Text => "text",
+ }
+ }
+}
+
+impl Display for ItemKind {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub enum Details {
+ Chart,
+ Image(#[serde(skip_serializing)] ImageSurface),
+ Heading(Heading),
Message(Box<Diagnostic>),
PageBreak,
Table(Box<PivotTable>),
}
impl Details {
- pub fn as_group(&self) -> Option<&[Arc<Item>]> {
+ pub fn into_item(self) -> Item {
+ Item::new(self)
+ }
+
+ pub fn children(&self) -> &[Arc<Item>] {
+ match self {
+ Self::Heading(heading) => heading.0.as_slice(),
+ _ => &[],
+ }
+ }
+
+ pub fn mut_children(&mut self) -> Option<&mut Vec<Arc<Item>>> {
+ match self {
+ Self::Heading(heading) => Some(&mut heading.0),
+ _ => None,
+ }
+ }
+
+ pub fn as_message(&self) -> Option<&Diagnostic> {
+ match self {
+ Self::Message(diagnostic) => Some(diagnostic),
+ _ => None,
+ }
+ }
+
+ pub fn as_table(&self) -> Option<&PivotTable> {
+ match self {
+ Self::Table(table) => Some(table),
+ _ => None,
+ }
+ }
+
+ pub fn as_text(&self) -> Option<&Text> {
+ match self {
+ Self::Text(text) => Some(text),
+ _ => None,
+ }
+ }
+
+ pub fn as_image(&self) -> Option<&ImageSurface> {
match self {
- Self::Group(children) => Some(children.as_slice()),
+ Self::Image(image_surface) => Some(image_surface),
_ => None,
}
}
pub fn command_name(&self) -> Option<&String> {
match self {
Details::Chart
- | Details::Image
- | Details::Group(_)
+ | Details::Image(_)
+ | Details::Heading(_)
| Details::Message(_)
| Details::PageBreak
| Details::Text(_) => None,
- Details::Table(pivot_table) => pivot_table.command_c.as_ref(),
+ Details::Table(pivot_table) => pivot_table.metadata.command_c.as_ref(),
}
}
pub fn label(&self) -> Cow<'static, str> {
match self {
- Details::Chart => todo!(),
- Details::Image => todo!(),
- Details::Group(_) => Cow::from("Group"),
+ Details::Chart => Cow::from("chart"),
+ Details::Image(_) => Cow::from("Image"),
+ Details::Heading(_) => Cow::from("Group"),
Details::Message(diagnostic) => Cow::from(diagnostic.severity.as_title_str()),
Details::PageBreak => Cow::from("Page Break"),
Details::Table(pivot_table) => Cow::from(pivot_table.label()),
}
}
+ pub fn is_heading(&self) -> bool {
+ matches!(self, Self::Heading(_))
+ }
+
+ pub fn is_message(&self) -> bool {
+ matches!(self, Self::Message(_))
+ }
+
pub fn is_page_break(&self) -> bool {
matches!(self, Self::PageBreak)
}
+
+ pub fn is_table(&self) -> bool {
+ matches!(self, Self::Table(_))
+ }
+
+ pub fn is_text(&self) -> bool {
+ matches!(self, Self::Text(_))
+ }
+
+ pub fn kind(&self) -> ItemKind {
+ match self {
+ Details::Chart => ItemKind::Chart,
+ Details::Image(_) => ItemKind::Image,
+ Details::Heading(_) => ItemKind::Heading,
+ Details::Message(_) => ItemKind::Message,
+ Details::PageBreak => ItemKind::PageBreak,
+ Details::Table(_) => ItemKind::Table,
+ Details::Text(_) => ItemKind::Text,
+ }
+ }
}
impl<A> FromIterator<A> for Details
where
T: IntoIterator<Item = A>,
{
- Self::Group(iter.into_iter().map(|value| value.into()).collect())
+ Self::Heading(Heading(
+ iter.into_iter().map(|value| value.into()).collect(),
+ ))
}
}
}
}
+#[derive(Clone, Debug, Serialize)]
+pub struct Chart;
+
+impl Chart {
+ pub fn into_item(self) -> Item {
+ Details::Chart.into_item()
+ }
+}
+
#[derive(Clone, Debug, Serialize)]
pub struct Text {
- type_: TextType,
+ pub type_: TextType,
- content: Value,
+ pub content: Value,
}
impl Text {
- pub fn new_log(value: impl Into<Value>) -> Self {
+ pub fn new(type_: TextType, content: impl Into<Value>) -> Self {
Self {
- type_: TextType::Log,
- content: value.into(),
+ type_,
+ content: content.into(),
}
}
+ pub fn new_log(content: impl Into<Value>) -> Self {
+ Self::new(TextType::Log, content)
+ }
+
+ pub fn into_item(self) -> Item {
+ Details::Text(Box::new(self)).into_item()
+ }
}
fn text_item_table_look() -> Arc<Look> {
}
}
+pub struct ItemRefIterator<'a> {
+ next: Option<&'a Item>,
+ stack: Vec<(&'a Item, usize)>,
+}
+
+impl<'a> ItemRefIterator<'a> {
+ pub fn without_hidden(start: &'a Item) -> impl Iterator<Item = &'a Item> {
+ Self::with_hidden(start).filter(|item| item.is_shown())
+ }
+
+ pub fn with_hidden(start: &'a Item) -> Self {
+ Self {
+ next: Some(start),
+ stack: Vec::new(),
+ }
+ }
+}
+
+impl<'a> Iterator for ItemRefIterator<'a> {
+ type Item = &'a Item;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let cur = self.next.take()?;
+ if let Some(first_child) = cur.details.children().first() {
+ self.next = Some(&*first_child);
+ self.stack.push((cur, 1));
+ } else {
+ while let Some((item, index)) = self.stack.pop() {
+ if let Some(child) = item.details.children().get(index) {
+ self.next = Some(&*child);
+ self.stack.push((item, index + 1));
+ return Some(cur);
+ }
+ }
+ }
+ Some(cur)
+ }
+}
+
pub struct ItemCursor {
cur: Option<Arc<Item>>,
stack: Vec<(Arc<Item>, usize)>,
+ include_hidden: bool,
}
impl ItemCursor {
pub fn new(start: Arc<Item>) -> Self {
+ Self {
+ cur: start.is_shown().then_some(start),
+ stack: Vec::new(),
+ include_hidden: false,
+ }
+ }
+
+ pub fn with_hidden(start: Arc<Item>) -> Self {
Self {
cur: Some(start),
stack: Vec::new(),
+ include_hidden: true,
}
}
}
pub fn next(&mut self) {
- let Some(cur) = self.cur.take() else {
- return;
+ fn inner(this: &mut ItemCursor) {
+ let Some(cur) = this.cur.take() else {
+ return;
+ };
+ if let Some(first_child) = cur.details.children().first() {
+ this.cur = Some(first_child.clone());
+ this.stack.push((cur, 1));
+ } else {
+ while let Some((item, index)) = this.stack.pop() {
+ if let Some(child) = item.details.children().get(index) {
+ this.cur = Some(child.clone());
+ this.stack.push((item, index + 1));
+ return;
+ }
+ }
+ }
+ }
+
+ inner(self);
+ while let Some(cur) = &self.cur
+ && !cur.is_shown()
+ {
+ inner(self);
+ }
+ }
+
+ // Returns the label for the heading with the given `level` in the stack
+ // above the current item. Level 0 is the top level. Levels without a
+ // label are skipped.
+ pub fn heading(&self, level: usize) -> Option<&str> {
+ self.stack
+ .iter()
+ .filter_map(|(item, _index)| item.label.as_ref())
+ .nth(level)
+ .map(|s| s.as_str())
+ }
+}
+
+/// Information for output items that were read from an SPV file.
+///
+/// This is for debugging and troubleshooting purposes.
+#[derive(Clone, Debug)]
+pub struct SpvInfo {
+ /// True if there was an error reading the output item (e.g. because of
+ /// corruption or because PSPP doesn't understand the format).
+ pub error: bool,
+
+ /// Name of structure member in ZIP file.
+ pub structure_member: String,
+
+ /// Additional members based on item type.
+ pub members: Option<SpvMembers>,
+}
+
+impl SpvInfo {
+ pub fn new(structure_member: &str) -> Self {
+ Self {
+ error: false,
+ structure_member: structure_member.into(),
+ members: None,
+ }
+ }
+
+ pub fn with_members(self, members: SpvMembers) -> Self {
+ Self {
+ members: Some(members),
+ ..self
+ }
+ }
+
+ pub fn with_error(self) -> Self {
+ Self {
+ error: true,
+ ..self
+ }
+ }
+
+ pub fn member_names(&self) -> Vec<&str> {
+ let mut member_names = vec![self.structure_member.as_str()];
+ if let Some(members) = &self.members {
+ member_names.extend(members.iter());
+ }
+ member_names
+ }
+}
+
+/// Identifies ZIP file members for one kind of output item in an SPV file.
+#[derive(Clone, Debug)]
+pub enum SpvMembers {
+ /// Light table detail members.
+ LightTable(
+ /// `.bin` member name.
+ String,
+ ),
+ /// Legacy table detail members.
+ LegacyTable {
+ /// `.xml` member name.
+ xml: String,
+ /// `.bin` member name.
+ binary: String,
+ },
+ /// Image members.
+ Image(
+ /// `.png` file.
+ String,
+ ),
+ /// Chart members.
+ Graph {
+ /// Data member name.
+ data: Option<String>,
+ /// XML member name.
+ xml: String,
+ /// CVS member name.
+ csv: Option<String>,
+ },
+}
+
+impl SpvMembers {
+ pub fn iter(&self) -> impl Iterator<Item = &str> {
+ let (a, b, c) = match self {
+ SpvMembers::LightTable(a) => (Some(a), None, None),
+ SpvMembers::LegacyTable { xml, binary } => (Some(xml), Some(binary), None),
+ SpvMembers::Image(a) => (Some(a), None, None),
+ SpvMembers::Graph { data, xml, csv } => (data.as_ref(), Some(xml), csv.as_ref()),
+ };
+ once(a)
+ .flatten()
+ .chain(once(b).flatten())
+ .chain(once(c).flatten())
+ .map(|s| s.as_str())
+ }
+}
+
+/// Classifications for output items. These only roughly correspond to the
+/// output item types; for example, "warnings" are a subset of text items.
+#[derive(Debug, EnumSetType)]
+pub enum Class {
+ Charts,
+ Headings,
+ Logs,
+ Models,
+ Tables,
+ Texts,
+ Trees,
+ Warnings,
+ OutlineHeaders,
+ PageTitle,
+ Notes,
+ Unknown,
+ Other,
+}
+
+impl FromStr for Class {
+ type Err = ();
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "charts" => Ok(Self::Charts),
+ "headings" => Ok(Self::Headings),
+ "logs" => Ok(Self::Logs),
+ "models" => Ok(Self::Models),
+ "tables" => Ok(Self::Tables),
+ "texts" => Ok(Self::Texts),
+ "trees" => Ok(Self::Trees),
+ "warnings" => Ok(Self::Warnings),
+ "outlineheaders" => Ok(Self::OutlineHeaders),
+ "pagetitle" => Ok(Self::PageTitle),
+ "notes" => Ok(Self::Notes),
+ "unknown" => Ok(Self::Unknown),
+ "other" => Ok(Self::Other),
+ _ => Err(()),
+ }
+ }
+}
+
+impl Item {
+ fn class(&self) -> Class {
+ let label = self.label.as_ref().map(|s| s.as_str());
+ match &self.details {
+ Details::Chart => Class::Charts,
+ Details::Image(_) => Class::Other,
+ Details::Heading(_) => Class::OutlineHeaders,
+ Details::Message(diagnostic) => match diagnostic.severity {
+ Severity::Note => Class::Notes,
+ Severity::Error | Severity::Warning => Class::Warnings,
+ },
+ Details::PageBreak => Class::Other,
+ Details::Table(_) => match label {
+ Some("Warnings") => Class::Warnings,
+ Some("Notes") => Class::Notes,
+ _ => Class::Tables,
+ },
+ Details::Text(_) => match label {
+ Some("Title") => Class::Headings,
+ Some("Log") => Class::Logs,
+ Some("Page Title") => Class::PageTitle,
+ _ => Class::Texts,
+ },
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Selection {
+ /// - `None`: Include all objects.
+ /// - `Some(true)`: Include only visible objects.
+ /// - `Some(false)`: Include only hidden objects.
+ pub visible: Option<bool>,
+
+ /// - `None`: Include all objects.
+ /// - `Some(false)`: Include only objects with no error on loading.
+ /// - `Some(true)`: Include only objects with an error on loading.
+ pub error: Option<bool>,
+
+ /// Classes to include.
+ pub classes: EnumSet<Class>,
+
+ /// Command names to match.
+ pub commands: StringMatch,
+
+ /// Subtypes to match.
+ pub subtypes: StringMatch,
+
+ /// Labels to match.
+ pub labels: StringMatch,
+
+ /// Include objects under commands with the given 1-based indexes. Without
+ /// any indexes, include all objects.
+ pub nth_commands: Vec<usize>,
+
+ /// Include the objects with the given 1-based indexes within each of the
+ /// commands that are included. Indexes are 1-based. Index 0 represents
+ /// the last instance in a command.
+ pub instances: Vec<usize>,
+
+ /// Include only XML and binary member names that match. Without any member
+ /// names, include all objects.
+ pub members: Vec<String>,
+}
+
+impl Selection {
+ pub fn parse_nth_commands(s: &str) -> Result<Vec<usize>, anyhow::Error> {
+ s.split(',')
+ .map(|s| match s.trim().parse::<usize>() {
+ Ok(n) if n > 0 => Ok(n),
+ Ok(_) => Err(anyhow!("--nth-commmands values must be positive")),
+ Err(error) => Err(error.into()),
+ })
+ .collect()
+ }
+
+ pub fn parse_instances(s: &str) -> Result<Vec<usize>, anyhow::Error> {
+ s.split(',')
+ .map(|s| {
+ let s = s.trim();
+ if s == "last" {
+ Ok(0)
+ } else {
+ match s.parse::<usize>() {
+ Ok(n) if n > 0 => Ok(n),
+ Ok(_) => Err(anyhow!("--instances values must be positive")),
+ Err(error) => Err(error.into()),
+ }
+ }
+ })
+ .collect()
+ }
+
+ pub fn parse_classes(s: &str) -> Result<EnumSet<Class>, anyhow::Error> {
+ if s.is_empty() {
+ return Ok(EnumSet::all());
+ }
+ let (s, invert) = match s.strip_prefix('^') {
+ Some(rest) => (rest, true),
+ None => (s, false),
};
- match cur.details {
- Details::Group(ref children) if !children.is_empty() => {
- self.cur = Some(children[0].clone());
- self.stack.push((cur, 1));
+ let mut classes = EnumSet::empty();
+ for name in s.split(',') {
+ if name == "all" {
+ classes = EnumSet::all();
+ } else {
+ classes.insert(
+ name.trim()
+ .parse()
+ .map_err(|_| anyhow!("unknown output class `{name}`"))?,
+ );
}
- _ => {
- while let Some((item, index)) = self.stack.pop() {
- let children = item.details.as_group().unwrap();
- if index < children.len() {
- self.cur = Some(children[index].clone());
- self.stack.push((item, index + 1));
- return;
+ }
+ if invert {
+ classes = !classes;
+ }
+ Ok(classes)
+ }
+}
+
+impl Default for Selection {
+ fn default() -> Self {
+ Self {
+ visible: Some(true),
+ error: None,
+ classes: EnumSet::all(),
+ commands: Default::default(),
+ subtypes: Default::default(),
+ labels: Default::default(),
+ nth_commands: Default::default(),
+ members: Default::default(),
+ instances: Default::default(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum StringMatch {
+ /// Include any string that matches any of the patterns.
+ Include(Vec<String>),
+ /// Include only the strings that do not match any of the patterns.
+ Exclude(Vec<String>),
+}
+
+impl Default for StringMatch {
+ fn default() -> Self {
+ // Include everything, by excluding nothing.
+ Self::Exclude(Vec::new())
+ }
+}
+
+impl StringMatch {
+ pub fn is_default(&self) -> bool {
+ if let Self::Exclude(strings) = self
+ && strings.is_empty()
+ {
+ true
+ } else {
+ false
+ }
+ }
+ pub fn matches(&self, s: &str) -> bool {
+ fn inner(items: &[String], s: &str) -> bool {
+ items.iter().any(|item| match item.strip_suffix('*') {
+ Some(prefix) => s.starts_with(prefix),
+ None => s == item,
+ })
+ }
+
+ match self {
+ StringMatch::Include(items) => inner(items, s),
+ StringMatch::Exclude(items) => !inner(items, s),
+ }
+ }
+}
+
+/// Can't fail.
+#[derive(Debug, thiserror::Error)]
+pub enum Infallible {}
+
+impl FromStr for StringMatch {
+ type Err = Infallible;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ if let Some(rest) = s.strip_prefix("^") {
+ Ok(Self::Exclude(rest.split(",").map_into().collect()))
+ } else {
+ Ok(Self::Include(s.split(",").map_into().collect()))
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct Criteria(pub Vec<Selection>);
+
+impl Criteria {
+ /// Returns output items that are a subset of `input` that match the
+ /// criteria.
+ pub fn apply(&self, input: Vec<Item>) -> Vec<Arc<Item>> {
+ fn take_children(item: &Item) -> Vec<&Item> {
+ item.details.children().iter().map(|item| &**item).collect()
+ }
+ fn flatten_children<'a>(
+ children: Vec<&'a Item>,
+ depth: usize,
+ items: &mut Vec<(&'a Item, usize)>,
+ ) {
+ for child in children {
+ flatten(child, depth, items);
+ }
+ }
+ fn flatten<'a>(item: &'a Item, depth: usize, items: &mut Vec<(&'a Item, usize)>) {
+ let children = take_children(item);
+ items.push((item, depth));
+ flatten_children(children, depth + 1, items);
+ }
+
+ fn select_matches(items: &[(&Item, usize)], selection: &Selection, include: &mut BitVec) {
+ let mut instance_within_command = 0;
+ let mut last_instance = None;
+ let mut command_item = None;
+ let mut command_command_item = None;
+ let mut nth_command = 0;
+ for (index, (item, depth)) in items.into_iter().enumerate() {
+ if *depth == 0 {
+ command_item = Some(index);
+ if let Some(last_instance) = last_instance.take() {
+ include.set(last_instance, true);
}
+ instance_within_command = 0;
+ }
+ if !selection.classes.contains(item.class()) {
+ continue;
+ }
+ if let Some(visible) = selection.visible
+ && !item.details.is_heading()
+ && visible != item.show
+ {
+ continue;
+ }
+ if let Some(error) = selection.error
+ && error
+ != item
+ .spv_info
+ .as_ref()
+ .map_or(false, |spv_info| spv_info.error)
+ {
+ continue;
+ }
+ if !selection
+ .commands
+ .matches(item.command_name.as_ref().map_or("", |name| name.as_str()))
+ {
+ continue;
+ }
+ if !selection.nth_commands.is_empty() {
+ if command_item != command_command_item {
+ command_command_item = command_command_item;
+ nth_command += 1;
+ }
+ if !selection.nth_commands.contains(&nth_command) {
+ continue;
+ }
+ }
+ if !selection.subtypes.is_default() {
+ let Some(table) = item.details.as_table() else {
+ continue;
+ };
+ let subtype = table.subtype().display(table).to_string();
+ if !selection.subtypes.matches(&subtype) {
+ continue;
+ }
+ }
+ if !selection.labels.matches(&item.label()) {
+ continue;
+ }
+ if !selection.members.is_empty() {
+ let Some(spv_info) = item.spv_info.as_ref() else {
+ continue;
+ };
+ let member_names = spv_info.member_names();
+ if !selection
+ .members
+ .iter()
+ .any(|name| member_names.contains(&name.as_str()))
+ {
+ continue;
+ }
+ }
+ if !selection.instances.is_empty() {
+ if *depth == 0 {
+ continue;
+ }
+ instance_within_command += 1;
+ if !selection.instances.contains(&instance_within_command) {
+ if selection.instances.contains(&0) {
+ last_instance = Some(index);
+ }
+ continue;
+ }
+ }
+
+ include.set(index, true);
+ }
+ }
+ fn unflatten_items(
+ items: Vec<Arc<Item>>,
+ include: &mut bit_vec::Iter,
+ out: &mut Vec<Arc<Item>>,
+ ) {
+ for item in items {
+ unflatten_item(Arc::unwrap_or_clone(item), include, out);
+ }
+ }
+ fn unflatten_item(mut item: Item, include: &mut bit_vec::Iter, out: &mut Vec<Arc<Item>>) {
+ let include_item = include.next().unwrap();
+ if let Some(children) = item.details.mut_children() {
+ if !include_item {
+ unflatten_items(take(children), include, out);
+ return;
+ }
+ unflatten_items(take(children), include, children);
+ }
+ if include_item {
+ out.push(Arc::new(item));
+ }
+ }
+
+ let mut items = Vec::new();
+ flatten_children(input.iter().collect(), 0, &mut items);
+
+ let mut include = BitVec::from_elem(items.len(), false);
+ let selections = if self.0.is_empty() {
+ &[Selection::default()]
+ } else {
+ self.0.as_slice()
+ };
+ for selection in selections {
+ select_matches(&items, selection, &mut include);
+ }
+
+ let mut output = Vec::new();
+ unflatten_items(
+ input.into_iter().map(Arc::new).collect(),
+ &mut include.iter(),
+ &mut output,
+ );
+ output
+ }
+}
+
+impl FromArgMatches for Criteria {
+ fn from_arg_matches(matches: &ArgMatches) -> Result<Self, clap::Error> {
+ let mut this = Self::default();
+ this.update_from_arg_matches(matches)?;
+ Ok(this)
+ }
+
+ fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), clap::Error> {
+ #[derive(Debug)]
+ enum Value {
+ Or,
+ Classes(EnumSet<Class>),
+ Commands(StringMatch),
+ Subtypes(StringMatch),
+ Labels(StringMatch),
+ NthCommands(Vec<usize>),
+ Instances(Vec<usize>),
+ ShowHidden(bool),
+ Errors(bool),
+ }
+
+ fn extract<F, T: Clone + Send + Sync + 'static>(
+ matches: &ArgMatches,
+ id: &str,
+ output: &mut BTreeMap<usize, Value>,
+ f: F,
+ ) where
+ F: Fn(T) -> Value,
+ {
+ if !matches.contains_id(id) || matches.try_get_many::<clap::Id>(id).is_ok() {
+ // ignore groups
+ return;
+ }
+ let value_source = matches.value_source(id).expect("id came from matches");
+ if value_source != clap::parser::ValueSource::CommandLine {
+ // Any other source just gets tacked on at the end (like default values)
+ return;
+ }
+ for (value, index) in matches
+ .try_get_many::<T>(id)
+ .unwrap()
+ .unwrap()
+ .zip(matches.indices_of(id).unwrap())
+ {
+ output.insert(index, f(value.clone()));
+ }
+ }
+
+ let mut values = BTreeMap::new();
+ extract(matches, "_or", &mut values, |_: bool| Value::Or);
+ extract(matches, "select", &mut values, Value::Classes);
+ extract(matches, "commands", &mut values, Value::Commands);
+ extract(matches, "subtypes", &mut values, Value::Subtypes);
+ extract(matches, "labels", &mut values, Value::Labels);
+ extract(matches, "nth_commands", &mut values, Value::NthCommands);
+ extract(matches, "instances", &mut values, Value::Instances);
+ extract(matches, "show_hidden", &mut values, Value::ShowHidden);
+ extract(matches, "errors", &mut values, Value::Errors);
+
+ if !values.is_empty() {
+ let mut selection = Selection::default();
+ for value in values.into_values() {
+ match value {
+ Value::Or => self.0.push(take(&mut selection)),
+ Value::Classes(classes) => selection.classes = classes,
+ Value::Commands(commands) => selection.commands = commands,
+ Value::Subtypes(subtypes) => selection.subtypes = subtypes,
+ Value::Labels(labels) => selection.labels = labels,
+ Value::NthCommands(nth_commands) => selection.nth_commands = nth_commands,
+ Value::Instances(instances) => selection.instances = instances,
+ Value::ShowHidden(show) => {
+ selection.visible = if show { None } else { Some(true) }
+ }
+ Value::Errors(only) => selection.error = if only { Some(true) } else { None },
}
}
+ self.0.push(selection);
}
+ Ok(())
+ }
+}
+
+impl Args for Criteria {
+ fn augment_args(cmd: clap::Command) -> clap::Command {
+ SelectionArgs::augment_args(cmd.next_help_heading("Input selection options"))
+ }
+
+ fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
+ Self::augment_args(cmd)
+ }
+}
+
+/// Show information about SPSS viewer files (SPV files).
+#[derive(Args, Clone, Debug)]
+struct SelectionArgs {
+ /// Classes of objects to include or, with leading `^`, to exclude. The
+ /// supported classes are: charts, headings, logs, models, tables, texts,
+ /// trees, warnings, outlineheaders, pagetitle, notes, unknown, other.
+ #[arg(long, required = false, value_parser = Selection::parse_classes, action = ArgAction::Append)]
+ select: EnumSet<Class>,
+
+ /// Identifiers of commands to include or, with leading `^`, to exclude.
+ #[arg(long, required = false, value_parser = StringMatch::from_str, action = ArgAction::Append)]
+ commands: StringMatch,
+
+ /// Table subtypes to include or, with leading `^`, to exclude.
+ #[arg(long, required = false, value_parser = StringMatch::from_str, action = ArgAction::Append)]
+ subtypes: StringMatch,
+
+ /// Labels (table titles) to include or, with leading `^`, to exclude.
+ #[arg(long, required = false, value_parser = StringMatch::from_str, action = ArgAction::Append)]
+ labels: StringMatch,
+
+ /// Include only objects from the Nth (1-based) command that matches
+ /// `--command`.
+ #[arg(long, required = false, value_parser = Selection::parse_nth_commands, action = ArgAction::Append)]
+ nth_commands: Vec<usize>,
+
+ /// Include only the given instances of an object that matches the other
+ /// criteria within a single command. Each instance may be a number (1 for
+ /// the first, and so on), or `last` for the last instance.
+ #[arg(long, required = false, value_parser = Selection::parse_instances, action = ArgAction::Append)]
+ instances: Vec<usize>,
+
+ /// Include hidden objects in the output (by default, they are excluded)
+ #[arg(long, required = false, action = ArgAction::Append, num_args = 0, value_parser = value_parser!(bool), default_missing_value = "true", default_value = "false")]
+ show_hidden: bool,
+
+ /// Include only objects that cause an error when read (by default, objects
+ /// with and without errors are included).
+ #[arg(long, required = false, action = ArgAction::Append, num_args = 0, value_parser = value_parser!(bool), default_missing_value = "true", default_value = "false")]
+ errors: bool,
+
+ /// Include only XML and binary member names that match. Without any member
+ /// names, include all objects.
+ #[arg(long, required = false, action = ArgAction::Append)]
+ pub members: Vec<String>,
+
+ /// Separate two groups of selection options.
+ #[arg(long, action = ArgAction::Append, long = "or", num_args = 0, value_parser = value_parser!(bool), default_missing_value = "true", default_value = "false")]
+ _or: bool,
+}
+
+#[cfg(test)]
+mod tests {
+ use clap::Parser;
+ use enumset::EnumSet;
+
+ use crate::output::{
+ Class, Criteria, Heading, Item, Selection, StringMatch, pivot::PivotTable,
+ };
+
+ #[test]
+ fn parse_classes() {
+ assert_eq!(Selection::parse_classes("").unwrap(), EnumSet::all());
+ assert_eq!(
+ Selection::parse_classes("tables").unwrap(),
+ EnumSet::only(Class::Tables)
+ );
+ assert_eq!(
+ Selection::parse_classes("tables,pagetitle").unwrap(),
+ EnumSet::only(Class::Tables) | EnumSet::only(Class::PageTitle)
+ );
+ assert_eq!(
+ Selection::parse_classes("^tables,pagetitle").unwrap(),
+ !(EnumSet::only(Class::Tables) | EnumSet::only(Class::PageTitle))
+ );
+ }
+
+ #[test]
+ fn parse_nth_commands() {
+ assert_eq!(Selection::parse_nth_commands("1").unwrap(), vec![1]);
+ assert_eq!(
+ Selection::parse_nth_commands("1,2,3").unwrap(),
+ vec![1, 2, 3]
+ );
+ assert!(Selection::parse_nth_commands("0").is_err());
+ }
+
+ #[test]
+ fn parse_instances() {
+ assert_eq!(Selection::parse_instances("1").unwrap(), vec![1]);
+ assert_eq!(Selection::parse_instances("2,3").unwrap(), vec![2, 3]);
+ assert_eq!(Selection::parse_instances("last,1").unwrap(), vec![0, 1]);
+ assert!(Selection::parse_instances("0").is_err());
+ }
+
+ #[test]
+ fn string_matches() {
+ assert!(StringMatch::default().matches("xyzzy"));
+ assert!(StringMatch::Exclude(Vec::new()).matches("xyzzy"));
+ assert!(!StringMatch::Include(Vec::new()).matches("xyzzy"));
+
+ let m = StringMatch::Include(vec![String::from("xyz"), String::from("abc*")]);
+ assert!(m.matches("xyz"));
+ assert!(!m.matches("xyzzy"));
+ assert!(!m.matches("ab"));
+ assert!(m.matches("abc"));
+ assert!(m.matches("abcd"));
+
+ let m = StringMatch::Exclude(vec![String::from("xyz"), String::from("abc*")]);
+ assert!(!m.matches("xyz"));
+ assert!(m.matches("xyzzy"));
+ assert!(m.matches("ab"));
+ assert!(!m.matches("abc"));
+ assert!(!m.matches("abcd"));
+ }
+
+ #[test]
+ fn selections() {
+ #[derive(Parser, Debug, PartialEq, Eq)]
+ struct Command {
+ #[command(flatten)]
+ selections: Criteria,
+ }
+
+ let args = vec![
+ "myprog",
+ "--select=tables",
+ "--or",
+ "--commands=Frequencies,Descriptives",
+ ];
+ let matches = Command::parse_from(args);
+ assert_eq!(
+ matches,
+ Command {
+ selections: Criteria(vec![
+ Selection {
+ classes: EnumSet::only(Class::Tables),
+ ..Selection::default()
+ },
+ Selection {
+ commands: StringMatch::Include(vec![
+ String::from("Frequencies"),
+ String::from("Descriptives")
+ ]),
+ ..Selection::default()
+ }
+ ])
+ }
+ );
+ }
+
+ fn regress_item() -> Item {
+ [
+ Heading::new()
+ .into_item()
+ .with_label("Set")
+ .with_some_command_name("Set"),
+ [Heading::new()
+ .into_item()
+ .with_label("Page Title")
+ .with_some_command_name("Title")]
+ .into_iter()
+ .collect::<Item>()
+ .with_label("Title")
+ .with_some_command_name("Title"),
+ [PivotTable::new([])
+ .with_title("Reading 1 record from INLINE.")
+ .with_subtype("Fixed Data Records")
+ .into_item()
+ .with_some_command_name("Data List")]
+ .into_iter()
+ .collect::<Item>()
+ .with_label("Data List")
+ .with_some_command_name("Data List"),
+ Heading::new()
+ .into_item()
+ .with_label("Begin Data")
+ .with_some_command_name("Begin Data"),
+ [PivotTable::new([])
+ .with_title("Data List")
+ .into_item()
+ .with_some_command_name("List")]
+ .into_iter()
+ .collect::<Item>()
+ .with_label("List")
+ .with_some_command_name("List"),
+ ]
+ .into_iter()
+ .collect::<Item>()
+ .with_label("Output")
}
}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use pango::SCALE;
-
-use crate::output::pivot::HorzAlign;
-
-mod driver;
-pub mod fsm;
-pub mod pager;
-
-pub use driver::{CairoConfig, CairoDriver};
-
-/// Conversion from 1/96" units ("pixels") to Cairo/Pango units.
-fn px_to_xr(x: usize) -> usize {
- x * 3 * (SCALE as usize * 72 / 96) / 3
-}
-
-fn xr_to_pt(x: usize) -> f64 {
- x as f64 / SCALE as f64
-}
-
-fn horz_align_to_pango(horz_align: HorzAlign) -> pango::Alignment {
- match horz_align {
- HorzAlign::Right | HorzAlign::Decimal { .. } => pango::Alignment::Right,
- HorzAlign::Left => pango::Alignment::Left,
- HorzAlign::Center => pango::Alignment::Center,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::output::cairo::{CairoConfig, CairoDriver};
-
- #[test]
- fn create() {
- CairoDriver::new(&CairoConfig::new("test.pdf")).unwrap();
- }
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use std::{
- borrow::Cow,
- path::{Path, PathBuf},
- sync::Arc,
-};
-
-use cairo::{Context, PdfSurface};
-use enum_map::{EnumMap, enum_map};
-use pango::SCALE;
-use serde::{Deserialize, Serialize};
-
-use crate::output::{
- Item,
- cairo::{
- fsm::{CairoFsmStyle, parse_font_style},
- pager::{CairoPageStyle, CairoPager},
- },
- driver::Driver,
- page::PageSetup,
- pivot::{Color, Coord2, FontStyle},
-};
-
-use crate::output::pivot::Axis2;
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub struct CairoConfig {
- /// Output file name.
- pub file: PathBuf,
-
- /// Page setup.
- pub page_setup: Option<PageSetup>,
-}
-
-impl CairoConfig {
- pub fn new(path: impl AsRef<Path>) -> Self {
- Self {
- file: path.as_ref().to_path_buf(),
- page_setup: None,
- }
- }
-}
-
-pub struct CairoDriver {
- fsm_style: Arc<CairoFsmStyle>,
- page_style: Arc<CairoPageStyle>,
- pager: Option<CairoPager>,
- surface: PdfSurface,
-}
-
-impl CairoDriver {
- pub fn new(config: &CairoConfig) -> cairo::Result<Self> {
- fn scale(inches: f64) -> usize {
- (inches * 72.0 * SCALE as f64).max(0.0).round() as usize
- }
-
- let default_page_setup;
- let page_setup = match &config.page_setup {
- Some(page_setup) => page_setup,
- None => {
- default_page_setup = PageSetup::default();
- &default_page_setup
- }
- };
- let printable = page_setup.printable_size();
- let page_style = CairoPageStyle {
- margins: EnumMap::from_fn(|axis| {
- [
- scale(page_setup.margins[axis][0]),
- scale(page_setup.margins[axis][1]),
- ]
- }),
- headings: page_setup.headings.clone(),
- initial_page_number: page_setup.initial_page_number,
- };
- let size = Coord2::new(scale(printable[Axis2::X]), scale(printable[Axis2::Y]));
- let font = FontStyle {
- bold: false,
- italic: false,
- underline: false,
- markup: false,
- font: "Sans Serif".into(),
- fg: [Color::BLACK, Color::BLACK],
- bg: [Color::WHITE, Color::WHITE],
- size: 10,
- };
- let font = parse_font_style(&font);
- let fsm_style = CairoFsmStyle {
- size,
- min_break: enum_map! {
- Axis2::X => size[Axis2::X] / 2,
- Axis2::Y => size[Axis2::Y] / 2,
- },
- font,
- fg: Color::BLACK,
- use_system_colors: false,
- object_spacing: scale(page_setup.object_spacing),
- font_resolution: 72.0,
- };
- let surface = PdfSurface::new(
- page_setup.paper[Axis2::X] * 72.0,
- page_setup.paper[Axis2::Y] * 72.0,
- &config.file,
- )?;
- Ok(Self {
- fsm_style: Arc::new(fsm_style),
- page_style: Arc::new(page_style),
- pager: None,
- surface,
- })
- }
-}
-
-impl Driver for CairoDriver {
- fn name(&self) -> Cow<'static, str> {
- Cow::from("cairo")
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- let pager = self.pager.get_or_insert_with(|| {
- let mut pager = CairoPager::new(self.page_style.clone(), self.fsm_style.clone());
- pager.add_page(Context::new(&self.surface).unwrap());
- pager
- });
- pager.add_item(item.clone());
- dbg!();
- while pager.needs_new_page() {
- dbg!();
- pager.finish_page();
- let context = Context::new(&self.surface).unwrap();
- context.show_page().unwrap();
- pager.add_page(context);
- }
- dbg!();
- }
-}
-
-impl Drop for CairoDriver {
- fn drop(&mut self) {
- dbg!();
- if self.pager.is_some() {
- dbg!();
- let context = Context::new(&self.surface).unwrap();
- context.show_page().unwrap();
- }
- }
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use std::{cmp::min, f64::consts::PI, fmt::Write, ops::DerefMut, sync::Arc};
-
-use cairo::Context;
-use enum_map::{EnumMap, enum_map};
-use itertools::Itertools;
-use pango::{
- AttrFloat, AttrFontDesc, AttrInt, AttrList, Attribute, FontDescription, FontMask, Layout,
- SCALE, SCALE_SMALL, Underline, Weight, parse_markup,
-};
-use pangocairo::functions::show_layout;
-use smallvec::{SmallVec, smallvec};
-
-use crate::output::cairo::{horz_align_to_pango, px_to_xr, xr_to_pt};
-use crate::output::pivot::{Axis2, BorderStyle, Coord2, FontStyle, HorzAlign, Rect2, Stroke};
-use crate::output::render::{Device, Extreme, Pager, Params};
-use crate::output::table::DrawCell;
-use crate::output::{Details, Item};
-use crate::output::{pivot::Color, table::Content};
-
-/// Width of an ordinary line.
-const LINE_WIDTH: usize = LINE_SPACE / 2;
-
-/// Space between double lines.
-const LINE_SPACE: usize = SCALE as usize;
-
-/// Conversion from 1/96" units ("pixels") to Cairo/Pango units.
-fn pxf_to_xr(x: f64) -> usize {
- (x * (SCALE as f64 * 72.0 / 96.0)).round() as usize
-}
-
-#[derive(Clone, Debug)]
-pub struct CairoFsmStyle {
- /// Page size.
- pub size: Coord2,
-
- /// Minimum cell size to allow breaking.
- pub min_break: EnumMap<Axis2, usize>,
-
- /// The basic font.
- pub font: FontDescription,
-
- /// Foreground color.
- pub fg: Color,
-
- /// Use system colors?
- pub use_system_colors: bool,
-
- /// Vertical space between different output items.
- pub object_spacing: usize,
-
- /// Resolution, in units per inch, used for measuring font "points":
- ///
- /// - 72.0 if 1 pt is one device unit, e.g. for rendering to a surface
- /// created by [cairo::PsSurface::new] with its default transformation
- /// matrix of 72 units/inch.p
- ///
- /// - 96.0 is traditional for a screen-based surface.
- pub font_resolution: f64,
-}
-
-impl CairoFsmStyle {
- fn new_layout(&self, context: &Context) -> Layout {
- let pangocairo_context = pangocairo::functions::create_context(context);
- pangocairo::functions::context_set_resolution(&pangocairo_context, self.font_resolution);
- Layout::new(&pangocairo_context)
- }
-}
-
-pub struct CairoFsm {
- style: Arc<CairoFsmStyle>,
- params: Params,
- item: Arc<Item>,
- layer_iterator: Option<Box<dyn Iterator<Item = SmallVec<[usize; 4]>>>>,
- pager: Option<Pager>,
-}
-
-impl CairoFsm {
- pub fn new(
- style: Arc<CairoFsmStyle>,
- printing: bool,
- context: &Context,
- item: Arc<Item>,
- ) -> Self {
- let params = Params {
- size: style.size,
- font_size: {
- let layout = style.new_layout(context);
- layout.set_font_description(Some(&style.font));
- layout.set_text("0");
- let char_size = layout.size();
- enum_map! {
- Axis2::X => char_size.0.max(0) as usize,
- Axis2::Y => char_size.1.max(0) as usize
- }
- },
- line_widths: enum_map! {
- Stroke::None => 0,
- Stroke::Solid | Stroke::Dashed => LINE_WIDTH,
- Stroke::Thick => LINE_WIDTH * 2,
- Stroke::Thin => LINE_WIDTH / 2,
- Stroke::Double => LINE_WIDTH * 2 + LINE_SPACE,
- },
- px_size: Some(px_to_xr(1)),
- min_break: style.min_break,
- supports_margins: true,
- rtl: false,
- printing,
- can_adjust_break: false, // XXX
- can_scale: true,
- };
- let device = CairoDevice {
- style: &style,
- params: ¶ms,
- context,
- };
- let (layer_iterator, pager) = match &item.details {
- Details::Table(pivot_table) => {
- let mut layer_iterator = pivot_table.layers(printing);
- let layer_indexes = layer_iterator.next();
- (
- Some(layer_iterator),
- Some(Pager::new(
- &device,
- pivot_table,
- layer_indexes.as_ref().map(|indexes| indexes.as_slice()),
- )),
- )
- }
- _ => (None, None),
- };
- Self {
- style,
- params,
- item,
- layer_iterator,
- pager,
- }
- }
-
- pub fn draw_slice(&mut self, context: &Context, space: usize) -> usize {
- debug_assert!(self.params.printing);
-
- context.save().unwrap();
- let used = match &self.item.details {
- Details::Table(_) => self.draw_table(context, space),
- _ => todo!(),
- };
- context.restore().unwrap();
-
- used
- }
-
- fn draw_table(&mut self, context: &Context, space: usize) -> usize {
- let Details::Table(pivot_table) = &self.item.details else {
- unreachable!()
- };
- let Some(pager) = &mut self.pager else {
- return 0;
- };
- let mut device = CairoDevice {
- style: &self.style,
- params: &self.params,
- context,
- };
- let mut used = pager.draw_next(&mut device, space);
- if !pager.has_next(&device) {
- match self.layer_iterator.as_mut().unwrap().next() {
- Some(layer_indexes) => {
- self.pager = Some(Pager::new(
- &device,
- pivot_table,
- Some(layer_indexes.as_slice()),
- ));
- if pivot_table.look.paginate_layers {
- used = space;
- } else {
- used += self.style.object_spacing;
- }
- }
- _ => {
- self.pager = None;
- }
- }
- }
- used.min(space)
- }
-
- pub fn is_done(&self) -> bool {
- match &self.item.details {
- Details::Table(_) => self.pager.is_none(),
- _ => todo!(),
- }
- }
-}
-
-fn xr_clip(context: &Context, clip: &Rect2) {
- if clip[Axis2::X].end != usize::MAX || clip[Axis2::Y].end != usize::MAX {
- let x0 = xr_to_pt(clip[Axis2::X].start);
- let y0 = xr_to_pt(clip[Axis2::Y].start);
- let x1 = xr_to_pt(clip[Axis2::X].end);
- let y1 = xr_to_pt(clip[Axis2::Y].end);
- context.rectangle(x0, y0, x1 - x0, y1 - y0);
- context.clip();
- }
-}
-
-fn xr_set_color(context: &Context, color: &Color) {
- fn as_frac(x: u8) -> f64 {
- x as f64 / 255.0
- }
-
- context.set_source_rgba(
- as_frac(color.r),
- as_frac(color.g),
- as_frac(color.b),
- as_frac(color.alpha),
- );
-}
-
-fn xr_fill_rectangle(context: &Context, rectangle: Rect2) {
- context.new_path();
- context.set_line_width(xr_to_pt(LINE_WIDTH));
-
- let x0 = xr_to_pt(rectangle[Axis2::X].start);
- let y0 = xr_to_pt(rectangle[Axis2::Y].start);
- let width = xr_to_pt(rectangle[Axis2::X].len());
- let height = xr_to_pt(rectangle[Axis2::Y].len());
- context.rectangle(x0, y0, width, height);
- context.fill().unwrap();
-}
-
-fn margin(cell: &DrawCell, axis: Axis2) -> usize {
- px_to_xr(
- cell.style.cell_style.margins[axis]
- .iter()
- .sum::<i32>()
- .max(0) as usize,
- )
-}
-
-pub fn parse_font_style(font_style: &FontStyle) -> FontDescription {
- let font = font_style.font.as_str();
- let font = if font.eq_ignore_ascii_case("Monospaced") {
- "Monospace"
- } else {
- font
- };
- let mut font_desc = FontDescription::from_string(font);
- if !font_desc.set_fields().contains(FontMask::SIZE) {
- let default_size = if font_style.size != 0 {
- font_style.size * 1000
- } else {
- 10_000
- };
- font_desc.set_size(((default_size as f64 / 1000.0) * (SCALE as f64)) as i32);
- }
- font_desc.set_weight(if font_style.bold {
- Weight::Bold
- } else {
- Weight::Normal
- });
- font_desc.set_style(if font_style.italic {
- pango::Style::Italic
- } else {
- pango::Style::Normal
- });
- font_desc
-}
-
-/// Deal with an oddity of the Unicode line-breaking algorithm (or perhaps in
-/// Pango's implementation of it): it will break after a period or a comma that
-/// precedes a digit, e.g. in `.000` it will break after the period. This code
-/// looks for such a situation and inserts a U+2060 WORD JOINER to prevent the
-/// break.
-///
-/// This isn't necessary when the decimal point is between two digits
-/// (e.g. `0.000` won't be broken) or when the display width is not limited so
-/// that word wrapping won't happen.
-///
-/// It isn't necessary to look for more than one period or comma, as would
-/// happen with grouping like `1,234,567.89` or `1.234.567,89` because if groups
-/// are present then there will always be a digit on both sides of every period
-/// and comma.
-fn avoid_decimal_split(mut s: String) -> String {
- if let Some(position) = s.find(['.', ',']) {
- let followed_by_digit = s[position + 1..]
- .chars()
- .next()
- .is_some_and(|c| c.is_ascii_digit());
- let not_preceded_by_digit = s[..position]
- .chars()
- .next_back()
- .is_none_or(|c| !c.is_ascii_digit());
- if followed_by_digit && not_preceded_by_digit {
- s.insert(position + 1, '\u{2060}');
- }
- }
- s
-}
-
-struct CairoDevice<'a> {
- style: &'a CairoFsmStyle,
- params: &'a Params,
- context: &'a Context,
-}
-
-impl CairoDevice<'_> {
- fn layout_cell(&self, cell: &DrawCell, mut bb: Rect2, clip: &Rect2) -> Coord2 {
- // XXX rotation
- //let h = if cell.rotate { Axis2::Y } else { Axis2::X };
-
- let layout = self.style.new_layout(self.context);
-
- let cell_font = if !cell.style.font_style.font.is_empty() {
- Some(parse_font_style(&cell.style.font_style))
- } else {
- None
- };
- let font = cell_font.as_ref().unwrap_or(&self.style.font);
- layout.set_font_description(Some(font));
-
- let (body, suffixes) = cell.display().split_suffixes();
- let horz_align = cell.horz_align(&body);
- let body = body.to_string();
-
- match horz_align {
- HorzAlign::Decimal { offset, decimal } if !cell.rotate => {
- let decimal_position = if let Some(position) = body.rfind(char::from(decimal)) {
- layout.set_text(&body[position..]);
- layout.set_width(-1);
- layout.size().0.max(0) as usize
- } else {
- 0
- };
- bb[Axis2::X].end -= pxf_to_xr(offset).saturating_sub(decimal_position);
- }
- _ => (),
- }
-
- let mut attrs = None;
- let mut body = if cell.style.font_style.markup {
- match parse_markup(&body, 0 as char) {
- Ok((markup_attrs, string, _accel)) => {
- attrs = Some(markup_attrs);
- string.into()
- }
- Err(_) => body,
- }
- } else {
- avoid_decimal_split(body)
- };
-
- if cell.style.font_style.underline {
- attrs
- .get_or_insert_default()
- .insert(AttrInt::new_underline(Underline::Single));
- }
-
- if !suffixes.is_empty() {
- let subscript_ofs = body.len();
- #[allow(unstable_name_collisions)]
- body.extend(suffixes.subscripts().intersperse(","));
- let has_subscripts = subscript_ofs != body.len();
-
- let footnote_ofs = body.len();
- for (index, footnote) in suffixes.footnotes().enumerate() {
- if index > 0 {
- body.push(',');
- }
- write!(&mut body, "{footnote}").unwrap();
- }
- let has_footnotes = footnote_ofs != body.len();
-
- // Allow footnote markers to occupy the right margin. That way,
- // numbers in the column are still aligned.
- if has_footnotes && horz_align == HorzAlign::Right {
- // Measure the width of the footnote marker, so we know how much we
- // need to make room for.
- layout.set_text(&body[footnote_ofs..]);
-
- let footnote_attrs = AttrList::new();
- footnote_attrs.insert(AttrFloat::new_scale(SCALE_SMALL));
- footnote_attrs.insert(AttrInt::new_rise(3000));
- layout.set_attributes(Some(&footnote_attrs));
- let footnote_width = layout.size().0.max(0) as usize;
-
- // Bound the adjustment by the width of the right margin.
- let right_margin =
- px_to_xr(cell.style.cell_style.margins[Axis2::X][1].max(0) as usize);
- let footnote_adjustment = min(footnote_width, right_margin);
-
- // Adjust the bounding box.
- if cell.rotate {
- bb[Axis2::X].end = bb[Axis2::X].end.saturating_sub(footnote_adjustment);
- } else {
- bb[Axis2::X].end = bb[Axis2::X].end.saturating_add(footnote_adjustment);
- }
-
- // Clean up.
- layout.set_attributes(None);
- }
-
- fn with_start<T: DerefMut<Target = Attribute>>(index: usize, mut attr: T) -> T {
- attr.deref_mut().set_start_index(index.try_into().unwrap());
- attr
- }
- fn with_end<T: DerefMut<Target = Attribute>>(index: usize, mut attr: T) -> T {
- attr.deref_mut().set_end_index(index.try_into().unwrap());
- attr
- }
-
- // Set attributes.
- let attrs = attrs.get_or_insert_default();
- attrs.insert(with_start(subscript_ofs, AttrFontDesc::new(font)));
- attrs.insert(with_start(subscript_ofs, AttrFloat::new_scale(SCALE_SMALL)));
- if has_subscripts {
- attrs.insert(with_start(
- subscript_ofs,
- with_end(footnote_ofs, AttrInt::new_rise(-3000)),
- ));
- }
- if has_footnotes {
- let rise = 3000; // XXX check look for superscript vs subscript
- attrs.insert(with_start(footnote_ofs, AttrInt::new_rise(rise)));
- }
- }
-
- layout.set_attributes(attrs.as_ref());
- layout.set_text(&body);
- layout.set_alignment(horz_align_to_pango(horz_align));
- if bb[Axis2::X].end == usize::MAX {
- layout.set_width(-1);
- } else {
- layout.set_width(bb[Axis2::X].len() as i32);
- }
-
- let size = layout.size();
-
- if !clip.is_empty() {
- self.context.save().unwrap();
- if !cell.rotate {
- xr_clip(self.context, clip);
- }
- if cell.rotate {
- let extra = bb[Axis2::X].len().saturating_sub(size.1.max(0) as usize);
- let halign_offset = extra / 2;
- self.context.translate(
- xr_to_pt(bb[Axis2::X].start + halign_offset),
- xr_to_pt(bb[Axis2::Y].end),
- );
- self.context.rotate(-PI / 2.0);
- } else {
- self.context
- .translate(xr_to_pt(bb[Axis2::X].start), xr_to_pt(bb[Axis2::Y].start));
- }
- show_layout(self.context, &layout);
- self.context.restore().unwrap();
- }
-
- layout.set_attributes(None);
-
- Coord2::new(size.0.max(0) as usize, size.1.max(0) as usize)
- }
-
- fn do_draw_line(
- &self,
- x0: usize,
- y0: usize,
- x1: usize,
- y1: usize,
- stroke: Stroke,
- color: Color,
- ) {
- self.context.new_path();
- self.context.set_line_width(xr_to_pt(match stroke {
- Stroke::Thick => LINE_WIDTH * 2,
- Stroke::Thin => LINE_WIDTH / 2,
- _ => LINE_WIDTH,
- }));
- self.context.move_to(xr_to_pt(x0), xr_to_pt(y0));
- self.context.line_to(xr_to_pt(x1), xr_to_pt(y1));
- if !self.style.use_system_colors {
- xr_set_color(self.context, &color);
- }
- if stroke == Stroke::Dashed {
- self.context.set_dash(&[2.0], 0.0);
- let _ = self.context.stroke();
- self.context.set_dash(&[], 0.0);
- } else {
- let _ = self.context.stroke();
- }
- }
-}
-
-impl Device for CairoDevice<'_> {
- fn params(&self) -> &Params {
- self.params
- }
-
- fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap<Extreme, usize> {
- fn add_margins(cell: &DrawCell, width: usize) -> usize {
- if width > 0 {
- width + margin(cell, Axis2::X)
- } else {
- 0
- }
- }
-
- /// An empty clipping rectangle.
- fn clip() -> Rect2 {
- Rect2::default()
- }
-
- enum_map![
- Extreme::Min => {
- let bb = Rect2::new(0..1, 0..usize::MAX);
- add_margins(cell, self.layout_cell(cell, bb, &clip()).x())
- }
- Extreme::Max => {
- let bb = Rect2::new(0..usize::MAX, 0..usize::MAX);
- add_margins(cell, self.layout_cell(cell, bb, &clip()).x())
- },
- ]
- }
-
- fn measure_cell_height(&self, cell: &DrawCell, width: usize) -> usize {
- let margins = &cell.style.cell_style.margins;
- let bb = Rect2::new(
- 0..width.saturating_sub(px_to_xr(margins[Axis2::X].len())),
- 0..usize::MAX,
- );
- self.layout_cell(cell, bb, &Rect2::default()).y() + margin(cell, Axis2::Y)
- }
-
- fn adjust_break(&self, _cell: &Content, _size: Coord2) -> usize {
- todo!()
- }
-
- fn draw_line(&mut self, bb: Rect2, styles: EnumMap<Axis2, [BorderStyle; 2]>) {
- let x0 = bb[Axis2::X].start;
- let y0 = bb[Axis2::Y].start;
- let x3 = bb[Axis2::X].end;
- let y3 = bb[Axis2::Y].end;
-
- let top = styles[Axis2::X][0].stroke;
- let bottom = styles[Axis2::X][1].stroke;
- let left = styles[Axis2::Y][0].stroke;
- let right = styles[Axis2::Y][1].stroke;
-
- let top_color = styles[Axis2::X][0].color;
- let bottom_color = styles[Axis2::X][1].color;
- let left_color = styles[Axis2::Y][0].color;
- let right_color = styles[Axis2::Y][1].color;
-
- // The algorithm here is somewhat subtle, to allow it to handle
- // all the kinds of intersections that we need.
- //
- // Three additional ordinates are assigned along the X axis. The first
- // is `xc`, midway between `x0` and `x3`. The others are `x1` and `x2`;
- // for a single vertical line these are equal to `xc`, and for a double
- // vertical line they are the ordinates of the left and right half of
- // the double line.
- //
- // `yc`, `y1`, and `y2` are assigned similarly along the Y axis.
- //
- // The following diagram shows the coordinate system and output for
- // double top and bottom lines, single left line, and no right line:
- //
- // ```
- // x0 x1 xc x2 x3
- // y0 ________________________
- // | # # |
- // | # # |
- // | # # |
- // | # # |
- // | # # |
- // y1 = y2 = yc |######### # |
- // | # # |
- // | # # |
- // | # # |
- // | # # |
- // y3 |________#_____#_______|
- // ```
-
- // Offset from center of each line in a pair of double lines.
- let double_line_ofs = (LINE_SPACE + LINE_WIDTH) / 2;
-
- // Are the lines along each axis single or double? (It doesn't make
- // sense to have different kinds of line on the same axis, so we don't
- // try to gracefully handle that case.)
- let double_vert = top == Stroke::Double || bottom == Stroke::Double;
- let double_horz = left == Stroke::Double || right == Stroke::Double;
-
- // When horizontal lines are doubled, the left-side line along `y1`
- // normally runs from `x0` to `x2`, and the right-side line along `y1`
- // from `x3` to `x1`. If the top-side line is also doubled, we shorten
- // the `y1` lines, so that the left-side line runs only to `x1`, and the
- // right-side line only to `x2`. Otherwise, the horizontal line at `y =
- // y1` below would cut off the intersection, which looks ugly:
- //
- // ```
- // x0 x1 x2 x3
- // y0 ________________________
- // | # # |
- // | # # |
- // | # # |
- // | # # |
- // y1 |######### ########|
- // | |
- // | |
- // y2 |######################|
- // | |
- // | |
- // y3 |______________________|
- // ```
- //
- // It is more of a judgment call when the horizontal line is single. We
- // choose to cut off the line anyhow, as shown in the first diagram
- // above.
- let shorten_y1_lines = top == Stroke::Double;
- let shorten_y2_lines = bottom == Stroke::Double;
- let shorten_yc_line = shorten_y1_lines && shorten_y2_lines;
- let horz_line_ofs = if double_vert { double_line_ofs } else { 0 };
- let xc = (x0 + x3) / 2;
- let x1 = xc - horz_line_ofs;
- let x2 = xc + horz_line_ofs;
-
- let shorten_x1_lines = left == Stroke::Double;
- let shorten_x2_lines = right == Stroke::Double;
- let shorten_xc_line = shorten_x1_lines && shorten_x2_lines;
- let vert_line_ofs = if double_horz { double_line_ofs } else { 0 };
- let yc = (y0 + y3) / 2;
- let y1 = yc - vert_line_ofs;
- let y2 = yc + vert_line_ofs;
-
- let horz_lines: SmallVec<[_; 2]> = if double_horz {
- smallvec![(y1, shorten_y1_lines), (y2, shorten_y2_lines)]
- } else {
- smallvec![(yc, shorten_yc_line)]
- };
- for (y, shorten) in horz_lines {
- if left != Stroke::None
- && right != Stroke::None
- && !shorten
- && left_color == right_color
- {
- self.do_draw_line(x0, y, x3, y, left, left_color);
- } else {
- if left != Stroke::None {
- self.do_draw_line(x0, y, if shorten { x1 } else { x2 }, y, left, left_color);
- }
- if right != Stroke::None {
- self.do_draw_line(if shorten { x2 } else { x1 }, y, x3, y, right, right_color);
- }
- }
- }
-
- let vert_lines: SmallVec<[_; 2]> = if double_vert {
- smallvec![(x1, shorten_x1_lines), (x2, shorten_x2_lines)]
- } else {
- smallvec![(xc, shorten_xc_line)]
- };
- for (x, shorten) in vert_lines {
- if top != Stroke::None
- && bottom != Stroke::None
- && !shorten
- && top_color == bottom_color
- {
- self.do_draw_line(x, y0, x, y3, top, top_color);
- } else {
- if top != Stroke::None {
- self.do_draw_line(x, y0, x, if shorten { y1 } else { y2 }, top, top_color);
- }
- if bottom != Stroke::None {
- self.do_draw_line(
- x,
- if shorten { y2 } else { y1 },
- x,
- y3,
- bottom,
- bottom_color,
- );
- }
- }
- }
- }
-
- fn draw_cell(
- &mut self,
- draw_cell: &DrawCell,
- alternate_row: bool,
- mut bb: Rect2,
- valign_offset: usize,
- spill: EnumMap<Axis2, [usize; 2]>,
- clip: &Rect2,
- ) {
- let fg = &draw_cell.style.font_style.fg[alternate_row as usize];
- let bg = &draw_cell.style.font_style.bg[alternate_row as usize];
-
- if (bg.r != 255 || bg.g != 255 || bg.b != 255) && bg.alpha != 0 {
- self.context.save().unwrap();
- let bg_clip = Rect2::from_fn(|axis| {
- let start = if bb[axis].start == clip[axis].start {
- clip[axis].start.saturating_sub(spill[axis][0])
- } else {
- clip[axis].start
- };
- let end = if bb[axis].end == clip[axis].end {
- clip[axis].end + spill[axis][1]
- } else {
- clip[axis].end
- };
- start..end
- });
- xr_clip(self.context, &bg_clip);
- xr_set_color(self.context, bg);
- let x0 = bb[Axis2::X].start.saturating_sub(spill[Axis2::X][0]);
- let y0 = bb[Axis2::Y].start.saturating_sub(spill[Axis2::X][1]);
- let x1 = bb[Axis2::X].end + spill[Axis2::X][1];
- let y1 = bb[Axis2::Y].end + spill[Axis2::Y][1];
- xr_fill_rectangle(self.context, Rect2::new(x0..x1, y0..y1));
- self.context.restore().unwrap();
- }
-
- if !self.style.use_system_colors {
- xr_set_color(self.context, fg);
- }
-
- self.context.save().unwrap();
- bb[Axis2::Y].start += valign_offset;
- for axis in [Axis2::X, Axis2::Y] {
- bb[axis].start += px_to_xr(draw_cell.style.cell_style.margins[axis][0].max(0) as usize);
- bb[axis].end = bb[axis]
- .end
- .saturating_sub(draw_cell.style.cell_style.margins[axis][0].max(0) as usize);
- }
- if bb[Axis2::X].start < bb[Axis2::X].end && bb[Axis2::Y].start < bb[Axis2::Y].end {
- self.layout_cell(draw_cell, bb, clip);
- }
- self.context.restore().unwrap();
- }
-
- fn scale(&mut self, factor: f64) {
- self.context.scale(factor, factor);
- }
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use std::sync::Arc;
-
-use cairo::{Context, RecordingSurface};
-use enum_map::EnumMap;
-use pango::{FontDescription, Layout};
-
-use crate::output::{
- Item, ItemCursor,
- cairo::{
- fsm::{CairoFsm, CairoFsmStyle},
- horz_align_to_pango, xr_to_pt,
- },
- page::Heading,
- pivot::Axis2,
-};
-
-#[derive(Clone, Debug)]
-pub struct CairoPageStyle {
- pub margins: EnumMap<Axis2, [usize; 2]>,
- pub headings: [Heading; 2],
- pub initial_page_number: i32,
-}
-
-pub struct CairoPager {
- page_style: Arc<CairoPageStyle>,
- fsm_style: Arc<CairoFsmStyle>,
- page_index: i32,
- heading_heights: [usize; 2],
- iter: Option<ItemCursor>,
- context: Option<Context>,
- fsm: Option<CairoFsm>,
- y: usize,
-}
-
-impl CairoPager {
- pub fn new(mut page_style: Arc<CairoPageStyle>, mut fsm_style: Arc<CairoFsmStyle>) -> Self {
- let heading_heights = measure_headings(&page_style, &fsm_style);
- let total = heading_heights.iter().sum::<usize>();
- if (0..fsm_style.size[Axis2::Y]).contains(&total) {
- let fsm_style = Arc::make_mut(&mut fsm_style);
- let page_style = Arc::make_mut(&mut page_style);
- #[allow(clippy::needless_range_loop)]
- for i in 0..2 {
- page_style.margins[Axis2::Y][i] += heading_heights[i];
- }
- fsm_style.size[Axis2::Y] -= total;
- }
- Self {
- page_style,
- fsm_style,
- page_index: 0,
- heading_heights,
- iter: None,
- context: None,
- fsm: None,
- y: 0,
- }
- }
-
- pub fn add_page(&mut self, context: Context) {
- assert!(self.context.is_none());
- context.save().unwrap();
- self.y = 0;
-
- context.translate(
- xr_to_pt(self.page_style.margins[Axis2::X][0]),
- xr_to_pt(self.page_style.margins[Axis2::Y][0]),
- );
-
- let page_number = self.page_index + self.page_style.initial_page_number;
- self.page_index += 1;
-
- if self.heading_heights[0] > 0 {
- render_heading(
- &context,
- &self.fsm_style.font,
- &self.page_style.headings[0],
- page_number,
- self.fsm_style.size[Axis2::X],
- 0, /* XXX*/
- self.fsm_style.font_resolution,
- );
- }
- if self.heading_heights[0] > 0 {
- render_heading(
- &context,
- &self.fsm_style.font,
- &self.page_style.headings[1],
- page_number,
- self.fsm_style.size[Axis2::X],
- self.fsm_style.size[Axis2::Y] + self.fsm_style.object_spacing,
- self.fsm_style.font_resolution,
- );
- }
-
- self.context = Some(context);
- self.run();
- }
-
- pub fn finish_page(&mut self) {
- if let Some(context) = self.context.take() {
- context.restore().unwrap();
- }
- }
-
- pub fn needs_new_page(&mut self) -> bool {
- if self.iter.is_some()
- && (self.context.is_none() || self.y >= self.fsm_style.size[Axis2::Y])
- {
- self.finish_page();
- true
- } else {
- false
- }
- }
-
- pub fn add_item(&mut self, item: Arc<Item>) {
- self.iter = Some(ItemCursor::new(item));
- self.run();
- }
-
- fn run(&mut self) {
- let Some(context) = self.context.as_ref().cloned() else {
- return;
- };
- if self.iter.is_none() || self.y >= self.fsm_style.size[Axis2::Y] {
- return;
- }
-
- loop {
- // Make sure we've got an object to render.
- let fsm = match &mut self.fsm {
- Some(fsm) => fsm,
- None => {
- // If there are no remaining objects to render, then we're done.
- let Some(iter) = self.iter.as_mut() else {
- return;
- };
- let Some(item) = iter.cur().cloned() else {
- self.iter = None;
- return;
- };
- iter.next();
- self.fsm
- .insert(CairoFsm::new(self.fsm_style.clone(), true, &context, item))
- }
- };
-
- // Prepare to render the current object.
- let chunk = fsm.draw_slice(
- &context,
- self.fsm_style.size[Axis2::Y].saturating_sub(self.y),
- );
- self.y += chunk + self.fsm_style.object_spacing;
- context.translate(0.0, xr_to_pt(chunk + self.fsm_style.object_spacing));
-
- if fsm.is_done() {
- self.fsm = None;
- } else if chunk == 0 {
- assert!(self.y > 0);
- self.y = usize::MAX;
- return;
- }
- }
- }
-}
-
-fn measure_headings(page_style: &CairoPageStyle, fsm_style: &CairoFsmStyle) -> [usize; 2] {
- let surface = RecordingSurface::create(cairo::Content::Color, None).unwrap();
- let context = Context::new(&surface).unwrap();
-
- let mut heading_heights = Vec::with_capacity(2);
- for heading in &page_style.headings {
- let mut height = render_heading(
- &context,
- &fsm_style.font,
- heading,
- -1,
- fsm_style.size[Axis2::X],
- 0,
- fsm_style.font_resolution,
- );
- if height > 0 {
- height += fsm_style.object_spacing;
- }
- heading_heights.push(height);
- }
- heading_heights.try_into().unwrap()
-}
-
-fn render_heading(
- context: &Context,
- font: &FontDescription,
- heading: &Heading,
- _page_number: i32,
- width: usize,
- base_y: usize,
- font_resolution: f64,
-) -> usize {
- let pangocairo_context = pangocairo::functions::create_context(context);
- pangocairo::functions::context_set_resolution(&pangocairo_context, font_resolution);
- let layout = Layout::new(&pangocairo_context);
- layout.set_font_description(Some(font));
-
- let mut y = 0;
- for paragraph in &heading.0 {
- // XXX substitute heading variables
- layout.set_markup(¶graph.markup);
-
- layout.set_alignment(horz_align_to_pango(paragraph.horz_align));
- layout.set_width(width as i32);
-
- context.save().unwrap();
- context.translate(0.0, xr_to_pt(y + base_y));
- pangocairo::functions::show_layout(context, &layout);
- context.restore().unwrap();
-
- y += layout.height() as usize;
- }
- y
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use std::{
- borrow::Cow,
- fmt::Display,
- fs::File,
- io::{BufWriter, Error, Write},
- path::PathBuf,
- sync::Arc,
-};
-
-use serde::{
- Deserialize, Deserializer, Serialize,
- de::{Unexpected, Visitor},
-};
-
-use crate::output::pivot::Coord2;
-
-use super::{Details, Item, TextType, driver::Driver, pivot::PivotTable, table::Table};
-
-#[derive(Clone, Debug, Serialize, Deserialize)]
-pub struct CsvConfig {
- file: PathBuf,
- #[serde(flatten)]
- options: CsvOptions,
-}
-
-pub struct CsvDriver {
- file: BufWriter<File>,
- options: CsvOptions,
-
- /// Number of items written so far.
- n_items: usize,
-}
-
-#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
-#[serde(default)]
-struct CsvOptions {
- #[serde(deserialize_with = "deserialize_ascii_char")]
- quote: u8,
- delimiter: u8,
-}
-
-fn deserialize_ascii_char<'de, D>(deserializer: D) -> Result<u8, D::Error>
-where
- D: Deserializer<'de>,
-{
- struct AsciiCharVisitor;
- impl<'de> Visitor<'de> for AsciiCharVisitor {
- type Value = u8;
- fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
- write!(f, "a single ASCII character")
- }
- fn visit_str<E>(self, s: &str) -> Result<u8, E>
- where
- E: serde::de::Error,
- {
- if s.len() == 1 {
- Ok(s.chars().next().unwrap() as u8)
- } else {
- Err(serde::de::Error::invalid_value(Unexpected::Str(s), &self))
- }
- }
- }
- deserializer.deserialize_char(AsciiCharVisitor)
-}
-
-impl Default for CsvOptions {
- fn default() -> Self {
- Self {
- quote: b'"',
- delimiter: b',',
- }
- }
-}
-
-impl CsvOptions {
- fn byte_needs_quoting(&self, b: u8) -> bool {
- b == b'\r' || b == b'\n' || b == self.quote || b == self.delimiter
- }
-
- fn string_needs_quoting(&self, s: &str) -> bool {
- s.bytes().any(|b| self.byte_needs_quoting(b))
- }
-}
-
-struct CsvField<'a> {
- text: &'a str,
- options: CsvOptions,
-}
-
-impl<'a> CsvField<'a> {
- fn new(text: &'a str, options: CsvOptions) -> Self {
- Self { text, options }
- }
-}
-
-impl Display for CsvField<'_> {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- if self.options.string_needs_quoting(self.text) {
- let quote = self.options.quote as char;
- write!(f, "{quote}")?;
- for c in self.text.chars() {
- if c == quote {
- write!(f, "{c}")?;
- }
- write!(f, "{c}")?;
- }
- write!(f, "{quote}")
- } else {
- write!(f, "{}", self.text)
- }
- }
-}
-
-impl CsvDriver {
- pub fn new(config: &CsvConfig) -> std::io::Result<Self> {
- Ok(Self {
- file: BufWriter::new(File::create(&config.file)?),
- options: config.options,
- n_items: 0,
- })
- }
-
- fn start_item(&mut self) {
- if self.n_items > 0 {
- writeln!(&mut self.file).unwrap();
- }
- self.n_items += 1;
- }
-
- fn output_table_layer(&mut self, pt: &PivotTable, layer: &[usize]) -> Result<(), Error> {
- let output = pt.output(layer, true);
- self.start_item();
-
- self.output_table(pt, output.title.as_ref(), Some("Table"))?;
- self.output_table(pt, output.layers.as_ref(), Some("Layer"))?;
- self.output_table(pt, Some(&output.body), None)?;
- self.output_table(pt, output.caption.as_ref(), Some("Caption"))?;
- self.output_table(pt, output.footnotes.as_ref(), Some("Footnote"))?;
- Ok(())
- }
-
- fn output_table(
- &mut self,
- pivot_table: &PivotTable,
- table: Option<&Table>,
- leader: Option<&str>,
- ) -> Result<(), Error> {
- let Some(table) = table else {
- return Ok(());
- };
-
- for y in 0..table.n.y() {
- for x in 0..table.n.x() {
- if x > 0 {
- write!(&mut self.file, "{}", self.options.delimiter as char)?;
- }
-
- let coord = Coord2::new(x, y);
- let content = table.get(coord);
- if content.is_top_left() {
- let display = content.inner().value.display(pivot_table);
- let s = match leader {
- Some(leader) if x == 0 && y == 0 => format!("{leader}: {display}"),
- _ => display.to_string(),
- };
- write!(&mut self.file, "{}", CsvField::new(&s, self.options))?;
- }
- }
- writeln!(&mut self.file)?;
- }
-
- Ok(())
- }
-}
-
-impl Driver for CsvDriver {
- fn name(&self) -> Cow<'static, str> {
- Cow::from("csv")
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- // todo: error handling (should not unwrap)
- match &item.details {
- Details::Chart | Details::Image | Details::Group(_) => (),
- Details::Message(diagnostic) => {
- self.start_item();
- let text = diagnostic.to_string();
- writeln!(&mut self.file, "{}", CsvField::new(&text, self.options)).unwrap();
- }
- Details::Table(pivot_table) => {
- for layer in pivot_table.layers(true) {
- self.output_table_layer(pivot_table, &layer).unwrap();
- }
- }
- Details::PageBreak => {
- self.start_item();
- writeln!(&mut self.file).unwrap();
- }
- Details::Text(text) => match text.type_ {
- TextType::Syntax | TextType::PageTitle => (),
- TextType::Title | TextType::Log => {
- self.start_item();
- for line in text.content.display(()).to_string().lines() {
- writeln!(&mut self.file, "{}", CsvField::new(line, self.options)).unwrap();
- }
- }
- },
- }
- }
-
- fn flush(&mut self) {
- let _ = self.file.flush();
- }
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use std::{borrow::Cow, path::Path, sync::Arc};
-
-use clap::ValueEnum;
-use serde::{Deserialize, Serialize};
-
-use crate::output::{
- cairo::{CairoConfig, CairoDriver},
- csv::{CsvConfig, CsvDriver},
- html::{HtmlConfig, HtmlDriver},
- json::{JsonConfig, JsonDriver},
- spv::{SpvConfig, SpvDriver},
- text::{TextConfig, TextDriver},
-};
-
-use super::{Item, page::PageSetup};
-
-// An output driver.
-pub trait Driver {
- fn name(&self) -> Cow<'static, str>;
-
- fn write(&mut self, item: &Arc<Item>);
-
- /// Returns false if the driver doesn't support page setup.
- fn setup(&mut self, page_setup: &PageSetup) -> bool {
- let _ = page_setup;
- false
- }
-
- /// Ensures that anything written with [Self::write] has been displayed.
- ///
- /// This is called from the text-based UI before showing the command prompt,
- /// to ensure that the user has actually been shown any preceding output If
- /// it doesn't make sense for this driver to be used this way, then this
- /// function need not do anything.
- fn flush(&mut self) {}
-
- /// Ordinarily, the core driver code will skip passing hidden output items
- /// to [Self::write]. If this returns true, the core driver hands them to
- /// the driver to let it handle them itself.
- fn handles_show(&self) -> bool {
- false
- }
-
- /// Ordinarily, the core driver code will flatten groups of output items
- /// before passing them to [Self::write]. If this returns true, the core
- /// driver code leaves them in place for the driver to handle.
- fn handles_groups(&self) -> bool {
- false
- }
-}
-
-impl Driver for Box<dyn Driver> {
- fn name(&self) -> Cow<'static, str> {
- (**self).name()
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- (**self).write(item);
- }
-
- fn setup(&mut self, page_setup: &PageSetup) -> bool {
- (**self).setup(page_setup)
- }
-
- fn flush(&mut self) {
- (**self).flush();
- }
-
- fn handles_show(&self) -> bool {
- (**self).handles_show()
- }
-
- fn handles_groups(&self) -> bool {
- (**self).handles_groups()
- }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize)]
-#[serde(tag = "driver", rename_all = "snake_case")]
-pub enum Config {
- Text(TextConfig),
- Pdf(CairoConfig),
- Html(HtmlConfig),
- Json(JsonConfig),
- Csv(CsvConfig),
- Spv(SpvConfig),
-}
-
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, ValueEnum)]
-#[serde(rename_all = "snake_case")]
-pub enum DriverType {
- Text,
- Pdf,
- Html,
- Csv,
- Json,
- Spv,
-}
-
-impl dyn Driver {
- pub fn new(config: &Config) -> anyhow::Result<Box<Self>> {
- match config {
- Config::Text(text_config) => Ok(Box::new(TextDriver::new(text_config)?)),
- Config::Pdf(cairo_config) => Ok(Box::new(CairoDriver::new(cairo_config)?)),
- Config::Html(html_config) => Ok(Box::new(HtmlDriver::new(html_config)?)),
- Config::Csv(csv_config) => Ok(Box::new(CsvDriver::new(csv_config)?)),
- Config::Json(json_config) => Ok(Box::new(JsonDriver::new(json_config)?)),
- Config::Spv(spv_config) => Ok(Box::new(SpvDriver::new(spv_config)?)),
- }
- }
-
- pub fn driver_type_from_filename(file: impl AsRef<Path>) -> Option<&'static str> {
- match file.as_ref().extension()?.to_str()? {
- "txt" | "text" => Some("text"),
- "pdf" => Some("pdf"),
- "htm" | "html" => Some("html"),
- "csv" => Some("csv"),
- "json" => Some("json"),
- "spv" => Some("spv"),
- _ => None,
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use serde::Serialize;
-
- use crate::output::driver::Config;
-
- #[test]
- fn toml() {
- let config = r#"driver = "text"
-file = "filename.text"
-"#;
- let toml: Config = toml::from_str(config).unwrap();
- println!("{}", toml::to_string_pretty(&toml).unwrap());
-
- #[derive(Serialize)]
- struct Map<'a> {
- file: &'a str,
- }
- println!(
- "{}",
- toml::to_string_pretty(&Map { file: "filename" }).unwrap()
- );
- }
-}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use std::{borrow::Cow, fmt::Write, path::Path, sync::Arc};
+
+use anyhow::{anyhow, bail};
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ data::{ByteString, Case, Datum},
+ dictionary::Dictionary,
+};
+
+use super::{Item, page::PageSetup};
+
+pub mod cairo;
+use cairo::{CairoConfig, CairoDriver};
+
+pub mod csv;
+use csv::{CsvConfig, CsvDriver};
+
+pub mod html;
+use html::{HtmlConfig, HtmlDriver};
+
+pub mod json;
+use json::{JsonConfig, JsonDriver};
+
+pub mod por;
+use por::{PorConfig, PorDriver};
+
+pub mod sav;
+use sav::{SavConfig, SavDriver};
+
+pub mod spv;
+use spv::{SpvConfig, SpvDriver};
+
+pub mod text;
+use text::{TextConfig, TextDriver};
+
+// An output driver.
+pub trait Driver {
+ fn name(&self) -> Cow<'static, str>;
+
+ fn write(&mut self, item: &Arc<Item>);
+
+ fn can_serialize(&self) -> bool {
+ false
+ }
+
+ fn serialize(&mut self, item: &dyn erased_serde::Serialize) {
+ let _ = item;
+ unreachable!("This driver does not support serialization");
+ }
+
+ fn can_write_data_file(&self) -> bool {
+ false
+ }
+
+ fn write_data_file<'a>(
+ &'a mut self,
+ dictionary: &'a Dictionary,
+ ) -> anyhow::Result<Option<Box<dyn CaseWriter + 'a>>> {
+ let _ = dictionary;
+ Ok(None)
+ }
+
+ /// Returns false if the driver doesn't support page setup.
+ fn setup(&mut self, page_setup: &PageSetup) -> bool {
+ let _ = page_setup;
+ false
+ }
+
+ /// Ensures that anything written with [Self::write] has been displayed.
+ ///
+ /// This is called from the text-based UI before showing the command prompt,
+ /// to ensure that the user has actually been shown any preceding output If
+ /// it doesn't make sense for this driver to be used this way, then this
+ /// function need not do anything.
+ fn flush(&mut self) {}
+
+ /// Ordinarily, the core driver code will skip passing hidden output items
+ /// to [Self::write]. If this returns true, the core driver hands them to
+ /// the driver to let it handle them itself.
+ fn handles_show(&self) -> bool {
+ false
+ }
+
+ /// Ordinarily, the core driver code will flatten groups of output items
+ /// before passing them to [Self::write]. If this returns true, the core
+ /// driver code leaves them in place for the driver to handle.
+ fn handles_groups(&self) -> bool {
+ false
+ }
+}
+
+impl Driver for Box<dyn Driver> {
+ fn name(&self) -> Cow<'static, str> {
+ (**self).name()
+ }
+
+ fn write(&mut self, item: &Arc<Item>) {
+ (**self).write(item);
+ }
+
+ fn setup(&mut self, page_setup: &PageSetup) -> bool {
+ (**self).setup(page_setup)
+ }
+
+ fn flush(&mut self) {
+ (**self).flush();
+ }
+
+ fn handles_show(&self) -> bool {
+ (**self).handles_show()
+ }
+
+ fn handles_groups(&self) -> bool {
+ (**self).handles_groups()
+ }
+
+ fn can_serialize(&self) -> bool {
+ (**self).can_serialize()
+ }
+
+ fn serialize(&mut self, item: &dyn erased_serde::Serialize) {
+ (**self).serialize(item);
+ }
+
+ fn can_write_data_file(&self) -> bool {
+ (**self).can_write_data_file()
+ }
+
+ fn write_data_file<'a>(
+ &'a mut self,
+ dictionary: &'a Dictionary,
+ ) -> anyhow::Result<Option<Box<dyn CaseWriter + 'a>>> {
+ (**self).write_data_file(dictionary)
+ }
+}
+
+pub trait CaseWriter {
+ fn write_case(&mut self, case: Case<Vec<Datum<ByteString>>>) -> anyhow::Result<()>;
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(tag = "driver", rename_all = "snake_case")]
+pub enum Config {
+ Text(TextConfig),
+ Pdf(CairoConfig),
+ Html(HtmlConfig),
+ Json(JsonConfig),
+ Csv(CsvConfig),
+ Por(PorConfig),
+ Sav(SavConfig),
+ Spv(SpvConfig),
+}
+
+impl dyn Driver {
+ /// Creates a driver for writing to `file`. If `file` is `None`, then the
+ /// driver will write to stdout. `options` may specify options to pass to
+ /// the driver, in TOML format.
+ ///
+ /// The driver used is the one specified in `options`. If `options` doesn't
+ /// specify a driver, then:
+ ///
+ /// - If `file` is provided, then it is chosen based on `file`'s extension,
+ /// returning an error if the extension is unknown.
+ ///
+ /// - If `file` is `None`, then `default_driver` is the driver.
+ pub fn from_options<P>(
+ file: Option<P>,
+ options: &[String],
+ default_driver: &str,
+ ) -> anyhow::Result<Box<Self>>
+ where
+ P: AsRef<Path>,
+ {
+ // Compose initial TOML from the options.
+ let mut config = String::new();
+ for option in options {
+ writeln!(&mut config, "{option}").unwrap();
+ }
+ let mut config: toml::Table = toml::from_str(&config)?;
+
+ // Insert `file`, if we have one.
+ let file = file.as_ref().map(|p| p.as_ref());
+ if let Some(file) = file {
+ let Some(file) = file.to_str() else {
+ bail!("{}: not a valid UTF-8 filename", file.display())
+ };
+ config.insert(String::from("file"), toml::Value::String(file.into()));
+ }
+
+ // Choose a driver.
+ if !config.contains_key("driver") {
+ let driver = if let Some(file) = file {
+ <dyn Driver>::driver_type_from_filename(file).ok_or_else(|| {
+ anyhow!("{}: no default output format for file name", file.display())
+ })?
+ } else {
+ default_driver
+ };
+ config.insert(String::from("driver"), toml::Value::String(driver.into()));
+ }
+
+ Self::new(&Config::deserialize(config)?)
+ }
+
+ pub fn new(config: &Config) -> anyhow::Result<Box<Self>> {
+ match config {
+ Config::Csv(csv_config) => Ok(Box::new(CsvDriver::new(csv_config)?)),
+ Config::Html(html_config) => Ok(Box::new(HtmlDriver::new(html_config)?)),
+ Config::Json(json_config) => Ok(Box::new(JsonDriver::new(json_config)?)),
+ Config::Pdf(cairo_config) => Ok(Box::new(CairoDriver::new(cairo_config)?)),
+ Config::Por(por_config) => Ok(Box::new(PorDriver::new(por_config)?)),
+ Config::Sav(sav_config) => Ok(Box::new(SavDriver::new(sav_config)?)),
+ Config::Spv(spv_config) => Ok(Box::new(SpvDriver::new(spv_config)?)),
+ Config::Text(text_config) => Ok(Box::new(TextDriver::new(text_config)?)),
+ }
+ }
+
+ pub fn driver_type_from_filename(file: impl AsRef<Path>) -> Option<&'static str> {
+ match file.as_ref().extension()?.to_str()? {
+ "txt" | "text" => Some("text"),
+ "pdf" => Some("pdf"),
+ "htm" | "html" => Some("html"),
+ "csv" => Some("csv"),
+ "json" | "ndjson" => Some("json"),
+ "spv" => Some("spv"),
+ "sav" => Some("sav"),
+ "por" => Some("por"),
+ _ => None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde::Serialize;
+
+ use crate::output::drivers::Config;
+
+ #[test]
+ fn toml() {
+ let config = r#"driver = "text"
+file = "filename.text"
+"#;
+ let toml: Config = toml::from_str(config).unwrap();
+ println!("{}", toml::to_string_pretty(&toml).unwrap());
+
+ #[derive(Serialize)]
+ struct Map<'a> {
+ file: &'a str,
+ }
+ println!(
+ "{}",
+ toml::to_string_pretty(&Map { file: "filename" }).unwrap()
+ );
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use pango::SCALE;
+
+use crate::output::pivot::look::HorzAlign;
+
+mod driver;
+pub mod fsm;
+pub mod pager;
+
+pub use driver::{CairoConfig, CairoDriver};
+
+/// Conversion from 1/96" units ("pixels") to Cairo/Pango units.
+fn px_to_xr(x: isize) -> isize {
+ x * 3 * (SCALE as isize * 72 / 96) / 3
+}
+
+fn xr_to_pt(x: isize) -> f64 {
+ x as f64 / SCALE as f64
+}
+
+impl From<HorzAlign> for pango::Alignment {
+ fn from(horz_align: HorzAlign) -> Self {
+ match horz_align {
+ HorzAlign::Right | HorzAlign::Decimal { .. } => pango::Alignment::Right,
+ HorzAlign::Left => pango::Alignment::Left,
+ HorzAlign::Center => pango::Alignment::Center,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::output::drivers::cairo::{CairoConfig, CairoDriver};
+
+ #[test]
+ fn create() {
+ CairoDriver::new(&CairoConfig::new("test.pdf")).unwrap();
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use std::{
+ borrow::Cow,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+
+use cairo::{Context, PdfSurface};
+use chrono::{Local, NaiveDateTime};
+use enum_map::{EnumMap, enum_map};
+use pango::SCALE;
+use paper_sizes::Unit;
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ output::{
+ Item, ItemCursor, TextType,
+ drivers::{
+ Driver,
+ cairo::{
+ fsm::{CairoFsmStyle, parse_font_style},
+ pager::{CairoPageStyle, CairoPager},
+ },
+ },
+ page::PageSetup,
+ pivot::{
+ Coord2,
+ look::{Color, FontStyle},
+ },
+ },
+ spv::html::Variable,
+};
+
+use crate::output::pivot::Axis2;
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct CairoConfig {
+ /// Output file name.
+ pub file: PathBuf,
+
+ /// Page setup.
+ pub page_setup: Option<PageSetup>,
+}
+
+impl CairoConfig {
+ pub fn new(path: impl AsRef<Path>) -> Self {
+ Self {
+ file: path.as_ref().to_path_buf(),
+ page_setup: None,
+ }
+ }
+}
+
+pub struct CairoDriver {
+ now: NaiveDateTime,
+ fsm_style: Arc<CairoFsmStyle>,
+ page_style: Arc<CairoPageStyle>,
+ pager: Option<CairoPager>,
+ surface: PdfSurface,
+ title: String,
+}
+
+impl CairoDriver {
+ pub fn new(config: &CairoConfig) -> cairo::Result<Self> {
+ fn scale(inches: f64) -> isize {
+ (inches * 72.0 * SCALE as f64).max(0.0).round() as isize
+ }
+
+ let default_page_setup;
+ let page_setup = match &config.page_setup {
+ Some(page_setup) => page_setup,
+ None => {
+ default_page_setup = PageSetup::default();
+ &default_page_setup
+ }
+ };
+ let printable = page_setup.printable_size();
+ let page_style = CairoPageStyle {
+ margins: EnumMap::from_fn(|axis| {
+ [
+ scale(page_setup.margins.0[axis][0].into_unit(Unit::Inch)),
+ scale(page_setup.margins.0[axis][1].into_unit(Unit::Inch)),
+ ]
+ }),
+ header: page_setup.header.clone(),
+ footer: page_setup.footer.clone(),
+ initial_page_number: page_setup.initial_page_number,
+ };
+ let size = Coord2::new(scale(printable[Axis2::X]), scale(printable[Axis2::Y]));
+ let font = FontStyle::default().with_size(9);
+ let font = parse_font_style(&font);
+ let fsm_style = CairoFsmStyle {
+ size,
+ min_break: enum_map! {
+ Axis2::X => size[Axis2::X] / 2,
+ Axis2::Y => size[Axis2::Y] / 2,
+ },
+ font,
+ fg: Color::BLACK,
+ use_system_colors: false,
+ object_spacing: scale(page_setup.object_spacing.into_unit(Unit::Inch)),
+ font_resolution: 72.0,
+ };
+ let (width, height) = page_setup.paper.as_unit(Unit::Point).into_width_height();
+ let surface = PdfSurface::new(width, height, &config.file)?;
+ Ok(Self {
+ now: Local::now().naive_local(),
+ fsm_style: Arc::new(fsm_style),
+ page_style: Arc::new(page_style),
+ pager: None,
+ surface,
+ title: String::new(),
+ })
+ }
+}
+
+impl Driver for CairoDriver {
+ fn name(&self) -> Cow<'static, str> {
+ Cow::from("cairo")
+ }
+
+ fn write(&mut self, item: &Arc<Item>) {
+ let pager = self.pager.get_or_insert_with(|| {
+ CairoPager::new(self.page_style.clone(), self.fsm_style.clone())
+ });
+ let mut cursor = ItemCursor::new(item.clone());
+ while let Some(item) = cursor.cur() {
+ if let Some(text) = item.details.as_text()
+ && text.type_ == TextType::PageTitle
+ {
+ self.title = text.content.display(()).to_string();
+ } else {
+ pager.add_item(item.clone());
+ while pager.needs_new_page() {
+ let context = Context::new(&self.surface).unwrap();
+ if pager.has_page() {
+ pager.finish_page();
+ context.show_page().unwrap();
+ }
+
+ pager.add_page(context, |variable, page_number| match variable {
+ Variable::Date => self.now.format("%Y-%m-%d").to_string(),
+ Variable::Time => self.now.format("%H:%M:%S").to_string(),
+ Variable::Head(level) => {
+ cursor.heading(level as usize).unwrap_or_default().into()
+ }
+ Variable::PageTitle => self.title.clone(),
+ Variable::Page => page_number.to_string(),
+ });
+ }
+ }
+ cursor.next();
+ }
+ }
+}
+
+impl Drop for CairoDriver {
+ fn drop(&mut self) {
+ if self.pager.is_some() {
+ let context = Context::new(&self.surface).unwrap();
+ context.show_page().unwrap();
+ }
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use std::{cmp::min, f64::consts::PI, fmt::Write, ops::DerefMut, sync::Arc};
+
+use cairo::Context;
+use enum_map::{EnumMap, enum_map};
+use itertools::Itertools;
+use pango::{
+ AttrFloat, AttrFontDesc, AttrInt, AttrList, Attribute, FontDescription, FontMask, Layout,
+ SCALE, SCALE_SMALL, Underline, Weight,
+};
+use pangocairo::functions::show_layout;
+use smallvec::{SmallVec, smallvec};
+
+use crate::{
+ output::{
+ Details, Item,
+ drivers::cairo::{px_to_xr, xr_to_pt},
+ pivot::{
+ Axis2, Coord2, PivotTable, Rect2,
+ look::{BorderStyle, Color, FontStyle, HorzAlign, Stroke},
+ },
+ render::{Device, Extreme, Pager, Params},
+ table::{Content, DrawCell},
+ },
+ spv::html::Markup,
+};
+
+/// Width of an ordinary line.
+const LINE_WIDTH: isize = LINE_SPACE / 2;
+
+/// Space between double lines.
+const LINE_SPACE: isize = SCALE as isize;
+
+/// Conversion from 1/96" units ("pixels") to Cairo/Pango units.
+fn pxf_to_xr(x: f64) -> isize {
+ (x * (SCALE as f64 * 72.0 / 96.0)).round() as isize
+}
+
+#[derive(Clone, Debug)]
+pub struct CairoFsmStyle {
+ /// Page size.
+ pub size: Coord2,
+
+ /// Minimum cell size to allow breaking.
+ pub min_break: EnumMap<Axis2, isize>,
+
+ /// The basic font.
+ pub font: FontDescription,
+
+ /// Foreground color.
+ pub fg: Color,
+
+ /// Use system colors?
+ pub use_system_colors: bool,
+
+ /// Vertical space between different output items.
+ pub object_spacing: isize,
+
+ /// Resolution, in units per inch, used for measuring font "points":
+ ///
+ /// - 72.0 if 1 pt is one device unit, e.g. for rendering to a surface
+ /// created by [cairo::PsSurface::new] with its default transformation
+ /// matrix of 72 units/inch.
+ ///
+ /// - 96.0 is traditional for a screen-based surface.
+ pub font_resolution: f64,
+}
+
+impl CairoFsmStyle {
+ fn new_layout(&self, context: &Context) -> Layout {
+ let pangocairo_context = pangocairo::functions::create_context(context);
+ pangocairo::functions::context_set_resolution(&pangocairo_context, self.font_resolution);
+ Layout::new(&pangocairo_context)
+ }
+}
+
+pub struct CairoFsm {
+ style: Arc<CairoFsmStyle>,
+ params: Params,
+ item: Arc<Item>,
+ layer_iterator: Option<Box<dyn Iterator<Item = SmallVec<[usize; 4]>>>>,
+ pager: Option<Pager>,
+}
+
+impl CairoFsm {
+ pub fn new(
+ style: Arc<CairoFsmStyle>,
+ printing: bool,
+ context: &Context,
+ item: Arc<Item>,
+ ) -> Self {
+ let params = Params {
+ size: style.size,
+ font_size: {
+ let layout = style.new_layout(context);
+ layout.set_font_description(Some(&style.font));
+ layout.set_text("0");
+ let char_size = layout.size();
+ enum_map! {
+ Axis2::X => char_size.0 as isize,
+ Axis2::Y => char_size.1 as isize
+ }
+ },
+ line_widths: enum_map! {
+ Stroke::None => 0,
+ Stroke::Solid | Stroke::Dashed => LINE_WIDTH,
+ Stroke::Thick => LINE_WIDTH * 2,
+ Stroke::Thin => LINE_WIDTH / 2,
+ Stroke::Double => LINE_WIDTH * 2 + LINE_SPACE,
+ },
+ px_size: Some(px_to_xr(1)),
+ min_break: style.min_break,
+ supports_margins: true,
+ rtl: false,
+ printing,
+ can_adjust_break: false, // XXX
+ can_scale: true,
+ };
+ let device = CairoDevice {
+ style: &style,
+ params: ¶ms,
+ context,
+ };
+ let item = if let Some(text) = item.details.as_text() {
+ Arc::new(Item::new(PivotTable::from(text.clone())))
+ } else {
+ item
+ };
+ let (layer_iterator, pager) = match &item.details {
+ Details::Table(pivot_table) => {
+ let mut layer_iterator = pivot_table.layers(printing);
+ let layer_indexes = layer_iterator.next();
+ (
+ Some(layer_iterator),
+ Some(Pager::new(
+ &device,
+ pivot_table,
+ layer_indexes.as_ref().map(|indexes| indexes.as_slice()),
+ )),
+ )
+ }
+ _ => (None, None),
+ };
+ Self {
+ style,
+ params,
+ item,
+ layer_iterator,
+ pager,
+ }
+ }
+
+ pub fn draw_slice(&mut self, context: &Context, space: isize) -> isize {
+ debug_assert!(self.params.printing);
+
+ context.save().unwrap();
+ let used = match &self.item.details {
+ Details::Table(_) => self.draw_table(context, space),
+ _ => 0,
+ };
+ context.restore().unwrap();
+
+ used
+ }
+
+ fn draw_table(&mut self, context: &Context, space: isize) -> isize {
+ let pivot_table = self.item.details.as_table().unwrap();
+ let Some(pager) = &mut self.pager else {
+ return 0;
+ };
+ let mut device = CairoDevice {
+ style: &self.style,
+ params: &self.params,
+ context,
+ };
+ let mut used = pager.draw_next(&mut device, space);
+ if pager.has_next(&device).is_none() {
+ if let Some(layer_indexes) = self.layer_iterator.as_mut().unwrap().next() {
+ self.pager = Some(Pager::new(
+ &device,
+ pivot_table,
+ Some(layer_indexes.as_slice()),
+ ));
+ if pivot_table.style.look.paginate_layers {
+ used = space;
+ } else {
+ used += self.style.object_spacing;
+ }
+ } else {
+ self.pager = None;
+ }
+ }
+ used.min(space)
+ }
+
+ pub fn is_done(&self) -> bool {
+ match &self.item.details {
+ Details::Table(_) => self.pager.is_none(),
+ _ => true,
+ }
+ }
+}
+
+fn xr_clip(context: &Context, clip: &Rect2) {
+ if clip[Axis2::X].end != isize::MAX || clip[Axis2::Y].end != isize::MAX {
+ let x0 = xr_to_pt(clip[Axis2::X].start);
+ let y0 = xr_to_pt(clip[Axis2::Y].start);
+ let x1 = xr_to_pt(clip[Axis2::X].end);
+ let y1 = xr_to_pt(clip[Axis2::Y].end);
+ context.rectangle(x0, y0, x1 - x0, y1 - y0);
+ context.clip();
+ }
+}
+
+fn xr_set_color(context: &Context, color: Color) {
+ fn as_frac(x: u8) -> f64 {
+ x as f64 / 255.0
+ }
+
+ context.set_source_rgba(
+ as_frac(color.r),
+ as_frac(color.g),
+ as_frac(color.b),
+ as_frac(color.alpha),
+ );
+}
+
+fn xr_fill_rectangle(context: &Context, rectangle: Rect2) {
+ context.new_path();
+ context.set_line_width(xr_to_pt(LINE_WIDTH));
+
+ let x0 = xr_to_pt(rectangle[Axis2::X].start);
+ let y0 = xr_to_pt(rectangle[Axis2::Y].start);
+ let width = xr_to_pt(rectangle[Axis2::X].len() as isize);
+ let height = xr_to_pt(rectangle[Axis2::Y].len() as isize);
+ context.rectangle(x0, y0, width, height);
+ context.fill().unwrap();
+}
+
+fn margin(cell: &DrawCell, axis: Axis2) -> isize {
+ px_to_xr(cell.cell_style.margins[axis].iter().sum::<i32>() as isize)
+}
+
+pub fn parse_font_style(font_style: &FontStyle) -> FontDescription {
+ let font = font_style.font.as_str();
+ let font = if font.eq_ignore_ascii_case("Monospaced") {
+ "Monospace"
+ } else {
+ font
+ };
+ let mut font_desc = FontDescription::from_string(font);
+ if !font_desc.set_fields().contains(FontMask::SIZE) {
+ let default_size = if font_style.size != 0 {
+ font_style.size * 1000
+ } else {
+ 10_000
+ };
+ font_desc.set_size(((default_size as f64 / 1000.0) * (SCALE as f64)) as i32);
+ }
+ font_desc.set_weight(if font_style.bold {
+ Weight::Bold
+ } else {
+ Weight::Normal
+ });
+ font_desc.set_style(if font_style.italic {
+ pango::Style::Italic
+ } else {
+ pango::Style::Normal
+ });
+ font_desc
+}
+
+/// Deal with an oddity of the Unicode line-breaking algorithm (or perhaps in
+/// Pango's implementation of it): it will break after a period or a comma that
+/// precedes a digit, e.g. in `.000` it will break after the period. This code
+/// looks for such a situation and inserts a U+2060 WORD JOINER to prevent the
+/// break.
+///
+/// This isn't necessary when the decimal point is between two digits
+/// (e.g. `0.000` won't be broken) or when the display width is not limited so
+/// that word wrapping won't happen.
+///
+/// It isn't necessary to look for more than one period or comma, as would
+/// happen with grouping like `1,234,567.89` or `1.234.567,89` because if groups
+/// are present then there will always be a digit on both sides of every period
+/// and comma.
+fn avoid_decimal_split(mut s: String) -> String {
+ if let Some(position) = s.find(['.', ',']) {
+ let followed_by_digit = s[position + 1..]
+ .chars()
+ .next()
+ .is_some_and(|c| c.is_ascii_digit());
+ let not_preceded_by_digit = s[..position]
+ .chars()
+ .next_back()
+ .is_none_or(|c| !c.is_ascii_digit());
+ if followed_by_digit && not_preceded_by_digit {
+ s.insert(position + 1, '\u{2060}');
+ }
+ }
+ s
+}
+
+impl<'a, 'b> DrawCell<'a, 'b> {
+ pub(crate) fn layout(&self, bb: &Rect2, layout: &mut Layout, default_font: &FontDescription) {
+ // XXX rotation
+
+ let mut bb = bb.clone();
+ layout.set_attributes(None);
+
+ let parsed_font;
+ let font = if !self.font_style.font.is_empty() {
+ parsed_font = parse_font_style(&self.font_style);
+ &parsed_font
+ } else {
+ default_font
+ };
+ layout.set_font_description(Some(font));
+
+ let (body_display, suffixes) = self.display().split_suffixes();
+ let horz_align = self.horz_align(&body_display);
+
+ let (mut body, mut attrs) = if let Some(markup) = body_display.markup() {
+ let (body, attrs) = Markup::to_pango(markup, self.substitutions);
+ (body, Some(attrs))
+ } else {
+ (avoid_decimal_split(body_display.to_string()), None)
+ };
+
+ if let Some(decimal_offset) = horz_align.decimal_offset()
+ && !self.rotate
+ && let Some(decimal) = body_display.decimal()
+ && let Some(index) = body.rfind(char::from(decimal))
+ {
+ layout.set_text(&body[index..]);
+ layout.set_width(-1);
+ bb[Axis2::X].end -= pxf_to_xr(decimal_offset).saturating_sub(layout.size().0 as isize);
+ }
+
+ if self.font_style.underline {
+ attrs
+ .get_or_insert_default()
+ .insert(AttrInt::new_underline(Underline::Single));
+ }
+
+ if !suffixes.is_empty() {
+ let subscript_ofs = body.len();
+ #[allow(unstable_name_collisions)]
+ body.extend(suffixes.subscripts().intersperse(","));
+ let has_subscripts = subscript_ofs != body.len();
+
+ let footnote_ofs = body.len();
+ for (index, footnote) in suffixes.footnotes().enumerate() {
+ if index > 0 {
+ body.push(',');
+ }
+ write!(&mut body, "{footnote}").unwrap();
+ }
+ let has_footnotes = footnote_ofs != body.len();
+
+ // Allow footnote markers to occupy the right margin. That way,
+ // numbers in the column are still aligned.
+ if has_footnotes && horz_align == HorzAlign::Right {
+ // Measure the width of the footnote marker, so we know how much we
+ // need to make room for.
+ layout.set_text(&body[footnote_ofs..]);
+
+ let footnote_attrs = AttrList::new();
+ footnote_attrs.insert(AttrFloat::new_scale(SCALE_SMALL));
+ footnote_attrs.insert(AttrInt::new_rise(3000));
+ layout.set_attributes(Some(&footnote_attrs));
+ let footnote_width = layout.size().0 as isize;
+
+ // Bound the adjustment by the width of the right margin.
+ let right_margin = px_to_xr(self.cell_style.margins[Axis2::X][1] as isize);
+ let footnote_adjustment = min(footnote_width, right_margin);
+
+ // Adjust the bounding box.
+ if self.rotate {
+ bb[Axis2::X].end = bb[Axis2::X].end.saturating_sub(footnote_adjustment);
+ } else {
+ bb[Axis2::X].end = bb[Axis2::X].end.saturating_add(footnote_adjustment);
+ }
+
+ // Clean up.
+ layout.set_attributes(None);
+ }
+
+ fn with_start<T: DerefMut<Target = Attribute>>(index: usize, mut attr: T) -> T {
+ attr.deref_mut().set_start_index(index.try_into().unwrap());
+ attr
+ }
+ fn with_end<T: DerefMut<Target = Attribute>>(index: usize, mut attr: T) -> T {
+ attr.deref_mut().set_end_index(index.try_into().unwrap());
+ attr
+ }
+
+ // Set attributes.
+ let attrs = attrs.get_or_insert_default();
+ attrs.insert(with_start(subscript_ofs, AttrFontDesc::new(font)));
+ attrs.insert(with_start(subscript_ofs, AttrFloat::new_scale(SCALE_SMALL)));
+ if has_subscripts {
+ attrs.insert(with_start(
+ subscript_ofs,
+ with_end(footnote_ofs, AttrInt::new_rise(-3000)),
+ ));
+ }
+ if has_footnotes {
+ let rise = 3000; // XXX check look for superscript vs subscript
+ attrs.insert(with_start(footnote_ofs, AttrInt::new_rise(rise)));
+ }
+ }
+
+ layout.set_attributes(attrs.as_ref());
+ layout.set_text(&body);
+ layout.set_alignment(horz_align.into());
+ if bb[Axis2::X].end == isize::MAX {
+ layout.set_width(-1);
+ } else {
+ layout.set_width(bb[Axis2::X].len() as i32);
+ }
+ }
+
+ pub(crate) fn draw(
+ &self,
+ bb: &Rect2,
+ layout: &Layout,
+ clip: Option<&Rect2>,
+ context: &Context,
+ ) {
+ context.save().unwrap();
+ if !self.rotate
+ && let Some(clip) = clip
+ {
+ xr_clip(context, clip);
+ }
+ if self.rotate {
+ let extra = (bb[Axis2::X].len() as isize - layout.size().1 as isize).max(0);
+ let halign_offset = extra / 2;
+ context.translate(
+ xr_to_pt(bb[Axis2::X].start + halign_offset),
+ xr_to_pt(bb[Axis2::Y].end),
+ );
+ context.rotate(-PI / 2.0);
+ } else {
+ context.translate(xr_to_pt(bb[Axis2::X].start), xr_to_pt(bb[Axis2::Y].start));
+ }
+ show_layout(context, &layout);
+ context.restore().unwrap();
+ }
+}
+
+struct CairoDevice<'a> {
+ style: &'a CairoFsmStyle,
+ params: &'a Params,
+ context: &'a Context,
+}
+
+impl CairoDevice<'_> {
+ fn measure_cell(&self, cell: &DrawCell, bb: Rect2) -> Coord2 {
+ let mut layout = self.style.new_layout(self.context);
+ cell.layout(&bb, &mut layout, &self.style.font);
+ let (width, height) = layout.size();
+ Coord2::new(width as isize, height as isize)
+ }
+
+ fn cell_draw(&self, cell: &DrawCell, bb: Rect2, clip: &Rect2) {
+ let mut layout = self.style.new_layout(self.context);
+ cell.layout(&bb, &mut layout, &self.style.font);
+ cell.draw(&bb, &layout, Some(clip), &self.context);
+ }
+
+ fn do_draw_line(
+ &self,
+ x0: isize,
+ y0: isize,
+ x1: isize,
+ y1: isize,
+ stroke: Stroke,
+ color: Color,
+ ) {
+ self.context.new_path();
+ self.context.set_line_width(xr_to_pt(match stroke {
+ Stroke::Thick => LINE_WIDTH * 2,
+ Stroke::Thin => LINE_WIDTH / 2,
+ _ => LINE_WIDTH,
+ }));
+ self.context.move_to(xr_to_pt(x0), xr_to_pt(y0));
+ self.context.line_to(xr_to_pt(x1), xr_to_pt(y1));
+ if !self.style.use_system_colors {
+ xr_set_color(self.context, color);
+ }
+ if stroke == Stroke::Dashed {
+ self.context.set_dash(&[2.0], 0.0);
+ let _ = self.context.stroke();
+ self.context.set_dash(&[], 0.0);
+ } else {
+ let _ = self.context.stroke();
+ }
+ }
+}
+
+impl Device for CairoDevice<'_> {
+ fn params(&self) -> &Params {
+ self.params
+ }
+
+ fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap<Extreme, isize> {
+ fn add_margins(cell: &DrawCell, width: isize) -> isize {
+ if width > 0 {
+ width + margin(cell, Axis2::X)
+ } else {
+ 0
+ }
+ }
+
+ enum_map![
+ Extreme::Min => {
+ let bb = Rect2::new(0..1, 0..isize::MAX);
+ add_margins(cell, self.measure_cell(cell, bb).x())
+ }
+ Extreme::Max => {
+ let bb = Rect2::new(0..isize::MAX, 0..isize::MAX);
+ add_margins(cell, self.measure_cell(cell, bb).x())
+ },
+ ]
+ }
+
+ fn measure_cell_height(&self, cell: &DrawCell, width: isize) -> isize {
+ let margins = &cell.cell_style.margins;
+ let bb = Rect2::new(
+ 0..width.saturating_sub(px_to_xr(margins[Axis2::X].len() as isize)),
+ 0..isize::MAX,
+ );
+ self.measure_cell(cell, bb).y() + margin(cell, Axis2::Y)
+ }
+
+ fn adjust_break(&self, _cell: &Content, _size: Coord2) -> isize {
+ todo!()
+ }
+
+ fn draw_line(&mut self, bb: Rect2, styles: EnumMap<Axis2, [BorderStyle; 2]>) {
+ let x0 = bb[Axis2::X].start;
+ let y0 = bb[Axis2::Y].start;
+ let x3 = bb[Axis2::X].end;
+ let y3 = bb[Axis2::Y].end;
+
+ let top = styles[Axis2::X][0].stroke;
+ let bottom = styles[Axis2::X][1].stroke;
+ let left = styles[Axis2::Y][0].stroke;
+ let right = styles[Axis2::Y][1].stroke;
+
+ let top_color = styles[Axis2::X][0].color;
+ let bottom_color = styles[Axis2::X][1].color;
+ let left_color = styles[Axis2::Y][0].color;
+ let right_color = styles[Axis2::Y][1].color;
+
+ // The algorithm here is somewhat subtle, to allow it to handle
+ // all the kinds of intersections that we need.
+ //
+ // Three additional ordinates are assigned along the X axis. The first
+ // is `xc`, midway between `x0` and `x3`. The others are `x1` and `x2`;
+ // for a single vertical line these are equal to `xc`, and for a double
+ // vertical line they are the ordinates of the left and right half of
+ // the double line.
+ //
+ // `yc`, `y1`, and `y2` are assigned similarly along the Y axis.
+ //
+ // The following diagram shows the coordinate system and output for
+ // double top and bottom lines, single left line, and no right line:
+ //
+ // ```
+ // x0 x1 xc x2 x3
+ // y0 ________________________
+ // | # # |
+ // | # # |
+ // | # # |
+ // | # # |
+ // | # # |
+ // y1 = y2 = yc |######### # |
+ // | # # |
+ // | # # |
+ // | # # |
+ // | # # |
+ // y3 |________#_____#_______|
+ // ```
+
+ // Offset from center of each line in a pair of double lines.
+ let double_line_ofs = (LINE_SPACE + LINE_WIDTH) / 2;
+
+ // Are the lines along each axis single or double? (It doesn't make
+ // sense to have different kinds of line on the same axis, so we don't
+ // try to gracefully handle that case.)
+ let double_vert = top == Stroke::Double || bottom == Stroke::Double;
+ let double_horz = left == Stroke::Double || right == Stroke::Double;
+
+ // When horizontal lines are doubled, the left-side line along `y1`
+ // normally runs from `x0` to `x2`, and the right-side line along `y1`
+ // from `x3` to `x1`. If the top-side line is also doubled, we shorten
+ // the `y1` lines, so that the left-side line runs only to `x1`, and the
+ // right-side line only to `x2`. Otherwise, the horizontal line at `y =
+ // y1` below would cut off the intersection, which looks ugly:
+ //
+ // ```
+ // x0 x1 x2 x3
+ // y0 ________________________
+ // | # # |
+ // | # # |
+ // | # # |
+ // | # # |
+ // y1 |######### ########|
+ // | |
+ // | |
+ // y2 |######################|
+ // | |
+ // | |
+ // y3 |______________________|
+ // ```
+ //
+ // It is more of a judgment call when the horizontal line is single. We
+ // choose to cut off the line anyhow, as shown in the first diagram
+ // above.
+ let shorten_y1_lines = top == Stroke::Double;
+ let shorten_y2_lines = bottom == Stroke::Double;
+ let shorten_yc_line = shorten_y1_lines && shorten_y2_lines;
+ let horz_line_ofs = if double_vert { double_line_ofs } else { 0 };
+ let xc = (x0 + x3) / 2;
+ let x1 = xc - horz_line_ofs;
+ let x2 = xc + horz_line_ofs;
+
+ let shorten_x1_lines = left == Stroke::Double;
+ let shorten_x2_lines = right == Stroke::Double;
+ let shorten_xc_line = shorten_x1_lines && shorten_x2_lines;
+ let vert_line_ofs = if double_horz { double_line_ofs } else { 0 };
+ let yc = (y0 + y3) / 2;
+ let y1 = yc - vert_line_ofs;
+ let y2 = yc + vert_line_ofs;
+
+ let horz_lines: SmallVec<[_; 2]> = if double_horz {
+ smallvec![(y1, shorten_y1_lines), (y2, shorten_y2_lines)]
+ } else {
+ smallvec![(yc, shorten_yc_line)]
+ };
+ for (y, shorten) in horz_lines {
+ if left != Stroke::None
+ && right != Stroke::None
+ && !shorten
+ && left_color == right_color
+ {
+ self.do_draw_line(x0, y, x3, y, left, left_color);
+ } else {
+ if left != Stroke::None {
+ self.do_draw_line(x0, y, if shorten { x1 } else { x2 }, y, left, left_color);
+ }
+ if right != Stroke::None {
+ self.do_draw_line(if shorten { x2 } else { x1 }, y, x3, y, right, right_color);
+ }
+ }
+ }
+
+ let vert_lines: SmallVec<[_; 2]> = if double_vert {
+ smallvec![(x1, shorten_x1_lines), (x2, shorten_x2_lines)]
+ } else {
+ smallvec![(xc, shorten_xc_line)]
+ };
+ for (x, shorten) in vert_lines {
+ if top != Stroke::None
+ && bottom != Stroke::None
+ && !shorten
+ && top_color == bottom_color
+ {
+ self.do_draw_line(x, y0, x, y3, top, top_color);
+ } else {
+ if top != Stroke::None {
+ self.do_draw_line(x, y0, x, if shorten { y1 } else { y2 }, top, top_color);
+ }
+ if bottom != Stroke::None {
+ self.do_draw_line(
+ x,
+ if shorten { y2 } else { y1 },
+ x,
+ y3,
+ bottom,
+ bottom_color,
+ );
+ }
+ }
+ }
+ }
+
+ fn draw_cell(
+ &mut self,
+ draw_cell: &DrawCell,
+ mut bb: Rect2,
+ valign_offset: isize,
+ spill: EnumMap<Axis2, [isize; 2]>,
+ clip: &Rect2,
+ ) {
+ let bg = draw_cell.font_style.bg;
+ if (bg.r != 255 || bg.g != 255 || bg.b != 255) && bg.alpha != 0 {
+ self.context.save().unwrap();
+ let bg_clip = Rect2::from_fn(|axis| {
+ let start = if bb[axis].start == clip[axis].start {
+ clip[axis].start.saturating_sub(spill[axis][0])
+ } else {
+ clip[axis].start
+ };
+ let end = if bb[axis].end == clip[axis].end {
+ clip[axis].end + spill[axis][1]
+ } else {
+ clip[axis].end
+ };
+ start..end
+ });
+ xr_clip(self.context, &bg_clip);
+ xr_set_color(self.context, bg);
+ let x0 = bb[Axis2::X].start.saturating_sub(spill[Axis2::X][0]);
+ let y0 = bb[Axis2::Y].start.saturating_sub(spill[Axis2::X][1]);
+ let x1 = bb[Axis2::X].end + spill[Axis2::X][1];
+ let y1 = bb[Axis2::Y].end + spill[Axis2::Y][1];
+ xr_fill_rectangle(self.context, Rect2::new(x0..x1, y0..y1));
+ self.context.restore().unwrap();
+ }
+
+ if !self.style.use_system_colors {
+ xr_set_color(self.context, draw_cell.font_style.fg);
+ }
+
+ self.context.save().unwrap();
+ bb[Axis2::Y].start += valign_offset;
+ for axis in [Axis2::X, Axis2::Y] {
+ bb[axis].start += px_to_xr(draw_cell.cell_style.margins[axis][0] as isize);
+ bb[axis].end -= px_to_xr(draw_cell.cell_style.margins[axis][1] as isize);
+ }
+ if bb[Axis2::X].start < bb[Axis2::X].end && bb[Axis2::Y].start < bb[Axis2::Y].end {
+ self.cell_draw(draw_cell, bb, clip);
+ }
+ self.context.restore().unwrap();
+ }
+
+ fn scale(&mut self, factor: f64) {
+ self.context.scale(factor, factor);
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use std::{borrow::Cow, sync::Arc};
+
+use cairo::{Context, RecordingSurface};
+use enum_map::EnumMap;
+use pango::Layout;
+
+use crate::{
+ output::{
+ Item,
+ drivers::cairo::{
+ fsm::{CairoFsm, CairoFsmStyle},
+ xr_to_pt,
+ },
+ pivot::{
+ Axis2, Rect2,
+ look::{CellStyle, FontStyle},
+ value::ValueOptions,
+ },
+ table::DrawCell,
+ },
+ spv::html::{Document, Variable},
+};
+
+#[derive(Clone, Debug)]
+pub struct CairoPageStyle {
+ pub margins: EnumMap<Axis2, [isize; 2]>,
+ pub header: Document,
+ pub footer: Document,
+ pub initial_page_number: i32,
+}
+
+pub struct CairoPager {
+ page_style: Arc<CairoPageStyle>,
+ fsm_style: Arc<CairoFsmStyle>,
+ page_index: i32,
+ item: Option<Arc<Item>>,
+ context: Option<Context>,
+ fsm: Option<CairoFsm>,
+ y: isize,
+ y_max: isize,
+}
+
+impl CairoPager {
+ pub fn new(page_style: Arc<CairoPageStyle>, fsm_style: Arc<CairoFsmStyle>) -> Self {
+ Self {
+ page_style,
+ fsm_style,
+ page_index: 0,
+ item: None,
+ context: None,
+ fsm: None,
+ y: 0,
+ y_max: 0,
+ }
+ }
+
+ pub fn has_page(&self) -> bool {
+ self.context.is_some()
+ }
+
+ pub fn add_page<F>(&mut self, context: Context, substitutions: F)
+ where
+ F: Fn(Variable, i32) -> String,
+ {
+ assert!(self.context.is_none());
+ context.save().unwrap();
+
+ context.translate(
+ xr_to_pt(self.page_style.margins[Axis2::X][0]),
+ xr_to_pt(self.page_style.margins[Axis2::Y][0]),
+ );
+
+ let page_number = self.page_index + self.page_style.initial_page_number;
+ self.page_index += 1;
+
+ // Render header.
+ let header = RenderHeading {
+ fsm_style: &self.fsm_style,
+ heading: &self.page_style.header,
+ width: self.fsm_style.size[Axis2::X],
+ font_resolution: self.fsm_style.font_resolution,
+ page_number,
+ substitutions,
+ };
+ self.y = header.render(&context, 0);
+
+ // Render footer.
+ let footer = header.with_heading(&self.page_style.footer);
+ let footer_size = footer.measure();
+ self.y_max = self.fsm_style.size[Axis2::Y] - footer_size;
+ if footer_size > 0 {
+ footer.render(&context, self.y_max);
+ }
+
+ context.translate(0.0, xr_to_pt(self.y));
+
+ self.context = Some(context);
+ self.run();
+ }
+
+ pub fn finish_page(&mut self) {
+ if let Some(context) = self.context.take() {
+ context.restore().unwrap();
+ }
+ }
+
+ pub fn needs_new_page(&self) -> bool {
+ (self.item.is_some() || self.fsm.is_some())
+ && (self.context.is_none() || self.y >= self.y_max)
+ }
+
+ pub fn add_item(&mut self, item: Arc<Item>) {
+ self.item = Some(item);
+ self.run();
+ }
+
+ fn run(&mut self) {
+ if self.needs_new_page() {
+ return;
+ }
+ let Some(context) = self.context.as_ref().cloned() else {
+ return;
+ };
+
+ loop {
+ // Make sure we've got an object to render.
+ let fsm = match &mut self.fsm {
+ Some(fsm) => fsm,
+ None => {
+ let Some(item) = self.item.take() else {
+ return;
+ };
+ self.fsm
+ .insert(CairoFsm::new(self.fsm_style.clone(), true, &context, item))
+ }
+ };
+
+ // Prepare to render the current object.
+ let chunk = fsm.draw_slice(&context, (self.y_max - self.y).max(0));
+ self.y += chunk + self.fsm_style.object_spacing;
+ context.translate(0.0, xr_to_pt(chunk + self.fsm_style.object_spacing));
+
+ if fsm.is_done() {
+ self.fsm = None;
+ } else if chunk == 0 {
+ assert!(self.y > 0);
+ self.y = isize::MAX;
+ return;
+ }
+ }
+ }
+}
+
+struct RenderHeading<'a, F> {
+ heading: &'a Document,
+ fsm_style: &'a CairoFsmStyle,
+ page_number: i32,
+ width: isize,
+ font_resolution: f64,
+ substitutions: F,
+}
+
+impl<'a, F> RenderHeading<'a, F>
+where
+ F: Fn(Variable, i32) -> String,
+{
+ fn with_heading(self, heading: &'a Document) -> Self {
+ Self { heading, ..self }
+ }
+
+ fn measure(&self) -> isize {
+ let surface = RecordingSurface::create(cairo::Content::Color, None).unwrap();
+ let context = Context::new(&surface).unwrap();
+ self.render(&context, 0)
+ }
+
+ fn render(&self, context: &Context, base_y: isize) -> isize {
+ let pangocairo_context = pangocairo::functions::create_context(context);
+ pangocairo::functions::context_set_resolution(&pangocairo_context, self.font_resolution);
+
+ let mut y = 0;
+ let default_cell_style = CellStyle::default();
+ let default_font_style = FontStyle::default();
+ let value_options = ValueOptions::default();
+ let substitutions =
+ &|variable| Some(Cow::from((self.substitutions)(variable, self.page_number)));
+
+ for block in self.heading.to_values() {
+ // XXX substitute heading variables
+ let cell = DrawCell {
+ rotate: false,
+ inner: &block.inner,
+ cell_style: block.cell_style().unwrap_or(&default_cell_style),
+ font_style: block.font_style().unwrap_or(&default_font_style),
+ subscripts: block.subscripts(),
+ footnotes: block.footnotes(),
+ value_options: &value_options,
+ substitutions,
+ };
+ let mut layout = Layout::new(&pangocairo_context);
+ let bb = Rect2::new(0..self.width, y + base_y..isize::MAX);
+ cell.layout(&bb, &mut layout, &self.fsm_style.font);
+ cell.draw(&bb, &layout, None, context);
+ y += layout.size().1 as isize;
+ }
+ if y > 0 {
+ y + self.fsm_style.object_spacing
+ } else {
+ 0
+ }
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use std::{
+ borrow::Cow,
+ fmt::Display,
+ fs::File,
+ io::{BufWriter, Error, Write, stdout},
+ path::PathBuf,
+ sync::Arc,
+};
+
+use chrono::{Datelike, NaiveTime, Timelike};
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ calendar::calendar_offset_to_gregorian,
+ data::{ByteString, Case, Datum, WithEncoding},
+ dictionary::Dictionary,
+ format::{DisplayPlain, Type},
+ output::{Item, drivers::Driver, table::CellPos},
+ util::ToSmallString as _,
+ variable::Variable,
+};
+
+use crate::output::{Details, TextType, pivot::PivotTable, table::Table};
+
+use super::CaseWriter;
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct CsvConfig {
+ file: Option<PathBuf>,
+ #[serde(flatten)]
+ options: CsvOptions,
+}
+
+pub struct CsvDriver {
+ file: Box<dyn Write>,
+ options: CsvOptions,
+
+ /// Number of items written so far.
+ n_items: usize,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
+#[serde(default)]
+struct CsvOptions {
+ quote: char,
+ delimiter: char,
+
+ /// Omit writing variable names as the first line of output.
+ var_names: bool,
+
+ /// Writes user-missing values like system-missing values. Otherwise,
+ /// user-missing values are written the same way as non-missing values.
+ recode: bool,
+
+ /// Write value labels instead of values.
+ labels: bool,
+
+ /// Use print formats for numeric variables.
+ print_formats: bool,
+
+ /// Decimal point.
+ decimal: char,
+}
+
+impl Default for CsvOptions {
+ fn default() -> Self {
+ Self {
+ quote: '"',
+ delimiter: ',',
+ var_names: true,
+ recode: false,
+ labels: false,
+ print_formats: false,
+ decimal: '.',
+ }
+ }
+}
+
+impl CsvOptions {
+ fn field<'a>(&'a self, text: &'a str) -> CsvField<'a> {
+ CsvField::new(text, self)
+ }
+
+ fn write_field<W>(
+ &self,
+ datum: &Datum<WithEncoding<ByteString>>,
+ variable: &Variable,
+ file: &mut W,
+ ) -> std::io::Result<()>
+ where
+ W: Write,
+ {
+ if self.labels
+ && let Some(label) = variable.value_labels.get(datum)
+ {
+ write!(file, "{}", self.field(label))
+ } else if datum.is_sysmis() || (self.recode && variable.missing_values().contains(datum)) {
+ write!(file, "{}", self.field(" "))
+ } else if self.print_formats || datum.is_string() {
+ write!(
+ file,
+ "{}",
+ self.field(
+ &datum
+ .display(variable.print_format)
+ .without_spaces()
+ .to_small_string::<64>(),
+ )
+ )
+ } else {
+ let number = datum.as_number().unwrap().unwrap();
+ match variable.print_format.type_() {
+ Type::F
+ | Type::Comma
+ | Type::Dot
+ | Type::Dollar
+ | Type::Pct
+ | Type::E
+ | Type::CC(_)
+ | Type::N
+ | Type::Z
+ | Type::P
+ | Type::PK
+ | Type::IB
+ | Type::PIB
+ | Type::PIBHex
+ | Type::RB
+ | crate::format::Type::RBHex
+ | Type::WkDay
+ | Type::Month => write!(
+ file,
+ "{}",
+ self.field(
+ &number
+ .display_plain()
+ .with_decimal(self.decimal)
+ .to_small_string::<64>()
+ )
+ ),
+
+ Type::Date
+ | Type::ADate
+ | Type::EDate
+ | Type::JDate
+ | Type::SDate
+ | Type::QYr
+ | Type::MoYr
+ | Type::WkYr => {
+ if number >= 0.0
+ && let Some(date) =
+ calendar_offset_to_gregorian(number / 60.0 / 60.0 / 24.0)
+ {
+ write!(
+ file,
+ "{}",
+ self.field(
+ &format_args!(
+ "{:02}/{:02}/{:04}",
+ date.month(),
+ date.day(),
+ date.year()
+ )
+ .to_small_string::<64>()
+ )
+ )
+ } else {
+ write!(file, "{}", self.field(" "))
+ }
+ }
+
+ Type::DateTime | Type::YmdHms => {
+ if number >= 0.0
+ && let Some(date) =
+ calendar_offset_to_gregorian(number / 60.0 / 60.0 / 24.0)
+ && let Some(time) = NaiveTime::from_num_seconds_from_midnight_opt(
+ (number % (60.0 * 60.0 * 24.0)) as u32,
+ 0,
+ )
+ {
+ write!(
+ file,
+ "{}",
+ self.field(
+ &format_args!(
+ "{:02}/{:02}/{:04} {:02}:{:02}:{:02}",
+ date.month(),
+ date.day(),
+ date.year(),
+ time.hour(),
+ time.minute(),
+ time.second()
+ )
+ .to_small_string::<64>(),
+ )
+ )
+ } else {
+ write!(file, "{}", self.field(" "))
+ }
+ }
+
+ Type::MTime | Type::Time | Type::DTime => {
+ if let Some(time) =
+ NaiveTime::from_num_seconds_from_midnight_opt(number.abs() as u32, 0)
+ {
+ write!(
+ file,
+ "{}",
+ self.field(
+ &format_args!(
+ "{}{:02}:{:02}:{:02}",
+ if number.is_sign_negative() { "-" } else { "" },
+ time.hour(),
+ time.minute(),
+ time.second()
+ )
+ .to_small_string::<64>(),
+ )
+ )
+ } else {
+ write!(file, "{}", self.field(" "))
+ }
+ }
+
+ Type::A | Type::AHex => unreachable!(),
+ }
+ }
+ }
+}
+
+struct CsvField<'a> {
+ text: &'a str,
+ delimiter: char,
+ quote: char,
+}
+
+impl<'a> CsvField<'a> {
+ fn new(text: &'a str, options: &CsvOptions) -> Self {
+ Self {
+ text,
+ delimiter: options.delimiter,
+ quote: options.quote,
+ }
+ }
+
+ fn char_needs_quoting(&self, b: char) -> bool {
+ b == '\r' || b == '\n' || b == self.quote || b == self.delimiter
+ }
+
+ fn needs_quoting(&self) -> bool {
+ self.text.chars().any(|b| self.char_needs_quoting(b))
+ }
+}
+
+impl Display for CsvField<'_> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if self.needs_quoting() {
+ let quote = self.quote;
+ write!(f, "{quote}")?;
+ for c in self.text.chars() {
+ if c == quote {
+ write!(f, "{c}")?;
+ }
+ write!(f, "{c}")?;
+ }
+ write!(f, "{quote}")
+ } else {
+ write!(f, "{}", self.text)
+ }
+ }
+}
+
+impl CsvDriver {
+ pub fn new(config: &CsvConfig) -> std::io::Result<Self> {
+ Ok(Self {
+ file: match &config.file {
+ Some(file) => Box::new(BufWriter::new(File::create(file)?)),
+ None => Box::new(stdout()),
+ },
+ options: config.options,
+ n_items: 0,
+ })
+ }
+
+ fn start_item(&mut self) {
+ if self.n_items > 0 {
+ writeln!(&mut self.file).unwrap();
+ }
+ self.n_items += 1;
+ }
+
+ fn output_table_layer(&mut self, pt: &PivotTable, layer: &[usize]) -> Result<(), Error> {
+ let output = pt.output(layer, true);
+ self.start_item();
+
+ self.output_table(pt, output.title.as_ref(), Some("Table"))?;
+ self.output_table(pt, output.layers.as_ref(), Some("Layer"))?;
+ self.output_table(pt, Some(&output.body), None)?;
+ self.output_table(pt, output.caption.as_ref(), Some("Caption"))?;
+ self.output_table(pt, output.footnotes.as_ref(), Some("Footnote"))?;
+ Ok(())
+ }
+
+ fn output_table(
+ &mut self,
+ pivot_table: &PivotTable,
+ table: Option<&Table>,
+ leader: Option<&str>,
+ ) -> Result<(), Error> {
+ let Some(table) = table else {
+ return Ok(());
+ };
+
+ for y in 0..table.n.y {
+ for x in 0..table.n.x {
+ if x > 0 {
+ write!(&mut self.file, "{}", self.options.delimiter)?;
+ }
+
+ let coord = CellPos { x, y };
+ let content = table.get(coord);
+ if content.is_top_left() {
+ let display = content.inner().value.display(pivot_table);
+ let s = match leader {
+ Some(leader) if x == 0 && y == 0 => format!("{leader}: {display}"),
+ _ => display.to_string(),
+ };
+ write!(&mut self.file, "{}", CsvField::new(&s, &self.options))?;
+ }
+ }
+ writeln!(&mut self.file)?;
+ }
+
+ Ok(())
+ }
+}
+
+impl Driver for CsvDriver {
+ fn name(&self) -> Cow<'static, str> {
+ Cow::from("csv")
+ }
+
+ fn write(&mut self, item: &Arc<Item>) {
+ // todo: error handling (should not unwrap)
+ match &item.details {
+ Details::Chart | Details::Image(_) | Details::Heading(_) => (),
+ Details::Message(diagnostic) => {
+ self.start_item();
+ let text = diagnostic.to_string();
+ writeln!(&mut self.file, "{}", CsvField::new(&text, &self.options)).unwrap();
+ }
+ Details::Table(pivot_table) => {
+ for layer in pivot_table.layers(true) {
+ self.output_table_layer(pivot_table, &layer).unwrap();
+ }
+ }
+ Details::PageBreak => {
+ self.start_item();
+ writeln!(&mut self.file).unwrap();
+ }
+ Details::Text(text) => match text.type_ {
+ TextType::Syntax | TextType::PageTitle => (),
+ TextType::Title | TextType::Log => {
+ self.start_item();
+ for line in text.content.display(()).to_string().lines() {
+ writeln!(&mut self.file, "{}", CsvField::new(line, &self.options)).unwrap();
+ }
+ }
+ },
+ }
+ }
+
+ fn flush(&mut self) {
+ let _ = self.file.flush();
+ }
+
+ fn can_write_data_file(&self) -> bool {
+ true
+ }
+
+ fn write_data_file<'a>(
+ &'a mut self,
+ dictionary: &'a Dictionary,
+ ) -> anyhow::Result<Option<Box<dyn CaseWriter + 'a>>> {
+ for (index, variable) in dictionary.variables.iter().enumerate() {
+ if index > 0 {
+ write!(&mut self.file, "{}", self.options.delimiter)?;
+ }
+ let name = variable.name.as_str();
+ write!(&mut self.file, "{}", CsvField::new(name, &self.options))?;
+ }
+ writeln!(&mut self.file)?;
+ Ok(Some(Box::new(CsvDriverCaseWriter {
+ driver: self,
+ dictionary,
+ })))
+ }
+}
+
+struct CsvDriverCaseWriter<'a> {
+ driver: &'a mut CsvDriver,
+ dictionary: &'a Dictionary,
+}
+
+impl<'a> CaseWriter for CsvDriverCaseWriter<'a> {
+ fn write_case(&mut self, case: Case<Vec<Datum<ByteString>>>) -> anyhow::Result<()> {
+ for (datum, variable) in case.into_iter().zip(self.dictionary.variables.iter()) {
+ self.driver
+ .options
+ .write_field(&datum, variable, &mut self.driver.file)?;
+ }
+ writeln!(&mut self.driver.file).unwrap();
+ Ok(())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use 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,
+ drivers::Driver,
+ pivot::{
+ Axis2, PivotTable,
+ look::{BorderStyle, Color, HorzAlign, Stroke, VertAlign},
+ },
+ table::{CellPos, CellRect, DrawCell, Table},
+};
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct HtmlConfig {
+ file: PathBuf,
+}
+
+pub struct HtmlDriver<W> {
+ 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<File> {
+ pub fn new(config: &HtmlConfig) -> std::io::Result<Self> {
+ Ok(Self::for_writer(File::create(&config.file)?))
+ }
+}
+
+impl<W> HtmlDriver<W>
+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, "<table")?;
+ if let Some(notes) = &pivot_table.metadata.notes {
+ write!(&mut self.writer, r#" title="{}""#, Escape::new(notes))?;
+ }
+ writeln!(&mut self.writer, ">")?;
+
+ if let Some(title) = output.title {
+ let cell = title.get(CellPos::new(0, 0));
+ self.put_cell(
+ DrawCell::new(cell.inner(), &title),
+ CellRect::new(0..1, 0..1),
+ "caption",
+ None,
+ )?;
+ }
+
+ if let Some(layers) = output.layers {
+ writeln!(&mut self.writer, "<thead>")?;
+ for cell in layers.cells() {
+ writeln!(&mut self.writer, "<tr>")?;
+ self.put_cell(
+ DrawCell::new(cell.inner(), &layers),
+ CellRect::new(0..output.body.n[Axis2::X], 0..1),
+ "td",
+ None,
+ )?;
+ writeln!(&mut self.writer, "</tr>")?;
+ }
+ writeln!(&mut self.writer, "</thead>")?;
+ }
+
+ writeln!(&mut self.writer, "<tbody>")?;
+ for y in 0..output.body.n.y {
+ writeln!(&mut self.writer, "<tr>")?;
+ for x in output.body.iter_x(y) {
+ let cell = output.body.get(CellPos { 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" };
+ self.put_cell(
+ DrawCell::new(cell.inner(), &output.body),
+ cell.rect(),
+ tag,
+ Some(&output.body),
+ )?;
+ }
+ }
+ writeln!(&mut self.writer, "</tr>")?;
+ }
+ writeln!(&mut self.writer, "</tbody>")?;
+
+ if output.caption.is_some() || output.footnotes.is_some() {
+ writeln!(&mut self.writer, "<tfoot>")?;
+ writeln!(&mut self.writer, "<tr>")?;
+ if let Some(caption) = output.caption {
+ self.put_cell(
+ DrawCell::new(caption.get(CellPos::new(0, 0)).inner(), &caption),
+ CellRect::new(0..output.body.n[Axis2::X], 0..1),
+ "td",
+ None,
+ )?;
+ }
+ writeln!(&mut self.writer, "</tr>")?;
+
+ if let Some(footnotes) = output.footnotes {
+ for cell in footnotes.cells() {
+ writeln!(&mut self.writer, "<tr>")?;
+ self.put_cell(
+ DrawCell::new(cell.inner(), &footnotes),
+ CellRect::new(0..output.body.n[Axis2::X], 0..1),
+ "td",
+ None,
+ )?;
+ writeln!(&mut self.writer, "</tr>")?;
+ }
+ }
+ writeln!(&mut self.writer, "</tfoot>")?;
+ }
+ }
+ Ok(())
+ }
+
+ fn put_cell(
+ &mut self,
+ cell: DrawCell<'_, '_>,
+ rect: CellRect,
+ 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.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.font_style.bg;
+ if bg != Color::WHITE {
+ write!(&mut style, "background: {}; ", bg.display_css()).unwrap();
+ }
+
+ let fg = cell.font_style.fg;
+ if fg != Color::BLACK {
+ write!(&mut style, "color: {}; ", fg.display_css()).unwrap();
+ }
+
+ if !cell.font_style.font.is_empty() {
+ write!(
+ &mut style,
+ r#"font-family: "{}"; "#,
+ Escape::new(&cell.font_style.font)
+ )
+ .unwrap();
+ }
+
+ if cell.font_style.bold {
+ write!(&mut style, "font-weight: bold; ").unwrap();
+ }
+ if cell.font_style.italic {
+ write!(&mut style, "font-style: italic; ").unwrap();
+ }
+ if cell.font_style.underline {
+ write!(&mut style, "text-decoration: underline; ").unwrap();
+ }
+ if cell.font_style.size != 0 {
+ write!(&mut style, "font-size: {}pt; ", cell.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.x.end == table.n[Axis2::X] {
+ Self::put_border(
+ &mut style,
+ table.get_rule(Axis2::X, CellPos::new(rect.x.end, rect.y.start)),
+ "right",
+ );
+ }
+ if rect.y.end == table.n[Axis2::Y] {
+ Self::put_border(
+ &mut style,
+ table.get_rule(Axis2::Y, CellPos::new(rect.x.start, rect.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.x.len();
+ if col_span > 1 {
+ write!(&mut self.writer, r#" colspan="{col_span}""#)?;
+ }
+
+ let row_span = rect.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("<br>")
+ )?;
+
+ if suffixes.has_subscripts() {
+ write!(&mut self.writer, "<sub>")?;
+ 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("<br>")
+ )?;
+ }
+ write!(&mut self.writer, "</sub>")?;
+ }
+
+ if suffixes.has_footnotes() {
+ write!(&mut self.writer, "<sup>")?;
+ 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("<br>")
+ )?;
+ }
+ write!(&mut self.writer, "</sup>")?;
+ }
+
+ writeln!(&mut self.writer, "</{tag}>")
+ }
+
+ 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<W>(mut writer: W) -> std::io::Result<()>
+where
+ W: Write,
+{
+ write!(
+ &mut writer,
+ r#"<!doctype html>
+<html>
+<head>
+<title>PSPP Output</title>
+<meta name="generator" content="PSPP {}"/>
+<meta http-equiv="content-type" content="text/html; charset=utf8"/>
+{}
+"#,
+ Escape::new(env!("CARGO_PKG_VERSION")),
+ HEADER_CSS,
+ )?;
+ Ok(())
+}
+
+const HEADER_CSS: &str = r#"<style>
+body {
+ background: white;
+ color: black;
+ padding: 0em 12em 0em 3em;
+ margin: 0
+}
+body>p {
+ margin: 0pt 0pt 0pt 0em
+}
+body>p + p {
+ text-indent: 1.5em;
+}
+h1 {
+ font-size: 150%;
+ margin-left: -1.33em
+}
+h2 {
+ font-size: 125%;
+ font-weight: bold;
+ margin-left: -.8em
+}
+h3 {
+ font-size: 100%;
+ font-weight: bold;
+ margin-left: -.5em }
+h4 {
+ font-size: 100%;
+ margin-left: 0em
+}
+h1, h2, h3, h4, h5, h6 {
+ font-family: sans-serif;
+ color: blue
+}
+html {
+ margin: 0
+}
+code {
+ font-family: sans-serif
+}
+table {
+ border-collapse: collapse;
+ margin-bottom: 1em
+}
+caption {
+ text-align: left;
+ width: 100%
+}
+th { font-weight: normal }
+a:link {
+ color: #1f00ff;
+}
+a:visited {
+ color: #9900dd;
+}
+a:active {
+ color: red;
+}
+</style>
+</head>
+<body>
+"#;
+
+impl<W> Driver for HtmlDriver<W>
+where
+ W: Write + 'static,
+{
+ fn name(&self) -> Cow<'static, str> {
+ Cow::from("html")
+ }
+
+ fn write(&mut self, item: &Arc<Item>) {
+ 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(())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use 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<PathBuf>,
+
+ /// 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<bool>,
+
+ /// 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<bool>,
+}
+
+pub struct JsonDriver {
+ file: Box<dyn Write>,
+ pretty: bool,
+ tables: bool,
+}
+
+impl JsonDriver {
+ pub fn new(config: &JsonConfig) -> std::io::Result<Self> {
+ 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<T>(&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<Item>) {
+ 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();
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use 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<Self> {
+ 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<Item>) {
+ todo!()
+ }
+
+ fn can_write_data_file(&self) -> bool {
+ true
+ }
+
+ fn write_data_file<'a>(
+ &'a mut self,
+ dictionary: &'a Dictionary,
+ ) -> anyhow::Result<Option<Box<dyn CaseWriter + 'a>>> {
+ Ok(Some(Box::new(PorDriverCaseWriter {
+ writer: WriteOptions::new().write_file(&dictionary, &self.file)?,
+ })))
+ }
+}
+
+struct PorDriverCaseWriter {
+ writer: Writer<BufWriter<File>>,
+}
+
+impl CaseWriter for PorDriverCaseWriter {
+ fn write_case(&mut self, case: Case<Vec<Datum<ByteString>>>) -> anyhow::Result<()> {
+ self.writer.write_case(case)?;
+ Ok(())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use 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<Compression>,
+}
+
+pub struct SavDriver {
+ file: PathBuf,
+ compression: Option<Compression>,
+}
+
+impl SavDriver {
+ pub fn new(config: &SavConfig) -> std::io::Result<Self> {
+ 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<Item>) {
+ todo!()
+ }
+
+ fn can_write_data_file(&self) -> bool {
+ true
+ }
+
+ fn write_data_file<'a>(
+ &'a mut self,
+ dictionary: &'a Dictionary,
+ ) -> anyhow::Result<Option<Box<dyn CaseWriter + 'a>>> {
+ Ok(Some(Box::new(SavDriverCaseWriter {
+ writer: WriteOptions::new()
+ .with_compression(self.compression)
+ .write_file(&dictionary, &self.file)?,
+ })))
+ }
+}
+
+struct SavDriverCaseWriter {
+ writer: Writer<BufWriter<File>>,
+}
+
+impl CaseWriter for SavDriverCaseWriter {
+ fn write_case(&mut self, case: Case<Vec<Datum<ByteString>>>) -> anyhow::Result<()> {
+ self.writer.write_case(case)?;
+ Ok(())
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use 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<PageSetup>,
+}
+
+pub struct SpvDriver<W>
+where
+ W: Write + Seek,
+{
+ writer: Writer<W>,
+}
+
+impl<W> Driver for SpvDriver<W>
+where
+ W: Write + Seek + 'static,
+{
+ fn name(&self) -> Cow<'static, str> {
+ Cow::from("spv")
+ }
+
+ fn write(&mut self, item: &Arc<Item>) {
+ 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<File> {
+ pub fn new(config: &SpvConfig) -> std::io::Result<Self> {
+ 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<W> SpvDriver<W>
+where
+ W: Write + Seek,
+{
+ pub fn for_writer(writer: W) -> Self {
+ Self {
+ writer: Writer::for_writer(writer).unwrap(),
+ }
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use 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<PathBuf>,
+
+ /// 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<usize>,
+
+ /// 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<TextLine>,
+}
+
+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<Stroke> 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<Lines, char>);
+
+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<Lines> for BoxChars {
+ type Output = char;
+
+ fn index(&self, lines: Lines) -> &Self::Output {
+ &self.0[lines]
+ }
+}
+
+static ASCII_BOX: LazyLock<BoxChars> = 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<BoxChars> = 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<dyn IoWrite>,
+ renderer: TextRenderer,
+}
+
+impl TextDriver {
+ pub fn new(config: &TextConfig) -> std::io::Result<TextDriver> {
+ 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<W>(&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<W>(&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<Item = (usize, BreakOpportunity)> + Clone + 'a,
+{
+ text: &'a str,
+ max_width: usize,
+ indexes: Range<usize>,
+ width: usize,
+ saved: Option<(usize, BreakOpportunity)>,
+ breaks: B,
+ trailing_newlines: usize,
+}
+
+impl<'a, B> Iterator for LineBreaks<'a, B>
+where
+ B: Iterator<Item = (usize, BreakOpportunity)> + Clone + 'a,
+{
+ type Item = &'a str;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ 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<Item = (usize, BreakOpportunity)> + 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<Item>) {
+ let _ = self.renderer.render(item, &mut FmtAdapter(&mut self.file));
+ }
+}
+
+struct FmtAdapter<W>(W);
+
+impl<W> FmtWrite for FmtAdapter<W>
+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<Extreme, isize> {
+ 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<Axis2, [BorderStyle; 2]>) {
+ 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<Axis2, [isize; 2]>,
+ 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::<Vec<_>>();
+ 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"]);
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use 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<F>(&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<usize>,
+
+ /// Byte offests.
+ offsets: Range<usize>,
+}
+
+/// 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<Self::Item> {
+ 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<isize>,
+ clip: &Range<isize>,
+) -> 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::<Emphasis>() {
+ for uppercase in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for uppercase in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for hiragana in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for hiragana in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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:?}"
+ );
+ }
+ }
+ }
+}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use 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<W> {
- 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<File> {
- pub fn new(config: &HtmlConfig) -> std::io::Result<Self> {
- Ok(Self::for_writer(File::create(&config.file)?))
- }
-}
-
-impl<W> HtmlDriver<W>
-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, "<table")?;
- if let Some(notes) = &pivot_table.notes {
- write!(&mut self.writer, r#" title="{}""#, Escape::new(notes))?;
- }
- writeln!(&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, "<thead>")?;
- for cell in layers.cells() {
- writeln!(&mut self.writer, "<tr>")?;
- 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, "</tr>")?;
- }
- writeln!(&mut self.writer, "</thead>")?;
- }
-
- writeln!(&mut self.writer, "<tbody>")?;
- for y in 0..output.body.n.y() {
- writeln!(&mut self.writer, "<tr>")?;
- 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, "</tr>")?;
- }
- writeln!(&mut self.writer, "</tbody>")?;
-
- if output.caption.is_some() || output.footnotes.is_some() {
- writeln!(&mut self.writer, "<tfoot>")?;
- writeln!(&mut self.writer, "<tr>")?;
- 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, "</tr>")?;
-
- if let Some(footnotes) = output.footnotes {
- for cell in footnotes.cells() {
- writeln!(&mut self.writer, "<tr>")?;
- 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, "</tr>")?;
- }
- }
- writeln!(&mut self.writer, "</tfoot>")?;
- }
- }
- 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("<br>")
- )?;
-
- if suffixes.has_subscripts() {
- write!(&mut self.writer, "<sub>")?;
- 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("<br>")
- )?;
- }
- write!(&mut self.writer, "</sub>")?;
- }
-
- if suffixes.has_footnotes() {
- write!(&mut self.writer, "<sup>")?;
- 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("<br>")
- )?;
- }
- write!(&mut self.writer, "</sup>")?;
- }
-
- writeln!(&mut self.writer, "</{tag}>")
- }
-
- 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<W>(mut writer: W) -> std::io::Result<()>
-where
- W: Write,
-{
- write!(
- &mut writer,
- r#"<!doctype html>
-<html>
-<head>
-<title>PSPP Output</title>
-<meta name="generator" content="PSPP {}"/>
-<meta http-equiv="content-type" content="text/html; charset=utf8"/>
-{}
-"#,
- Escape::new(env!("CARGO_PKG_VERSION")),
- HEADER_CSS,
- )?;
- Ok(())
-}
-
-const HEADER_CSS: &str = r#"<style>
-body {
- background: white;
- color: black;
- padding: 0em 12em 0em 3em;
- margin: 0
-}
-body>p {
- margin: 0pt 0pt 0pt 0em
-}
-body>p + p {
- text-indent: 1.5em;
-}
-h1 {
- font-size: 150%;
- margin-left: -1.33em
-}
-h2 {
- font-size: 125%;
- font-weight: bold;
- margin-left: -.8em
-}
-h3 {
- font-size: 100%;
- font-weight: bold;
- margin-left: -.5em }
-h4 {
- font-size: 100%;
- margin-left: 0em
-}
-h1, h2, h3, h4, h5, h6 {
- font-family: sans-serif;
- color: blue
-}
-html {
- margin: 0
-}
-code {
- font-family: sans-serif
-}
-table {
- border-collapse: collapse;
- margin-bottom: 1em
-}
-caption {
- text-align: left;
- width: 100%
-}
-th { font-weight: normal }
-a:link {
- color: #1f00ff;
-}
-a:visited {
- color: #9900dd;
-}
-a:active {
- color: red;
-}
-</style>
-</head>
-<body>
-"#;
-
-impl<W> Driver for HtmlDriver<W>
-where
- W: Write,
-{
- fn name(&self) -> Cow<'static, str> {
- Cow::from("html")
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- 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(())
- }
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use 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<File>,
-}
-
-impl JsonDriver {
- pub fn new(config: &JsonConfig) -> std::io::Result<Self> {
- Ok(Self {
- file: BufWriter::new(File::create(&config.file)?),
- })
- }
-}
-
-impl Driver for JsonDriver {
- fn name(&self) -> Cow<'static, str> {
- Cow::from("json")
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- serde_json::to_writer_pretty(&mut self.file, item).unwrap(); // XXX handle errors
- }
-
- fn flush(&mut self) {
- let _ = self.file.flush();
- }
-}
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <http://www.gnu.org/licenses/>.
+use std::{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")]
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<Paragraph>);
-
-#[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<Axis2, f64>,
+ #[serde(deserialize_with = "deserialize_paper_size")]
+ pub paper: PaperSize,
- /// Margin width in inches.
- pub margins: EnumMap<Axis2, [f64; 2]>,
+ /// 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<Catalog> = LazyLock::new(|| Catalog::new());
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub struct Margins(pub EnumMap<Axis2, [Length; 2]>);
+
+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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum Margins {
+ Array(Vec<Length>),
+ 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<PaperSize, D::Error>
+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<Axis2, f64> {
+ 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#"<p align="center">&[PageTitle]</p>"#),
+ footer: Document::from_html(r#"<p align="right">Page &[Page]</p>"#),
}
}
}
impl PageSetup {
pub fn printable_size(&self) -> EnumMap<Axis2, f64> {
- 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::<Margins>("\"1in\"").unwrap(),
+ Margins::new_uniform(a)
+ );
+ assert_eq!(
+ serde_json::from_str::<Margins>("[\"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::<PageSetup>(&s).unwrap(),
+ PageSetup::default()
+ );
}
}
//! 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- 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<i32>,
-
- /// 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<usize>,
+#[cfg(test)]
+pub mod tests;
- /// Keeps: columns to keep together on a page if possible.
- keeps: Vec<Range<usize>>,
-}
+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<Self> {
+ /// Transposes the X and Y axes. Returns `None` if this represents the Z
+ /// axis.
+ pub fn transpose(&self) -> Option<Self> {
match self {
Axis3::X => Some(Axis3::Y),
Axis3::Y => Some(Axis3::X),
pub dimensions: Vec<usize>,
}
-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,
}
impl PivotTable {
- pub fn with_look(mut self, look: Arc<Look>) -> Self {
- self.look = look;
- self
- }
- pub fn insert_number(&mut self, data_indexes: &[usize], number: Option<f64>, 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<Item = (Axis3, Dimension)>) -> Self {
+ let mut dims = Vec::new();
+ let mut axes = EnumMap::<Axis3, Axis>::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<Value>) -> 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<Value>) -> 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<usize>,
-
- /// 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<Value>) -> 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<Look>) -> 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<Path<'_>> {
- 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<Value>) -> 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<Value>,
- /// The child categories.
- ///
- /// A group usually has multiple children, but it is allowed to have
- /// only one or even (pathologically) none.
- pub children: Vec<Category>,
-
- /// Whether to show the group's label.
- pub show_label: bool,
-}
-
-impl Group {
- pub fn new(name: impl Into<Value>) -> Self {
- Self::with_capacity(name, 0)
+ /// Returns this pivot table with the given `show_values`.
+ pub fn with_show_values(self, show_values: Option<Show>) -> Self {
+ Self {
+ style: self.style.with_show_values(show_values),
+ ..self
+ }
}
- pub fn with_capacity(name: impl Into<Value>, capacity: usize) -> Self {
+ /// Returns this pivot table with the given `show_variables`.
+ pub fn with_show_variables(self, show_variables: Option<Show>) -> 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<Category>) {
- 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<Category>) -> 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<C>(mut self, children: impl IntoIterator<Item = C>) -> Self
- where
- C: Into<Category>,
- {
- 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<Path<'a>> {
- 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<f64>, 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<C> Extend<C> for Group
-where
- C: Into<Category>,
-{
- fn extend<T: IntoIterator<Item = C>>(&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<Arc<Footnote>>);
-
-impl Footnotes {
- pub fn new() -> Self {
- Self::default()
- }
-
- pub fn push(&mut self, footnote: Footnote) -> Arc<Footnote> {
- 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<Value>,
-}
-
-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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- 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<Axis3, Axis> {
+ &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<Path<'a>> {
- 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<Group> for Category {
- fn from(group: Group) -> Self {
- Self::Group(group)
- }
-}
-impl From<Leaf> 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<usize, Value> {
+ &self.cells
}
-}
-impl From<Value> 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<C>(&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<String> 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<C>(&mut self, cell_index: C, value: impl Into<Value>)
+ 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<C>(&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<String>,
-
- /// 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<HeadingRegion, RangeInclusive<usize>>,
-
- /// 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<Area, AreaStyle>,
-
- /// Styles for borders in the pivot table.
- pub borders: EnumMap<Border, BorderStyle>,
-
- pub print_all_layers: bool,
-
- pub paginate_layers: bool,
-
- pub shrink_to_fit: EnumMap<Axis2, bool>,
-
- pub top_continuation: bool,
-
- pub bottom_continuation: bool,
-
- pub continuation: Option<String>,
-
- 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<Border, BorderStyle>) -> Self {
- self.borders = borders;
+ /// Returns the pivot table with cell indexes and values from `iter`
+ /// inserted as data.
+ pub fn with_data<C>(mut self, iter: impl IntoIterator<Item = (C, Value)>) -> 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<Look> {
- static LOOK: OnceLock<Arc<Look>> = OnceLock::new();
- LOOK.get_or_init(|| Arc::new(Look::default())).clone()
- }
-
- pub fn from_xml(xml: &str) -> Result<Self, ParseLookError> {
- Ok(from_str::<TableProperties>(xml)
- .map_err(ParseLookError::from)?
- .into())
- }
-
- pub fn from_binary(tlo: &[u8]) -> Result<Self, ParseLookError> {
- parse_tlo(tlo).map_err(ParseLookError::from)
- }
-
- pub fn from_data(data: &[u8]) -> Result<Self, ParseLookError> {
- 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<Axis3, &[usize]>,
+ ) -> 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<R>(mut reader: R) -> Result<Self, ParseLookError>
- 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<dyn Iterator<Item = SmallVec<[usize; 4]>>> {
+ 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<Axis2> 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<Item = &Dimension> + 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<HorzAlign>,
- 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<Axis2, [i32; 2]>,
-}
-
-#[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::<Axis3>() {
+ 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<Rgba8> 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<Self, Self::Err> {
- 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<Srgb> = 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- where
- S: serde::Serializer,
- {
- serializer.serialize_str(&self.display_css().to_small_string::<32>())
- }
-}
-
-impl<'de> Deserialize<'de> for Color {
- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
- 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<E>(self, v: &'de str) -> Result<Self::Value, E>
- 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- where
- S: serde::Serializer,
- {
- let mut s = serializer.serialize_struct("BorderStyle", 2)?;
- s.serialize_field("stroke", &self.stroke)?;
- s.serialize_field("color", &self.color)?;
- s.end()
+ 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<T>(x: T, y: T) -> EnumMap<Axis2, T> {
- 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<usize>,
- 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<Axis2, usize>);
-
-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: 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<EnumMap<Axis2, usize>> for Coord2 {
- fn from(value: EnumMap<Axis2, usize>) -> 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<Axis2> 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<Path<'_>> {
+ self.root.leaf_path(index, SmallVec::new())
}
-}
-impl IndexMut<Axis2> 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<IndexVec> {
+ self.root.index_path(index, SmallVec::new())
}
}
-#[derive(Clone, Debug, Default)]
-pub struct Rect2(pub EnumMap<Axis2, Range<usize>>);
+/// 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<usize>, y_range: Range<usize>) -> 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<usize>), b_range: Range<usize>) -> 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: F) -> Self
- where
- F: FnMut(Axis2) -> Range<usize>,
- {
- 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<EnumMap<Axis2, Range<usize>>> for Rect2 {
- fn from(value: EnumMap<Axis2, Range<usize>>) -> 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<Axis2> for Rect2 {
- type Output = Range<usize>;
- 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<Axis2> 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<usize> {
+ (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<Value>,
-#[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<Category>,
}
-#[derive(Copy, Clone, Debug)]
-pub struct ValueOptions {
- pub show_values: Option<Show>,
-
- pub show_variables: Option<Show>,
-
- 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<Value>) -> 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<Value>, 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<Category>) {
+ 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<Category>) -> 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<C>(mut self, children: impl IntoIterator<Item = C>) -> Self
+ where
+ C: Into<Category>,
+ {
+ 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<Look>,
-
- 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<Path<'a>> {
+ 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<IndexVec> {
+ 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<IndexVec> {
+ 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<Show>,
+ /// 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<Show>,
+ /// 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<usize>,
+ /// 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<Axis2, Option<Box<Sizing>>>,
+ /// Returns this group's label.
+ pub fn name(&self) -> &Value {
+ &self.name
+ }
+}
- /// Format settings.
- pub settings: FormatSettings,
+impl<C> Extend<C> for Group
+where
+ C: Into<Category>,
+{
+ fn extend<T: IntoIterator<Item = C>>(&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<char>,
+/// 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<Arc<Footnote>>);
- pub small: f64,
+impl Footnotes {
+ /// Constructs a new, empty collection of footnotes.
+ pub fn new() -> Self {
+ Self::default()
+ }
- pub command_local: Option<String>,
- pub command_c: Option<String>,
- pub language: Option<String>,
- pub locale: Option<String>,
- pub dataset: Option<String>,
- pub datafile: Option<String>,
- pub date: Option<NaiveDateTime>,
- pub footnotes: Footnotes,
- pub title: Option<Box<Value>>,
- pub subtype: Option<Box<Value>>,
- pub corner_text: Option<Box<Value>>,
- pub caption: Option<Box<Value>>,
- pub notes: Option<String>,
- pub dimensions: Vec<Dimension>,
- pub axes: EnumMap<Axis3, Axis>,
- pub cells: HashMap<usize, Value>,
-}
+ /// 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<Value>) -> 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<Value>) -> 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<Footnote> {
+ 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<Value>) -> 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<Footnote>> {
+ self.0.get(index)
}
+}
- pub fn with_subtype(self, subtype: impl Into<Value>) -> Self {
- Self {
- subtype: Some(Box::new(subtype.into())),
- ..self
- }
+impl Index<usize> for Footnotes {
+ type Output = Arc<Footnote>;
+
+ 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<Footnote>;
+
+ type IntoIter = std::slice::Iter<'a, Arc<Footnote>>;
+
+ 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<Footnote> for Footnotes {
+ fn from_iter<T: IntoIterator<Item = Footnote>>(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<Value>);
+
+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<usize>) -> impl Iterator<Item = Leaf> {
+ 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<I>(data_indexes: &[usize], dimensions: I) -> usize
-where
- I: ExactSizeIterator<Item = usize>,
-{
- 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<Item = (Axis3, Dimension)>) -> Self {
- let mut dimensions = Vec::new();
- let mut axes = EnumMap::<Axis3, Axis>::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<Value>) {
- 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<I>(mut self, iter: impl IntoIterator<Item = (I, Value)>) -> Self
- where
- I: AsRef<[usize]>,
- {
- self.extend(iter);
- self
+ fn leaf_path<'a>(&'a self, index: usize, groups: GroupVec<'a>) -> Option<Path<'a>> {
+ 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<Axis3, &[usize]>,
- ) -> 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<IndexVec> {
+ 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<dyn Iterator<Item = SmallVec<[usize; 4]>>> {
- 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<IndexVec> {
+ 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<Group> for Category {
+ fn from(group: Group) -> Self {
+ Self::Group(group)
}
+}
- pub fn axis_dimensions(
- &self,
- axis: Axis3,
- ) -> impl DoubleEndedIterator<Item = &Dimension> + ExactSizeIterator {
- self.axes[axis]
- .dimensions
- .iter()
- .copied()
- .map(|index| &self.dimensions[index])
+impl From<Leaf> 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::<Axis3>() {
- for (position, dimension) in self.axes[axis].dimensions.iter().copied().enumerate() {
- if dimension == dim_index {
- return Some((axis, position));
- }
- }
- }
- None
+impl From<Value> 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<String> 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<I> Extend<(I, Value)> for PivotTable
-where
- I: AsRef<[usize]>,
-{
- fn extend<T: IntoIterator<Item = (I, Value)>>(&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<T>(x: T, y: T) -> EnumMap<Axis2, T> {
+ 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<Value>,
- pub marker: Option<Box<Value>>,
- 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<Value>) -> 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<Value>) -> 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<Axis3> for Axis2 {
+ type Error = ZAxis;
+
+ fn try_from(value: Axis3) -> Result<Self, Self::Error> {
+ 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<Axis2, isize>);
+
+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: 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<EnumMap<Axis2, isize>> for Coord2 {
+ fn from(value: EnumMap<Axis2, isize>) -> 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<Axis2> 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<Axis2> 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<Box<ValueStyle>>,
-}
+/// A 2-dimensional rectangle.
+#[derive(Clone, Debug, Default)]
+pub struct Rect2(
+ /// The range along each axis.
+ pub EnumMap<Axis2, Range<isize>>,
+);
-impl Serialize for Value {
- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- 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<isize>, y_range: Range<isize>) -> 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<isize>), b_range: Range<isize>) -> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- 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<f64>, 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<B>(value: &Datum<B>) -> Self
+ /// Construct a new `Rect2` that covers the ranges returned by `f`.
+ pub fn from_fn<F>(f: F) -> Self
where
- B: EncodedString,
+ F: FnMut(Axis2) -> Range<isize>,
{
- 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<ByteString>) -> 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<f64>) -> Self {
- Self::new_number_with_format(x, Format::F8_2)
- }
- pub fn new_integer(x: Option<f64>) -> Self {
- Self::new_number_with_format(x, Format::F40)
- }
- pub fn new_text(s: impl Into<String>) -> Self {
- Self::new_user_text(s)
- }
- pub fn new_user_text(s: impl Into<String>) -> 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<Footnote>) -> Self {
- self.add_footnote(footnote);
- self
- }
- pub fn add_footnote(&mut self, footnote: &Arc<Footnote>) {
- 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<Show>) -> 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<Show>) -> Self {
- if let ValueInner::Variable(variable_value) = &mut self.inner {
- variable_value.show = show;
- }
- self
- }
- pub fn with_value_label(mut self, label: Option<String>) -> 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<EnumMap<Axis2, Range<isize>>> for Rect2 {
+ fn from(value: EnumMap<Axis2, Range<isize>>) -> Self {
+ Self(value)
}
}
-impl From<String> for Value {
- fn from(value: String) -> Self {
- Self::new_text(value)
+impl Index<Axis2> for Rect2 {
+ type Output = Range<isize>;
+
+ 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<Axis2> 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<Footnote>],
- 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<Item = &str> {
- 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<Look>,
+
+ /// 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<Show>,
+
+ /// 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<Show>,
+
+ /// Column and row sizing and page breaks:
+ ///
+ /// - `sizing[Axis2::X]` is sizes for columns.
+ /// - `sizing[Axis2::Y]` is sizes for rows.
+ pub sizing: EnumMap<Axis2, Option<Box<Sizing>>>,
+
+ /// Format settings.
+ pub settings: Arc<FormatSettings>,
+
+ /// Numeric grouping character (usually `.` or `,`).
+ pub grouping: Option<char>,
+
+ /// 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<Item = DisplayMarker<'_>> {
- 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<Look>) -> 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<Show>) -> 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<Show>) -> 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<Footnote>]) -> 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<Box<Value>>,
- 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<Box<Value>>,
- 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<Box<Value>>,
- 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<String>,
- fn template(
- &self,
- f: &mut std::fmt::Formatter<'_>,
- template: &str,
- args: &[Vec<Value>],
- ) -> 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<String>,
- fn inner_template(
- &self,
- f: &mut std::fmt::Formatter<'_>,
- template: &[u8],
- escape: u8,
- args: &[Value],
- ) -> Result<usize, std::fmt::Error> {
- 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<String>,
-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<String>,
-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<Box<Value>>,
+
+ /// The language used in output.
+ pub language: Option<String>,
+
+ /// A locale, including an encoding, such as `en_US.windows-1252` or
+ /// `it_IT.windows-1252`.
+ pub locale: Option<String>,
+
+ /// Name of the dataset analyzed to produce the output, e.g. `DataSet1`.
+ pub dataset: Option<String>,
+
+ /// Name of the file that the dataset is from, e.g. `C:\Users\foo\bar.sav`.
+ pub datafile: Option<String>,
+
+ /// Creation date for the table.
+ pub date: Option<NaiveDateTime>,
}
-fn interpret_show(
- global_show: impl Fn() -> Show,
- table_show: Option<Show>,
- value_show: Option<Show>,
- 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<Value>) -> 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 <https://en.wikipedia.org/wiki/Pivot_table> 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<usize>,
- 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<Dimension>,
- for footnote in self.footnotes {
- write!(f, "[{}]", footnote.display_marker(self.options))?;
- }
+ /// Axes.
+ axes: EnumMap<Axis3, Axis>,
- Ok(())
- }
+ /// Data.
+ cells: HashMap<usize, Value>,
}
-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<f64>,
- pub format: Format,
- pub show: Option<Show>,
- pub honor_small: bool,
- pub variable: Option<String>,
- pub value_label: Option<String>,
+/// 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<I>(self, dimensions: I) -> usize
+ where
+ I: ExactSizeIterator<Item = usize>;
}
-impl Serialize for NumberValue {
- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+impl<T> CellIndex for T
+where
+ T: AsRef<[usize]>,
+{
+ fn cell_index<I>(self, dimensions: I) -> usize
where
- S: serde::Serializer,
+ I: ExactSizeIterator<Item = usize>,
{
- 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+/// A precomputed index.
+pub struct PrecomputedIndex(
+ /// The index.
+ pub usize,
+);
+
+impl CellIndex for PrecomputedIndex {
+ fn cell_index<I>(self, _dimensions: I) -> usize
where
- S: Serializer,
+ I: ExactSizeIterator<Item = usize>,
{
- 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<C> Extend<(C, Value)> for PivotTable
+where
+ C: CellIndex,
+{
+ fn extend<T: IntoIterator<Item = (C, Value)>>(&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<Show>,
+ /// The footnote text.
+ pub content: Box<Value>,
- pub var_name: Option<String>,
- pub value_label: Option<String>,
-}
+ /// The footnote marker.
+ ///
+ /// This is usually `None`, in which case [FootnoteMarkerType] determines
+ /// the default marker.
+ pub marker: Option<Box<Value>>,
-#[derive(Clone, Debug, Serialize)]
-pub struct VariableValue {
- pub show: Option<Show>,
- pub var_name: String,
- pub variable_label: Option<String>,
+ /// 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<String>,
- /// Identifier.
- pub id: Option<String>,
-}
+impl Footnote {
+ /// Constructs a new footnote.
+ pub fn new(content: impl Into<Value>) -> Self {
+ Self {
+ index: 0,
+ content: Box::new(content.into()),
+ marker: None,
+ show: true,
+ }
+ }
-impl Serialize for TextValue {
- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- where
- S: serde::Serializer,
- {
- if self.user_provided && self.c.is_none() && self.id.is_none() {
- serializer.serialize_str(&self.localized)
- } else {
- let mut s = serializer.serialize_struct(
- "TextValue",
- 2 + self.c.is_some() as usize + self.id.is_some() as usize,
- )?;
- s.serialize_field("user_provided", &self.user_provided)?;
- s.serialize_field("localized", &self.localized)?;
- if let Some(c) = &self.c {
- s.serialize_field("c", &c)?;
- }
- if let Some(id) = &self.id {
- s.serialize_field("id", &id)?;
- }
- s.end()
+ /// Returns the footnote with the given optional marker.
+ pub fn with_marker(self, marker: Option<Value>) -> 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<Value>) -> 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<Vec<Value>>,
- pub localized: String,
- pub id: String,
-}
+ /// Returns an object for formatting the footnote's marker.
+ pub fn display_marker(&self, options: impl Into<ValueOptions>) -> 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<ValueOptions>) -> 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<Show> {
- 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<AreaStyle>,
- pub subscripts: Vec<String>,
- pub footnotes: Vec<Arc<Footnote>>,
+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>, 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) {
}
}
+/// A value in a metadata table.
pub enum MetadataValue {
- Leaf(Value),
- Group(Vec<MetadataEntry>),
+ /// A value.
+ Leaf(
+ /// The value.
+ Value,
+ ),
+ /// A nested group of entries.
+ Group(
+ /// The entries.
+ Vec<MetadataEntry>,
+ ),
}
impl MetadataValue {
+ /// Construct a new "leaf" metadata value.
pub fn new_leaf(value: impl Into<Value>) -> Self {
Self::Leaf(value.into())
}
#[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() {
}
}
+ #[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 {
}"#
);
- 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());
}
}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+//! 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<usize> for RowParity {
+ fn from(value: usize) -> Self {
+ if value % 2 == 1 {
+ Self::Odd
+ } else {
+ Self::Even
+ }
+ }
+}
+
+impl Display for RowParity {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RowParity::Even => write!(f, "even"),
+ RowParity::Odd => write!(f, "odd"),
+ }
+ }
+}
+
+/// Table borders for styling purposes.
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
+pub enum Border {
+ /// 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<Border, BorderStyle> {
+ 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<i32>,
+
+ /// 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<usize>,
+
+ /// Keeps: columns to keep together on a page if possible.
+ pub keeps: Vec<Range<usize>>,
+}
+
+/// 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<String>,
+
+ /// 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<HeadingRegion, RangeInclusive<isize>>,
+
+ /// 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<Area, AreaStyle>,
+
+ /// Styles for borders in the pivot table.
+ pub borders: EnumMap<Border, BorderStyle>,
+
+ /// 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<Axis2, bool>,
+
+ /// 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<String>,
+
+ /// 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<Border, BorderStyle>) -> 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<Look> {
+ static LOOK: OnceLock<Arc<Look>> = 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<Self, ParseLookError> {
+ Ok(from_str::<TableProperties>(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<Self, ParseLookError> {
+ 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<Self, ParseLookError> {
+ 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<R>(mut reader: R) -> Result<Self, ParseLookError>
+ 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<Axis2> 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<HorzAlign>,
+
+ /// 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<Axis2, [i32; 2]>,
+}
+
+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<HorzAlign>) -> 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<Axis2, [i32; 2]>) -> Self {
+ Self { margins, ..self }
+ }
+}
+
+/// Horizontal alignment of text.
+///
+/// "Mixed" alignment is implemented at a higher level using
+/// `Option<HorzAlign>`.
+#[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<Self, Self::Err> {
+ 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,
+
+ /// <u>Underline</u>
+ 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<String>) -> 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<Rgba8> 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<Self, Self::Err> {
+ 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<Srgb> = 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.display_css().to_small_string::<32>())
+ }
+}
+
+impl<'de> Deserialize<'de> for Color {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ 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<E>(self, v: &str) -> Result<Self::Value, E>
+ 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut s = serializer.serialize_struct("BorderStyle", 2)?;
+ s.serialize_field("stroke", &self.stroke)?;
+ s.serialize_field("color", &self.color)?;
+ s.end()
+ }
+}
+
+impl From<Stroke> 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)
+ }
+}
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")]
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,
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() {
}
}
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Debug, Deserialize)]
struct GeneralProperties {
#[serde(rename = "@hideEmptyRows")]
hide_empty_rows: bool,
row_label_position: LabelPosition,
}
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct FootnoteProperties {
#[serde(rename = "@markerPosition")]
marker_type: FootnoteMarkerType,
}
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CellFormatProperties {
caption: CellStyleHolder,
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")]
#[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")]
#[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,
},
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]
Italic,
}
-#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
enum FontWeight {
#[default]
Bold,
}
-#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
enum FontUnderline {
#[default]
Underline,
}
-#[derive(Deserialize, Debug, Default)]
+#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
enum TextAlignment {
Left,
Mixed,
}
-#[derive(Deserialize, Debug, Default)]
+#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
enum LabelLocationVertical {
/// Top.
Center,
}
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BorderProperties {
bottom_inner_frame: BorderStyle,
vertical_dimension_border_columns: BorderStyle,
}
-#[derive(Deserialize, Debug, Default)]
+#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct PrintingProperties {
#[serde(rename = "@printAllLayers")]
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<Length> 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<Self, Self::Err> {
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,
// 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),
InvalidUnit(String),
}
-impl<'de> Deserialize<'de> for Dimension {
+impl<'de> Deserialize<'de> for Length {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
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<E>(self, v: &'de str) -> Result<Self::Value, E>
+ fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
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::<f64>().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::<f64>().unwrap_err()
))
);
assert_eq!(
- Dimension::from_str("1asdf"),
- Err(DimensionParseError::InvalidUnit("asdf".into()))
+ Length::from_str("1asdf"),
+ Err(LengthParseError::InvalidUnit("asdf".into()))
);
}
</tableProperties>
"##;
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);
}
}
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.
};
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;
}
}
I: Iterator<Item = Box<Value>> + 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),
);
}
}
fn borders(&self, printing: bool) -> EnumMap<Border, BorderStyle> {
+ fn resolve_border_style(
+ border: Border,
+ borders: &EnumMap<Border, BorderStyle>,
+ 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] {
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<Table> {
- 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<Table> {
let mut layers = Vec::new();
for (dimension, &layer_index) in zip(
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();
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<Table> {
- 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<Footnote>]) -> Option<Table> {
self.create_aux_table_if_nonempty(
Area::Footer,
)
}
+ /// 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 = [
}
}
+/// [Table]s for outputting a layer of a [PivotTable].
pub struct OutputTables {
+ /// Title table, if any.
pub title: Option<Table>,
+ /// Layers table, if any.
pub layers: Option<Table>,
+ /// Table body.
pub body: Table,
+ /// Table caption, if any.
pub caption: Option<Table>,
+ /// Footnotes, if any.
pub footnotes: Option<Table>,
}
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<usize>) {
+ 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)
}
}
}
) {
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::<Vec<_>>();
- 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
// ┌─────────────────────────────────────────────────────┐
}
}
- // 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
// ┌─────────────────────────────────────────────────────┐
}
}
- 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()),
);
}
}
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
.collect::<Vec<_>>();
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())
);
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
// ┌─────────────────────────────────────────────────────┐ __
}
}
}
-
-pub fn try_range<R>(range: R, bounds: std::ops::RangeTo<usize>) -> Option<std::ops::Range<usize>>
-where
- R: std::ops::RangeBounds<usize>,
-{
- 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<Border, BorderStyle>,
- 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,
- }
- }
- }
-}
--- /dev/null
+╭──┬──┬──┬──╮
+│ │a1│a2│a3│
+├──┼──┼──┼──┤
+│b1│ 0│ 1│ 2│
+│b2│ 3│ 4│ 5│
+│b3│ 6│ 7│ 8│
+╰──┴──┴──┴──╯
+Caption
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+Columns
+╭────────╮
+│ a │
+├──┬──┬──┤
+│a1│a2│a3│
+├──┼──┼──┤
+│ 0│ 1│ 2│
+╰──┴──┴──╯
--- /dev/null
+Rows
+╭──┬─╮
+│a │ │
+├──┼─┤
+│a1│0│
+│a2│1│
+│a3│2│
+╰──┴─╯
--- /dev/null
+Columns
+╭────────┬────────┬────────╮
+│ b1 │ b2 │ b3 │
+├──┬──┬──┼──┬──┬──┼──┬──┬──┤
+│a1│a2│a3│a1│a2│a3│a1│a2│a3│
+├──┼──┼──┼──┼──┼──┼──┼──┼──┤
+│ 0│ 1│ 2│ 3│ 4│ 5│ 6│ 7│ 8│
+╰──┴──┴──┴──┴──┴──┴──┴──┴──╯
--- /dev/null
+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│
+╰──┴──┴──┴──┴──┴──┴──┴──┴──╯
--- /dev/null
+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│
+╰──┴──┴──╯
--- /dev/null
+Column x b1
+b: b1
+╭──┬──┬──╮
+│a1│a2│a3│
+├──┼──┼──┤
+│ 0│ 1│ 2│
+╰──┴──┴──╯
--- /dev/null
+Column x b2
+b: b2
+╭──┬──┬──╮
+│a1│a2│a3│
+├──┼──┼──┤
+│ 3│ 4│ 5│
+╰──┴──┴──╯
--- /dev/null
+Column x Row
+╭──┬──┬──┬──╮
+│ │a1│a2│a3│
+├──┼──┼──┼──┤
+│b1│ 0│ 1│ 2│
+│b2│ 3│ 4│ 5│
+│b3│ 6│ 7│ 8│
+╰──┴──┴──┴──╯
--- /dev/null
+Column x Row - Corner
+╭──┬────────╮
+│ │ a │
+│ ├──┬──┬──┤
+│b │a1│a2│a3│
+├──┼──┼──┼──┤
+│b1│ 0│ 1│ 2│
+│b2│ 3│ 4│ 5│
+│b3│ 6│ 7│ 8│
+╰──┴──┴──┴──╯
--- /dev/null
+Column x Row - Nested
+╭────┬────────╮
+│ │ a │
+│ ├──┬──┬──┤
+│ │a1│a2│a3│
+├────┼──┼──┼──┤
+│b b1│ 0│ 1│ 2│
+│ b2│ 3│ 4│ 5│
+│ b3│ 6│ 7│ 8│
+╰────┴──┴──┴──╯
--- /dev/null
+Row x Column
+╭──┬──┬──┬──╮
+│ │b1│b2│b3│
+├──┼──┼──┼──┤
+│a1│ 0│ 3│ 6│
+│a2│ 1│ 4│ 7│
+│a3│ 2│ 5│ 8│
+╰──┴──┴──┴──╯
--- /dev/null
+Row x Column - Corner
+╭──┬────────╮
+│ │ b │
+│ ├──┬──┬──┤
+│a │b1│b2│b3│
+├──┼──┼──┼──┤
+│a1│ 0│ 3│ 6│
+│a2│ 1│ 4│ 7│
+│a3│ 2│ 5│ 8│
+╰──┴──┴──┴──╯
--- /dev/null
+Row x Column - Nested
+╭────┬────────╮
+│ │ b │
+│ ├──┬──┬──┤
+│ │b1│b2│b3│
+├────┼──┼──┼──┤
+│a a1│ 0│ 3│ 6│
+│ a2│ 1│ 4│ 7│
+│ a3│ 2│ 5│ 8│
+╰────┴──┴──┴──╯
--- /dev/null
+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│
+╰──┴─╯
--- /dev/null
+Row x b1
+b: b1
+╭──┬─╮
+│a1│0│
+│a2│1│
+│a3│2│
+╰──┴─╯
--- /dev/null
+Row x b2
+b: b2
+╭──┬─╮
+│a1│3│
+│a2│4│
+│a3│5│
+╰──┴─╯
--- /dev/null
+Rows
+╭─────┬─╮
+│b1 a1│0│
+│ a2│1│
+│ a3│2│
+├─────┼─┤
+│b2 a1│3│
+│ a2│4│
+│ a3│5│
+├─────┼─┤
+│b3 a1│6│
+│ a2│7│
+│ a3│8│
+╰─────┴─╯
--- /dev/null
+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│
+╰─────┴─╯
--- /dev/null
+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│
+╰─────────┴─╯
--- /dev/null
+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│
+╰──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──╯
--- /dev/null
+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│
+╰───────┴──┴──┴──╯
--- /dev/null
+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│
+╰──┴──┴──┴──┴──┴──╯
--- /dev/null
+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│
+╰──────────┴──╯
--- /dev/null
+Column x b1 x a1
+b: b1
+a: a1
+╭──┬──┬──┬──┬──╮
+│c1│c2│c3│c4│c5│
+├──┼──┼──┼──┼──┤
+│ 0│12│24│36│48│
+╰──┴──┴──┴──┴──╯
--- /dev/null
+Column x b2 x a1
+b: b2
+a: a1
+╭──┬──┬──┬──┬──╮
+│c1│c2│c3│c4│c5│
+├──┼──┼──┼──┼──┤
+│ 3│15│27│39│51│
+╰──┴──┴──┴──┴──╯
--- /dev/null
+Column x b3 x a2
+b: b3
+a: a2
+╭──┬──┬──┬──┬──╮
+│c1│c2│c3│c4│c5│
+├──┼──┼──┼──┼──┤
+│ 7│19│31│43│55│
+╰──┴──┴──┴──┴──╯
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+Empty Groups
+╭──┬──┬──╮
+│ │a1│a3│
+├──┼──┼──┤
+│b2│ 0│ 1│
+│b3│ 2│ 3│
+╰──┴──┴──╯
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+╭────────────────────┬──────────╮
+│Name 1 │Value 1 │
+├────────────────────┼──────────┤
+│Subgroup 1 Subname 1│Subvalue 1│
+│ Subname 2│Subvalue 2│
+│ Subname 3│ 3│
+├────────────────────┼──────────┤
+│Name 2 │Value 2 │
+╰────────────────────┴──────────╯
--- /dev/null
+No Dimensions
+╭╮
+╰╯
--- /dev/null
+╭──┬──┬──┬──╮
+│ │a1│a2│a3│
+├──┼──┼──┼──┤
+│b1│ 0│ 1│ 2│
+│b2│ 3│ 4│ 5│
+│b3│ 6│ 7│ 8│
+╰──┴──┴──┴──╯
--- /dev/null
+One Empty Dimension
--- /dev/null
+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│
+╰────────┴─────────┴─────────┴────────┴────────╯
--- /dev/null
+Three Dimensions, Two Empty
--- /dev/null
+Title
+╭──┬──┬──┬──╮
+│ │a1│a2│a3│
+├──┼──┼──┼──┤
+│b1│ 0│ 1│ 2│
+│b2│ 3│ 4│ 5│
+│b3│ 6│ 7│ 8│
+╰──┴──┴──┴──╯
+Caption
--- /dev/null
+Two Empty Dimensions
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() {
#[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
}
}
#[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]
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]
[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│
-╰─────┴─╯
-",
);
}
[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]
[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│
-╰──┴──┴──┴──╯
-",
);
}
[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]
[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│
-╰──┴──┴──┴──╯
-",
);
}
[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<LabelPosition>) -> 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]
}
}
}
- 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"));
#[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]
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]
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]
);
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]
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]
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()));
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]
}
}
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(
}),
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]
}),
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]
}),
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]
}),
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]
}),
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]
}),
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 {
}),
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]
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);
}
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;
}
/// 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()
}
}
/// 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()
}
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! {
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() {
None,
#[br(magic = 1u16)]
Some {
+ #[br(parse_with(parse_tlo_color))]
color: Color,
style: u16,
width: u16,
}
}
-impl BinRead for Color {
- type Args<'a> = ();
-
- fn read_options<R: std::io::Read + std::io::Seek>(
- reader: &mut R,
- endian: binrw::Endian,
- _args: (),
- ) -> BinResult<Self> {
- let raw = <u32>::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<Color> {
+ let raw = <u32>::read_options(reader, endian, ())?;
+ Ok(Color::new(raw as u8, (raw >> 8) as u8, (raw >> 16) as u8))
}
#[binread]
#[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)]
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
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,
},
}
},
},
- 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,
},
}
rtf_charset_number: u32,
x: u8,
font_name: U8String,
+ #[br(parse_with(parse_tlo_color))]
text_color: Color,
#[br(temp, magic = 0u16)]
_tmp: (),
}
#[binrw::parser(reader, endian)]
-fn parse_bool() -> BinResult<bool> {
+pub fn parse_bool() -> BinResult<bool> {
let byte = <u8>::read_options(reader, endian, ())?;
match byte {
0 => Ok(false),
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+//! 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<Box<ValueStyle>>,
+}
+
+impl Serialize for Value {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ self.inner.serialize(serializer)
+ }
+}
+/// 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<T>(pub T);
+impl<T> Serialize for BareValue<T>
+where
+ T: Borrow<Value>,
+{
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<f64>) -> 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<f64>) -> 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<String>) -> 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<String>) -> 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<B>(datum: &Datum<B>) -> 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<ByteString>, 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<WithEncoding<ByteString>>> {
+ self.inner.datum()
+ }
+
+ /// Returns this `Value` with the added `footnote`.
+ pub fn with_footnote(mut self, footnote: &Arc<Footnote>) -> Self {
+ self.add_footnote(footnote);
+ self
+ }
+
+ /// Adds `footnote` to this `Value`.
+ pub fn add_footnote(&mut self, footnote: &Arc<Footnote>) {
+ 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<Show>) -> 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<String>) -> 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<String>) -> 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<Show>) -> 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<Box<ValueStyle>>) -> 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<Footnote>] {
+ 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<ValueOptions>) -> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ BareValue(self).serialize(serializer)
+ }
+}
+
+impl From<&str> for Value {
+ fn from(value: &str) -> Self {
+ Self::new_text(value)
+ }
+}
+
+impl From<String> 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<Footnote>],
+ 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<Item = &str> + 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<Item = impl Display> + 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<Footnote>]) -> 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<Decimal> {
+ 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<WithEncoding<ByteString>>,
+
+ /// 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<Show>,
+
+ /// 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<String>,
+
+ /// The value label associated with `value`, if any.
+ pub value_label: Option<String>,
+}
+
+impl Serialize for DatumValue {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<B>(datum: &Datum<B>) -> 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<f64>) -> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<String>,
+
+ /// Whether to show `var_name` or `variable_label` or both.
+ ///
+ /// If this is unset, then a higher-level default is used.
+ pub show: Option<Show>,
+}
+
+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<String>,
+
+ /// Identifier.
+ ///
+ /// Only for strings that are not user-provided, and only if it is different
+ /// from `localized`.
+ pub id: Option<String>,
+}
+
+impl Serialize for TextValue {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ if self.user_provided && self.c.is_none() && self.id.is_none() {
+ serializer.serialize_str(&self.localized)
+ } else {
+ let mut s = serializer.serialize_struct(
+ "TextValue",
+ 2 + self.c.is_some() as usize + self.id.is_some() as usize,
+ )?;
+ s.serialize_field("user_provided", &self.user_provided)?;
+ s.serialize_field("localized", &self.localized)?;
+ if let Some(c) = &self.c {
+ s.serialize_field("c", &c)?;
+ }
+ if let Some(id) = &self.id {
+ s.serialize_field("id", &id)?;
+ }
+ s.end()
+ }
+ }
+}
+
+impl 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<Vec<Value>>,
+
+ /// Optional identifier for the template.
+ pub id: Option<String>,
+}
+
+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<usize, std::fmt::Error> {
+ 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<WithEncoding<ByteString>>> {
+ 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<Show> {
+ 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<ValueOptions>) -> DisplayValue<'_> {
+ fn interpret_show(
+ global_show: impl Fn() -> Show,
+ table_show: Option<Show>,
+ value_show: Option<Show>,
+ 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<CellStyle>,
+
+ /// Font style.
+ pub font_style: Option<FontStyle>,
+
+ /// Subscripts.
+ pub subscripts: Vec<String>,
+
+ /// Footnotes.
+ pub footnotes: Vec<Arc<Footnote>>,
+}
+
+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<Show>,
+
+ /// Whether to show variable names or variable labels, or both.
+ ///
+ /// When this is `None`, a global default is used.
+ pub show_variables: Option<Show>,
+
+ /// 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<format::Settings>,
+}
+
+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()
+ }
+}
// this program. If not, see <http://www.gnu.org/licenses/>.
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.
///
/// 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<Axis2, usize>,
+ pub font_size: EnumMap<Axis2, isize>,
/// Width of different kinds of lines.
- pub line_widths: EnumMap<Stroke, usize>,
+ pub line_widths: EnumMap<Stroke, isize>,
/// 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<usize>,
+ /// 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<isize>,
/// 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<Axis2, usize>,
+ pub min_break: EnumMap<Axis2, isize>,
/// True if the driver supports cell margins. (If false, the rendering
/// engine will insert a small space betweeen adjacent cells that don't have
impl Params {
/// Returns a small but visible width.
- fn em(&self) -> usize {
+ fn em(&self) -> isize {
self.font_size[Axis2::X]
}
}
///
/// - `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<Extreme, usize>;
+ fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap<Extreme, isize>;
/// 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,
///
/// 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`.
///
fn draw_cell(
&mut self,
draw_cell: &DrawCell,
- alternate_row: bool,
bb: Rect2,
- valign_offset: usize,
- spill: EnumMap<Axis2, [usize; 2]>,
+ valign_offset: isize,
+ spill: EnumMap<Axis2, [isize; 2]>,
clip: &Rect2,
);
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<Table>,
-
- /// 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<Axis2, [Map; 2]>,
+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<Axis2, Vec<usize>>,
-
- /// [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<Coord2, EnumMap<Axis2, [usize; 2]>>,
-
- /// 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<Axis2, [bool; 2]>,
-}
-
-/// Returns the width of `extent` along `axis`.
-fn axis_width(cp: &[usize], extent: Range<usize>) -> usize {
- cp[extent.end] - cp[extent.start]
-}
-
-/// Returns the width of cells within `extent` along `axis`.
-fn joined_width(cp: &[usize], extent: Range<usize>) -> 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<Axis2, Vec<isize>>,
}
-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<Table>, 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::*;
// 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];
}
}
- 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];
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],
);
}
}
// 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::<usize>();
- let table_widths = EnumMap::from_fn(|ext| columns[ext].iter().sum::<usize>() + rule_widths);
+ let rule_widths = rules[X].iter().copied().sum::<isize>();
+ let table_widths = EnumMap::from_fn(|ext| columns[ext].iter().sum::<isize>() + 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 {
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;
}
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],
);
}
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<usize> {
+ /// 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<isize> {
let mut vec = once(0)
.chain(interleave(rules, rows).copied())
.collect::<Vec<_>>();
vec
}
- fn interpolate_row_widths(
- params: &Params,
- rows_min: &[usize],
- rows_max: &[usize],
- w_min: usize,
- w_max: usize,
- rules: &[usize],
- ) -> Vec<usize> {
- let avail = params.size[Axis2::X] - w_min;
- let wanted = w_max - w_min;
+ fn interpolate_column_widths(
+ target: isize,
+ columns: &EnumMap<Extreme, Vec<isize>>,
+ widths: &EnumMap<Extreme, isize>,
+ rules: &[isize],
+ ) -> Vec<isize> {
+ 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;
}
/// Returns the width of `extent` along `axis`.
- fn axis_width(&self, axis: Axis2, extent: Range<usize>) -> usize {
+ fn axis_width(&self, axis: Axis2, extent: Range<usize>) -> 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>) -> usize {
+ fn joined_width(&self, axis: Axis2, extent: Range<usize>) -> 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)
}
/// 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<Axis2, [Map; 2]> {
- 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<RenderedTable>,
+ ranges: EnumMap<Axis2, Range<isize>>,
+}
- 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<Self>,
- a: Axis2,
- extent: Range<usize>,
- pixel0: usize,
- pixel1: usize,
- ) -> Arc<Self> {
- 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<isize>, b: &Range<isize>) -> 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 {
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
.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<usize>) -> 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<usize>) -> 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 {
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])
}
}
/// 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 {
debug_assert_eq!(spanned.len(), n);
debug_assert_eq!(rules.len(), n + 1);
- let total_unspanned = unspanned.iter().sum::<usize>()
+ let total_unspanned = unspanned.iter().sum::<isize>()
+ rules
.get(1..n)
- .map_or(0, |rules| rules.iter().copied().sum::<usize>());
+ .map_or(0, |rules| rules.iter().copied().sum::<isize>());
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
/// 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;
}
}
#[derive(Debug)]
-struct Break {
- page: Arc<Page>,
+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<Page>, 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
/// 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<Arc<Page>> {
+ fn next(&mut self, device: &dyn Device, size: isize) -> Result<Option<Page>, ()> {
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<isize>, 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!()
}
}
/// [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<Page>; 5]>,
+ pages: SmallVec<[Page; 5]>,
x_break: Option<Break>,
y_break: Option<Break>,
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
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();
// 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>| page.total_size(Axis2::Y))
- .sum::<usize>() as f64;
+ .map(|page: &Page| page.width(Axis2::Y))
+ .sum::<isize>() as f64;
let max_height = device.params().size[Axis2::Y] as f64;
if total_height * scale >= max_height {
scale *= max_height / total_height;
}
/// 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
/// 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]
}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use 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<PageSetup>,
-}
-
-pub struct SpvDriver<W>
-where
- W: Write + Seek,
-{
- writer: ZipWriter<W>,
- needs_page_break: bool,
- next_table_id: u64,
- next_heading_id: u64,
- page_setup: Option<PageSetup>,
-}
-
-impl SpvDriver<File> {
- pub fn new(config: &SpvConfig) -> std::io::Result<Self> {
- 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<W> SpvDriver<W>
-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<W> {
- 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<X>(
- &mut self,
- item: &Item,
- pivot_table: &PivotTable,
- structure: &mut XmlWriter<X>,
- ) 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<X>(&mut self, item: &Item, text: &Text, structure: &mut XmlWriter<X>)
- 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<X>(&mut self, item: &Item, structure: &mut XmlWriter<X>)
- where
- X: Write,
- {
- match &item.details {
- super::Details::Chart => todo!(),
- super::Details::Image => todo!(),
- super::Details::Group(children) => {
- let mut attributes = Vec::<Attribute>::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<X, F>(
- &mut self,
- writer: &mut XmlWriter<X>,
- item: &Item,
- inner_elem: &str,
- closure: F,
- ) where
- X: Write,
- F: FnOnce(ElementWriter<X>),
- {
- 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<W: Write + Seek>(
- &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<Args<'a> = ()> {
- (
- pivot_table.settings.epoch.0 as u32,
- u8::from(pivot_table.settings.decimal),
- b',',
- )
- }
-
- fn custom_currency(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> {
- (
- 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<Args<'a> = ()> {
- (
- 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<Args<'a> = ()> {
- Counted::new((
- 0u32, // n-row-heights
- 0u32, // n-style-maps
- 0u32, // n-styles,
- 0u32,
- ))
- }
-
- fn y1(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> + 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<Args<'a> = ()> {
- (custom_currency(pivot_table), b'.', SpvBool(false))
- }
-
- fn x3(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> + 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<W> Driver for SpvDriver<W>
-where
- W: Write + Seek,
-{
- fn name(&self) -> Cow<'static, str> {
- Cow::from("spv")
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- 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<X>(page_setup: &PageSetup, writer: &mut XmlWriter<X>) -> 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<X>(
- heading: &Heading,
- name: &str,
- writer: &mut XmlWriter<X>,
-) -> 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<I>,
-) -> ElementWriter<'a, W>
-where
- I: Into<Attribute<'b>>,
-{
- if let Some(attr) = attr {
- element.with_attribute(attr)
- } else {
- element
- }
-}
-
-impl BinWrite for Dimension {
- type Args<'a> = (usize, u8);
-
- fn write_options<W: Write + Seek>(
- &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<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- D: Iterator<Item = usize>,
- {
- 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<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- D: Iterator<Item = usize>,
- {
- (
- self.name(),
- 0u8,
- 0u8,
- 0u8,
- 2u32,
- data_indexes.next().unwrap() as u32,
- 0u32,
- )
- .write_le(writer)
- }
-}
-
-impl Group {
- fn write_le<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- D: Iterator<Item = usize>,
- {
- (
- 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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &self,
- writer: &mut W,
- endian: binrw::Endian,
- args: Self::Args<'_>,
- ) -> binrw::BinResult<()> {
- (self.0 as u8).write_options(writer, endian, args)
- }
-}
-
-struct SpvString<T>(T);
-impl<'a> SpvString<&'a str> {
- fn optional(s: &'a Option<String>) -> Self {
- Self(s.as_ref().map_or("", |s| s.as_str()))
- }
-}
-impl<T> BinWrite for SpvString<T>
-where
- T: AsRef<str>,
-{
- type Args<'a> = ();
-
- fn write_options<W: Write + Seek>(
- &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<Show>) -> u8 {
- match this {
- None => 0,
- Some(Show::Value) => 1,
- Some(Show::Label) => 2,
- Some(Show::Both) => 3,
- }
- }
-}
-
-struct Count(u64);
-
-impl Count {
- fn new<W>(writer: &mut W) -> binrw::BinResult<Self>
- where
- W: Write + Seek,
- {
- 0u32.write_le(writer)?;
- Ok(Self(writer.stream_position()?))
- }
-
- fn finish<W>(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<W>(self, writer: &mut W) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- {
- self.finish(writer, Endian::Little)
- }
-
- fn finish_be32<W>(self, writer: &mut W) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- {
- self.finish(writer, Endian::Big)
- }
-}
-
-struct Counted<T> {
- inner: T,
- endian: Option<Endian>,
-}
-
-impl<T> Counted<T> {
- fn new(inner: T) -> Self {
- Self {
- inner,
- endian: None,
- }
- }
- fn with_endian(self, endian: Endian) -> Self {
- Self {
- inner: self.inner,
- endian: Some(endian),
- }
- }
-}
-
-impl<T> BinWrite for Counted<T>
-where
- T: BinWrite,
- for<'a> T: BinWrite<Args<'a> = ()>,
-{
- type Args<'a> = T::Args<'a>;
-
- fn write_options<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<f64> {
- 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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<T>(Option<T>);
-
-impl<T> BinWrite for Optional<T>
-where
- T: BinWrite,
-{
- type Args<'a> = T::Args<'a>;
-
- fn write_options<W: Write + Seek>(
- &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<Box<ValueStyle>>,
- 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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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(())
- }
-}
//! 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<F>(mut f: F) -> Self
+ where
+ F: FnMut(Axis2) -> usize,
+ {
+ Self::new(f(Axis2::X), f(Axis2::Y))
+ }
+}
+
+impl Index<Axis2> for CellPos {
+ type Output = usize;
+
+ fn index(&self, index: Axis2) -> &Self::Output {
+ match index {
+ Axis2::X => &self.x,
+ Axis2::Y => &self.y,
+ }
+ }
+}
+
+impl IndexMut<Axis2> 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<usize>,
+ /// Y range.
+ pub y: Range<usize>,
+}
+
+impl CellRect {
+ pub fn new(x: Range<usize>, y: Range<usize>) -> 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<usize>), b: Range<usize>) -> 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<F>(&self, mut f: F) -> Self
+ where
+ F: FnMut(Axis2, Range<usize>) -> Range<usize>,
+ {
+ Self {
+ x: f(Axis2::X, self.x.clone()),
+ y: f(Axis2::Y, self.y.clone()),
+ }
+ }
+}
+
+impl Index<Axis2> for CellRect {
+ type Output = Range<usize>;
+
+ fn index(&self, index: Axis2) -> &Self::Output {
+ match index {
+ Axis2::X => &self.x,
+ Axis2::Y => &self.y,
+ }
+ }
+}
+
+impl IndexMut<Axis2> 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,
}
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 {
/// 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,
}
/// 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
})
}
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 }
}
}
}
}
+ pub fn with_rotate(self, rotate: bool) -> Self {
+ Self { rotate, ..self }
+ }
+
pub fn is_empty(&self) -> bool {
self.value.inner.is_empty()
}
#[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<Content>,
/// Styles for areas of the table.
pub borders: EnumMap<Border, BorderStyle>,
/// Horizontal ([Axis2::Y]) and vertical ([Axis2::X]) rules.
- pub rules: EnumMap<Axis2, Array2<Border>>,
+ pub rules: EnumMap<Axis2, Array2<Option<Border>>>,
/// How to present values.
#[debug(skip)]
impl Table {
pub fn new(
- n: Coord2,
- headers: Coord2,
+ n: CellPos,
+ headers: CellPos,
areas: EnumMap<Area, AreaStyle>,
borders: EnumMap<Border, BorderStyle>,
- value_options: ValueOptions,
+ value_options: impl Into<ValueOptions>,
) -> 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())
}
}
pub fn h_line(&mut self, border: Border, x: Range<usize>, 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<usize>) {
for y in y {
- self.rules[Axis2::X][[x, y]] = border;
+ self.rules[Axis2::X][[x, y]] = Some(border);
}
}
}
/// The heading region that `pos` is part of, if any.
- pub fn heading_region(&self, pos: Coord2) -> Option<HeadingRegion> {
- if pos.x() < self.h.x() {
+ pub fn heading_region(&self, pos: CellPos) -> Option<HeadingRegion> {
+ 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
fn next(&mut self) -> Option<Self::Item> {
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);
next: if table.is_empty() {
None
} else {
- Some(table.get(Coord2::new(0, 0)))
+ Some(table.get(CellPos::new(0, 0)))
},
}
}
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;
};
}
}
-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<Footnote>],
pub value_options: &'a ValueOptions,
+ pub substitutions: &'b dyn Fn(html::Variable) -> Option<Cow<'b, str>>,
}
-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()))
}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use 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<PathBuf>,
-
- /// 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<usize>,
-
- /// 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<TextLine>,
-}
-
-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<Stroke> 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<Lines, char>);
-
-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<Lines> for BoxChars {
- type Output = char;
-
- fn index(&self, lines: Lines) -> &Self::Output {
- &self.0[lines]
- }
-}
-
-static ASCII_BOX: LazyLock<BoxChars> = 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<BoxChars> = 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<File>,
- renderer: TextRenderer,
-}
-
-impl TextDriver {
- pub fn new(config: &TextConfig) -> std::io::Result<TextDriver> {
- 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<W>(&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<W>(&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<Item = (usize, BreakOpportunity)> + Clone + 'a,
-{
- text: &'a str,
- max_width: usize,
- indexes: Range<usize>,
- width: usize,
- saved: Option<(usize, BreakOpportunity)>,
- breaks: B,
- trailing_newlines: usize,
-}
-
-impl<'a, B> Iterator for LineBreaks<'a, B>
-where
- B: Iterator<Item = (usize, BreakOpportunity)> + Clone + 'a,
-{
- type Item = &'a str;
-
- fn next(&mut self) -> Option<Self::Item> {
- 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<Item = (usize, BreakOpportunity)> + 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<Item>) {
- let _ = self.renderer.render(item, &mut FmtAdapter(&mut self.file));
- }
-}
-
-struct FmtAdapter<W>(W);
-
-impl<W> FmtWrite for FmtAdapter<W>
-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<Extreme, usize> {
- 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<Axis2, [BorderStyle; 2]>) {
- 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<Axis2, [usize; 2]>,
- 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::<Vec<_>>();
- 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"]);
- }
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use 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<F>(&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<usize>,
-
- /// Byte offests.
- offsets: Range<usize>,
-}
-
-/// 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<Self::Item> {
- 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<usize>,
- clip: &Range<usize>,
-) -> 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::<Emphasis>() {
- for uppercase in all::<Emphasis>() {
- 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::<Emphasis>() {
- for uppercase in all::<Emphasis>() {
- 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::<Emphasis>() {
- for hiragana in all::<Emphasis>() {
- 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::<Emphasis>() {
- for hiragana in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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:?}"
- );
- }
- }
- }
-}
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},
};
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),
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);
}
}
use crate::{
data::cases_to_output,
output::{
- Details, Item, Text,
+ Item, Text,
pivot::{PivotTable, tests::assert_lines_eq},
},
pc::PcFile,
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();
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},
};
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),
use crate::{
data::cases_to_output,
output::{
- Details, Item, Text,
+ Item, Text,
pivot::{PivotTable, tests::assert_lines_eq},
},
por::{PortableFile, ReadPad},
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();
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
pub commands: Compatibility,
pub global: Compatibility,
pub syntax: Compatibility,
- pub formats: FormatSettings,
+ pub formats: Arc<FormatSettings>,
pub endian: EndianSettings,
pub small: f64,
pub show_values: Show,
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(),
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use 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<PathBuf>,
-
- /// 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<String>,
-
- /// Output format.
- #[arg(long, short = 'f', help_heading = "Output options")]
- format: Option<ShowFormat>,
-}
-
-enum Output {
- Driver {
- driver: Rc<RefCell<Box<dyn Driver>>>,
- mode: Mode,
- },
- Json {
- writer: Rc<RefCell<Box<dyn Write>>>,
- 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<T>(&self, value: &T) -> Result<()>
- where
- T: Serialize,
- for<'a> &'a T: Into<Details>,
- {
- 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<T>(&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 {
- <dyn Driver>::driver_type_from_filename(file).ok_or_else(|| {
- anyhow!("{}: no default output format for file name", file.display())
- })?
- } else {
- "text"
- };
-
- #[derive(Serialize)]
- struct DriverConfig {
- 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(<dyn Driver>::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<Record> = reader.records().collect::<Result<Vec<_>, _>>()?;
- 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<Record> = reader.records().collect::<Result<Vec<_>, _>>()?;
- 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,
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use anyhow::{Result, anyhow};
-use clap::{Args, ValueEnum};
-use 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<PathBuf>,
-
- /// 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<String>,
-
- /// Output format.
- #[arg(long, short = 'f', help_heading = "Output options")]
- format: Option<ShowFormat>,
-}
-
-enum Output {
- Driver {
- driver: Rc<RefCell<Box<dyn Driver>>>,
- mode: Mode,
- },
- Json {
- writer: Rc<RefCell<Box<dyn Write>>>,
- pretty: bool,
- },
- Discard,
-}
-
-impl Output {
- fn show_json<T>(&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 {
- <dyn Driver>::driver_type_from_filename(file).ok_or_else(|| {
- anyhow!("{}: no default output format for file name", file.display())
- })?
- } else {
- "text"
- };
-
- #[derive(Serialize)]
- struct DriverConfig {
- 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(<dyn Driver>::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,
-}
+++ /dev/null
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program. If not, see <http://www.gnu.org/licenses/>.
-
-use anyhow::{Result, anyhow};
-use clap::{Args, ValueEnum};
-use 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<PathBuf>,
-
- /// 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<String>,
-
- /// Output format.
- #[arg(long, short = 'f', help_heading = "Output options")]
- format: Option<ShowFormat>,
-}
-
-enum Output {
- Driver {
- driver: Rc<RefCell<Box<dyn Driver>>>,
- mode: Mode,
- },
- Json {
- writer: Rc<RefCell<Box<dyn Write>>>,
- pretty: bool,
- },
- Discard,
-}
-
-impl Output {
- fn show_json<T>(&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 {
- <dyn Driver>::driver_type_from_filename(file).ok_or_else(|| {
- anyhow!("{}: no default output format for file name", file.display())
- })?
- } else {
- "text"
- };
-
- #[derive(Serialize)]
- struct DriverConfig {
- 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(<dyn Driver>::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::<Vec<_>>();
- 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,
-}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+//! 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;
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+#![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<F> {
+ /// 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<String>,
+}
+
+impl<F> ReadOptions<F> {
+ /// 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<String>) -> Self {
+ Self { password, ..self }
+ }
+}
+
+pub trait ReadSeek: Read + Seek {}
+impl<T> ReadSeek for T where T: Read + Seek {}
+
+impl<F> ReadOptions<F>
+where
+ F: FnMut(Warning) + 'static,
+{
+ /// Opens the file at `path`.
+ pub fn open_file<P>(self, path: P) -> Result<SpvFile, Error>
+ where
+ P: AsRef<Path>,
+ {
+ self.open_reader(File::open(path)?)
+ }
+
+ /// Opens the file read from `reader`.
+ pub fn open_reader<R>(self, reader: R) -> Result<SpvFile, Error>
+ where
+ R: Read + Seek + 'static,
+ {
+ let reader = match &self.password {
+ None => Box::new(reader) as Box<dyn ReadSeek>,
+ Some(password) => Box::new(EncryptedReader::open(reader, password)?),
+ };
+ self.open_reader_inner(Box::new(reader))
+ }
+
+ fn open_reader_inner(self, reader: Box<dyn ReadSeek>) -> Result<SpvFile, Error> {
+ 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<Box<dyn ReadSeek>>,
+ ) -> Result<SpvFile, Error> {
+ // 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<dyn FnMut(Warning)>));
+ 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<Item>,
+
+ /// The page setup in the SPV file, if any.
+ pub page_setup: Option<page::PageSetup>,
+
+ /// The Zip archive that the file was read from.
+ pub archive: ZipArchive<Box<dyn ReadSeek>>,
+}
+
+impl SpvFile {
+ /// Returns the contents of the `SpvFile`.
+ pub fn into_contents(self) -> (Vec<Item>, Option<page::PageSetup>) {
+ (self.items, self.page_setup)
+ }
+
+ /// Returns just the [Item]s.
+ pub fn into_items(self) -> Vec<Item> {
+ 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<Value>) -> Item {
+ Text::new_log(message).into_item().with_label("Error")
+}
+
+fn read_heading<R>(
+ archive: &mut ZipArchive<R>,
+ file_number: usize,
+ structure_member: &str,
+ warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+) -> Result<(Vec<Item>, Option<page::PageSetup>), 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<String>,
+ #[serde(rename = "@commandName")]
+ command_name: Option<String>,
+ label: Label,
+ page_setup: Option<PageSetup>,
+
+ #[serde(rename = "$value")]
+ #[serde(default)]
+ children: Vec<HeadingContent>,
+}
+
+impl Heading {
+ fn decode<R>(
+ self,
+ archive: &mut ZipArchive<R>,
+ structure_member: &str,
+ warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+ ) -> Result<Vec<Item>, 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::<Item>()
+ .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<i32>,
+ #[serde(rename = "@chart-size")]
+ pub chart_size: Option<ChartSize>,
+ #[serde(rename = "@margin-left")]
+ pub margin_left: Option<Length>,
+ #[serde(rename = "@margin-right")]
+ pub margin_right: Option<Length>,
+ #[serde(rename = "@margin-top")]
+ pub margin_top: Option<Length>,
+ #[serde(rename = "@margin-bottom")]
+ pub margin_bottom: Option<Length>,
+ #[serde(rename = "@paper-height")]
+ pub paper_height: Option<Length>,
+ #[serde(rename = "@paper-width")]
+ pub paper_width: Option<Length>,
+ #[serde(rename = "@reference-orientation")]
+ pub reference_orientation: Option<String>,
+ #[serde(rename = "@space-after")]
+ pub space_after: Option<Length>,
+ pub page_header: PageHeader,
+ pub page_footer: PageFooter,
+}
+
+impl PageSetup {
+ fn decode(
+ &self,
+ warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+ 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<PageParagraph>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageFooter {
+ page_paragraph: Option<PageParagraph>,
+}
+
+#[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<ReferenceOrientation> 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<ChartSize> 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<Heading>),
+}
+
+#[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<TextAlign>,
+ #[serde(rename = "@width")]
+ width: Option<String>,
+ 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<String>,
+ path: String,
+ csv_path: Option<String>,
+}
+
+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<R>(
+ archive: &mut ZipArchive<R>,
+ structure_member: &str,
+ command_name: &Option<String>,
+ image_name: &str,
+) -> Result<Item, Error>
+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<String>,
+ data_path: String,
+}
+
+impl Image {
+ fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
+ 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<String>,
+ #[serde(rename = "@uri")]
+ uri: String,
+}
+
+impl Object {
+ fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
+ 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<i64>,
+ #[serde(rename = "@type")]
+ table_type: TableType,
+ properties: Option<TableProperties>,
+ table_structure: TableStructure,
+}
+
+impl Table {
+ fn decode<R>(
+ &self,
+ archive: &mut ZipArchive<R>,
+ structure_member: &str,
+ warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+ ) -> Result<Item, Error>
+ 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<dyn FnMut(LightWarning)>));
+ 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<String>,
+ 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<String>,
+ /// The `.bin` member name.
+ data_path: String,
+ /// Rarely used, not understood.
+ #[serde(rename = "csvPath")]
+ _csv_path: Option<String>,
+}
--- /dev/null
+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<Self::Item> {
+ let mut s = self.0;
+ loop {
+ s = s.trim_start();
+ if let Some(rest) = s.strip_prefix("<!--") {
+ s = rest;
+ } else 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<Self> {
+ 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<Style> {
+ let mut lexer = Lexer(css);
+ let mut styles = Vec::new();
+ 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()
+ && let Some((style, add)) = match key.as_ref() {
+ "color" => value.parse().ok().map(|color| (Style::Color(color), true)),
+ "font-weight" => Some((Style::Bold, value == "bold")),
+ "font-style" => Some((Style::Italic, value == "italic")),
+ "text-decoration" => Some((Style::Underline, value == "underline")),
+ "font-family" => Some((Style::Face(value.into()), true)),
+ "font-size" => value
+ .strip_suffix("pt")
+ .unwrap_or(&value)
+ .parse::<i32>()
+ .ok()
+ .map(|size| (Style::Size(size as f64 * 0.75), true)),
+ _ => None,
+ }
+ {
+ // Remove from `styles` any style of the same kind as `style`.
+ styles.retain(|s| discriminant(s) != discriminant(&style));
+ if add {
+ styles.push(style);
+ }
+ }
+ }
+ styles
+ }
+}
+
+impl FontStyle {
+ /// Parses the CSS found in `css` and modifies this `FontStyle` accordingly.
+ ///
+ /// This is only good enough to parse the simple CSS found in SPV files.
+ pub fn parse_css(&mut self, css: &str) {
+ 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()
+ {
+ match key.as_ref() {
+ "color" => {
+ if let Ok(color) = value.parse() {
+ self.fg = color;
+ }
+ }
+ "font-weight" => self.bold = value == "bold",
+ "font-style" => self.italic = value == "italic",
+ "text-decoration" => self.underline = value == "underline",
+ "font-family" => self.font = value.into(),
+ "font-size" => {
+ if let Ok(size) = value.parse::<i32>() {
+ self.size = (size as i64 * 3 / 4) as i32;
+ }
+ }
+ _ => (),
+ }
+ }
+ }
+ }
+
+ /// Parses the CSS found in `css` and returns a corresponding `FontStyle`
+ /// (starting from [FontStyle::default]).
+ ///
+ /// This is only good enough to parse the simple CSS found in SPV files.
+ pub fn from_css(css: &str) -> Self {
+ let mut style = FontStyle::default();
+ style.parse_css(css);
+ style
+ }
+
+ /// Returns CSS for the differences from `base` to this font style. Returns
+ /// `None` if there are no differences.
+ pub fn to_css(&self, base: &FontStyle) -> Option<String> {
+ let mut settings = Vec::new();
+ if self.font != base.font {
+ if is_css_ident(&self.font) {
+ settings.push(format!("font-family: {}", &self.font));
+ } else {
+ settings.push(format!("font-family: {}", CssString(&self.font)));
+ }
+ }
+ if self.bold != base.bold {
+ settings.push(format!(
+ "font-weight: {}",
+ if self.bold { "bold" } else { "normal" }
+ ));
+ }
+ if self.italic != base.italic {
+ settings.push(format!(
+ "font-style: {}",
+ if self.bold { "italic" } else { "normal" }
+ ));
+ }
+ if self.underline != base.underline {
+ settings.push(format!(
+ "text-decoration: {}",
+ if self.bold { "underline" } else { "none" }
+ ));
+ }
+ if self.size != base.size {
+ settings.push(format!("font-size: {}", self.size as i64 * 4 / 3));
+ }
+ if self.fg != base.fg {
+ settings.push(format!("color: {}", self.fg.display_css()));
+ }
+ settings
+ .is_empty()
+ .not()
+ .then(|| format!("<!-- p {{ {} }} -->", settings.into_iter().join("; ")))
+ }
+}
+
+fn is_css_ident(s: &str) -> bool {
+ fn is_nmstart(c: char) -> bool {
+ c.is_ascii_alphabetic() || c == '_'
+ }
+ s.chars().next().is_some_and(is_nmstart) && s.chars().all(|c| is_nmstart(c) || c as u32 > 159)
+}
+
+struct CssString<'a>(&'a str);
+
+impl<'a> Display for CssString<'a> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let quote = if self.0.contains('"') && !self.0.contains('\'') {
+ '\''
+ } else {
+ '"'
+ };
+ f.write_char(quote)?;
+ for c in self.0.chars() {
+ match c {
+ _ if c == quote || c == '\\' => {
+ f.write_char('\\')?;
+ f.write_char(c)?;
+ }
+ '\n' => f.write_str("\\00000a")?,
+ c => f.write_char(c)?,
+ }
+ }
+ f.write_char(quote)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::borrow::Cow;
+
+ use crate::{
+ output::pivot::look::{Color, FontStyle, HorzAlign},
+ spv::read::css::{Lexer, Token},
+ };
+
+ #[test]
+ fn css_horz_align() {
+ assert_eq!(
+ HorzAlign::from_css("text-align: left"),
+ Some(HorzAlign::Left)
+ );
+ assert_eq!(
+ HorzAlign::from_css("margin-top: 0; text-align:center"),
+ Some(HorzAlign::Center)
+ );
+ assert_eq!(
+ HorzAlign::from_css("text-align: Right; margin-top:0"),
+ Some(HorzAlign::Right)
+ );
+ assert_eq!(HorzAlign::from_css("text-align: other"), None);
+ assert_eq!(HorzAlign::from_css("margin-top: 0"), None);
+ }
+
+ #[test]
+ fn css_strings() {
+ #[track_caller]
+ fn test_string(css: &str, value: &str) {
+ let mut lexer = Lexer(css);
+ assert_eq!(lexer.next(), Some(Token::Id(Cow::from(value))));
+ assert_eq!(lexer.next(), None);
+ }
+
+ test_string(r#""abc""#, "abc");
+ test_string(r#""a\"'\'bc""#, "a\"''bc");
+ test_string(r#""a\22 bc""#, "a\" bc");
+ test_string(r#""a\000022bc""#, "a\"bc");
+ test_string(r#""a'bc""#, "a'bc");
+ test_string(
+ r#""\\\
+xyzzy""#,
+ "\\xyzzy",
+ );
+
+ test_string(r#"'abc'"#, "abc");
+ test_string(r#"'a"\"\'bc'"#, "a\"\"'bc");
+ test_string(r#"'a\22 bc'"#, "a\" bc");
+ test_string(r#"'a\000022bc'"#, "a\"bc");
+ test_string(r#"'a\'bc'"#, "a'bc");
+ test_string(
+ r#"'a\'bc\
+xyz'"#,
+ "a'bcxyz",
+ );
+ test_string(r#"'\\'"#, "\\");
+ }
+
+ #[test]
+ fn style_from_css() {
+ assert_eq!(FontStyle::from_css(""), FontStyle::default());
+ assert_eq!(
+ FontStyle::from_css(r#"p{color:ff0000}"#),
+ FontStyle::default().with_fg(Color::RED)
+ );
+ assert_eq!(
+ FontStyle::from_css("p {font-weight: bold; text-decoration: underline}"),
+ FontStyle::default().with_bold(true).with_underline(true)
+ );
+ assert_eq!(
+ FontStyle::from_css("p {font-family: Monospace}"),
+ FontStyle::default().with_font("Monospace")
+ );
+ assert_eq!(
+ FontStyle::from_css("p {font-size: 24}"),
+ FontStyle::default().with_size(18)
+ );
+ assert_eq!(
+ FontStyle::from_css(
+ "<!--color: red; font-weight: bold; font-style: italic; text-decoration: underline; font-family: Serif-->"
+ ),
+ FontStyle::default()
+ .with_fg(Color::RED)
+ .with_bold(true)
+ .with_italic(true)
+ .with_underline(true)
+ .with_font("Serif")
+ );
+ }
+
+ #[test]
+ fn style_to_css() {
+ let base = FontStyle::default();
+ assert_eq!(base.to_css(&base), None);
+ assert_eq!(
+ FontStyle::default().with_size(18).to_css(&base),
+ Some("<!-- p { font-size: 24 } -->".into())
+ );
+ assert_eq!(
+ FontStyle::default()
+ .with_bold(true)
+ .with_underline(true)
+ .to_css(&base),
+ Some("<!-- p { font-weight: bold; text-decoration: underline } -->".into())
+ );
+ assert_eq!(
+ FontStyle::default().with_fg(Color::RED).to_css(&base),
+ Some("<!-- p { color: #ff0000 } -->".into())
+ );
+ assert_eq!(
+ FontStyle::default().with_font("Monospace").to_css(&base),
+ Some("<!-- p { font-family: Monospace } -->".into())
+ );
+ assert_eq!(
+ FontStyle::default()
+ .with_font("Times New Roman")
+ .to_css(&base),
+ Some(r#"<!-- p { font-family: "Times New Roman" } -->"#.into())
+ );
+ }
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+//! HTML parsing and formatting.
+//!
+//! SPV files contain text in a simple subset of HTML. [Markup] represents
+//! parsed text in this form suitable for a single [Value], whereas [Document]
+//! can contain multiple paragraphs of markup, each paragraph represented by a
+//! [Block].
+#![warn(dead_code)]
+use std::{
+ borrow::{Borrow, Cow},
+ fmt::{Display, Write as _},
+ io::{Cursor, Write},
+ mem::{discriminant, take},
+ str::FromStr,
+};
+
+use hashbrown::HashMap;
+use html_parser::{Dom, Element, Node};
+use pango::{AttrColor, AttrInt, AttrList, AttrSize, AttrString, IsAttribute};
+use quick_xml::{
+ Writer as XmlWriter,
+ escape::unescape,
+ events::{BytesText, Event},
+};
+use serde::{Deserialize, Deserializer, Serialize, ser::SerializeMap};
+
+use crate::output::pivot::{
+ look::{CellStyle, Color, FontStyle, HorzAlign},
+ value::Value,
+};
+
+fn lowercase<'a>(s: &'a str) -> Cow<'a, str> {
+ if s.chars().any(|c| c.is_ascii_uppercase()) {
+ Cow::from(s.to_ascii_lowercase())
+ } else {
+ Cow::from(s)
+ }
+}
+
+/// Inline styled text.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Markup {
+ /// A sequence.
+ Seq(
+ /// The sequence.
+ Vec<Markup>,
+ ),
+ /// A text string.
+ Text(
+ /// The text.
+ String,
+ ),
+ /// A substitution variable.
+ Variable(
+ /// The variable.
+ Variable,
+ ),
+ /// Styled text.
+ Style {
+ /// The style to apply to the contents of `child`.
+ style: Style,
+ /// The styled child markup.
+ child: Box<Markup>,
+ },
+}
+
+/// A substitution variable within [Markup].
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)]
+pub enum Variable {
+ /// `&[Date]`
+ Date,
+ /// `&[Time]`
+ Time,
+ /// `&[HeadN]`
+ Head(
+ /// `N`.
+ u8,
+ ),
+ /// `&[PageTitle]`.
+ PageTitle,
+ /// `&[Page]`.
+ Page,
+}
+
+/// Unknown variable error returned by [Variable::from_str].
+#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
+#[error("Unknown variable")]
+pub struct UnknownVariable;
+
+impl FromStr for Variable {
+ type Err = UnknownVariable;
+
+ /// Parses `Date` into [Self::Date], and so on.
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "Date" => Ok(Self::Date),
+ "Time" => Ok(Self::Time),
+ "PageTitle" => Ok(Self::PageTitle),
+ "Page" => Ok(Self::Page),
+ _ => {
+ if let Some(suffix) = s.strip_prefix("Head")
+ && let Ok(number) = suffix.parse()
+ && number >= 1
+ {
+ Ok(Self::Head(number))
+ } else {
+ Err(UnknownVariable)
+ }
+ }
+ }
+ }
+}
+
+impl Variable {
+ fn as_str(&self) -> Cow<'static, str> {
+ match self {
+ Variable::Date => Cow::from("Date"),
+ Variable::Time => Cow::from("Time"),
+ Variable::Head(index) => Cow::from(format!("Head{index}")),
+ Variable::PageTitle => Cow::from("PageTitle"),
+ Variable::Page => Cow::from("Page"),
+ }
+ }
+}
+
+impl Display for Variable {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+impl Default for Markup {
+ fn default() -> Self {
+ Self::Seq(Vec::new())
+ }
+}
+
+impl Serialize for Markup {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ match self {
+ Markup::Seq(inner) => serializer.collect_seq(inner),
+ Markup::Text(string) => serializer.serialize_str(string.as_str()),
+ Markup::Variable(name) => serializer.serialize_newtype_struct("Variable", name),
+ Markup::Style { style, child } => {
+ let (mut style, mut child) = (style, child);
+ let mut styles = HashMap::new();
+ loop {
+ styles.insert(discriminant(style), style);
+ match &**child {
+ Markup::Style {
+ style: inner,
+ child: inner_child,
+ } => {
+ style = inner;
+ child = inner_child;
+ }
+ _ => break,
+ }
+ }
+ let mut map = serializer.serialize_map(Some(styles.len() + 1))?;
+ for style in styles.into_values() {
+ match style {
+ Style::Bold => map.serialize_entry("bool", &true),
+ Style::Italic => map.serialize_entry("italic", &true),
+ Style::Underline => map.serialize_entry("underline", &true),
+ Style::Strike => map.serialize_entry("strike", &true),
+ Style::Emphasis => map.serialize_entry("em", &true),
+ Style::Strong => map.serialize_entry("strong", &true),
+ Style::Face(name) => map.serialize_entry("font", name),
+ Style::Color(color) => map.serialize_entry("color", color),
+ Style::Size(size) => map.serialize_entry("size", size),
+ }?;
+ }
+ map.serialize_entry("content", child)?;
+ map.end()
+ }
+ }
+ }
+}
+
+impl Display for Markup {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ fn inner(this: &Markup, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match this {
+ Markup::Seq(seq) => {
+ for markup in seq {
+ inner(markup, f)?;
+ }
+ Ok(())
+ }
+ Markup::Text(string) => f.write_str(string.as_str()),
+ Markup::Variable(name) => write!(f, "&[{name}]"),
+ Markup::Style { child, .. } => inner(child, f),
+ }
+ }
+ inner(self, f)
+ }
+}
+
+impl Markup {
+ /// Returns true if this markup contains no text.
+ pub fn is_empty(&self) -> bool {
+ match self {
+ Markup::Seq(seq) => seq.iter().all(|markup| markup.is_empty()),
+ Markup::Text(s) => s.is_empty(),
+ Markup::Variable(_) => false,
+ Markup::Style { style: _, child } => child.is_empty(),
+ }
+ }
+ /// Returns true if this is a [Markup::Style].
+ pub fn is_style(&self) -> bool {
+ matches!(self, Markup::Style { .. })
+ }
+ /// If this is [Markup::Style], returns its contents, and otherwise `None`.
+ pub fn into_style(self) -> Option<(Style, Markup)> {
+ match self {
+ Markup::Style { style, child } => Some((style, *child)),
+ _ => None,
+ }
+ }
+ /// Returns true if this is a [Markup::Text].
+ pub fn is_text(&self) -> bool {
+ matches!(self, Markup::Text(_))
+ }
+ /// For [Markup::Text], returns the text, and otherwise `None`.
+ pub fn as_text(&self) -> Option<&str> {
+ match self {
+ Markup::Text(text) => Some(text.as_str()),
+ _ => None,
+ }
+ }
+ /// If this is [Markup::Text], returns its contents, and otherwise `None`.
+ pub fn into_text(self) -> Option<String> {
+ match self {
+ Markup::Text(text) => Some(text),
+ _ => None,
+ }
+ }
+ fn write_html<X>(&self, writer: &mut XmlWriter<X>) -> std::io::Result<()>
+ where
+ X: Write,
+ {
+ match self {
+ Markup::Seq(children) => {
+ for child in children {
+ child.write_html(writer)?;
+ }
+ }
+ Markup::Text(text) => writer.write_event(Event::Text(BytesText::new(text.as_str())))?,
+ Markup::Variable(name) => {
+ writer.write_event(Event::Text(BytesText::new(&format!("&[{name}]"))))?
+ }
+ Markup::Style { style, child } => {
+ let mut elements = Vec::new();
+ let mut attributes = Vec::new();
+ fn add_style(
+ style: &Style,
+ elements: &mut Vec<&'static str>,
+ attributes: &mut Vec<(&'static str, String)>,
+ ) {
+ match style {
+ Style::Bold => elements.push("b"),
+ Style::Italic => elements.push("i"),
+ Style::Underline => elements.push("u"),
+ Style::Strike => elements.push("strike"),
+ Style::Emphasis => elements.push("em"),
+ Style::Strong => elements.push("strong"),
+ Style::Face(face) => attributes.push(("face", face.clone())),
+ Style::Color(color) => {
+ attributes.push(("color", color.display_css().to_string()))
+ }
+ Style::Size(points) => {
+ attributes.push(("size", format!("{}pt", *points / 0.75)))
+ }
+ }
+ }
+
+ add_style(style, &mut elements, &mut attributes);
+ let mut next = &**child;
+ while let Markup::Style { style, child } = next {
+ add_style(style, &mut elements, &mut attributes);
+ next = &**child;
+ }
+
+ elements.sort();
+ attributes.sort();
+ next.write_styles(writer, &elements, &attributes)?;
+ }
+ }
+ Ok(())
+ }
+
+ fn write_styles<X>(
+ &self,
+ writer: &mut XmlWriter<X>,
+ elements: &[&str],
+ attributes: &[(&str, String)],
+ ) -> std::io::Result<()>
+ where
+ X: Write,
+ {
+ if !attributes.is_empty() {
+ writer
+ .create_element("font")
+ .with_attributes(
+ attributes
+ .into_iter()
+ .map(|(name, value)| (*name, Cow::from(value))),
+ )
+ .write_inner_content(|w| self.write_styles(w, elements, &[]))?;
+ } else if let Some((element, rest)) = elements.split_first() {
+ writer
+ .create_element(*element)
+ .write_inner_content(|w| self.write_styles(w, rest, attributes))?;
+ } else {
+ self.write_html(writer)?;
+ }
+ Ok(())
+ }
+
+ /// Returns this markup converted into XHTML. The returned string contains
+ /// a single `<html>...</html>` element.
+ ///
+ /// Substitution variables in the markup are converted back into their
+ /// source forms as `&[PageTitle]`, etc.
+ pub fn to_html(&self) -> String {
+ let mut writer = XmlWriter::new(Cursor::new(Vec::new()));
+ writer
+ .create_element("html")
+ .write_inner_content(|w| self.write_html(w))
+ .expect("writing to a Vec can't fail");
+ String::from_utf8(writer.into_inner().into_inner())
+ .expect("XmlWriter should only output UTF-8")
+ }
+
+ /// Returns this markup as text and attributes suitable for passing as the
+ /// argument to [pango::Layout::set_text] and
+ /// [pango::Layout::set_attributes], respectively.
+ ///
+ /// Calls `expand` to obtain expansions for variables in the markup.
+ pub fn to_pango<'a, F>(&self, expand: F) -> (String, AttrList)
+ where
+ F: Fn(Variable) -> Option<Cow<'a, str>>,
+ {
+ let mut s = String::new();
+ let mut attrs = AttrList::new();
+ self.to_pango_inner(&expand, &mut s, &mut attrs);
+ (s, attrs)
+ }
+
+ fn to_pango_inner<'a, F>(&self, expand: &F, s: &mut String, attrs: &mut AttrList)
+ where
+ F: Fn(Variable) -> Option<Cow<'a, str>>,
+ {
+ match self {
+ Markup::Seq(seq) => {
+ for child in seq {
+ child.to_pango_inner(expand, s, attrs);
+ }
+ }
+ Markup::Text(string) => s.push_str(&string),
+ Markup::Variable(variable) => match expand(*variable) {
+ Some(value) => s.push_str(&*value),
+ None => write!(s, "&[{variable}]").unwrap(),
+ },
+ Markup::Style { style, child } => {
+ let start_index = s.len();
+ child.to_pango_inner(expand, s, attrs);
+ let end_index = s.len();
+
+ let mut attr = match style {
+ Style::Bold | Style::Strong => {
+ AttrInt::new_weight(pango::Weight::Bold).upcast()
+ }
+ Style::Italic | Style::Emphasis => {
+ AttrInt::new_style(pango::Style::Italic).upcast()
+ }
+ Style::Underline => AttrInt::new_underline(pango::Underline::Single).upcast(),
+ Style::Strike => AttrInt::new_strikethrough(true).upcast(),
+ Style::Face(face) => AttrString::new_family(&face).upcast(),
+ Style::Color(color) => {
+ let (r, g, b) = color.into_rgb16();
+ AttrColor::new_foreground(r, g, b).upcast()
+ }
+ Style::Size(points) => AttrSize::new((points * 1024.0) as i32).upcast(),
+ };
+ attr.set_start_index(start_index as u32);
+ attr.set_end_index(end_index as u32);
+ attrs.insert(attr);
+ }
+ }
+ }
+
+ fn parse_variables(&self) -> Option<Vec<Markup>> {
+ let Some(mut s) = self.as_text() else {
+ return None;
+ };
+ let mut results = Vec::new();
+ let mut offset = 0;
+ while let Some(start) = s[offset..].find("&[").map(|pos| pos + offset)
+ && let Some(end) = s[start..].find("]").map(|pos| pos + start)
+ {
+ if let Ok(variable) = Variable::from_str(&s[start + 2..end]) {
+ if start > 0 {
+ results.push(Markup::Text(s[..start].into()));
+ }
+ results.push(Markup::Variable(variable));
+ s = &s[end + 1..];
+ offset = 0;
+ } else {
+ offset = end + 1;
+ }
+ }
+ if results.is_empty() {
+ None
+ } else {
+ if !s.is_empty() {
+ results.push(Markup::Text(s.into()));
+ }
+ Some(results)
+ }
+ }
+}
+
+/// A block of styled text.
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct Block {
+ /// Contents.
+ pub markup: Markup,
+
+ /// Horizontal alignment.
+ pub horz_align: HorzAlign,
+}
+
+impl Default for Block {
+ fn default() -> Self {
+ Self {
+ markup: Markup::default(),
+ horz_align: HorzAlign::Left,
+ }
+ }
+}
+
+impl Block {
+ fn new(mut markup: Markup, horz_align: HorzAlign, css: &[Style]) -> Self {
+ for style in css {
+ apply_style(&mut markup, style.clone());
+ }
+ Self { markup, horz_align }
+ }
+
+ /// Returns a [Value] with this `Block`'s contents.
+ pub fn into_value(self) -> Value {
+ let mut font_style = FontStyle::default().with_size(10);
+ let cell_style = CellStyle::default().with_horz_align(Some(self.horz_align));
+ let mut strike = false;
+ let mut markup = self.markup;
+ let mut markup = loop {
+ if let Markup::Style { style, child } = markup {
+ match style {
+ Style::Bold => font_style.bold = true,
+ Style::Italic => font_style.italic = true,
+ Style::Underline => font_style.underline = true,
+ Style::Strike => strike = true,
+ Style::Emphasis => font_style.italic = true,
+ Style::Strong => font_style.bold = true,
+ Style::Face(face) => font_style.font = face,
+ Style::Color(color) => font_style.fg = color,
+ Style::Size(points) => font_style.size = points as i32,
+ };
+ markup = *child;
+ } else {
+ break markup;
+ }
+ };
+ if strike {
+ apply_style(&mut markup, Style::Strike);
+ }
+ match markup {
+ Markup::Text(text) => Value::new_user_text(text),
+ markup => Value::new_markup(markup),
+ }
+ .with_font_style(font_style)
+ .with_cell_style(cell_style)
+ }
+}
+
+/// Blocks of styled text.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Document(
+ /// The blocks.
+ pub Vec<Block>,
+);
+
+impl<'de> Deserialize<'de> for Document {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ Ok(Document::from_html(&String::deserialize(deserializer)?))
+ }
+}
+
+impl Serialize for Document {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ self.to_html().serialize(serializer)
+ }
+}
+
+impl Document {
+ /// Returns true if this document contains no [Block]s.
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ /// Parses HTML `input` into a `Document`. If `input` is not valid HTML,
+ /// then it is treated as plain text instead.
+ pub fn from_html(input: &str) -> Self {
+ match Dom::parse(&format!("<!doctype html>{input}")) {
+ Ok(dom) => Self(parse_dom(&dom)),
+ Err(_) if !input.is_empty() => Self(vec![Block {
+ markup: Markup::Text(input.into()),
+ horz_align: HorzAlign::Left,
+ }]),
+ Err(_) => Self::default(),
+ }
+ }
+
+ /// Returns the document converted to a [Value]. If the document contains
+ /// more than one [Block], only the first one appears in the [Value].
+ pub fn into_value(self) -> Value {
+ self.0.into_iter().next().unwrap_or_default().into_value()
+ }
+
+ /// Returns the document converted to XHTML, except that the result will not
+ /// be a single `<html>...</html>` element but instead the contents for such
+ /// an element.
+ pub fn to_html(&self) -> String {
+ let mut writer = XmlWriter::new(Cursor::new(Vec::new()));
+ writer
+ .create_element("html")
+ .write_inner_content(|w| {
+ for block in &self.0 {
+ w.create_element("p")
+ .with_attribute(("align", block.horz_align.as_str().unwrap_or("right")))
+ .write_inner_content(|w| block.markup.write_html(w))?;
+ }
+ Ok(())
+ })
+ .expect("writing to a Vec can't fail");
+
+ // Return the result with `<html>` and `</html>` stripped off.
+ str::from_utf8(&writer.into_inner().into_inner())
+ .expect("XmlWriter should only output UTF-8")
+ .strip_prefix("<html>")
+ .expect("<html> should always be present")
+ .strip_suffix("</html>")
+ .expect("</html> should always be present")
+ .into()
+ }
+
+ /// Returns the document converted to a series of [Value]s.
+ pub fn to_values(&self) -> Vec<Value> {
+ self.0
+ .iter()
+ .map(|block| block.clone().into_value())
+ .collect()
+ }
+}
+
+/// A text style.
+///
+/// Used in [Markup::Style].
+#[derive(Clone, Debug, PartialEq)]
+pub enum Style {
+ /// **Bold**.
+ Bold,
+
+ /// *Italic*.
+ Italic,
+
+ /// __Underline__.
+ Underline,
+
+ /// ~~Strikethrough~~.
+ Strike,
+
+ /// <em>Emphasis</em>.
+ Emphasis,
+
+ /// <strong>Strong</strong>.
+ Strong,
+
+ /// Sets the typeface.
+ Face(
+ /// The typeface name.
+ String,
+ ),
+
+ /// Font color.
+ Color(
+ /// The color
+ Color,
+ ),
+
+ /// Font size.
+ Size(
+ /// In 1/72" units.
+ f64,
+ ),
+}
+
+fn node_as_element<'a>(node: &'a Node, name: &str) -> Option<&'a Element> {
+ if let Node::Element(element) = node
+ && element.name.eq_ignore_ascii_case(name)
+ {
+ Some(element)
+ } else {
+ None
+ }
+}
+
+fn node_is_element(node: &Node, name: &str) -> bool {
+ node_as_element(node, name).is_some()
+}
+
+/// Returns the horizontal alignment for the `<p>` element in `p`.
+fn horz_align_from_p(p: &Element) -> Option<HorzAlign> {
+ if let Some(Some(s)) = p.attributes.get("align")
+ && let Ok(align) = HorzAlign::from_str(s)
+ {
+ Some(align)
+ } else if let Some(Some(s)) = p.attributes.get("style")
+ && let Some(align) = HorzAlign::from_css(s)
+ {
+ Some(align)
+ } else {
+ None
+ }
+}
+
+fn apply_style(markup: &mut Markup, style: Style) {
+ let child = take(markup);
+ *markup = Markup::Style {
+ style,
+ child: Box::new(child),
+ };
+}
+
+fn parse_dom(dom: &Dom) -> Vec<Block> {
+ // Get the top-level elements, descending into an `html` element if
+ // there is one.
+ let roots = if dom.children.len() == 1
+ && let Some(first) = dom.children.first()
+ && let Some(html) = node_as_element(first, "html")
+ {
+ &html.children
+ } else {
+ &dom.children
+ };
+
+ // If there's a `head` element, parse it for CSS and then skip past it.
+ let mut head_styles = Vec::new();
+ let mut default_horz_align = HorzAlign::Left;
+ let roots = if let Some((first, rest)) = roots.split_first()
+ && let Some(head) = node_as_element(first, "head")
+ {
+ if let Some(style) = find_element(&head.children, "style") {
+ let mut text = String::new();
+ get_element_text(style, &mut text);
+ head_styles = Style::parse_css(&text);
+ if let Some(horz_align) = HorzAlign::from_css(&text) {
+ default_horz_align = horz_align;
+ }
+ }
+ rest
+ } else {
+ roots
+ };
+
+ // If only a `body` element is left, descend into it.
+ let body = if roots.len() == 1
+ && let Some(first) = roots.first()
+ && let Some(body) = node_as_element(first, "body")
+ {
+ &body.children
+ } else {
+ roots
+ };
+
+ let mut blocks = Vec::new();
+
+ let mut start = 0;
+ while start < body.len() {
+ let (end, align) = if let Some(p) = node_as_element(&body[start], "p") {
+ (
+ start + 1,
+ horz_align_from_p(p).unwrap_or(default_horz_align),
+ )
+ } else {
+ let mut end = start + 1;
+ while end < body.len() && !node_is_element(&body[end], "p") {
+ end += 1;
+ }
+ (end, default_horz_align)
+ };
+ blocks.push(Block::new(
+ parse_nodes(&body[start..end]),
+ align,
+ &head_styles,
+ ));
+ start = end;
+ }
+
+ blocks
+}
+
+fn parse_nodes(nodes: &[Node]) -> Markup {
+ // Appends `markup` to `dst`, merging text at the end of `dst` with text
+ // in `markup`.
+ fn add_markup(dst: &mut Vec<Markup>, markup: Markup) {
+ if let Markup::Text(suffix) = &markup
+ && let Some(Markup::Text(last)) = dst.last_mut()
+ {
+ last.push_str(&suffix);
+ } else {
+ dst.push(markup);
+ }
+
+ if let Some(last) = dst.last()
+ && let Some(mut expansion) = last.parse_variables()
+ {
+ dst.pop();
+ dst.append(&mut expansion);
+ }
+ }
+
+ let mut retval = Vec::new();
+ for (i, node) in nodes.iter().enumerate() {
+ match node {
+ Node::Comment(_) => (),
+ Node::Text(text) => {
+ let text = if i == 0 {
+ text.trim_start()
+ } else {
+ text.as_str()
+ };
+ let text = if i == nodes.len() - 1 {
+ text.trim_end()
+ } else {
+ text
+ };
+ add_markup(
+ &mut retval,
+ Markup::Text(unescape(&text).unwrap_or(Cow::from(text)).into_owned()),
+ );
+ }
+ // SPSS often starts paragraphs with an initial `<BR>` that it
+ // ignores, but it does honor `<br>`. So weird.
+ Node::Element(br) if br.name == "br" => {
+ add_markup(&mut retval, Markup::Text('\n'.into()));
+ }
+ Node::Element(element) => {
+ let mut inner = parse_nodes(&element.children);
+ if inner.is_empty() {
+ continue;
+ }
+
+ let style = match lowercase(&element.name).borrow() {
+ "b" => Some(Style::Bold),
+ "i" => Some(Style::Italic),
+ "u" => Some(Style::Underline),
+ "s" | "strike" => Some(Style::Strike),
+ "strong" => Some(Style::Strong),
+ "em" => Some(Style::Emphasis),
+ "font" => {
+ if let Some(Some(face)) = element.attributes.get("face") {
+ apply_style(&mut inner, Style::Face(face.clone()));
+ }
+ if let Some(Some(color)) = element.attributes.get("color")
+ && let Ok(color) = Color::from_str(&color)
+ {
+ apply_style(&mut inner, Style::Color(color));
+ }
+ if let Some(Some(html_size)) = element.attributes.get("size")
+ && let Ok(html_size) = usize::from_str(&html_size)
+ && let Some(index) = html_size.checked_sub(1)
+ && let Some(points) =
+ [6.0, 7.5, 9.0, 10.5, 13.5, 18.0, 27.0].get(index).copied()
+ {
+ apply_style(&mut inner, Style::Size(points * 0.75));
+ }
+ None
+ }
+ _ => None,
+ };
+ match style {
+ None => match inner {
+ Markup::Seq(seq) => {
+ for markup in seq {
+ add_markup(&mut retval, markup);
+ }
+ }
+ _ => add_markup(&mut retval, inner),
+ },
+ Some(style) => retval.push(Markup::Style {
+ style,
+ child: Box::new(inner),
+ }),
+ }
+ }
+ }
+ }
+ if retval.len() == 1 {
+ retval.into_iter().next().unwrap()
+ } else {
+ Markup::Seq(retval)
+ }
+}
+
+fn find_element<'a>(elements: &'a [Node], name: &str) -> Option<&'a Element> {
+ for element in elements {
+ if let Node::Element(element) = element
+ && element.name == name
+ {
+ return Some(element);
+ }
+ }
+ None
+}
+
+fn parse_entity(s: &str) -> (char, &str) {
+ static ENTITIES: [(&str, char); 6] = [
+ ("amp;", '&'),
+ ("lt;", '<'),
+ ("gt;", '>'),
+ ("apos;", '\''),
+ ("quot;", '"'),
+ ("nbsp;", '\u{00a0}'),
+ ];
+ for (name, ch) in ENTITIES {
+ if let Some(rest) = s.strip_prefix(name) {
+ return (ch, rest);
+ }
+ }
+ ('&', s)
+}
+
+fn get_node_text(node: &Node, text: &mut String) {
+ match node {
+ Node::Text(string) => {
+ let mut s = string.as_str();
+ while !s.is_empty() {
+ let amp = s.find('&').unwrap_or(s.len());
+ let (head, rest) = s.split_at(amp);
+ text.push_str(head);
+ if rest.is_empty() {
+ break;
+ }
+ let ch;
+ (ch, s) = parse_entity(&s[1..]);
+ text.push(ch);
+ }
+ }
+ Node::Element(element) => get_element_text(element, text),
+ Node::Comment(_) => (),
+ }
+}
+
+fn get_element_text(element: &Element, text: &mut String) {
+ for child in &element.children {
+ get_node_text(child, text);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{borrow::Cow, str::FromStr};
+
+ use crate::spv::read::html::{self, Document, Markup, Variable};
+
+ #[test]
+ fn variable() {
+ assert_eq!(Variable::from_str("Head1").unwrap(), Variable::Head(1));
+ assert_eq!(Variable::from_str("Page").unwrap(), Variable::Page);
+ assert_eq!(Variable::from_str("Date").unwrap(), Variable::Date);
+ assert_eq!(Variable::Head(1).to_string(), "Head1");
+ assert_eq!(Variable::Page.to_string(), "Page");
+ assert_eq!(Variable::Date.to_string(), "Date");
+ }
+
+ #[test]
+ fn parse_variables() {
+ assert_eq!(Markup::Text("asdf".into()).parse_variables(), None);
+ assert_eq!(Markup::Text("&[asdf]".into()).parse_variables(), None);
+ assert_eq!(
+ Markup::Text("&[Page]".into()).parse_variables(),
+ Some(vec![Markup::Variable(Variable::Page)])
+ );
+ assert_eq!(
+ Markup::Text("xyzzy &[Invalid] &[Page] &[Invalid2] quux".into()).parse_variables(),
+ Some(vec![
+ Markup::Text("xyzzy &[Invalid] ".into()),
+ Markup::Variable(Variable::Page),
+ Markup::Text(" &[Invalid2] quux".into()),
+ ])
+ );
+ }
+
+ /// Example from the documentation.
+ #[test]
+ fn example1() {
+ let text = r##"<xml><html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+ <head>
+
+ </head>
+ <body>
+ <p>
+ plain&#160;<font color="#000000" size="3" face="Monospaced"><b>bold</b></font>&#160;<font color="#000000" size="3" face="Monospaced"><i>italic</i>&#160;<strike>strikeout</strike></font>
+ </p>
+ </body>
+</html>
+</xml>"##;
+ let content = quick_xml::de::from_str::<String>(text).unwrap();
+ assert_eq!(
+ Document::from_html(&content).to_html(),
+ r##"<p align="left">plain <font color="#000000" face="Monospaced" size="9pt"><b>bold</b></font> <font color="#000000" face="Monospaced" size="9pt"><i>italic</i> <strike>strikeout</strike></font></p>"##
+ );
+ }
+
+ /// Another example from the documentation.
+ #[test]
+ fn example2() {
+ let text = r##"<xml><html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+ <head>
+
+ </head>
+ <body>
+ <p>left</p>
+ <p align="center"><font color="#000000" size="5" face="Monospaced">center&#160;large</font></p>
+ <p align="right"><font color="#000000" size="3" face="Monospaced"><b><i>right</i></b></font></p>
+ </body>
+</html></xml>
+"##;
+ let content = quick_xml::de::from_str::<String>(text).unwrap();
+ assert_eq!(
+ Document::from_html(&content).to_html(),
+ r##"<p align="left">left</p><p align="center"><font color="#000000" face="Monospaced" size="13.5pt">center large</font></p><p align="right"><font color="#000000" face="Monospaced" size="9pt"><b><i>right</i></b></font></p>"##
+ );
+ }
+
+ /*
+ #[test]
+ fn value() {
+ let value = parse_value(
+ r#"<b>bold</b><br><i>italic</i><BR><b><i>bold italic</i></b><br><font color="red" face="Serif">red serif</font><br><font size="7">big</font><br>"#,
+ );
+ assert_eq!(
+ value,
+ Value::new_markup(
+ r##"<b>bold</b>
+<i>italic</i>
+<b><i>bold italic</i></b>
+<span face="Serif" color="#ff0000">red serif</span>
+<span size="20480">big</span>
+"##
+ )
+ .with_font_style(FontStyle::default().with_size(10))
+ );
+ }*/
+
+ /// From the corpus (also included in the documentation).
+ #[test]
+ fn header1() {
+ let text = r##"<xml><html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+ <head>
+
+ </head>
+ <body>
+ <p style="text-align:center; margin-top: 0">
+ &[PageTitle]
+ </p>
+ </body>
+</html></xml>"##;
+ let content = quick_xml::de::from_str::<String>(text).unwrap();
+ assert_eq!(
+ Document::from_html(&content).to_html(),
+ r##"<p align="center">&[PageTitle]</p>"##
+ );
+ }
+
+ /// From the corpus (also included in the documentation).
+ #[test]
+ fn footer1() {
+ let text = r##"<xml><html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+ <head>
+
+ </head>
+ <body>
+ <p style="text-align:right; margin-top: 0">
+ Page &[Page]
+ </p>
+ </body>
+</html></xml>"##;
+ let content = quick_xml::de::from_str::<String>(text).unwrap();
+ assert_eq!(
+ Document::from_html(&content).to_html(),
+ r##"<p align="right">Page &[Page]</p>"##
+ );
+ }
+
+ /// From the corpus (also included in the documentation).
+ #[test]
+ fn header2() {
+ let text = r##"<xml><html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+ <head>
+ <style type="text/css">
+ p { font-family: sans-serif;
+ font-size: 10pt; text-align: center;
+ font-weight: normal;
+ color: #000000;
+ }
+ </style>
+ </head>
+ <body>
+ <p>&amp;[PageTitle]</p>
+ </body>
+</html></xml>"##;
+ let content = quick_xml::de::from_str::<String>(text).unwrap();
+ let document = Document::from_html(&content);
+ assert_eq!(
+ document.to_html(),
+ r##"<p align="center"><font color="#000000" face="sans-serif" size="10pt">&[PageTitle]</font></p>"##
+ );
+ assert_eq!(
+ document.0[0]
+ .markup
+ .to_pango(
+ |name| (name == html::Variable::PageTitle).then_some(Cow::from("The title"))
+ )
+ .0,
+ "The title"
+ );
+ }
+
+ /// From the corpus (also included in the documentation).
+ #[test]
+ fn footer2() {
+ let text = r##"<xml><html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+ <head>
+ <style type="text/css">
+ p { font-family: sans-serif;
+ font-size: 10pt; text-align: right;
+ font-weight: normal;
+ color: #000000;
+ }
+ </style>
+ </head>
+ <body>
+ <p>Page &amp;[Page]</p>
+ </body>
+</html>
+</xml>"##;
+ let content = quick_xml::de::from_str::<String>(text).unwrap();
+ let html = Document::from_html(&content);
+ assert_eq!(
+ html.to_html(),
+ r##"<p align="right"><font color="#000000" face="sans-serif" size="10pt">Page &[Page]</font></p>"##
+ );
+ }
+
+ /// Checks that the `escape-html` feature is enabled in [quick_xml], since
+ /// we need that to resolve ` ` and other HTML entities.
+ #[test]
+ fn html_escapes() {
+ let html = Document::from_html(" ");
+ assert_eq!(html.to_html(), "<p align=\"left\">\u{a0}</p>")
+ }
+}
--- /dev/null
+//! Legacy binary data.
+use std::{
+ collections::HashMap,
+ io::{Read, Seek, SeekFrom},
+};
+
+use binrw::{BinRead, BinResult, binread};
+use chrono::{NaiveDateTime, NaiveTime};
+use encoding_rs::UTF_8;
+use indexmap::IndexMap;
+
+use crate::{
+ data::Datum,
+ format::{Category, Format},
+ output::pivot::value::Value,
+ spv::read::light::{U32String, decode_format, parse_vec},
+};
+
+/// Legacy binary data.
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+pub struct LegacyBin {
+ #[br(magic(0u8), temp)]
+ version: Version,
+ #[br(temp)]
+ n_sources: u16,
+ #[br(temp)]
+ _member_size: u32,
+ #[br(count(n_sources), args { inner: (version,) })]
+ metadata: Vec<Metadata>,
+ #[br(parse_with(parse_data), args(metadata.as_slice()))]
+ data: Vec<Data>,
+ #[br(parse_with(parse_strings))]
+ strings: Option<Strings>,
+}
+
+impl LegacyBin {
+ /// Decodes legacy binary data into a map from a series name to a map of
+ /// variables, which in turn contains a vector of [DataValue]s.
+ pub fn decode(&self) -> IndexMap<String, IndexMap<String, Vec<DataValue>>> {
+ let mut sources = IndexMap::new();
+ for (metadata, data) in self.metadata.iter().zip(&self.data) {
+ let mut variables = IndexMap::new();
+ for variable in &data.variables {
+ variables.insert(
+ variable.variable_name.clone(),
+ variable
+ .values
+ .iter()
+ .map(|value| DataValue {
+ index: None,
+ value: Datum::Number((*value != f64::MIN).then_some(*value)),
+ })
+ .collect::<Vec<_>>(),
+ );
+ }
+ sources.insert(metadata.source_name.clone(), variables);
+ }
+ if let Some(strings) = &self.strings {
+ for map in &strings.source_maps {
+ let source = sources.get_mut(&map.source_name).unwrap(); // XXX unwrap
+ for var_map in &map.variable_maps {
+ let variable = source.get_mut(&var_map.variable_name).unwrap(); // XXX unwrap
+ for datum_map in &var_map.datum_maps {
+ // XXX two possibly out-of-range indexes below
+ variable[datum_map.value_idx].value =
+ Datum::String(strings.labels[datum_map.label_idx].label.clone());
+ }
+ }
+ }
+ }
+ sources
+ }
+}
+
+/// One data value.
+#[derive(Clone, Debug)]
+pub struct DataValue {
+ /// Optional index.
+ ///
+ /// This is always `None` as initially decoded.
+ pub index: Option<f64>,
+
+ /// Data value.
+ pub value: Datum<String>,
+}
+
+impl DataValue {
+ /// Category index, if any. This is either the numeric value of the datum,
+ /// if there is one, falling back to the index.
+ pub fn category(&self) -> Option<usize> {
+ match &self.value {
+ Datum::Number(number) => *number,
+ _ => self.index,
+ }
+ .and_then(|v| (v >= 0.0 && v < usize::MAX as f64).then_some(v as usize))
+ }
+
+ /// Interprets this data value as a [Format], first by looking it up in
+ /// `format_map` and otherwise by interpreting it as a [Format] directly.
+ ///
+ /// This should probably be a method on some hypothetical FormatMap.
+ pub fn as_format(&self, format_map: &HashMap<i64, Format>) -> Format {
+ let f = match &self.value {
+ Datum::Number(Some(number)) => *number as i64,
+ Datum::Number(None) => 0,
+ Datum::String(s) => s.parse().unwrap_or_default(),
+ };
+ match format_map.get(&f) {
+ Some(format) => *format,
+ None => decode_format(f as u32, &mut |_| () /*XXX*/),
+ }
+ }
+
+ /// Returns this data value interpreted using `format`.
+ pub fn as_pivot_value(&self, format: Format) -> Value {
+ if format.type_().category() == Category::Date
+ && let Some(s) = self.value.as_string()
+ && let Ok(date_time) =
+ NaiveDateTime::parse_from_str(s.as_str(), "%Y-%m-%dT%H:%M:%S%.3f")
+ {
+ Value::new_date(date_time)
+ } else if format.type_().category() == Category::Time
+ && let Some(s) = self.value.as_string()
+ && let Ok(time) = NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S%.3f")
+ {
+ Value::new_time(time)
+ } else {
+ Value::new_datum(&self.value)
+ }
+ .with_format(format)
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum Version {
+ #[br(magic = 0xafu8)]
+ Vaf,
+ #[br(magic = 0xb0u8)]
+ Vb0,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Metadata {
+ n_values: u32,
+ n_variables: u32,
+ data_offset: u32,
+ #[br(parse_with(parse_fixed_utf8_string), args(if version == Version::Vaf { 28 } else { 64 }))]
+ source_name: String,
+ #[br(if(version == Version::Vb0), temp)]
+ _x: u32,
+}
+
+#[derive(Debug)]
+struct Data {
+ variables: Vec<Variable>,
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_data(metadata: &[Metadata]) -> BinResult<Vec<Data>> {
+ let mut data = Vec::with_capacity(metadata.len());
+ for metadata in metadata {
+ reader.seek(SeekFrom::Start(metadata.data_offset as u64))?;
+ let mut variables = Vec::with_capacity(metadata.n_variables as usize);
+ for _ in 0..metadata.n_variables {
+ variables.push(Variable::read_options(
+ reader,
+ endian,
+ (metadata.n_values,),
+ )?);
+ }
+ data.push(Data { variables });
+ }
+ Ok(data)
+}
+
+impl BinRead for Data {
+ type Args<'a> = &'a [Metadata];
+
+ fn read_options<R: Read + Seek>(
+ reader: &mut R,
+ endian: binrw::Endian,
+ metadata: Self::Args<'_>,
+ ) -> binrw::BinResult<Self> {
+ let mut variables = Vec::with_capacity(metadata.len());
+ for metadata in metadata {
+ reader.seek(SeekFrom::Start(metadata.data_offset as u64))?;
+ variables.push(Variable::read_options(
+ reader,
+ endian,
+ (metadata.n_values,),
+ )?);
+ }
+ Ok(Self { variables })
+ }
+}
+
+#[binread]
+#[br(little, import(n_values: u32))]
+#[derive(Debug)]
+struct Variable {
+ #[br(parse_with(parse_fixed_utf8_string), args(288))]
+ variable_name: String,
+ #[br(count(n_values))]
+ values: Vec<f64>,
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_strings() -> BinResult<Option<Strings>> {
+ let position = reader.stream_position()?;
+ let length = reader.seek(SeekFrom::End(0))?;
+ if position != length {
+ reader.seek(SeekFrom::Start(position))?;
+ Ok(Some(Strings::read_options(reader, endian, ())?))
+ } else {
+ Ok(None)
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Strings {
+ #[br(parse_with(parse_vec))]
+ source_maps: Vec<SourceMap>,
+ #[br(parse_with(parse_vec))]
+ labels: Vec<Label>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct SourceMap {
+ #[br(parse_with(parse_utf8_string))]
+ source_name: String,
+ #[br(parse_with(parse_vec))]
+ variable_maps: Vec<VariableMap>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct VariableMap {
+ #[br(parse_with(parse_utf8_string))]
+ variable_name: String,
+ #[br(parse_with(parse_vec))]
+ datum_maps: Vec<DatumMap>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct DatumMap {
+ #[br(map(|x: u32| x as usize))]
+ value_idx: usize,
+ #[br(map(|x: u32| x as usize))]
+ label_idx: usize,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Label {
+ #[br(temp)]
+ _frequency: u32,
+ #[br(parse_with(parse_utf8_string))]
+ label: String,
+}
+
+/// Parses a UTF-8 string preceded by a 32-bit length.
+#[binrw::parser(reader, endian)]
+pub(super) fn parse_utf8_string() -> BinResult<String> {
+ Ok(U32String::read_options(reader, endian, ())?.decode(UTF_8))
+}
+
+/// Parses a UTF-8 string that is exactly `n` bytes long and whose contents end
+/// at the first null byte.
+#[binrw::parser(reader)]
+pub(super) fn parse_fixed_utf8_string(n: usize) -> BinResult<String> {
+ let mut buf = vec![0; n];
+ reader.read_exact(&mut buf)?;
+ let len = buf.iter().take_while(|b| **b != 0).count();
+ Ok(
+ std::str::from_utf8(&buf[..len]).unwrap().into(), // XXX unwrap
+ )
+}
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use std::{
+ cell::{Cell, RefCell},
+ collections::{BTreeMap, HashMap},
+ marker::PhantomData,
+ mem::take,
+ num::NonZeroUsize,
+ ops::Range,
+ sync::Arc,
+};
+
+use chrono::{NaiveDateTime, NaiveTime};
+use enum_map::{Enum, EnumMap};
+use hashbrown::HashSet;
+use indexmap::IndexMap;
+use ordered_float::OrderedFloat;
+use serde::Deserialize;
+
+use crate::{
+ data::{Datum, EncodedString},
+ format::{self, Decimal, F8_0, F40_2, Type, UncheckedFormat},
+ output::pivot::{
+ self, Axis2, Axis3, Category, CategoryLocator, Dimension, Group, Leaf, Length, PivotTable,
+ look::{
+ self, Area, AreaStyle, CellStyle, Color, HeadingRegion, HorzAlign, Look, RowParity,
+ VertAlign,
+ },
+ value::Value,
+ },
+ spv::read::legacy_bin::DataValue,
+};
+
+#[derive(Debug)]
+struct Ref<T> {
+ references: String,
+ _phantom: PhantomData<T>,
+}
+
+impl<T> Ref<T> {
+ fn get<'a>(&self, table: &HashMap<&str, &'a T>) -> Option<&'a T> {
+ table.get(self.references.as_str()).map(|v| &**v)
+ }
+}
+
+impl<'de, T> Deserialize<'de> for Ref<T> {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ Ok(Self {
+ references: String::deserialize(deserializer)?,
+ _phantom: PhantomData,
+ })
+ }
+}
+
+#[derive(Clone, Debug, Default)]
+struct Map(HashMap<OrderedFloat<f64>, Datum<String>>);
+
+impl Map {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ fn remap_formats(
+ &mut self,
+ format: &Option<Format>,
+ string_format: &Option<StringFormat>,
+ ) -> (crate::format::Format, Vec<Affix>) {
+ let (format, affixes, relabels, try_strings_as_numbers) = if let Some(format) = &format {
+ (
+ Some(format.decode()),
+ format.affixes.clone(),
+ format.relabels.as_slice(),
+ format.try_strings_as_numbers.unwrap_or_default(),
+ )
+ } else if let Some(string_format) = &string_format {
+ (
+ None,
+ string_format.affixes.clone(),
+ string_format.relabels.as_slice(),
+ false,
+ )
+ } else {
+ (None, Vec::new(), [].as_slice(), false)
+ };
+ for relabel in relabels {
+ let value = if try_strings_as_numbers && let Ok(to) = relabel.to.trim().parse::<f64>() {
+ Datum::Number(Some(to))
+ } else if let Some(format) = format
+ && let Ok(to) = relabel.to.trim().parse::<f64>()
+ {
+ Datum::String(
+ Datum::<String>::Number(Some(to))
+ .display(format)
+ .with_stretch()
+ .to_string(),
+ )
+ } else {
+ Datum::String(relabel.to.clone())
+ };
+ self.0.insert(OrderedFloat(relabel.from), value);
+ // XXX warn on duplicate
+ }
+ (format.unwrap_or(F8_0), affixes)
+ }
+
+ fn apply(&self, data: &mut Vec<DataValue>) {
+ for value in data {
+ let Datum::Number(Some(number)) = value.value else {
+ continue;
+ };
+ if let Some(to) = self.0.get(&OrderedFloat(number)) {
+ value.index = Some(number);
+ value.value = to.clone();
+ }
+ }
+ }
+
+ fn insert_labels(
+ &mut self,
+ data: &[DataValue],
+ label_series: &Series,
+ format: crate::format::Format,
+ ) {
+ for (value, label) in data.iter().zip(label_series.values.iter()) {
+ if let Some(Some(number)) = value.value.as_number() {
+ let dest = match &label.value {
+ Datum::Number(_) => label.value.display(format).with_stretch().to_string(),
+ Datum::String(s) => s.clone(),
+ };
+ self.0.insert(OrderedFloat(number), Datum::String(dest));
+ }
+ }
+ }
+
+ fn remap_vmes(&mut self, value_map: &[ValueMapEntry]) {
+ for vme in value_map {
+ for from in vme.from.split(';') {
+ let from = from.trim().parse::<f64>().unwrap(); // XXX
+ let to = if let Ok(to) = vme.to.trim().parse::<f64>() {
+ Datum::Number(Some(to))
+ } else {
+ Datum::String(vme.to.clone())
+ };
+ self.0.insert(OrderedFloat(from), to);
+ }
+ }
+ }
+
+ fn lookup<'a>(&'a self, dv: &'a DataValue) -> &'a Datum<String> {
+ if let Datum::Number(Some(number)) = &dv.value
+ && let Some(value) = self.0.get(&OrderedFloat(*number))
+ {
+ value
+ } else {
+ &dv.value
+ }
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct Visualization {
+ /// In format `YYYY-MM-DD`.
+ #[serde(rename = "@date")]
+ date: String,
+ // Locale used for output, e.g. `en-US`.
+ #[serde(rename = "@lang")]
+ lang: String,
+ /// Localized title of the pivot table.
+ #[serde(rename = "@name")]
+ name: String,
+ /// Base style for the pivot table.
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ #[serde(rename = "$value")]
+ children: Vec<VisChild>,
+}
+
+impl Visualization {
+ pub fn decode(
+ &self,
+ data: IndexMap<String, IndexMap<String, Vec<DataValue>>>,
+ mut look: Look,
+ ) -> Result<PivotTable, super::Error> {
+ let mut extension = None;
+ let mut user_source = None;
+ let mut source_variables = Vec::new();
+ let mut derived_variables = Vec::new();
+ let mut graph = None;
+ let mut labels = EnumMap::from_fn(|_| Vec::new());
+ let mut styles = HashMap::new();
+ let mut _layer_controller = None;
+ for child in &self.children {
+ match child {
+ VisChild::Extension(e) => extension = Some(e),
+ VisChild::UserSource(us) => user_source = Some(us),
+ VisChild::SourceVariable(source_variable) => source_variables.push(source_variable),
+ VisChild::DerivedVariable(derived_variable) => {
+ derived_variables.push(derived_variable)
+ }
+ VisChild::CategoricalDomain(_) => (),
+ VisChild::Graph(g) => graph = Some(g),
+ VisChild::LabelFrame(label_frame) => {
+ if let Some(label) = &label_frame.label
+ && let Some(purpose) = label.purpose
+ {
+ labels[purpose].push(label);
+ }
+ }
+ VisChild::Container(c) => {
+ for label_frame in &c.label_frames {
+ if let Some(label) = &label_frame.label
+ && let Some(purpose) = label.purpose
+ {
+ labels[purpose].push(label);
+ }
+ }
+ }
+ VisChild::Style(style) => {
+ if let Some(id) = &style.id {
+ styles.insert(id.as_str(), style);
+ }
+ }
+ VisChild::LayerController(lc) => _layer_controller = Some(lc),
+ }
+ }
+ let Some(graph) = graph else { todo!() };
+ let Some(_user_source) = user_source else {
+ todo!()
+ };
+
+ let mut axes = HashMap::new();
+ let mut major_ticks = HashMap::new();
+ for child in &graph.facet_layout.children {
+ if let FacetLayoutChild::FacetLevel(facet_level) = child {
+ axes.insert(facet_level.level, &facet_level.axis);
+ major_ticks.insert(
+ facet_level.axis.major_ticks.id.as_str(),
+ &facet_level.axis.major_ticks,
+ );
+ }
+ }
+
+ // Footnotes.
+ //
+ // Any [Value] might refer to footnotes, so it's important to process
+ // the footnotes early to ensure that those references can be resolved.
+ // There is a possible problem that a footnote might itself reference an
+ // as-yet-unprocessed footnote, but that's OK because footnote
+ // references don't actually look at the footnote contents but only
+ // resolve a pointer to where the footnote will go later.
+ //
+ // Before we really start, create all the footnotes we'll fill in. This
+ // is because sometimes footnotes refer to themselves or to each other
+ // and we don't want to reject those references.
+ let mut footnote_builder = BTreeMap::<usize, Footnote>::new();
+ if let Some(f) = &graph.interval.footnotes {
+ f.decode(&mut footnote_builder);
+ }
+ for child in &graph.interval.labeling.children {
+ if let LabelingChild::Footnotes(f) = child {
+ f.decode(&mut footnote_builder);
+ }
+ }
+ for label in &labels[Purpose::Footnote] {
+ for (index, text) in label.text().iter().enumerate() {
+ if let Some(uses_reference) = text.uses_reference {
+ let entry = footnote_builder
+ .entry(uses_reference.get() - 1)
+ .or_default();
+ if index % 2 == 1 {
+ entry.content = text.text.strip_suffix('\n').unwrap_or(&text.text).into();
+ } else {
+ entry.marker = Some(
+ text.text
+ .trim_end()
+ .strip_suffix('.')
+ .unwrap_or(&text.text)
+ .into(),
+ );
+ }
+ dbg!(entry);
+ }
+ }
+ }
+ let mut footnotes = Vec::new();
+ for (index, footnote) in footnote_builder {
+ while footnotes.len() < index {
+ footnotes.push(pivot::Footnote::default());
+ }
+ footnotes.push(
+ pivot::Footnote::new(footnote.content)
+ .with_marker(footnote.marker.map(|s| Value::new_user_text(s))),
+ );
+ }
+ dbg!(&footnotes);
+ let footnotes = pivot::Footnotes::from_iter(footnotes);
+
+ for (purpose, area) in [
+ (Purpose::Title, Area::Title),
+ (Purpose::SubTitle, Area::Caption),
+ (Purpose::Layer, Area::Layers),
+ (Purpose::Footnote, Area::Footer),
+ ] {
+ for label in &labels[purpose] {
+ label.decode_style(&mut look.areas[area], &styles);
+ }
+ }
+ let title = LabelFrame::decode_label(&labels[Purpose::Title], &footnotes);
+ let caption = LabelFrame::decode_label(&labels[Purpose::SubTitle], &footnotes);
+ if let Some(style) = &graph.interval.labeling.style
+ && let Some(style) = styles.get(style.references.as_str())
+ {
+ Style::decode_area(
+ Some(*style),
+ graph.cell_style.get(&styles),
+ &mut look.areas[Area::Data(RowParity::Even)],
+ );
+ look.areas[Area::Data(RowParity::Odd)] =
+ look.areas[Area::Data(RowParity::Even)].clone();
+ }
+
+ let _show_grid_lines = extension
+ .as_ref()
+ .and_then(|extension| extension.show_gridline);
+ if let Some(style) = styles.get(graph.cell_style.references.as_str())
+ && let Some(width) = &style.width
+ {
+ let mut parts = width.split(';');
+ parts.next();
+ if let Some(min_width) = parts.next()
+ && let Some(max_width) = parts.next()
+ && let Ok(min_width) = min_width.parse::<Length>()
+ && let Ok(max_width) = max_width.parse::<Length>()
+ {
+ look.heading_widths[HeadingRegion::Columns] =
+ min_width.as_pt_f64() as isize..=max_width.as_pt_f64() as isize;
+ }
+ }
+
+ let mut series = HashMap::<&str, Series>::new();
+ while let n_source = source_variables.len()
+ && let n_derived = derived_variables.len()
+ && (n_source > 0 || n_derived > 0)
+ {
+ for sv in take(&mut source_variables) {
+ match sv.decode(&data, &series) {
+ Ok(s) => {
+ series.insert(&sv.id, s);
+ }
+ Err(()) => source_variables.push(sv),
+ }
+ }
+
+ for dv in take(&mut derived_variables) {
+ match dv.decode(&series) {
+ Ok(s) => {
+ series.insert(&dv.id, s);
+ }
+ Err(()) => derived_variables.push(dv),
+ }
+ }
+
+ if n_source == source_variables.len() && n_derived == derived_variables.len() {
+ unreachable!();
+ }
+ }
+
+ fn decode_dimension<'a>(
+ variables: &[(&'a Series, usize)],
+ axes: &HashMap<usize, &Axis>,
+ styles: &HashMap<&str, &Style>,
+ a: Axis3,
+ look: &mut Look,
+ rotate_inner_column_labels: &mut bool,
+ rotate_outer_row_labels: &mut bool,
+ footnotes: &pivot::Footnotes,
+ dims: &mut Vec<Dim<'a>>,
+ ) {
+ let base_level = variables[0].1;
+ let show_label = if let Ok(a) = Axis2::try_from(a)
+ && let Some(axis) = axes.get(&(base_level + variables.len()))
+ && let Some(label) = &axis.label
+ {
+ let out = &mut look.areas[Area::Labels(a)];
+ *out = AreaStyle::default_for_area(Area::Labels(a));
+ let style = label.style.get(&styles);
+ Style::decode_area(
+ style,
+ label.text_frame_style.as_ref().and_then(|r| r.get(styles)),
+ out,
+ );
+ style.is_some_and(|s| s.visible.unwrap_or(true))
+ } else {
+ false
+ };
+ if a == Axis3::Y
+ && let Some(axis) = axes.get(&(base_level + variables.len() - 1))
+ {
+ Style::decode_area(
+ axis.major_ticks.style.get(&styles),
+ axis.major_ticks.tick_frame_style.get(&styles),
+ &mut look.areas[Area::Labels(Axis2::Y)],
+ );
+ }
+
+ if let Some(axis) = axes.get(&base_level)
+ && axis.major_ticks.label_angle == -90.0
+ {
+ if a == Axis3::X {
+ *rotate_inner_column_labels = true;
+ } else {
+ *rotate_outer_row_labels = true;
+ }
+ }
+
+ let variables = variables
+ .into_iter()
+ .map(|(series, _level)| *series)
+ .collect::<Vec<_>>();
+
+ #[derive(Clone, Debug)]
+ struct CatBuilder {
+ /// The category we've built so far.
+ category: Category,
+
+ /// The index in the series of one example of this category.
+ index: usize,
+
+ /// The range of leaf indexes covered by `category`.
+ ///
+ /// If `category` is a leaf, the range has a length of 1.
+ /// If `category` is a group, the length is at least 1.
+ leaves: Range<usize>,
+
+ /// How to find this category in its dimension.
+ location: CategoryLocator,
+ }
+
+ // Make leaf categories.
+ let mut coordinate_to_index = HashMap::new();
+ let mut cats = Vec::<CatBuilder>::new();
+ for (index, value) in variables[0].values.iter().enumerate() {
+ if let Some(coordinate) = value.category()
+ && !coordinate_to_index.contains_key(&coordinate)
+ {
+ coordinate_to_index.insert(coordinate, CategoryLocator::new_leaf(cats.len()));
+ cats.push(CatBuilder {
+ category: Category::from(Leaf::new(
+ variables[0].new_name(value, footnotes),
+ )),
+ index,
+ leaves: cats.len()..cats.len() + 1,
+ location: CategoryLocator::new_leaf(cats.len()),
+ });
+ }
+ }
+ *variables[0].coordinate_to_index.borrow_mut() = coordinate_to_index;
+
+ // Now group them, in one pass per grouping variable, innermost first.
+ for variable in &variables[1..] {
+ let mut coordinate_to_index = HashMap::new();
+ let mut next_cats = Vec::with_capacity(cats.len());
+ let mut start = 0;
+ dbg!(&variable.name, &variable.values);
+ for end in 1..=cats.len() {
+ let dv1 = &variable.values[cats[start].index];
+ if end < cats.len() && &variable.values[cats[end].index].value == &dv1.value {
+ println!("categories {start}..={end} have same value {dv1:?}");
+ } else {
+ dbg!(start..end);
+ let name = variable.map.lookup(dv1);
+ if name.is_number_or(|s| !s.is_empty()) {
+ dbg!();
+ let name = variable.new_name(dv1, footnotes);
+ let mut group = Group::new(name);
+ for i in start..end {
+ group.push(cats[i].category.clone());
+ }
+ let next_cat = CatBuilder {
+ category: Category::from(group),
+ index: cats[start].index,
+ leaves: cats[start].leaves.start..cats[end - 1].leaves.end,
+ location: cats[start].location.parent(),
+ };
+ coordinate_to_index
+ .insert(dv1.category().unwrap() /*XXX?*/, next_cat.location);
+ next_cats.push(next_cat);
+ } else {
+ // XXX coordinate_to_index?
+ dbg!();
+ for cat in &cats[start..end] {
+ next_cats.push(cat.clone());
+ }
+ };
+ start = end;
+ }
+ }
+ *variable.coordinate_to_index.borrow_mut() = coordinate_to_index;
+ cats = next_cats;
+ dbg!(&cats);
+ }
+
+ let dimension = Dimension::new(
+ Group::new(
+ variables[0]
+ .label
+ .as_ref()
+ .map_or_else(|| Value::new_empty(), |label| Value::new_user_text(label)),
+ )
+ .with_multiple(cats.into_iter().map(|cb| cb.category))
+ .with_show_label(show_label),
+ );
+
+ for variable in &variables {
+ variable.dimension_index.set(Some(dims.len()));
+ }
+ dims.push(Dim {
+ axis: a,
+ dimension,
+ coordinate: variables[0],
+ });
+ }
+
+ fn decode_dimensions<'a, 'b>(
+ variables: impl IntoIterator<Item = &'a str>,
+ series: &'b HashMap<&str, Series>,
+ axes: &HashMap<usize, &Axis>,
+ styles: &HashMap<&str, &Style>,
+ a: Axis3,
+ look: &mut Look,
+ rotate_inner_column_labels: &mut bool,
+ rotate_outer_row_labels: &mut bool,
+ footnotes: &pivot::Footnotes,
+ level_ofs: usize,
+ dims: &mut Vec<Dim<'b>>,
+ ) -> Vec<&'b Series> {
+ let variables = variables
+ .into_iter()
+ .zip(level_ofs..)
+ .map(|(variable_name, level)| {
+ series
+ .get(variable_name)
+ .filter(|s| !s.values.is_empty())
+ .map(|s| (s, level))
+ })
+ .collect::<Vec<_>>();
+ let mut dim_vars = Vec::new();
+ let mut categorical_vars = Vec::new();
+ for var in variables {
+ if let Some((var, level)) = var {
+ dim_vars.push((var, level));
+ } else if !dim_vars.is_empty() {
+ categorical_vars.push(dim_vars[0].0);
+ decode_dimension(
+ &dim_vars,
+ axes,
+ styles,
+ a,
+ look,
+ rotate_inner_column_labels,
+ rotate_outer_row_labels,
+ footnotes,
+ dims,
+ );
+ dim_vars.clear();
+ }
+ }
+ if !dim_vars.is_empty() {
+ categorical_vars.push(&dim_vars[0].0);
+ decode_dimension(
+ &dim_vars,
+ axes,
+ styles,
+ a,
+ look,
+ rotate_inner_column_labels,
+ rotate_outer_row_labels,
+ footnotes,
+ dims,
+ );
+ }
+ categorical_vars
+ }
+
+ struct Dim<'a> {
+ axis: Axis3,
+ dimension: pivot::Dimension,
+ coordinate: &'a Series,
+ }
+
+ let mut rotate_inner_column_labels = false;
+ let mut rotate_outer_row_labels = false;
+ let cross = &graph.faceting.cross();
+ let columns = cross
+ .first()
+ .map(|child| child.variables())
+ .unwrap_or_default();
+ let mut dims = Vec::new();
+ decode_dimensions(
+ columns.into_iter().map(|vr| vr.reference.as_str()),
+ &series,
+ &axes,
+ &styles,
+ Axis3::X,
+ &mut look,
+ &mut rotate_inner_column_labels,
+ &mut rotate_outer_row_labels,
+ &footnotes,
+ 1,
+ &mut dims,
+ );
+ let rows = cross
+ .get(1)
+ .map(|child| child.variables())
+ .unwrap_or_default();
+ decode_dimensions(
+ rows.into_iter().map(|vr| vr.reference.as_str()),
+ &series,
+ &axes,
+ &styles,
+ Axis3::Y,
+ &mut look,
+ &mut rotate_inner_column_labels,
+ &mut rotate_outer_row_labels,
+ &footnotes,
+ 1 + columns.len(),
+ &mut dims,
+ );
+
+ let layer_series = decode_dimensions(
+ graph.faceting.layers(),
+ &series,
+ &axes,
+ &styles,
+ Axis3::Z,
+ &mut look,
+ &mut rotate_inner_column_labels,
+ &mut rotate_outer_row_labels,
+ &footnotes,
+ columns.len() + rows.len() + 1,
+ &mut dims,
+ );
+ let current_layer = layer_series
+ .iter()
+ .map(|series| {
+ let name = &series.name;
+ let coordinate = graph.faceting.layer_value(&name).unwrap();
+ series
+ .coordinate_to_index
+ .borrow()
+ .get(&coordinate)
+ .unwrap()
+ .as_leaf()
+ .unwrap()
+ })
+ .collect::<Vec<_>>();
+
+ let cell = series.get("cell").unwrap()/*XXX*/;
+ let mut coords = Vec::with_capacity(dims.len());
+ let (cell_formats, format_map) = graph.interval.labeling.decode_format_map(&series);
+ let cell_footnotes =
+ graph
+ .interval
+ .labeling
+ .children
+ .iter()
+ .find_map(|child| match child {
+ LabelingChild::Footnotes(footnotes) => series.get(footnotes.variable.as_str()),
+ _ => None,
+ });
+ let mut data = HashMap::new();
+ for (i, cell) in cell.values.iter().enumerate() {
+ coords.clear();
+ for dim in &dims {
+ // XXX indexing of values, and unwrap
+ let coordinate = dim.coordinate.values[i].category().unwrap();
+ let Some(index) = dim
+ .coordinate
+ .coordinate_to_index
+ .borrow()
+ .get(&coordinate)
+ .and_then(CategoryLocator::as_leaf)
+ else {
+ panic!("can't find {coordinate}") // XXX
+ };
+ debug_assert!(
+ index < dim.dimension.len(),
+ "{index}, {}",
+ dim.dimension.len()
+ );
+ coords.push(index);
+ }
+
+ let format = if let Some(cell_formats) = &cell_formats {
+ // XXX indexing of values
+ cell_formats.values[i].as_format(&format_map)
+ } else {
+ F40_2
+ };
+ let mut value = cell.as_pivot_value(format);
+
+ if let Some(cell_footnotes) = &cell_footnotes {
+ // XXX indexing
+ let dv = &cell_footnotes.values[i];
+ if let Some(s) = dv.value.as_string() {
+ dbg!(dv);
+ for part in s.split(',') {
+ if let Ok(index) = part.parse::<usize>()
+ && let Some(index) = index.checked_sub(1)
+ && let Some(footnote) = footnotes.get(index)
+ {
+ dbg!(footnote);
+ value = value.with_footnote(footnote);
+ }
+ }
+ }
+ }
+ if let Some(datum) = value.datum()
+ && datum.is_sysmis()
+ && value.footnotes().is_empty()
+ {
+ // A system-missing value without a footnote represents an empty cell.
+ } else {
+ // XXX cell_index might be invalid?
+ data.insert(coords.clone(), value);
+ }
+ }
+
+ for child in &graph.facet_layout.children {
+ let FacetLayoutChild::SetCellProperties(scp) = child else {
+ continue;
+ };
+
+ #[derive(Copy, Clone, Debug, PartialEq)]
+ enum TargetType {
+ Graph,
+ Labeling,
+ Interval,
+ MajorTicks,
+ }
+
+ impl TargetType {
+ fn from_id(
+ target: &str,
+ graph: &Graph,
+ major_ticks: &HashMap<&str, &MajorTicks>,
+ ) -> Option<Self> {
+ if let Some(id) = &graph.id
+ && id == target
+ {
+ Some(Self::Graph)
+ } else if let Some(id) = &graph.interval.labeling.id
+ && id == target
+ {
+ Some(Self::Labeling)
+ } else if let Some(id) = &graph.interval.id
+ && id == target
+ {
+ Some(Self::Interval)
+ } else if major_ticks.contains_key(target) {
+ Some(Self::MajorTicks)
+ } else {
+ None
+ }
+ }
+ }
+
+ #[derive(Default)]
+ struct Target<'a> {
+ graph: Option<&'a Style>,
+ labeling: Option<&'a Style>,
+ interval: Option<&'a Style>,
+ major_ticks: Option<&'a Style>,
+ frame: Option<&'a Style>,
+ format: Option<(&'a SetFormat, Option<TargetType>)>,
+ }
+ impl<'a> Target<'a> {
+ fn decode(
+ &self,
+ intersect: &Intersect,
+ look: &mut Look,
+ series: &HashMap<&str, Series>,
+ dims: &mut [Dim],
+ data: &mut HashMap<Vec<usize>, Value>,
+ ) {
+ let mut wheres = Vec::new();
+ let mut alternating = false;
+ for child in &intersect.children {
+ match child {
+ IntersectChild::Where(w) => wheres.push(w),
+ IntersectChild::IntersectWhere(_) => {
+ // Presumably we should do something (but we don't).
+ }
+ IntersectChild::Alternating => alternating = true,
+ IntersectChild::Empty => (),
+ }
+ }
+
+ match self {
+ Self {
+ graph: Some(_),
+ labeling: Some(_),
+ interval: None,
+ major_ticks: None,
+ frame: None,
+ format: None,
+ } if alternating => {
+ let mut style = AreaStyle::default_for_area(Area::Data(RowParity::Odd));
+ Style::decode_area(self.labeling, self.graph, &mut style);
+ let font_style = &mut look.areas[Area::Data(RowParity::Odd)].font_style;
+ font_style.fg = style.font_style.fg;
+ font_style.bg = style.font_style.bg;
+ }
+ Self {
+ graph: Some(_),
+ labeling: None,
+ interval: None,
+ major_ticks: None,
+ frame: None,
+ format: None,
+ } => {
+ // `graph.width` likely just sets the width of the table as a whole.
+ }
+ Self {
+ graph: None,
+ labeling: None,
+ interval: None,
+ major_ticks: None,
+ frame: None,
+ format: None,
+ } => {
+ // No-op. (Presumably there's a setMetaData we don't care about.)
+ }
+ Self {
+ format: Some((_, Some(TargetType::MajorTicks))),
+ ..
+ }
+ | Self {
+ major_ticks: Some(_),
+ ..
+ }
+ | Self { frame: Some(_), .. }
+ if !wheres.is_empty() =>
+ {
+ // Formatting for individual row or column labels.
+ for w in &wheres {
+ let Some(s) = series.get(w.variable.as_str()) else {
+ continue;
+ };
+ let Some(dim_index) = s.dimension_index.get() else {
+ continue;
+ };
+ let dimension = &mut dims[dim_index].dimension;
+ let Ok(axis) = Axis2::try_from(dims[dim_index].axis) else {
+ continue;
+ };
+ for index in
+ w.include.split(';').filter_map(|s| s.parse::<usize>().ok())
+ {
+ if let Some(locator) =
+ s.coordinate_to_index.borrow().get(&index).copied()
+ && let Some(category) = dimension.root.category_mut(locator)
+ {
+ Style::apply_to_value(
+ category.name_mut(),
+ self.format.map(|(sf, _)| sf),
+ self.major_ticks,
+ self.frame,
+ &look.areas[Area::Labels(axis)],
+ );
+ }
+ }
+ }
+ }
+ Self {
+ format: Some((_, Some(TargetType::Labeling))),
+ ..
+ }
+ | Self {
+ labeling: Some(_), ..
+ }
+ | Self {
+ interval: Some(_), ..
+ } => {
+ // Formatting for individual cells or groups of them
+ // with some dimensions in common.
+ let mut include = vec![HashSet::new(); dims.len()];
+ for w in &wheres {
+ let Some(s) = series.get(w.variable.as_str()) else {
+ continue;
+ };
+ let Some(dim_index) = s.dimension_index.get() else {
+ // Group indexes may be included even though
+ // they are redundant. Ignore them.
+ continue;
+ };
+ for index in
+ w.include.split(';').filter_map(|s| s.parse::<usize>().ok())
+ {
+ if let Some(locator) =
+ s.coordinate_to_index.borrow().get(&index).copied()
+ && let Some(leaf_index) = locator.as_leaf()
+ {
+ include[dim_index].insert(leaf_index);
+ }
+ }
+ }
+
+ // XXX This is inefficient in the common case where
+ // all of the dimensions are matched. We should use
+ // a heuristic where if all of the dimensions are
+ // matched and the product of n[*] is less than the
+ // number of cells then iterate through all the
+ // possibilities rather than all the cells. Or even
+ // only do it if there is just one possibility.
+ for (indexes, value) in data {
+ let mut skip = false;
+ for (dimension, index) in indexes.iter().enumerate() {
+ if !include[dimension].is_empty()
+ && !include[dimension].contains(index)
+ {
+ skip = true;
+ break;
+ }
+ }
+ if !skip {
+ Style::apply_to_value(
+ value,
+ self.format.map(|(sf, _)| sf),
+ self.major_ticks,
+ self.frame,
+ &look.areas[Area::Data(RowParity::Even)],
+ );
+ }
+ }
+ }
+ _ => (),
+ }
+ }
+ }
+ let mut target = Target::default();
+ for set in &scp.sets {
+ match set {
+ Set::SetStyle(set_style) => {
+ if let Some(style) = set_style.style.get(&styles) {
+ match TargetType::from_id(&set_style.target, graph, &major_ticks) {
+ Some(TargetType::Graph) => target.graph = Some(style),
+ Some(TargetType::Interval) => target.interval = Some(style),
+ Some(TargetType::Labeling) => target.labeling = Some(style),
+ Some(TargetType::MajorTicks) => target.major_ticks = Some(style),
+ None => (),
+ }
+ }
+ }
+ Set::SetFrameStyle(set_frame_style) => {
+ target.frame = set_frame_style.style.get(&styles)
+ }
+ Set::SetFormat(sf) => {
+ let target_type = TargetType::from_id(&sf.target, graph, &major_ticks);
+ target.format = Some((sf, target_type))
+ }
+ Set::SetMetaData(_) => (),
+ }
+ }
+
+ match (
+ scp.union_.as_ref(),
+ scp.apply_to_converse.unwrap_or_default(),
+ ) {
+ (Some(union_), false) => {
+ for intersect in &union_.intersects {
+ target.decode(
+ intersect,
+ &mut look,
+ &series,
+ dims.as_mut_slice(),
+ &mut data,
+ );
+ }
+ }
+ (Some(_), true) => {
+ // Not implemented, not seen in the corpus.
+ }
+ (None, true) => {
+ if target
+ .format
+ .is_some_and(|(_sf, target_type)| target_type == Some(TargetType::Labeling))
+ || target.labeling.is_some()
+ || target.interval.is_some()
+ {
+ for value in data.values_mut() {
+ Style::apply_to_value(
+ value,
+ target.format.map(|(sf, _target_type)| sf),
+ None,
+ None,
+ &look.areas[Area::Data(RowParity::Even)],
+ );
+ }
+ }
+ }
+ (None, false) => {
+ // Appears to be used to set the font for something—but what?
+ }
+ }
+ }
+
+ let dimensions = dims
+ .into_iter()
+ .map(|dim| (dim.axis, dim.dimension))
+ .collect::<Vec<_>>();
+ let mut pivot_table = PivotTable::new(dimensions)
+ .with_look(Arc::new(look))
+ .with_footnotes(footnotes)
+ .with_data(data)
+ .with_layer(¤t_layer);
+ let decimal = dbg!(Decimal::for_lang(dbg!(&self.lang)));
+ if pivot_table.style.settings.decimal != decimal {
+ Arc::make_mut(&mut pivot_table.style.settings).decimal = decimal;
+ }
+ if let Some(title) = title {
+ pivot_table = pivot_table.with_title(title);
+ }
+ if let Some(caption) = caption {
+ pivot_table = pivot_table.with_caption(caption);
+ }
+ Ok(pivot_table)
+ }
+}
+
+struct Series {
+ name: String,
+ label: Option<String>,
+ format: crate::format::Format,
+ remapped: bool,
+ values: Vec<DataValue>,
+ map: Map,
+ affixes: Vec<Affix>,
+ coordinate_to_index: RefCell<HashMap<usize, CategoryLocator>>,
+ dimension_index: Cell<Option<usize>>,
+}
+
+impl Series {
+ fn new(name: String, values: Vec<DataValue>, map: Map) -> Self {
+ Self {
+ name,
+ label: None,
+ format: F8_0,
+ remapped: false,
+ values,
+ map,
+ affixes: Vec::new(),
+ coordinate_to_index: Default::default(),
+ dimension_index: Default::default(),
+ }
+ }
+ fn with_format(self, format: crate::format::Format) -> Self {
+ Self { format, ..self }
+ }
+ fn with_label(self, label: Option<String>) -> Self {
+ Self { label, ..self }
+ }
+ fn with_affixes(self, affixes: Vec<Affix>) -> Self {
+ Self { affixes, ..self }
+ }
+ fn add_affixes(&self, mut value: Value, footnotes: &pivot::Footnotes) -> Value {
+ for affix in &self.affixes {
+ if let Some(index) = affix.defines_reference.checked_sub(1)
+ && let Ok(index) = usize::try_from(index)
+ && let Some(footnote) = footnotes.get(index)
+ {
+ value = value.with_footnote(footnote);
+ }
+ }
+ value
+ }
+
+ fn new_name(&self, dv: &DataValue, footnotes: &pivot::Footnotes) -> Value {
+ let dv = self.map.lookup(dv);
+ let name = Value::new_datum(dv);
+ self.add_affixes(name, &footnotes)
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum VisChild {
+ Extension(VisualizationExtension),
+ UserSource(UserSource),
+ SourceVariable(SourceVariable),
+ DerivedVariable(DerivedVariable),
+ CategoricalDomain(CategoricalDomain),
+ Graph(Graph),
+ LabelFrame(LabelFrame),
+ Container(Container),
+ Style(Style),
+ LayerController(LayerController),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename = "extension", rename_all = "camelCase")]
+struct VisualizationExtension {
+ #[serde(rename = "@showGridline")]
+ show_gridline: Option<bool>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SourceVariable {
+ #[serde(rename = "@id")]
+ id: String,
+
+ /// The `source-name` in the `tableData.bin` member.
+ #[serde(rename = "@source")]
+ source: String,
+
+ /// The name of a variable within the source, corresponding to the
+ /// `variable-name` in the `tableData.bin` member.
+ #[serde(rename = "@sourceName")]
+ source_name: String,
+
+ /// Variable label, if any.
+ #[serde(rename = "@label")]
+ label: Option<String>,
+
+ /// A variable whose string values correspond one-to-one with the values of
+ /// this variable and are suitable as value labels.
+ #[serde(rename = "@labelVariable")]
+ label_variable: Option<Ref<SourceVariable>>,
+
+ #[serde(default, rename = "extension")]
+ extensions: Vec<VariableExtension>,
+ format: Option<Format>,
+ string_format: Option<StringFormat>,
+}
+
+impl SourceVariable {
+ fn decode(
+ &self,
+ data: &IndexMap<String, IndexMap<String, Vec<DataValue>>>,
+ series: &HashMap<&str, Series>,
+ ) -> Result<Series, ()> {
+ let label_series = if let Some(label_variable) = &self.label_variable {
+ let Some(label_series) = series.get(label_variable.references.as_str()) else {
+ return Err(());
+ };
+ Some(label_series)
+ } else {
+ None
+ };
+
+ let Some(data) = data
+ .get(&self.source)
+ .and_then(|source| source.get(&self.source_name))
+ else {
+ todo!()
+ };
+ let mut map = Map::new();
+ let (format, affixes) = map.remap_formats(&self.format, &self.string_format);
+ let mut data = data.clone();
+ if !map.0.is_empty() {
+ map.apply(&mut data);
+ } else if let Some(label_series) = label_series {
+ map.insert_labels(&data, label_series, format);
+ }
+ Ok(Series::new(self.id.clone(), data, map)
+ .with_format(format)
+ .with_affixes(affixes)
+ .with_label(self.label.clone()))
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct DerivedVariable {
+ #[serde(rename = "@id")]
+ id: String,
+
+ /// An expression that defines the variable's value.
+ #[serde(rename = "@value")]
+ value: String,
+ #[serde(default, rename = "extension")]
+ extensions: Vec<VariableExtension>,
+ format: Option<Format>,
+ string_format: Option<StringFormat>,
+ #[serde(default, rename = "valueMapEntry")]
+ value_map: Vec<ValueMapEntry>,
+}
+
+impl DerivedVariable {
+ fn decode(&self, series: &HashMap<&str, Series>) -> Result<Series, ()> {
+ let mut values = if self.value == "constant(0)" {
+ let n_values = if let Some(series) = series.values().next() {
+ series.values.len()
+ } else {
+ return Err(());
+ };
+ (0..n_values)
+ .map(|_| DataValue {
+ index: Some(0.0),
+ value: Datum::Number(Some(0.0)),
+ })
+ .collect()
+ } else if self.value.starts_with("constant") {
+ vec![]
+ } else if let Some(rest) = self.value.strip_prefix("map(")
+ && let Some(var_name) = rest.strip_suffix(")")
+ {
+ let Some(dependency) = series.get(var_name) else {
+ return Err(());
+ };
+ dependency.values.clone()
+ } else {
+ unreachable!()
+ };
+ let mut map = Map::new();
+ map.remap_vmes(&self.value_map);
+ map.apply(&mut values);
+ map.remap_formats(&self.format, &self.string_format);
+ if values
+ .iter()
+ .all(|value| value.value.is_string_and(|s| s.is_empty()))
+ {
+ values.clear();
+ }
+ Ok(Series::new(self.id.clone(), values, map))
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename = "extension", rename_all = "camelCase")]
+struct VariableExtension {
+ #[serde(rename = "@from", default)]
+ from: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct UserSource {
+ #[serde(rename = "@id")]
+ id: String,
+
+ #[serde(rename = "@missing")]
+ missing: Option<Missing>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct CategoricalDomain {
+ variable_reference: VariableReference,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct VariableReference {
+ #[serde(rename = "@ref")]
+ reference: String,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Missing {
+ Listwise,
+ Pairwise,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct StringFormat {
+ #[serde(default, rename = "relabel")]
+ relabels: Vec<Relabel>,
+ #[serde(default, rename = "affix")]
+ affixes: Vec<Affix>,
+}
+
+#[derive(Deserialize, Debug, Default)]
+#[serde(rename_all = "camelCase")]
+struct Format {
+ #[serde(rename = "@baseFormat")]
+ base_format: Option<BaseFormat>,
+ #[serde(rename = "@errorCharacter")]
+ error_character: Option<char>,
+ #[serde(rename = "@separatorChars")]
+ separator_chars: Option<String>,
+ #[serde(rename = "@mdyOrder")]
+ mdy_order: Option<MdyOrder>,
+ #[serde(rename = "@showYear")]
+ show_year: Option<bool>,
+ #[serde(rename = "@showQuarter")]
+ show_quarter: Option<bool>,
+ #[serde(rename = "@quarterPrefix")]
+ quarter_prefix: Option<String>,
+ #[serde(rename = "@quarterSuffix")]
+ quarter_suffix: Option<String>,
+ #[serde(rename = "@yearAbbreviation")]
+ year_abbreviation: Option<bool>,
+ #[serde(rename = "@showMonth")]
+ show_month: Option<bool>,
+ #[serde(rename = "@monthFormat")]
+ month_format: Option<MonthFormat>,
+ #[serde(rename = "@dayPadding")]
+ day_padding: Option<bool>,
+ #[serde(rename = "@dayOfMonthPadding")]
+ day_of_month_padding: Option<bool>,
+ #[serde(rename = "@showWeek")]
+ show_week: Option<bool>,
+ #[serde(rename = "@weekPadding")]
+ week_padding: Option<bool>,
+ #[serde(rename = "@weekSuffix")]
+ week_suffix: Option<String>,
+ #[serde(rename = "@showDayOfWeek")]
+ show_day_of_week: Option<bool>,
+ #[serde(rename = "@dayOfWeekAbbreviation")]
+ day_of_week_abbreviation: Option<bool>,
+ #[serde(rename = "hourPadding")]
+ hour_padding: Option<bool>,
+ #[serde(rename = "minutePadding")]
+ minute_padding: Option<bool>,
+ #[serde(rename = "secondPadding")]
+ second_padding: Option<bool>,
+ #[serde(rename = "@showDay")]
+ show_day: Option<bool>,
+ #[serde(rename = "@showHour")]
+ show_hour: Option<bool>,
+ #[serde(rename = "@showMinute")]
+ show_minute: Option<bool>,
+ #[serde(rename = "@showSecond")]
+ show_second: Option<bool>,
+ #[serde(rename = "@showMillis")]
+ show_millis: Option<bool>,
+ #[serde(rename = "@dayType")]
+ day_type: Option<DayType>,
+ #[serde(rename = "@hourFormat")]
+ hour_format: Option<HourFormat>,
+ #[serde(rename = "@minimumIntegerDigits")]
+ minimum_integer_digits: Option<usize>,
+ #[serde(rename = "@maximumFractionDigits")]
+ maximum_fraction_digits: Option<i64>,
+ #[serde(rename = "@minimumFractionDigits")]
+ minimum_fraction_digits: Option<usize>,
+ #[serde(rename = "@useGrouping")]
+ use_grouping: Option<bool>,
+ #[serde(rename = "@scientific")]
+ scientific: Option<Scientific>,
+ #[serde(rename = "@small")]
+ small: Option<f64>,
+ #[serde(default, rename = "@prefix")]
+ prefix: String,
+ #[serde(default, rename = "@suffix")]
+ suffix: String,
+ #[serde(rename = "@tryStringsAsNumbers")]
+ try_strings_as_numbers: Option<bool>,
+ #[serde(rename = "@negativesOutside")]
+ negatives_outside: Option<bool>,
+ #[serde(default, rename = "relabel")]
+ relabels: Vec<Relabel>,
+ #[serde(default, rename = "affix")]
+ affixes: Vec<Affix>,
+}
+
+impl Format {
+ fn decode(&self) -> crate::format::Format {
+ if self.base_format.is_some() {
+ SignificantDateTimeFormat::from(self).decode()
+ } else {
+ SignificantNumberFormat::from(self).decode()
+ }
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct NumberFormat {
+ #[serde(rename = "@minimumIntegerDigits")]
+ minimum_integer_digits: Option<i64>,
+ #[serde(rename = "@maximumFractionDigits")]
+ maximum_fraction_digits: Option<i64>,
+ #[serde(rename = "@minimumFractionDigits")]
+ minimum_fraction_digits: Option<i64>,
+ #[serde(rename = "@useGrouping")]
+ use_grouping: Option<bool>,
+ #[serde(rename = "@scientific")]
+ scientific: Option<Scientific>,
+ #[serde(rename = "@small")]
+ small: Option<f64>,
+ #[serde(default, rename = "@prefix")]
+ prefix: String,
+ #[serde(default, rename = "@suffix")]
+ suffix: String,
+ #[serde(default, rename = "affix")]
+ affixes: Vec<Affix>,
+}
+
+struct SignificantNumberFormat<'a> {
+ scientific: Option<Scientific>,
+ prefix: &'a str,
+ suffix: &'a str,
+ use_grouping: Option<bool>,
+ maximum_fraction_digits: Option<i64>,
+}
+
+impl<'a> From<&'a NumberFormat> for SignificantNumberFormat<'a> {
+ fn from(value: &'a NumberFormat) -> Self {
+ Self {
+ scientific: value.scientific,
+ prefix: &value.prefix,
+ suffix: &value.suffix,
+ use_grouping: value.use_grouping,
+ maximum_fraction_digits: value.maximum_fraction_digits,
+ }
+ }
+}
+
+impl<'a> From<&'a Format> for SignificantNumberFormat<'a> {
+ fn from(value: &'a Format) -> Self {
+ Self {
+ scientific: value.scientific,
+ prefix: &value.prefix,
+ suffix: &value.suffix,
+ use_grouping: value.use_grouping,
+ maximum_fraction_digits: value.maximum_fraction_digits,
+ }
+ }
+}
+
+impl<'a> SignificantNumberFormat<'a> {
+ fn decode(&self) -> crate::format::Format {
+ let type_ = if self.scientific == Some(Scientific::True) {
+ Type::E
+ } else if self.prefix == "$" {
+ Type::Dollar
+ } else if self.suffix == "%" {
+ Type::Pct
+ } else if self.use_grouping == Some(true) {
+ Type::Comma
+ } else {
+ Type::F
+ };
+ let d = match self.maximum_fraction_digits {
+ Some(d) if (0..=15).contains(&d) => d,
+ _ => 2,
+ };
+ UncheckedFormat {
+ type_,
+ w: 40,
+ d: d as u8,
+ }
+ .fix()
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct DateTimeFormat {
+ #[serde(rename = "@baseFormat")]
+ base_format: BaseFormat,
+ #[serde(rename = "@separatorChars")]
+ separator_chars: Option<String>,
+ #[serde(rename = "@mdyOrder")]
+ mdy_order: Option<MdyOrder>,
+ #[serde(rename = "@showYear")]
+ show_year: Option<bool>,
+ #[serde(rename = "@showQuarter")]
+ show_quarter: Option<bool>,
+ #[serde(rename = "@quarterPrefix")]
+ quarter_prefix: Option<String>,
+ #[serde(rename = "@quarterSuffix")]
+ quarter_suffix: Option<String>,
+ #[serde(rename = "@yearAbbreviation")]
+ year_abbreviation: Option<bool>,
+ #[serde(rename = "@showMonth")]
+ show_month: Option<bool>,
+ #[serde(rename = "@monthFormat")]
+ month_format: Option<MonthFormat>,
+ #[serde(rename = "@dayPadding")]
+ day_padding: Option<bool>,
+ #[serde(rename = "@dayOfMonthPadding")]
+ day_of_month_padding: Option<bool>,
+ #[serde(rename = "@showWeek")]
+ show_week: Option<bool>,
+ #[serde(rename = "@weekPadding")]
+ week_padding: Option<bool>,
+ #[serde(rename = "@weekSuffix")]
+ week_suffix: Option<String>,
+ #[serde(rename = "@showDayOfWeek")]
+ show_day_of_week: Option<bool>,
+ #[serde(rename = "@dayOfWeekAbbreviation")]
+ day_of_week_abbreviation: Option<bool>,
+ #[serde(rename = "hourPadding")]
+ hour_padding: Option<bool>,
+ #[serde(rename = "minutePadding")]
+ minute_padding: Option<bool>,
+ #[serde(rename = "secondPadding")]
+ second_padding: Option<bool>,
+ #[serde(rename = "@showDay")]
+ show_day: Option<bool>,
+ #[serde(rename = "@showHour")]
+ show_hour: Option<bool>,
+ #[serde(rename = "@showMinute")]
+ show_minute: Option<bool>,
+ #[serde(rename = "@showSecond")]
+ show_second: Option<bool>,
+ #[serde(rename = "@showMillis")]
+ show_millis: Option<bool>,
+ #[serde(rename = "@dayType")]
+ day_type: Option<DayType>,
+ #[serde(rename = "@hourFormat")]
+ hour_format: Option<HourFormat>,
+ #[serde(default, rename = "affix")]
+ affixes: Vec<Affix>,
+}
+
+struct SignificantDateTimeFormat {
+ base_format: Option<BaseFormat>,
+ show_quarter: Option<bool>,
+ show_week: Option<bool>,
+ show_day: Option<bool>,
+ show_hour: Option<bool>,
+ show_second: Option<bool>,
+ show_millis: Option<bool>,
+ mdy_order: Option<MdyOrder>,
+ month_format: Option<MonthFormat>,
+ year_abbreviation: Option<bool>,
+}
+
+impl From<&Format> for SignificantDateTimeFormat {
+ fn from(value: &Format) -> Self {
+ Self {
+ base_format: value.base_format,
+ show_quarter: value.show_quarter,
+ show_week: value.show_week,
+ show_day: value.show_day,
+ show_hour: value.show_hour,
+ show_second: value.show_second,
+ show_millis: value.show_millis,
+ mdy_order: value.mdy_order,
+ month_format: value.month_format,
+ year_abbreviation: value.year_abbreviation,
+ }
+ }
+}
+impl From<&DateTimeFormat> for SignificantDateTimeFormat {
+ fn from(value: &DateTimeFormat) -> Self {
+ Self {
+ base_format: Some(value.base_format),
+ show_quarter: value.show_quarter,
+ show_week: value.show_week,
+ show_day: value.show_day,
+ show_hour: value.show_hour,
+ show_second: value.show_second,
+ show_millis: value.show_millis,
+ mdy_order: value.mdy_order,
+ month_format: value.month_format,
+ year_abbreviation: value.year_abbreviation,
+ }
+ }
+}
+impl SignificantDateTimeFormat {
+ fn decode(&self) -> crate::format::Format {
+ let type_ = match self.base_format {
+ Some(BaseFormat::Date) => {
+ let type_ = if self.show_quarter == Some(true) {
+ Type::QYr
+ } else if self.show_week == Some(true) {
+ Type::WkYr
+ } else {
+ match (self.mdy_order, self.month_format) {
+ (Some(MdyOrder::DayMonthYear), Some(MonthFormat::Number)) => Type::EDate,
+ (Some(MdyOrder::DayMonthYear), Some(MonthFormat::PaddedNumber)) => {
+ Type::EDate
+ }
+ (Some(MdyOrder::DayMonthYear), _) => Type::Date,
+ (Some(MdyOrder::YearMonthDay), _) => Type::SDate,
+ _ => Type::ADate,
+ }
+ };
+ let mut w = type_.min_width();
+ if self.year_abbreviation != Some(true) {
+ w += 2;
+ };
+ return UncheckedFormat { type_, w, d: 0 }.try_into().unwrap();
+ }
+ Some(BaseFormat::DateTime) => {
+ if self.mdy_order == Some(MdyOrder::YearMonthDay) {
+ Type::YmdHms
+ } else {
+ Type::DateTime
+ }
+ }
+ _ => {
+ if self.show_day == Some(true) {
+ Type::DTime
+ } else if self.show_hour == Some(true) {
+ Type::Time
+ } else {
+ Type::MTime
+ }
+ }
+ };
+ date_time_format(type_, self.show_second, self.show_millis)
+ }
+}
+
+impl DateTimeFormat {
+ fn decode(&self) -> crate::format::Format {
+ SignificantDateTimeFormat::from(self).decode()
+ }
+}
+
+fn date_time_format(
+ type_: Type,
+ show_second: Option<bool>,
+ show_millis: Option<bool>,
+) -> crate::format::Format {
+ let mut w = type_.min_width();
+ let mut d = 0;
+ if show_second == Some(true) {
+ w += 3;
+ if show_millis == Some(true) {
+ d = 3;
+ w += d as u16 + 1;
+ }
+ }
+ UncheckedFormat { type_, w, d }.try_into().unwrap()
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct ElapsedTimeFormat {
+ #[serde(rename = "@dayPadding")]
+ day_padding: Option<bool>,
+ #[serde(rename = "hourPadding")]
+ hour_padding: Option<bool>,
+ #[serde(rename = "minutePadding")]
+ minute_padding: Option<bool>,
+ #[serde(rename = "secondPadding")]
+ second_padding: Option<bool>,
+ #[serde(rename = "@showDay")]
+ show_day: Option<bool>,
+ #[serde(rename = "@showHour")]
+ show_hour: Option<bool>,
+ #[serde(rename = "@showMinute")]
+ show_minute: Option<bool>,
+ #[serde(rename = "@showSecond")]
+ show_second: Option<bool>,
+ #[serde(rename = "@showMillis")]
+ show_millis: Option<bool>,
+ #[serde(rename = "@showYear")]
+ show_year: Option<bool>,
+ #[serde(default, rename = "affix")]
+ affixes: Vec<Affix>,
+}
+
+impl ElapsedTimeFormat {
+ fn decode(&self) -> crate::format::Format {
+ let type_ = if self.show_day == Some(true) {
+ Type::DTime
+ } else if self.show_hour == Some(true) {
+ Type::Time
+ } else {
+ Type::MTime
+ };
+ date_time_format(type_, self.show_second, self.show_millis)
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum BaseFormat {
+ Date,
+ Time,
+ DateTime,
+ ElapsedTime,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum MdyOrder {
+ DayMonthYear,
+ MonthDayYear,
+ YearMonthDay,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum MonthFormat {
+ Long,
+ Short,
+ Number,
+ PaddedNumber,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum DayType {
+ Month,
+ Year,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum HourFormat {
+ #[serde(rename = "AMPM")]
+ AmPm,
+ #[serde(rename = "AS_24")]
+ As24,
+ #[serde(rename = "AS_12")]
+ As12,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Scientific {
+ OnlyForSmall,
+ WhenNeeded,
+ True,
+ False,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Affix {
+ /// The footnote number as a natural number: 1 for the first footnote, 2 for
+ /// the second, and so on.
+ #[serde(rename = "@definesReference")]
+ defines_reference: u64,
+
+ /// Position for the footnote label.
+ #[serde(rename = "@position")]
+ position: Position,
+
+ /// Whether the affix is a suffix (true) or a prefix (false).
+ #[serde(rename = "@suffix")]
+ suffix: bool,
+
+ /// The text of the suffix or prefix. Typically a letter, e.g. `a` for
+ /// footnote 1, `b` for footnote 2, ...
+ #[serde(rename = "@value")]
+ value: String,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Position {
+ Subscript,
+ Superscript,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Relabel {
+ #[serde(rename = "@from")]
+ from: f64,
+ #[serde(rename = "@to")]
+ to: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct ValueMapEntry {
+ #[serde(rename = "@from")]
+ from: String,
+ #[serde(rename = "@to")]
+ to: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Style {
+ #[serde(rename = "@id")]
+ id: Option<String>,
+
+ /// The text color or, in some cases, background color.
+ #[serde(rename = "@color")]
+ color: Option<Color>,
+
+ /// Not used.
+ #[serde(rename = "@color2")]
+ color2: Option<Color>,
+
+ /// Normally 0. The value -90 causes inner column or outer row labels to be
+ /// rotated vertically.
+ #[serde(rename = "@labelAngle")]
+ label_angle: Option<f64>,
+
+ #[serde(rename = "@border-bottom")]
+ border_bottom: Option<Border>,
+
+ #[serde(rename = "@border-top")]
+ border_top: Option<Border>,
+
+ #[serde(rename = "@border-left")]
+ border_left: Option<Border>,
+
+ #[serde(rename = "@border-right")]
+ border_right: Option<Border>,
+
+ #[serde(rename = "@border-bottom-color")]
+ border_bottom_color: Option<Color>,
+
+ #[serde(rename = "@border-top-color")]
+ border_top_color: Option<Color>,
+
+ #[serde(rename = "@border-left-color")]
+ border_left_color: Option<Color>,
+
+ #[serde(rename = "@border-right-color")]
+ border_right_color: Option<Color>,
+
+ #[serde(rename = "@font-family")]
+ font_family: Option<String>,
+
+ #[serde(rename = "@font-size")]
+ font_size: Option<String>,
+
+ #[serde(rename = "@font-weight")]
+ font_weight: Option<FontWeight>,
+
+ #[serde(rename = "@font-style")]
+ font_style: Option<FontStyle>,
+
+ #[serde(rename = "@font-underline")]
+ font_underline: Option<FontUnderline>,
+
+ #[serde(rename = "@margin-bottom")]
+ margin_bottom: Option<Length>,
+
+ #[serde(rename = "@margin-top")]
+ margin_top: Option<Length>,
+
+ #[serde(rename = "@margin-left")]
+ margin_left: Option<Length>,
+
+ #[serde(rename = "@margin-right")]
+ margin_right: Option<Length>,
+
+ #[serde(rename = "@textAlignment")]
+ text_alignment: Option<TextAlignment>,
+
+ #[serde(rename = "@labelLocationHorizontal")]
+ label_location_horizontal: Option<LabelLocation>,
+
+ #[serde(rename = "@labelLocationVertical")]
+ label_location_vertical: Option<LabelLocation>,
+
+ #[serde(rename = "@size")]
+ size: Option<String>,
+
+ #[serde(rename = "@width")]
+ width: Option<String>,
+
+ #[serde(rename = "@visible")]
+ visible: Option<bool>,
+
+ #[serde(rename = "@decimal-offset")]
+ decimal_offset: Option<Length>,
+}
+
+impl Style {
+ fn apply_to_value(
+ value: &mut Value,
+ sf: Option<&SetFormat>,
+ fg: Option<&Style>,
+ bg: Option<&Style>,
+ base_style: &AreaStyle,
+ ) {
+ if let Some(sf) = sf {
+ if sf.reset == Some(true) {
+ value.styling_mut().footnotes.clear();
+ }
+
+ let format = match &sf.child {
+ Some(SetFormatChild::Format(format)) => Some(format.decode()),
+ Some(SetFormatChild::NumberFormat(format)) => {
+ Some(SignificantNumberFormat::from(format).decode())
+ }
+ Some(SetFormatChild::StringFormat(_)) => None,
+ Some(SetFormatChild::DateTimeFormat(format)) => Some(format.decode()),
+ Some(SetFormatChild::ElapsedTimeFormat(format)) => Some(format.decode()),
+ None => None,
+ };
+ if let Some(format) = format
+ && let Some(datum_value) = value.inner.as_datum_value_mut()
+ {
+ match &datum_value.datum {
+ Datum::Number(_) => {
+ datum_value.format = format;
+ }
+ Datum::String(string) => {
+ if format.type_().category() == format::Category::Date
+ && let Ok(date_time) = NaiveDateTime::parse_from_str(
+ &string.as_str(),
+ "%Y-%m-%dT%H:%M:%S%.3f",
+ )
+ {
+ value.inner = Value::new_date(date_time).with_format(format).inner;
+ } else if format.type_().category() == format::Category::Time
+ && let Ok(time) =
+ NaiveTime::parse_from_str(&string.as_str(), "%H:%M:%S%.3f")
+ {
+ value.inner = Value::new_time(time).with_format(format).inner;
+ } else if let Ok(number) = string.as_str().parse::<f64>() {
+ value.inner = Value::new_number(Some(number)).with_format(format).inner;
+ }
+ }
+ }
+ }
+ }
+
+ if fg.is_some() || bg.is_some() {
+ let styling = value.styling_mut();
+ let font_style = styling
+ .font_style
+ .get_or_insert_with(|| base_style.font_style.clone());
+ let cell_style = styling
+ .cell_style
+ .get_or_insert_with(|| base_style.cell_style.clone());
+ Self::decode(fg, bg, cell_style, font_style);
+ }
+ }
+
+ fn decode(
+ fg: Option<&Style>,
+ bg: Option<&Style>,
+ cell_style: &mut CellStyle,
+ font_style: &mut look::FontStyle,
+ ) {
+ if let Some(fg) = fg {
+ if let Some(weight) = fg.font_weight {
+ font_style.bold = weight.is_bold();
+ }
+ if let Some(style) = fg.font_style {
+ font_style.italic = style.is_italic();
+ }
+ if let Some(underline) = fg.font_underline {
+ font_style.underline = underline.is_underline();
+ }
+ if let Some(color) = fg.color {
+ font_style.fg = color;
+ }
+ if let Some(font_size) = &fg.font_size {
+ if let Ok(size) = font_size
+ .trim_end_matches(|c: char| c.is_alphabetic())
+ .parse()
+ {
+ font_style.size = size;
+ } else {
+ // XXX warn?
+ }
+ }
+ if let Some(alignment) = fg.text_alignment {
+ cell_style.horz_align = alignment.as_horz_align(fg.decimal_offset);
+ }
+ if let Some(label_local_vertical) = fg.label_location_vertical {
+ cell_style.vert_align = label_local_vertical.into();
+ }
+ }
+ if let Some(bg) = bg {
+ if let Some(color) = bg.color {
+ font_style.bg = color;
+ }
+ }
+ }
+
+ fn decode_area(fg: Option<&Style>, bg: Option<&Style>, out: &mut AreaStyle) {
+ Self::decode(fg, bg, &mut out.cell_style, &mut out.font_style);
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Border {
+ Solid,
+ Thick,
+ Thin,
+ Double,
+ None,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontWeight {
+ Regular,
+ Bold,
+}
+
+impl FontWeight {
+ fn is_bold(&self) -> bool {
+ *self == Self::Bold
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontStyle {
+ Regular,
+ Italic,
+}
+
+impl FontStyle {
+ fn is_italic(&self) -> bool {
+ *self == Self::Italic
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontUnderline {
+ None,
+ Underline,
+}
+
+impl FontUnderline {
+ fn is_underline(&self) -> bool {
+ *self == Self::Underline
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum TextAlignment {
+ Left,
+ Right,
+ Center,
+ Decimal,
+ Mixed,
+}
+
+impl TextAlignment {
+ fn as_horz_align(&self, decimal_offset: Option<Length>) -> Option<HorzAlign> {
+ match self {
+ TextAlignment::Left => Some(HorzAlign::Left),
+ TextAlignment::Right => Some(HorzAlign::Right),
+ TextAlignment::Center => Some(HorzAlign::Center),
+ TextAlignment::Decimal => Some(HorzAlign::Decimal {
+ offset: decimal_offset.unwrap_or_default().as_px_f64(),
+ }),
+ TextAlignment::Mixed => None,
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum LabelLocation {
+ Positive,
+ Negative,
+ Center,
+}
+
+impl From<LabelLocation> for VertAlign {
+ fn from(value: LabelLocation) -> Self {
+ match value {
+ LabelLocation::Positive => VertAlign::Top,
+ LabelLocation::Negative => VertAlign::Bottom,
+ LabelLocation::Center => VertAlign::Middle,
+ }
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Graph {
+ #[serde(rename = "@id")]
+ id: Option<String>,
+
+ #[serde(rename = "@cellStyle")]
+ cell_style: Ref<Style>,
+
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ #[serde(rename = "location")]
+ locations: Vec<Location>,
+ coordinates: Coordinates,
+ faceting: Faceting,
+ facet_layout: FacetLayout,
+ interval: Interval,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Coordinates;
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Location {
+ /// The part of the table being located.
+ #[serde(rename = "@part")]
+ part: Part,
+
+ /// How the location is determined.
+ #[serde(rename = "@method")]
+ method: Method,
+
+ /// Minimum size.
+ #[serde(rename = "@min")]
+ min: Option<Length>,
+
+ /// Maximum size.
+ #[serde(rename = "@max")]
+ max: Option<Length>,
+
+ /// An element to attach to. Required when method is attach or same, not
+ /// observed otherwise.
+ #[serde(rename = "@target")]
+ target: Option<String>,
+
+ #[serde(rename = "@value")]
+ value: Option<String>,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Part {
+ Height,
+ Width,
+ Top,
+ Bottom,
+ Left,
+ Right,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Method {
+ SizeToContent,
+ Attach,
+ Fixed,
+ Same,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Faceting {
+ #[serde(rename = "@id")]
+ id: Option<String>,
+
+ #[serde(default, rename = "$value")]
+ children: Vec<FacetingChild>,
+}
+
+impl Faceting {
+ fn cross(&self) -> &[CrossChild] {
+ self.children
+ .iter()
+ .find_map(|child| match child {
+ FacetingChild::Cross(cross) => Some(cross.children.as_slice()),
+ _ => None,
+ })
+ .unwrap_or(&[])
+ }
+
+ fn layers(&self) -> Vec<&str> {
+ self.children
+ .iter()
+ .filter_map(|child| match child {
+ FacetingChild::Layer(layer) => Some(layer.variable.as_str()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ fn layer_value(&self, name: &str) -> Option<usize> {
+ self.children.iter().find_map(|child| {
+ if let FacetingChild::Layer(layer) = &child
+ && layer.variable == name
+ {
+ layer.value.parse().ok()
+ } else {
+ None
+ }
+ })
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum FacetingChild {
+ Cross(Cross),
+ Layer(Layer),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Cross {
+ #[serde(rename = "$value")]
+ children: Vec<CrossChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum CrossChild {
+ /// No dimensions along this axis.
+ Unity,
+ /// Dimensions along this axis.
+ Nest(Nest),
+}
+
+impl CrossChild {
+ fn variables(&self) -> &[VariableReference] {
+ match self {
+ CrossChild::Unity => &[],
+ CrossChild::Nest(nest) => &nest.variable_references,
+ }
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Nest {
+ #[serde(rename = "variableReference")]
+ variable_references: Vec<VariableReference>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Layer {
+ #[serde(rename = "@variable")]
+ variable: String,
+
+ #[serde(rename = "@value")]
+ value: String,
+
+ #[serde(rename = "@visible")]
+ visible: Option<bool>,
+
+ #[serde(rename = "@titleVisible")]
+ title_visible: Option<bool>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FacetLayout {
+ table_layout: TableLayout,
+ #[serde(rename = "$value")]
+ children: Vec<FacetLayoutChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum FacetLayoutChild {
+ SetCellProperties(SetCellProperties),
+ FacetLevel(FacetLevel),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct TableLayout {
+ #[serde(rename = "@verticalTitlesInCorner")]
+ vertical_titles_in_corner: bool,
+
+ #[serde(rename = "@style")]
+ style: Option<Ref<Style>>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetCellProperties {
+ #[serde(rename = "@id")]
+ id: Option<String>,
+
+ #[serde(rename = "@applyToConverse")]
+ apply_to_converse: Option<bool>,
+
+ #[serde(rename = "$value")]
+ sets: Vec<Set>,
+
+ #[serde(rename = "union")]
+ union_: Option<Union>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Union {
+ #[serde(default, rename = "intersect")]
+ intersects: Vec<Intersect>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Intersect {
+ #[serde(default, rename = "$value")]
+ children: Vec<IntersectChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum IntersectChild {
+ Where(Where),
+ IntersectWhere(IntersectWhere),
+ Alternating,
+ #[serde(other)]
+ Empty,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Where {
+ #[serde(rename = "@variable")]
+ variable: String,
+ #[serde(rename = "@include")]
+ include: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct IntersectWhere {
+ #[serde(rename = "@variable")]
+ variable: String,
+
+ #[serde(rename = "@variable2")]
+ variable2: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum Set {
+ SetStyle(SetStyle),
+ SetFrameStyle(SetFrameStyle),
+ SetFormat(SetFormat),
+ SetMetaData(SetMetaData),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetStyle {
+ #[serde(rename = "@target")]
+ target: String,
+
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetMetaData {
+ #[serde(rename = "@target")]
+ target: Ref<Graph>,
+
+ #[serde(rename = "@key")]
+ key: String,
+
+ #[serde(rename = "@value")]
+ value: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetFormat {
+ #[serde(rename = "@target")]
+ target: String,
+
+ #[serde(rename = "@reset")]
+ reset: Option<bool>,
+
+ #[serde(rename = "$value")]
+ child: Option<SetFormatChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum SetFormatChild {
+ Format(Format),
+ NumberFormat(NumberFormat),
+ StringFormat(Vec<StringFormat>),
+ DateTimeFormat(DateTimeFormat),
+ ElapsedTimeFormat(ElapsedTimeFormat),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetFrameStyle {
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ #[serde(rename = "@target")]
+ target: Ref<MajorTicks>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Interval {
+ #[serde(rename = "@id")]
+ id: Option<String>,
+
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ labeling: Labeling,
+ footnotes: Option<Footnotes>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Labeling {
+ #[serde(rename = "@id")]
+ id: Option<String>,
+
+ #[serde(rename = "@style")]
+ style: Option<Ref<Style>>,
+
+ #[serde(rename = "@variable")]
+ variable: String,
+
+ #[serde(rename = "$value", default)]
+ children: Vec<LabelingChild>,
+}
+
+impl Labeling {
+ fn decode_format_map<'a>(
+ &self,
+ series: &'a HashMap<&str, Series>,
+ ) -> (Option<&'a Series>, HashMap<i64, crate::format::Format>) {
+ let mut map = HashMap::new();
+ let mut cell_format = None;
+ for child in &self.children {
+ if let LabelingChild::Formatting(formatting) = child {
+ cell_format = series.get(formatting.variable.as_str());
+ for mapping in &formatting.mappings {
+ if let Some(format) = &mapping.format {
+ map.insert(mapping.from, format.decode());
+ }
+ }
+ }
+ }
+ (cell_format, map)
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum LabelingChild {
+ Formatting(Formatting),
+ Format(Format),
+ Footnotes(Footnotes),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Formatting {
+ #[serde(rename = "@variable")]
+ variable: String,
+
+ #[serde(rename = "$value", default)]
+ mappings: Vec<FormatMapping>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FormatMapping {
+ #[serde(rename = "@from")]
+ from: i64,
+
+ format: Option<Format>,
+}
+
+#[derive(Clone, Debug, Default)]
+struct Footnote {
+ content: String,
+ marker: Option<String>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Footnotes {
+ #[serde(rename = "@superscript")]
+ superscript: Option<bool>,
+
+ #[serde(rename = "@variable")]
+ variable: String,
+
+ #[serde(default, rename = "footnoteMapping")]
+ mappings: Vec<FootnoteMapping>,
+}
+
+impl Footnotes {
+ fn decode(&self, dst: &mut BTreeMap<usize, Footnote>) {
+ for f in &self.mappings {
+ dst.entry(f.defines_reference.get() - 1)
+ .or_default()
+ .content = f.to.clone();
+ }
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FootnoteMapping {
+ #[serde(rename = "@definesReference")]
+ defines_reference: NonZeroUsize,
+
+ #[serde(rename = "@from")]
+ from: i64,
+
+ #[serde(rename = "@to")]
+ to: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FacetLevel {
+ #[serde(rename = "@id")]
+ id: Option<String>,
+
+ #[serde(rename = "@level")]
+ level: usize,
+
+ #[serde(rename = "@gap")]
+ gap: Option<Length>,
+ axis: Axis,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Axis {
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ label: Option<Label>,
+ major_ticks: MajorTicks,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct MajorTicks {
+ #[serde(rename = "@id")]
+ id: String,
+
+ #[serde(rename = "@labelAngle")]
+ label_angle: f64,
+
+ #[serde(rename = "@length")]
+ length: Length,
+
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ #[serde(rename = "@tickFrameStyle")]
+ tick_frame_style: Ref<Style>,
+
+ #[serde(rename = "@labelFrequency")]
+ label_frequency: Option<i64>,
+
+ #[serde(rename = "@stagger")]
+ stagger: Option<bool>,
+
+ gridline: Option<Gridline>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Gridline {
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ #[serde(rename = "@zOrder")]
+ z_order: i64,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Label {
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ #[serde(rename = "@textFrameStyle")]
+ text_frame_style: Option<Ref<Style>>,
+
+ #[serde(rename = "@purpose")]
+ purpose: Option<Purpose>,
+
+ #[serde(rename = "$value")]
+ child: LabelChild,
+}
+
+impl Label {
+ fn text(&self) -> &[Text] {
+ match &self.child {
+ LabelChild::Text(texts) => texts.as_slice(),
+ LabelChild::DescriptionGroup(_) => &[],
+ }
+ }
+
+ fn decode_style(&self, area_style: &mut AreaStyle, styles: &HashMap<&str, &Style>) {
+ let fg = self.style.get(styles);
+ let bg = self.text_frame_style.as_ref().and_then(|r| r.get(styles));
+ Style::decode_area(fg, bg, area_style);
+ }
+}
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Enum)]
+#[serde(rename_all = "camelCase")]
+enum Purpose {
+ Title,
+ SubTitle,
+ SubSubTitle,
+ Layer,
+ Footnote,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum LabelChild {
+ Text(Vec<Text>),
+ DescriptionGroup(DescriptionGroup),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Text {
+ #[serde(rename = "@usesReference")]
+ uses_reference: Option<NonZeroUsize>,
+
+ #[serde(rename = "@definesReference")]
+ defines_reference: Option<NonZeroUsize>,
+
+ #[serde(rename = "@position")]
+ position: Option<Position>,
+
+ #[serde(rename = "@style")]
+ style: Option<Ref<Style>>,
+
+ #[serde(default, rename = "$text")]
+ text: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct DescriptionGroup {
+ #[serde(rename = "@target")]
+ target: Ref<Faceting>,
+
+ #[serde(rename = "@separator")]
+ separator: Option<String>,
+
+ #[serde(rename = "$value")]
+ children: Vec<DescriptionGroupChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum DescriptionGroupChild {
+ Description(Description),
+ Text(Text),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Description {
+ #[serde(rename = "@name")]
+ name: Name,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Name {
+ Variable,
+ Value,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct LabelFrame {
+ #[serde(rename = "@id")]
+ id: Option<String>,
+
+ #[serde(rename = "@style")]
+ style: Option<Ref<Style>>,
+
+ #[serde(rename = "location")]
+ locations: Vec<Location>,
+
+ label: Option<Label>,
+ paragraph: Option<Paragraph>,
+}
+
+impl LabelFrame {
+ fn decode_label(labels: &[&Label], footnotes: &pivot::Footnotes) -> Option<Value> {
+ if !labels.is_empty() {
+ let mut s = String::new();
+ let mut f = Vec::new();
+ for t in labels {
+ if let LabelChild::Text(text) = &t.child {
+ for t in text {
+ if let Some(defines_reference) = t.defines_reference
+ && let Some(footnote) = footnotes.get(defines_reference.get() - 1)
+ {
+ f.push(footnote);
+ } else {
+ s += &t.text;
+ }
+ }
+ }
+ }
+ let mut value = Value::new_user_text(s);
+ for footnote in f {
+ value = value.with_footnote(footnote);
+ }
+ Some(value)
+ } else {
+ None
+ }
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Paragraph;
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Container {
+ #[serde(rename = "@style")]
+ style: Ref<Style>,
+
+ #[serde(default, rename = "extension")]
+ extensions: Option<ContainerExtension>,
+ #[serde(default)]
+ locations: Vec<Location>,
+ #[serde(rename = "labelFrame")]
+ #[serde(default)]
+ label_frames: Vec<LabelFrame>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename = "extension", rename_all = "camelCase")]
+struct ContainerExtension {
+ #[serde(rename = "@combinedFootnotes")]
+ combined_footnotes: Option<bool>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct LayerController {
+ #[serde(rename = "@target")]
+ target: Option<Ref<Label>>,
+}
--- /dev/null
+use std::{
+ cell::RefCell,
+ fmt::Debug,
+ io::{Read, Seek},
+ ops::Deref,
+ rc::Rc,
+ str::FromStr,
+ sync::Arc,
+};
+
+use binrw::{
+ BinRead, BinResult, Endian, Error as BinError, VecArgs, binread, error::ContextExt,
+ io::TakeSeekExt,
+};
+use chrono::DateTime;
+use displaydoc::Display;
+use encoding_rs::{Encoding, WINDOWS_1252};
+use enum_map::{EnumMap, enum_map};
+use itertools::Itertools;
+
+use crate::{
+ data::Datum,
+ format::{
+ CC, Decimal, Decimals, Epoch, F40, F40_2, Format, NumberStyle, Settings, Type,
+ UncheckedFormat, Width,
+ },
+ output::pivot::{
+ self, Axis2, Axis3, FootnoteMarkerPosition, FootnoteMarkerType, Footnotes, Group,
+ PivotTable, PivotTableMetadata, PivotTableStyle, PrecomputedIndex,
+ look::{
+ self, AreaStyle, BoxBorder, Color, HeadingRegion, HorzAlign, LabelPosition, Look,
+ RowColBorder, RowParity, Stroke, VertAlign,
+ },
+ parse_bool,
+ value::{self, DatumValue, TemplateValue, ValueStyle, VariableValue},
+ },
+ settings::Show,
+};
+
+/// A warning decoding a light detail member.
+#[derive(Clone, Debug, Display, thiserror::Error)]
+pub enum LightWarning {
+ /// Unknown encoding {0:?}.
+ UnknownEncoding(String),
+
+ /// Unknown "show" value {0}.
+ UnknownShow(u8),
+
+ /// Invalid decimal point {0:?}.
+ InvalidDecimal(char),
+
+ /// Invalid custom currency definition {0:?}.
+ InvalidCustomCurrency(String),
+
+ /// Invalid format type {0}.
+ InvalidFormat(u16),
+
+ /// Expected {expected} dimensions along axes, found {actual} dimensions ({n_layers} layers + {n_rows} rows + {n_columns} columns).
+ WrongAxisCount {
+ /// Expected number of dimensions.
+ expected: usize,
+ /// Actual number of dimensions.
+ actual: usize,
+ /// Actual number of layer dimensions.
+ n_layers: usize,
+ /// Actual number of row dimensions.
+ n_rows: usize,
+ /// Actual number of column dimensions.
+ n_columns: usize,
+ },
+
+ /// Invalid dimension index {index} in table with {n} dimensions.
+ InvalidDimensionIndex { index: usize, n: usize },
+
+ /// Dimension with index {0} appears twice in table axes.
+ DuplicateDimensionIndex(usize),
+}
+
+struct Context {
+ version: Version,
+ warn: Rc<RefCell<Box<dyn FnMut(LightWarning)>>>,
+}
+
+impl Context {
+ fn new(version: Version, warn: Rc<RefCell<Box<dyn FnMut(LightWarning)>>>) -> Self {
+ Self { version, warn }
+ }
+ fn warn(&self, warning: LightWarning) {
+ (self.warn.borrow_mut())(warning);
+ }
+}
+
+#[binread]
+#[br(little, import(warn: Rc<RefCell<Box<dyn FnMut(LightWarning)>>>))]
+#[derive(Debug)]
+pub struct LightTable {
+ header: Header,
+ #[br(temp, calc(Context::new(header.version, warn)))]
+ context: Context,
+ #[br(args(&context))]
+ titles: Titles,
+ #[br(parse_with(parse_vec), args(&context))]
+ footnotes: Vec<Footnote>,
+ #[br(args(&context))]
+ areas: Areas,
+ #[br(parse_with(parse_counted))]
+ borders: Borders,
+ #[br(parse_with(parse_counted))]
+ print_settings: PrintSettings,
+ #[br(if(header.version == Version::V3), parse_with(parse_counted))]
+ table_settings: TableSettings,
+ #[br(if(header.version == Version::V1), temp)]
+ _ts: Option<Counted<Sponge>>,
+ #[br(args(&context))]
+ formats: Formats,
+ #[br(parse_with(parse_vec), args(&context))]
+ dimensions: Vec<Dimension>,
+ axes: Axes,
+ #[br(parse_with(parse_vec), args(&context))]
+ cells: Vec<Cell>,
+}
+
+impl LightTable {
+ fn decode_look(&self, encoding: &'static Encoding) -> Look {
+ Look {
+ name: self.table_settings.table_look.decode_optional(encoding),
+ hide_empty: self.table_settings.omit_empty,
+ row_label_position: if self.table_settings.show_row_labels_in_corner {
+ LabelPosition::Corner
+ } else {
+ LabelPosition::Nested
+ },
+ heading_widths: enum_map! {
+ HeadingRegion::Rows => self.header.min_row_heading_width as isize..=self.header.max_row_heading_width as isize,
+ HeadingRegion::Columns => self.header.min_column_heading_width as isize..=self.header.max_column_heading_width as isize,
+ },
+ footnote_marker_type: if self.table_settings.show_alphabetic_markers {
+ FootnoteMarkerType::Alphabetic
+ } else {
+ FootnoteMarkerType::Numeric
+ },
+ footnote_marker_position: if self.table_settings.footnote_marker_subscripts {
+ FootnoteMarkerPosition::Subscript
+ } else {
+ FootnoteMarkerPosition::Superscript
+ },
+ areas: self.areas.decode(encoding),
+ borders: self.borders.decode(),
+ print_all_layers: self.print_settings.alll_layers,
+ paginate_layers: self.print_settings.paginate_layers,
+ shrink_to_fit: enum_map! {
+ Axis2::X => self.print_settings.fit_width,
+ Axis2::Y => self.print_settings.fit_length,
+ },
+ show_continuations: [
+ self.print_settings.top_continuation,
+ self.print_settings.bottom_continuation,
+ ],
+ continuation: self
+ .print_settings
+ .continuation_string
+ .decode_optional(encoding),
+ n_orphan_lines: self.print_settings.n_orphan_lines,
+ }
+ }
+
+ pub fn decode(&self, warn: &mut dyn FnMut(LightWarning)) -> PivotTable {
+ let encoding = self.formats.encoding(warn);
+
+ let n1 = self.formats.n1();
+ let n2 = self.formats.n2();
+ let n3 = self.formats.n3();
+ let n3_inner = n3.and_then(|n3| n3.inner.as_ref());
+ let y1 = self.formats.y1();
+ let footnotes = self
+ .footnotes
+ .iter()
+ .map(|f| f.decode(encoding, &Footnotes::new()))
+ .collect();
+ let cells = self
+ .cells
+ .iter()
+ .map(|cell| {
+ (
+ PrecomputedIndex(cell.index as usize),
+ cell.value.decode(encoding, &footnotes),
+ )
+ })
+ .collect::<Vec<_>>();
+ let dimensions = self
+ .dimensions
+ .iter()
+ .map(|d| {
+ let mut root = Group::new(d.name.decode(encoding, &footnotes))
+ .with_show_label(!d.hide_dim_label);
+ for category in &d.categories {
+ category.decode(encoding, &footnotes, &mut root);
+ }
+ pivot::Dimension {
+ presentation_order: (0..root.len()).collect(), /*XXX*/
+ root,
+ hide_all_labels: d.hide_all_labels,
+ }
+ })
+ .collect::<Vec<_>>();
+ let dimensions = match self.axes.decode(dimensions) {
+ Ok(dimensions) => dimensions,
+ Err((warning, dimensions)) => {
+ warn(warning);
+ dimensions
+ .into_iter()
+ .map(|dimension| (Axis3::Y, dimension))
+ .collect()
+ }
+ };
+ let pivot_table = PivotTable::new(dimensions)
+ .with_style(PivotTableStyle {
+ look: Arc::new(self.decode_look(encoding)),
+ rotate_inner_column_labels: self.header.rotate_inner_column_labels,
+ rotate_outer_row_labels: self.header.rotate_outer_row_labels,
+ show_grid_lines: self.borders.show_grid_lines,
+ show_title: n1.map_or(true, |x1| x1.show_title != 10),
+ show_caption: n1.map_or(true, |x1| x1.show_caption),
+ show_values: n1.map_or(None, |x1| x1.show_values),
+ show_variables: n1.map_or(None, |x1| x1.show_variables),
+ sizing: self.table_settings.sizing.decode(
+ &self.formats.column_widths,
+ n2.map_or(&[], |x2| &x2.row_heights),
+ ),
+ settings: Arc::new(Settings {
+ epoch: self.formats.y0.epoch(),
+ decimal: self.formats.y0.decimal(warn),
+ leading_zero: y1.map_or(false, |y1| y1.include_leading_zero),
+ ccs: self.formats.custom_currency.decode(encoding, warn),
+ }),
+ grouping: {
+ let grouping = self.formats.y0.grouping;
+ b",.' ".contains(&grouping).then_some(grouping as char)
+ },
+ small: n3.map_or(0.0, |n3| n3.small),
+ weight_format: F40,
+ })
+ .with_metadata(PivotTableMetadata {
+ command_local: y1.map(|y1| y1.command_local.decode(encoding)),
+ command_c: y1.map(|y1| y1.command.decode(encoding)),
+ language: y1.map(|y1| y1.language.decode(encoding)),
+ locale: y1.map(|y1| y1.locale.decode(encoding)),
+ dataset: n3_inner.and_then(|strings| strings.dataset.decode_optional(encoding)),
+ datafile: n3_inner.and_then(|strings| strings.datafile.decode_optional(encoding)),
+ date: n3_inner.and_then(|inner| {
+ if inner.date != 0 {
+ DateTime::from_timestamp(inner.date as i64, 0).map(|dt| dt.naive_utc())
+ } else {
+ None
+ }
+ }),
+ title: Some(Box::new(self.titles.title.decode(encoding, &footnotes))),
+ subtype: Some(Box::new(self.titles.subtype.decode(encoding, &footnotes))),
+ corner_text: self
+ .titles
+ .corner_text
+ .as_ref()
+ .map(|corner| Box::new(corner.decode(encoding, &footnotes))),
+ caption: self
+ .titles
+ .caption
+ .as_ref()
+ .map(|caption| Box::new(caption.decode(encoding, &footnotes))),
+ notes: self.table_settings.notes.decode_optional(encoding),
+ notes_unexpanded: n3_inner
+ .and_then(|inner| inner.notes_unexpanded.decode_optional(encoding)),
+ })
+ .with_footnotes(footnotes)
+ .with_data(cells);
+ pivot_table
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Header {
+ #[br(magic = b"\x01\0")]
+ version: Version,
+ #[br(parse_with(parse_bool), temp)]
+ _x0: bool,
+ #[br(parse_with(parse_bool), temp)]
+ _x1: bool,
+ #[br(parse_with(parse_bool))]
+ rotate_inner_column_labels: bool,
+ #[br(parse_with(parse_bool))]
+ rotate_outer_row_labels: bool,
+ #[br(parse_with(parse_bool), temp)]
+ _x2: bool,
+ #[br(temp)]
+ _x3: i32,
+ min_column_heading_width: u32,
+ max_column_heading_width: u32,
+ min_row_heading_width: u32,
+ max_row_heading_width: u32,
+ #[br(temp)]
+ _table_id: i64,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum Version {
+ #[br(magic = 1u32)]
+ V1,
+ #[br(magic = 3u32)]
+ V3,
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct Titles {
+ #[br(args(context))]
+ title: Value,
+ #[br(temp)]
+ _1: Optional<One>,
+ #[br(args(context))]
+ subtype: Value,
+ #[br(temp)]
+ _2: Optional<One>,
+ #[br(magic = b'1')]
+ #[br(args(context))]
+ user_title: Value,
+ #[br(temp)]
+ _3: Optional<One>,
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ corner_text: Option<Value>,
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ caption: Option<Value>,
+}
+
+#[binread]
+#[br(little, magic = 1u8)]
+#[derive(Debug)]
+struct One;
+
+#[binread]
+#[br(little, magic = 0u8)]
+#[derive(Debug)]
+struct Zero;
+
+#[binrw::parser(reader, endian)]
+pub fn parse_explicit_optional<'a, T, A>(args: A, ...) -> BinResult<Option<T>>
+where
+ T: BinRead<Args<'a> = A>,
+{
+ let byte = <u8>::read_options(reader, endian, ())?;
+ match byte {
+ b'1' => Ok(Some(T::read_options(reader, endian, args)?)),
+ b'X' => Ok(None),
+ _ => Err(BinError::NoVariantMatch {
+ pos: reader.stream_position()? - 1,
+ }),
+ }
+}
+
+#[binrw::parser(reader, endian)]
+pub(super) fn parse_vec<'a, T, A>(inner: A, ...) -> BinResult<Vec<T>>
+where
+ T: BinRead<Args<'a> = A>,
+ A: Clone,
+ T: 'static,
+{
+ let count = u32::read_options(reader, endian, ())? as usize;
+ let mut vec = Vec::with_capacity(count);
+ for _ in 0..count {
+ vec.push(T::read_options(reader, endian, inner.clone())?);
+ }
+ Ok(vec)
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct Footnote {
+ #[br(args(context))]
+ text: Value,
+ #[br(parse_with(parse_explicit_optional))]
+ #[br(args(context))]
+ marker: Option<Value>,
+ show: i32,
+}
+
+impl Footnote {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Footnote {
+ pivot::Footnote::new(self.text.decode(encoding, footnotes))
+ .with_marker(self.marker.as_ref().map(|m| m.decode(encoding, footnotes)))
+ .with_show(self.show > 0)
+ }
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct Areas {
+ #[br(temp)]
+ _1: Optional<Zero>,
+ #[br(args(context))]
+ areas: [Area; 8],
+}
+
+impl Areas {
+ fn decode(&self, encoding: &'static Encoding) -> EnumMap<look::Area, AreaStyle> {
+ EnumMap::from_fn(|area| {
+ let index = match area {
+ look::Area::Title => 0,
+ look::Area::Caption => 1,
+ look::Area::Footer => 2,
+ look::Area::Corner => 3,
+ look::Area::Labels(Axis2::X) => 4,
+ look::Area::Labels(Axis2::Y) => 5,
+ look::Area::Data(_) => 6,
+ look::Area::Layers => 7,
+ };
+ let data_row = match area {
+ look::Area::Data(row) => row,
+ _ => RowParity::default(),
+ };
+ self.areas[index].decode(encoding, data_row)
+ })
+ }
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_color() -> BinResult<Color> {
+ let pos = reader.stream_position()?;
+ let string = U32String::read_options(reader, endian, ())?;
+ let string = string.decode(WINDOWS_1252);
+ if string.is_empty() {
+ Ok(Color::BLACK)
+ } else {
+ Color::from_str(&string).map_err(|error| binrw::Error::Custom {
+ pos,
+ err: Box::new(error),
+ })
+ }
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct Area {
+ #[br(temp)]
+ _index: u8,
+ #[br(magic = b'1')]
+ typeface: U32String,
+ size: f32,
+ style: i32,
+ #[br(parse_with(parse_bool))]
+ underline: bool,
+ halign: i32,
+ valign: i32,
+ #[br(parse_with(parse_color))]
+ fg: Color,
+ #[br(parse_with(parse_color))]
+ bg: Color,
+ #[br(parse_with(parse_bool))]
+ alternate: bool,
+ #[br(parse_with(parse_color))]
+ alt_fg: Color,
+ #[br(parse_with(parse_color))]
+ alt_bg: Color,
+ #[br(if(context.version == Version::V3))]
+ margins: Margins,
+}
+
+impl Area {
+ fn decode(&self, encoding: &'static Encoding, data_row: RowParity) -> AreaStyle {
+ AreaStyle {
+ cell_style: look::CellStyle {
+ horz_align: match self.halign {
+ 0 => Some(HorzAlign::Center),
+ 2 => Some(HorzAlign::Left),
+ 4 => Some(HorzAlign::Right),
+ _ => None,
+ },
+ vert_align: match self.valign {
+ 0 => VertAlign::Middle,
+ 3 => VertAlign::Bottom,
+ _ => VertAlign::Top,
+ },
+ margins: enum_map! {
+ Axis2::X => [self.margins.left_margin, self.margins.right_margin],
+ Axis2::Y => [self.margins.top_margin, self.margins.bottom_margin]
+ },
+ },
+ font_style: look::FontStyle {
+ bold: (self.style & 1) != 0,
+ italic: (self.style & 2) != 0,
+ underline: self.underline,
+ font: self.typeface.decode(encoding),
+ fg: if data_row == RowParity::Odd && self.alternate {
+ self.alt_fg
+ } else {
+ self.fg
+ },
+ bg: if data_row == RowParity::Odd && self.alternate {
+ self.alt_bg
+ } else {
+ self.bg
+ },
+ size: (self.size / 1.33) as i32,
+ },
+ }
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug, Default)]
+struct Margins {
+ left_margin: i32,
+ right_margin: i32,
+ top_margin: i32,
+ bottom_margin: i32,
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug)]
+struct Borders {
+ #[br(magic(1u32), parse_with(parse_vec))]
+ borders: Vec<Border>,
+
+ #[br(parse_with(parse_bool))]
+ show_grid_lines: bool,
+
+ #[br(temp, magic(b"\0\0\0"))]
+ _1: (),
+}
+
+impl Borders {
+ fn decode(&self) -> EnumMap<look::Border, look::BorderStyle> {
+ let mut borders = look::Border::default_borders();
+ for border in &self.borders {
+ if let Some((border, style)) = border.decode() {
+ borders[border] = style;
+ } else {
+ // warning
+ }
+ }
+ borders
+ }
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug)]
+struct Border {
+ #[br(map(|index: u32| index as usize))]
+ index: usize,
+ stroke: i32,
+ color: u32,
+}
+
+impl Border {
+ fn decode(&self) -> Option<(look::Border, look::BorderStyle)> {
+ let border = match self.index {
+ 0 => look::Border::Title,
+ 1 => look::Border::OuterFrame(BoxBorder::Left),
+ 2 => look::Border::OuterFrame(BoxBorder::Top),
+ 3 => look::Border::OuterFrame(BoxBorder::Right),
+ 4 => look::Border::OuterFrame(BoxBorder::Bottom),
+ 5 => look::Border::InnerFrame(BoxBorder::Left),
+ 6 => look::Border::InnerFrame(BoxBorder::Top),
+ 7 => look::Border::InnerFrame(BoxBorder::Right),
+ 8 => look::Border::InnerFrame(BoxBorder::Bottom),
+ 9 => look::Border::DataLeft,
+ 10 => look::Border::DataLeft,
+ 11 => look::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ 12 => look::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::Y)),
+ 13 => look::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ 14 => look::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::Y)),
+ 15 => look::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ 16 => look::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::Y)),
+ 17 => look::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ 18 => look::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::Y)),
+ _ => return None,
+ };
+
+ let stroke = match self.stroke {
+ 0 => Stroke::None,
+ 2 => Stroke::Dashed,
+ 3 => Stroke::Thick,
+ 4 => Stroke::Thin,
+ 6 => Stroke::Double,
+ _ => Stroke::Solid,
+ };
+
+ let color = Color::new(
+ (self.color >> 16) as u8,
+ (self.color >> 8) as u8,
+ self.color as u8,
+ )
+ .with_alpha((self.color >> 24) as u8);
+
+ Some((border, look::BorderStyle { stroke, color }))
+ }
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug)]
+struct PrintSettings {
+ #[br(magic = b"\0\0\0\x01")]
+ #[br(parse_with(parse_bool))]
+ alll_layers: bool,
+ #[br(parse_with(parse_bool))]
+ paginate_layers: bool,
+ #[br(parse_with(parse_bool))]
+ fit_width: bool,
+ #[br(parse_with(parse_bool))]
+ fit_length: bool,
+ #[br(parse_with(parse_bool))]
+ top_continuation: bool,
+ #[br(parse_with(parse_bool))]
+ bottom_continuation: bool,
+ #[br(map(|n: u32| n as usize))]
+ n_orphan_lines: usize,
+ continuation_string: U32String,
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug, Default)]
+struct TableSettings {
+ #[br(temp, magic = 1u32)]
+ _x5: i32,
+ current_layer: i32,
+ #[br(parse_with(parse_bool))]
+ omit_empty: bool,
+ #[br(parse_with(parse_bool))]
+ show_row_labels_in_corner: bool,
+ #[br(parse_with(parse_bool))]
+ show_alphabetic_markers: bool,
+ #[br(parse_with(parse_bool))]
+ footnote_marker_subscripts: bool,
+ #[br(temp)]
+ _x6: u8,
+ #[br(big, parse_with(parse_counted))]
+ sizing: Sizing,
+ notes: U32String,
+ table_look: U32String,
+ #[br(temp)]
+ _sponge: Sponge,
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug, Default)]
+struct Sizing {
+ #[br(parse_with(parse_vec))]
+ row_breaks: Vec<u32>,
+ #[br(parse_with(parse_vec))]
+ column_breaks: Vec<u32>,
+ #[br(parse_with(parse_vec))]
+ row_keeps: Vec<(i32, i32)>,
+ #[br(parse_with(parse_vec))]
+ column_keeps: Vec<(i32, i32)>,
+ #[br(parse_with(parse_vec))]
+ row_point_keeps: Vec<[i32; 3]>,
+ #[br(parse_with(parse_vec))]
+ column_point_keeps: Vec<[i32; 3]>,
+}
+
+impl Sizing {
+ fn decode(
+ &self,
+ column_widths: &[i32],
+ row_heights: &[i32],
+ ) -> EnumMap<Axis2, Option<Box<look::Sizing>>> {
+ fn decode_axis(
+ widths: &[i32],
+ breaks: &[u32],
+ keeps: &[(i32, i32)],
+ ) -> Option<Box<look::Sizing>> {
+ if widths.is_empty() && breaks.is_empty() && keeps.is_empty() {
+ None
+ } else {
+ Some(Box::new(look::Sizing {
+ widths: widths.into(),
+ breaks: breaks.into_iter().map(|b| *b as usize).collect(),
+ keeps: keeps
+ .into_iter()
+ .map(|(low, high)| *low as usize..*high as usize)
+ .collect(),
+ }))
+ }
+ }
+
+ enum_map! {
+ Axis2::X => decode_axis(column_widths, &self.column_breaks, &self.column_keeps),
+ Axis2::Y => decode_axis(row_heights, &self.row_breaks, &self.row_keeps),
+ }
+ }
+}
+
+#[binread]
+#[derive(Default)]
+pub(super) struct U32String {
+ #[br(parse_with(parse_vec))]
+ string: Vec<u8>,
+}
+
+impl U32String {
+ pub(super) fn decode(&self, encoding: &'static Encoding) -> String {
+ if let Ok(string) = str::from_utf8(&self.string) {
+ string.into()
+ } else {
+ encoding
+ .decode_without_bom_handling(&self.string)
+ .0
+ .into_owned()
+ }
+ }
+ pub(super) fn decode_optional(&self, encoding: &'static Encoding) -> Option<String> {
+ let string = self.decode(encoding);
+ if !string.is_empty() {
+ Some(string)
+ } else {
+ None
+ }
+ }
+}
+
+impl Debug for U32String {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let s = self.string.iter().map(|c| *c as char).collect::<String>();
+ write!(f, "{s:?}")
+ }
+}
+
+#[derive(Clone, Debug, Default)]
+struct Counted<T>(T);
+
+impl<T> Deref for Counted<T> {
+ type Target = T;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl<T> BinRead for Counted<T>
+where
+ T: BinRead,
+{
+ type Args<'a> = T::Args<'a>;
+
+ fn read_options<R: Read + Seek>(
+ reader: &mut R,
+ endian: binrw::Endian,
+ args: Self::Args<'_>,
+ ) -> BinResult<Self> {
+ let count = u32::read_options(reader, endian, ())? as u64;
+ let start = reader.stream_position()?;
+ let end = start + count;
+ let mut inner = reader.take_seek(count);
+ let result = <T>::read_options(&mut inner, Endian::Little, args)?;
+ let pos = inner.stream_position()?;
+ if pos != end {
+ let consumed = pos - start;
+ return Err(binrw::Error::Custom {
+ pos,
+ err: Box::new(format!(
+ "counted data not exhausted (consumed {consumed} bytes out of {count})"
+ )),
+ });
+ }
+ Ok(Self(result))
+ }
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_counted<T, A>(args: A, ...) -> BinResult<T>
+where
+ for<'a> T: BinRead<Args<'a> = A>,
+ A: Clone,
+ T: 'static,
+{
+ Ok(<Counted<T>>::read_options(reader, endian, args)?.0)
+}
+
+/// `BinRead` for `Option<T>` always requires the value to be there. This
+/// instead tries to read it and falls back to None if there's no match.
+#[derive(Clone, Debug)]
+struct Optional<T>(Option<T>);
+
+impl<T> Default for Optional<T> {
+ fn default() -> Self {
+ Self(None)
+ }
+}
+
+impl<T> Deref for Optional<T> {
+ type Target = Option<T>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl<T> BinRead for Optional<T>
+where
+ T: BinRead,
+{
+ type Args<'a> = T::Args<'a>;
+
+ fn read_options<R: Read + Seek>(
+ reader: &mut R,
+ endian: binrw::Endian,
+ args: Self::Args<'_>,
+ ) -> BinResult<Self> {
+ let start = reader.stream_position()?;
+ let result = <T>::read_options(reader, endian, args).ok();
+ if result.is_none() {
+ reader.seek(std::io::SeekFrom::Start(start))?;
+ }
+ Ok(Self(result))
+ }
+}
+
+#[binread]
+#[br(little)]
+#[br(import(context: &Context))]
+#[derive(Debug)]
+struct Formats {
+ #[br(parse_with(parse_vec))]
+ column_widths: Vec<i32>,
+ locale: U32String,
+ current_layer: i32,
+ #[br(temp, parse_with(parse_bool))]
+ _x7: bool,
+ #[br(temp, parse_with(parse_bool))]
+ _x8: bool,
+ #[br(temp, parse_with(parse_bool))]
+ _x9: bool,
+ y0: Y0,
+ custom_currency: CustomCurrency,
+ #[br(if(context.version == Version::V1))]
+ v1: Counted<Optional<N0>>,
+ #[br(if(context.version == Version::V3))]
+ v3: Option<Counted<FormatsV3>>,
+}
+
+impl Formats {
+ fn y1(&self) -> Option<&Y1> {
+ self.v1
+ .as_ref()
+ .map(|n0| &n0.y1)
+ .or_else(|| self.v3.as_ref().map(|v3| &v3.n3.y1))
+ }
+
+ fn n1(&self) -> Option<&N1> {
+ self.v3.as_ref().map(|v3| &v3.n1_n2.x1)
+ }
+
+ fn n2(&self) -> Option<&N2> {
+ self.v3.as_ref().map(|v3| &v3.n1_n2.x2)
+ }
+
+ fn n3(&self) -> Option<&N3> {
+ self.v3.as_ref().map(|v3| &v3.n3)
+ }
+
+ fn charset(&self) -> Option<&U32String> {
+ self.y1().map(|y1| &y1.charset)
+ }
+
+ fn encoding(&self, warn: &mut dyn FnMut(LightWarning)) -> &'static Encoding {
+ if let Some(charset) = self.charset() {
+ if let Some(encoding) = Encoding::for_label(&charset.string) {
+ return encoding;
+ }
+ warn(LightWarning::UnknownEncoding(
+ String::from_utf8_lossy(&charset.string).into_owned(),
+ ));
+ }
+
+ if let Ok(locale) = str::from_utf8(&self.locale.string)
+ && let Some(dot) = locale.find('.')
+ && let Some(encoding) = Encoding::for_label(locale[dot + 1..].as_bytes())
+ {
+ encoding
+ } else {
+ WINDOWS_1252
+ }
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct FormatsV3 {
+ #[br(parse_with(parse_counted))]
+ n1_n2: N1N2,
+ #[br(parse_with(parse_counted))]
+ n3: N3,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N1N2 {
+ x1: N1,
+ #[br(parse_with(parse_counted))]
+ x2: N2,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N0 {
+ #[br(temp)]
+ _bytes: [u8; 14],
+ y1: Y1,
+ _y2: Y2,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Y1 {
+ command: U32String,
+ command_local: U32String,
+ language: U32String,
+ charset: U32String,
+ locale: U32String,
+ #[br(temp, parse_with(parse_bool))]
+ _x10: bool,
+ #[br(parse_with(parse_bool))]
+ include_leading_zero: bool,
+ #[br(temp, parse_with(parse_bool))]
+ _x12: bool,
+ #[br(temp, parse_with(parse_bool))]
+ _x13: bool,
+ _y0: Y0,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Y2 {
+ custom_currency: CustomCurrency,
+ missing: u8,
+ #[br(temp, parse_with(parse_bool))]
+ _x17: bool,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N1 {
+ #[br(temp, parse_with(parse_bool))]
+ _x14: bool,
+ show_title: u8,
+ #[br(temp, parse_with(parse_bool))]
+ _x16: bool,
+ _lang: u8,
+ #[br(parse_with(parse_show), args(|warning| eprintln!("{warning}")))]
+ show_variables: Option<Show>,
+ #[br(parse_with(parse_show), args(|warning| eprintln!("{warning}")))]
+ show_values: Option<Show>,
+ #[br(temp)]
+ _x18: i32,
+ #[br(temp)]
+ _x19: i32,
+ #[br(temp)]
+ _zeros: [u8; 17],
+ #[br(temp, parse_with(parse_bool))]
+ _x20: bool,
+ #[br(parse_with(parse_bool))]
+ show_caption: bool,
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_show<F>(mut warn: F) -> BinResult<Option<Show>>
+where
+ F: FnMut(LightWarning),
+{
+ match <u8>::read_options(reader, endian, ())? {
+ 0 => Ok(None),
+ 1 => Ok(Some(Show::Value)),
+ 2 => Ok(Some(Show::Label)),
+ 3 => Ok(Some(Show::Both)),
+ other => {
+ warn(LightWarning::UnknownShow(other));
+ Ok(None)
+ }
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N2 {
+ #[br(parse_with(parse_vec))]
+ row_heights: Vec<i32>,
+ #[br(parse_with(parse_vec))]
+ style_map: Vec<(i64, i16)>,
+ #[br(parse_with(parse_vec))]
+ styles: Vec<StylePair>,
+ #[br(parse_with(parse_counted))]
+ tail: Optional<[u8; 8]>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N3 {
+ #[br(temp, magic = b"\x01\0")]
+ _x21: u8,
+ #[br(magic = b"\0\0\0")]
+ y1: Y1,
+ small: f64,
+ #[br(magic = 1u8, temp)]
+ _one: (),
+ inner: Optional<N3Inner>,
+ y2: Y2,
+ #[br(temp)]
+ _tail: Optional<N3Tail>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N3Inner {
+ dataset: U32String,
+ datafile: U32String,
+ notes_unexpanded: U32String,
+ date: i32,
+ #[br(magic = 0u32, temp)]
+ _tail: (),
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N3Tail {
+ #[br(temp)]
+ _x22: i32,
+ #[br(temp, assert(_zero == 0))]
+ _zero: i32,
+ #[br(temp, assert(_x25.is_none_or(|x25| x25 == 0 || x25 == 1)))]
+ _x25: Optional<u8>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Y0 {
+ epoch: i32,
+ decimal: u8,
+ grouping: u8,
+}
+
+impl Y0 {
+ fn epoch(&self) -> Epoch {
+ if (1000..=9999).contains(&self.epoch) {
+ Epoch(self.epoch)
+ } else {
+ Epoch::default()
+ }
+ }
+
+ fn decimal(&self, warn: &mut dyn FnMut(LightWarning)) -> Decimal {
+ let c = self.decimal as char;
+ match Decimal::try_from(c) {
+ Ok(decimal) => decimal,
+ Err(_) => {
+ if c != '\0' {
+ warn(LightWarning::InvalidDecimal(c));
+ }
+ Decimal::default()
+ }
+ }
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct CustomCurrency {
+ #[br(parse_with(parse_vec))]
+ ccs: Vec<U32String>,
+}
+
+impl CustomCurrency {
+ fn decode(
+ &self,
+ encoding: &'static Encoding,
+ warn: &mut dyn FnMut(LightWarning),
+ ) -> EnumMap<CC, Option<Box<NumberStyle>>> {
+ let mut ccs = EnumMap::default();
+ for (cc, string) in enum_iterator::all().zip(&self.ccs) {
+ let string = string.decode(encoding);
+ if !string.is_empty() {
+ if let Ok(style) = NumberStyle::from_str(&string) {
+ ccs[cc] = Some(Box::new(style));
+ } else {
+ warn(LightWarning::InvalidCustomCurrency(string));
+ }
+ }
+ }
+ ccs
+ }
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct ValueNumber {
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ mods: Option<ValueMods>,
+ #[br(parse_with(parse_format), args(context))]
+ format: Format,
+ x: f64,
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct ValueVarNumber {
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ mods: Option<ValueMods>,
+ #[br(parse_with(parse_format), args(context))]
+ format: Format,
+ x: f64,
+ var_name: U32String,
+ value_label: U32String,
+ #[br(parse_with(parse_show), args(|warning| eprintln!("{warning}")))]
+ show: Option<Show>,
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct ValueText {
+ local: U32String,
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ mods: Option<ValueMods>,
+ id: U32String,
+ c: U32String,
+ #[br(parse_with(parse_bool))]
+ fixed: bool,
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct ValueString {
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ mods: Option<ValueMods>,
+ #[br(parse_with(parse_format), args(context))]
+ format: Format,
+ value_label: U32String,
+ var_name: U32String,
+ #[br(parse_with(parse_show), args(|warning| eprintln!("{warning}")))]
+ show: Option<Show>,
+ s: U32String,
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct ValueVarName {
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ mods: Option<ValueMods>,
+ var_name: U32String,
+ var_label: U32String,
+ #[br(parse_with(parse_show), args(|warning| eprintln!("{warning}")))]
+ show: Option<Show>,
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct ValueFixedText {
+ local: U32String,
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ mods: Option<ValueMods>,
+ id: U32String,
+ c: U32String,
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct ValueTemplate {
+ #[br(parse_with(parse_explicit_optional), args(context))]
+ mods: Option<ValueMods>,
+ template: U32String,
+ #[br(parse_with(parse_vec), args(context))]
+ args: Vec<Argument>,
+}
+
+#[derive(Debug)]
+enum Value {
+ Number(ValueNumber),
+ VarNumber(ValueVarNumber),
+ Text(ValueText),
+ String(ValueString),
+ VarName(ValueVarName),
+ FixedText(ValueFixedText),
+ Template(ValueTemplate),
+}
+
+impl BinRead for Value {
+ type Args<'a> = (&'a Context,);
+
+ fn read_options<R: Read + Seek>(
+ reader: &mut R,
+ endian: Endian,
+ args: Self::Args<'_>,
+ ) -> BinResult<Self> {
+ let start = reader.stream_position()?;
+ let kind = loop {
+ let x = <u8>::read_options(reader, endian, ())?;
+ if x != 0 {
+ break x;
+ }
+ };
+ match kind {
+ 1 => ValueNumber::read_options(reader, endian, args).map(Self::Number),
+ 2 => Ok(Self::VarNumber(ValueVarNumber::read_options(
+ reader, endian, args,
+ )?)),
+ 3 => Ok(Self::Text(ValueText::read_options(reader, endian, args)?)),
+ 4 => Ok(Self::String(ValueString::read_options(
+ reader, endian, args,
+ )?)),
+ 5 => Ok(Self::VarName(ValueVarName::read_options(
+ reader, endian, args,
+ )?)),
+ 6 => Ok(Self::FixedText(ValueFixedText::read_options(
+ reader, endian, args,
+ )?)),
+ b'1' | b'X' => {
+ reader.seek(std::io::SeekFrom::Current(-1))?;
+ Ok(Self::Template(ValueTemplate::read_options(
+ reader, endian, args,
+ )?))
+ }
+ _ => Err(BinError::NoVariantMatch { pos: start }),
+ }
+ .map_err(|e| e.with_message(format!("while parsing Value starting at offset {start:#x}")))
+ }
+}
+
+pub(super) fn decode_format(raw: u32, warn: &mut dyn FnMut(LightWarning)) -> Format {
+ if raw == 0 || raw == 0x10000 || raw == 1 {
+ return F40_2;
+ }
+
+ let raw_type = (raw >> 16) as u16;
+ let type_ = if raw_type >= 40 {
+ Type::F
+ } else if let Ok(type_) = Type::try_from(raw_type) {
+ type_
+ } else {
+ warn(LightWarning::InvalidFormat(raw_type));
+ Type::F
+ };
+ let w = ((raw >> 8) & 0xff) as Width;
+ let d = raw as Decimals;
+
+ UncheckedFormat::new(type_, w, d).fix()
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_format(context: &Context) -> BinResult<Format> {
+ Ok(decode_format(
+ u32::read_options(reader, endian, ())?,
+ &mut *context.warn.borrow_mut(),
+ ))
+}
+
+impl ValueNumber {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> value::Value {
+ value::Value::new_number((self.x != -f64::MAX).then_some(self.x))
+ .with_format(self.format)
+ .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+ }
+}
+
+impl ValueVarNumber {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> value::Value {
+ value::Value::new_number((self.x != -f64::MAX).then_some(self.x))
+ .with_format(self.format)
+ .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+ .with_value_label(self.value_label.decode_optional(encoding))
+ .with_variable_name(Some(self.var_name.decode(encoding)))
+ .with_show_value_label(self.show)
+ }
+}
+
+impl ValueText {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> value::Value {
+ value::Value::new_general_text(
+ self.local.decode(encoding),
+ self.c.decode(encoding),
+ self.id.decode(encoding),
+ !self.fixed,
+ )
+ .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+ }
+}
+
+impl ValueString {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> value::Value {
+ value::Value::new(pivot::value::ValueInner::Datum(DatumValue {
+ datum: Datum::new_utf8(self.s.decode(encoding)),
+ format: self.format,
+ show: self.show,
+ honor_small: false,
+ variable: self.var_name.decode_optional(encoding),
+ value_label: self.value_label.decode_optional(encoding),
+ }))
+ .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+ }
+}
+
+impl ValueVarName {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> value::Value {
+ value::Value::new(pivot::value::ValueInner::Variable(VariableValue {
+ show: self.show,
+ var_name: self.var_name.decode(encoding),
+ variable_label: self.var_label.decode_optional(encoding),
+ }))
+ .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+ }
+}
+impl ValueFixedText {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> value::Value {
+ value::Value::new_general_text(
+ self.local.decode(encoding),
+ self.c.decode(encoding),
+ self.id.decode(encoding),
+ false,
+ )
+ .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+ }
+}
+
+impl ValueTemplate {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> value::Value {
+ value::Value::new(pivot::value::ValueInner::Template(TemplateValue {
+ args: self
+ .args
+ .iter()
+ .map(|argument| argument.decode(encoding, footnotes))
+ .collect(),
+ localized: self.template.decode(encoding),
+ id: self
+ .mods
+ .as_ref()
+ .and_then(|mods| mods.template_id(encoding)),
+ }))
+ .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+ }
+}
+
+impl Value {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> value::Value {
+ match self {
+ Value::Number(number) => number.decode(encoding, footnotes),
+ Value::VarNumber(var_number) => var_number.decode(encoding, footnotes),
+ Value::Text(text) => text.decode(encoding, footnotes),
+ Value::String(string) => string.decode(encoding, footnotes),
+ Value::VarName(var_name) => var_name.decode(encoding, footnotes),
+ Value::FixedText(fixed_text) => fixed_text.decode(encoding, footnotes),
+ Value::Template(template) => template.decode(encoding, footnotes),
+ }
+ }
+}
+
+#[derive(Debug)]
+struct Argument(Vec<Value>);
+
+impl BinRead for Argument {
+ type Args<'a> = (&'a Context,);
+
+ fn read_options<R: Read + Seek>(
+ reader: &mut R,
+ endian: Endian,
+ context: Self::Args<'_>,
+ ) -> BinResult<Self> {
+ let count = u32::read_options(reader, endian, ())? as usize;
+ if count == 0 {
+ Ok(Self(vec![Value::read_options(reader, endian, context)?]))
+ } else {
+ let zero = u32::read_options(reader, endian, ())?;
+ assert_eq!(zero, 0);
+ let values = <Vec<_>>::read_options(
+ reader,
+ endian,
+ VecArgs {
+ count,
+ inner: context,
+ },
+ )?;
+ Ok(Self(values))
+ }
+ }
+}
+
+impl Argument {
+ fn decode(
+ &self,
+ encoding: &'static Encoding,
+ footnotes: &pivot::Footnotes,
+ ) -> Vec<value::Value> {
+ self.0
+ .iter()
+ .map(|value| value.decode(encoding, footnotes))
+ .collect()
+ }
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct ValueMods {
+ #[br(parse_with(parse_vec))]
+ refs: Vec<i16>,
+ #[br(parse_with(parse_vec))]
+ subscripts: Vec<U32String>,
+ #[br(if(context.version == Version::V1))]
+ v1: Option<ValueModsV1>,
+ #[br(if(context.version == Version::V3), parse_with(parse_counted))]
+ v3: ValueModsV3,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug, Default)]
+struct ValueModsV1 {
+ #[br(temp, magic(0u8), assert(_1 == 1 || _1 == 2))]
+ _1: i32,
+ #[br(temp)]
+ _0: Optional<Zero>,
+ #[br(temp)]
+ _1: Optional<Zero>,
+ #[br(temp)]
+ _2: i32,
+ #[br(temp)]
+ _3: Optional<Zero>,
+ #[br(temp)]
+ _4: Optional<Zero>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug, Default)]
+struct ValueModsV3 {
+ #[br(parse_with(parse_counted))]
+ template_string: Optional<TemplateString>,
+ style_pair: StylePair,
+}
+
+impl ValueMods {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> ValueStyle {
+ let font_style = self
+ .v3
+ .style_pair
+ .font_style
+ .as_ref()
+ .map(|font_style| look::FontStyle {
+ bold: font_style.bold,
+ italic: font_style.italic,
+ underline: font_style.underline,
+ font: font_style.typeface.decode(encoding),
+ fg: font_style.fg,
+ bg: font_style.bg,
+ size: (font_style.size as i32) * 4 / 3,
+ });
+ let cell_style = self
+ .v3
+ .style_pair
+ .cell_style
+ .as_ref()
+ .map(|cell_style| look::CellStyle {
+ horz_align: match cell_style.halign {
+ 0 => Some(HorzAlign::Center),
+ 2 => Some(HorzAlign::Left),
+ 4 => Some(HorzAlign::Right),
+ 6 => Some(HorzAlign::Decimal {
+ offset: cell_style.decimal_offset,
+ }),
+ _ => None,
+ },
+ vert_align: match cell_style.valign {
+ 0 => VertAlign::Middle,
+ 3 => VertAlign::Bottom,
+ _ => VertAlign::Top,
+ },
+ margins: enum_map! {
+ Axis2::X => [cell_style.left_margin as i32, cell_style.right_margin as i32],
+ Axis2::Y => [cell_style.top_margin as i32, cell_style.bottom_margin as i32],
+ },
+ });
+ ValueStyle {
+ cell_style,
+ font_style,
+ subscripts: self.subscripts.iter().map(|s| s.decode(encoding)).collect(),
+ footnotes: self
+ .refs
+ .iter()
+ .flat_map(|index| footnotes.get(*index as usize))
+ .cloned()
+ .collect(),
+ }
+ }
+ fn decode_optional(
+ mods: &Option<Self>,
+ encoding: &'static Encoding,
+ footnotes: &pivot::Footnotes,
+ ) -> Option<Box<pivot::value::ValueStyle>> {
+ mods.as_ref()
+ .map(|mods| Box::new(mods.decode(encoding, footnotes)))
+ }
+ fn template_id(&self, encoding: &'static Encoding) -> Option<String> {
+ self.v3
+ .template_string
+ .as_ref()
+ .and_then(|template_string| template_string.id.as_ref())
+ .map(|s| s.decode(encoding))
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct TemplateString {
+ #[br(parse_with(parse_counted), temp)]
+ _sponge: Sponge,
+ #[br(parse_with(parse_explicit_optional))]
+ id: Option<U32String>,
+}
+
+#[derive(Debug, Default)]
+struct Sponge;
+
+impl BinRead for Sponge {
+ type Args<'a> = ();
+
+ fn read_options<R: Read + Seek>(reader: &mut R, _endian: Endian, _args: ()) -> BinResult<Self> {
+ let mut buf = [0; 32];
+ while reader.read(&mut buf)? > 0 {}
+ Ok(Self)
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug, Default)]
+struct StylePair {
+ #[br(parse_with(parse_explicit_optional))]
+ font_style: Option<FontStyle>,
+ #[br(parse_with(parse_explicit_optional))]
+ cell_style: Option<CellStyle>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct FontStyle {
+ #[br(parse_with(parse_bool))]
+ bold: bool,
+ #[br(parse_with(parse_bool))]
+ italic: bool,
+ #[br(parse_with(parse_bool))]
+ underline: bool,
+ #[br(parse_with(parse_bool))]
+ show: bool,
+ #[br(parse_with(parse_color))]
+ fg: Color,
+ #[br(parse_with(parse_color))]
+ bg: Color,
+ typeface: U32String,
+ size: u8,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct CellStyle {
+ halign: i32,
+ valign: i32,
+ decimal_offset: f64,
+ left_margin: i16,
+ right_margin: i16,
+ top_margin: i16,
+ bottom_margin: i16,
+}
+
+#[binread]
+#[br(little)]
+#[br(import(context: &Context))]
+#[derive(Debug)]
+struct Dimension {
+ #[br(args(context))]
+ name: Value,
+ #[br(temp)]
+ _x1: u8,
+ #[br(temp)]
+ _x2: u8,
+ #[br(temp)]
+ _x3: u32,
+ #[br(parse_with(parse_bool))]
+ hide_dim_label: bool,
+ #[br(parse_with(parse_bool))]
+ hide_all_labels: bool,
+ #[br(magic(1u8), temp)]
+ _dim_index: i32,
+ #[br(parse_with(parse_vec), args(context))]
+ categories: Vec<Category>,
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct Category {
+ #[br(args(context))]
+ name: Value,
+ #[br(args(context))]
+ child: Child,
+}
+
+impl Category {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &Footnotes, group: &mut pivot::Group) {
+ let name = self.name.decode(encoding, footnotes);
+ match &self.child {
+ Child::Leaf { leaf_index: _ } => {
+ group.push(pivot::Leaf::new(name));
+ }
+ Child::Group {
+ merge: true,
+ subcategories,
+ } => {
+ for subcategory in subcategories {
+ subcategory.decode(encoding, footnotes, group);
+ }
+ }
+ Child::Group {
+ merge: false,
+ subcategories,
+ } => {
+ let mut subgroup = Group::new(name).with_label_shown();
+ for subcategory in subcategories {
+ subcategory.decode(encoding, footnotes, &mut subgroup);
+ }
+ group.push(subgroup);
+ }
+ }
+ }
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+enum Child {
+ Leaf {
+ #[br(magic(0u16), parse_with(parse_bool), temp)]
+ _x24: bool,
+ #[br(magic(b"\x02\0\0\0"))]
+ leaf_index: u32,
+ #[br(magic(0u32), temp)]
+ _tail: (),
+ },
+ Group {
+ #[br(parse_with(parse_bool))]
+ merge: bool,
+ #[br(temp, magic(b"\0\x01"))]
+ _x23: i32,
+ #[br(magic(-1i32), parse_with(parse_vec), args(context))]
+ subcategories: Vec<Box<Category>>,
+ },
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Axes {
+ #[br(temp)]
+ n_layers: u32,
+ #[br(temp)]
+ n_rows: u32,
+ #[br(temp)]
+ n_columns: u32,
+ #[br(count(n_layers))]
+ layers: Vec<u32>,
+ #[br(count(n_rows))]
+ rows: Vec<u32>,
+ #[br(count(n_columns))]
+ columns: Vec<u32>,
+}
+
+impl Axes {
+ fn decode(
+ &self,
+ dimensions: Vec<pivot::Dimension>,
+ ) -> Result<Vec<(Axis3, pivot::Dimension)>, (LightWarning, Vec<pivot::Dimension>)> {
+ let n = self.layers.len() + self.rows.len() + self.columns.len();
+ if n != dimensions.len() {
+ // Warn, then treat all of the dimensions as rows.
+ return Err((
+ LightWarning::WrongAxisCount {
+ expected: dimensions.len(),
+ actual: n,
+ n_layers: self.layers.len(),
+ n_rows: self.rows.len(),
+ n_columns: self.columns.len(),
+ },
+ dimensions,
+ ));
+ }
+
+ fn axis_dims(axis: Axis3, dimensions: &[u32]) -> impl Iterator<Item = (Axis3, usize)> {
+ dimensions.iter().map(move |d| (axis, *d as usize))
+ }
+
+ let mut axes = vec![None; n];
+ for (axis, index) in axis_dims(Axis3::Z, &self.layers)
+ .chain(axis_dims(Axis3::Y, &self.rows))
+ .chain(axis_dims(Axis3::X, &self.columns))
+ {
+ if index >= n {
+ return Err((LightWarning::InvalidDimensionIndex { index, n }, dimensions));
+ } else if axes[index].is_some() {
+ return Err((LightWarning::DuplicateDimensionIndex(index), dimensions));
+ }
+ axes[index] = Some(axis);
+ }
+ Ok(axes.into_iter().flatten().zip_eq(dimensions).collect())
+ }
+}
+
+#[binread]
+#[br(little, import(context: &Context))]
+#[derive(Debug)]
+struct Cell {
+ index: u64,
+ #[br(if(context.version == Version::V1), temp)]
+ _zero: Optional<Zero>,
+ #[br(args(context))]
+ value: Value,
+}
--- /dev/null
+use std::{
+ fs::File,
+ io::{BufRead, BufReader, Seek},
+ path::Path,
+};
+
+use crate::{
+ output::{Text, pivot::tests::assert_lines_eq},
+ spv::ReadOptions,
+};
+
+#[test]
+fn legacy1() {
+ test_raw_spvfile("legacy1");
+}
+
+#[test]
+fn legacy2() {
+ test_raw_spvfile("legacy2");
+}
+
+#[test]
+fn legacy3() {
+ test_raw_spvfile("legacy3");
+}
+
+#[test]
+fn legacy4() {
+ test_raw_spvfile("legacy4");
+}
+
+/// Layer.
+///
+/// (But we need to support selecting a layer value, too.)
+#[test]
+fn legacy5() {
+ test_raw_spvfile("legacy5");
+}
+
+/// Layer, with a particular layer selected.
+#[test]
+fn legacy6() {
+ test_raw_spvfile("legacy6");
+}
+
+fn test_raw_spvfile(name: &str) {
+ let input_filename = Path::new("src/spv/testdata")
+ .join(name)
+ .with_extension("spv");
+ let spvfile = BufReader::new(File::open(&input_filename).unwrap());
+ let expected_filename = input_filename.with_extension("expected");
+ let expected = String::from_utf8(std::fs::read(&expected_filename).unwrap()).unwrap();
+ test_spvfile(spvfile, &expected, &expected_filename);
+}
+
+fn test_spvfile<R>(spvfile: R, expected: &str, expected_filename: &Path)
+where
+ R: BufRead + Seek + 'static,
+{
+ let mut warnings = Vec::new();
+ let output = match ReadOptions::new(move |warning| warnings.push(warning)).open_reader(spvfile)
+ {
+ Ok(spv_file) => {
+ let (items, _page_setup /*XXX*/) = spv_file.into_contents();
+
+ let mut output = Vec::new();
+ /* XXX
+ output.extend(
+ warnings
+ .into_iter()
+ .map(|warning| Item::from(Text::new_log(warning.to_string()))),
+ );*/
+ output.extend(items);
+ output.into_iter().collect()
+ }
+ Err(error) => Text::new_log(error.to_string()).into_item(),
+ };
+
+ let actual = output.to_string();
+ 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, "actual");
+}
--- /dev/null
+ Testcase 1
+╭───────────────────┬─────────┬───────┬─────────────┬──────────────────╮
+│ │Frequency│Percent│Valid Percent│Cumulative Percent│
+├───────────────────┼─────────┼───────┼─────────────┼──────────────────┤
+│Valid >2 years │ 1│ .5│ .5│ .5│
+│ 2-5 years │ 17│ 7.9│ 8.0│ 8.5│
+│ 6-10 years │ 122│ 57.0│ 57.3│ 65.7│
+│ 11-15 years│ 45│ 21.0│ 21.1│ 86.9│
+│ 16-20 years│ 18│ 8.4│ 8.5│ 95.3│
+│ >20 years │ 10│ 4.7│ 4.7│ 100.0│
+│ Total │ 213│ 99.5│ 100.0│ │
+├───────────────────┼─────────┼───────┼─────────────┼──────────────────┤
+│Missing System │ 1│ .5│ │ │
+├───────────────────┼─────────┼───────┼─────────────┼──────────────────┤
+│Total │ 214│ 100.0│ │ │
+╰───────────────────┴─────────┴───────┴─────────────┴──────────────────╯
--- /dev/null
+T-Test
+
+ One-Sample Statistics
+╭─────────────┬──┬─────┬──────────────┬───────────────╮
+│ │ N│ Mean│Std. Deviation│Std. Error Mean│
+├─────────────┼──┼─────┼──────────────┼───────────────┤
+│Variable Name│10│47.30│ 2.669│ .844│
+╰─────────────┴──┴─────┴──────────────┴───────────────╯
+
+ One-Sample Test
+╭─────────────┬────────────────────────────────────────────────────────────────────────────────────╮
+│ │ Test Value = 50 │
+│ ├──────┬──┬───────────────┬───────────────┬──────────────────────────────────────────┤
+│ │ │ │ │ │ 95% Confidence Interval of the Difference│
+│ │ │ │ │ ├─────────────────────┬────────────────────┤
+│ │ t │df│Sig. (2-tailed)│Mean Difference│ Lower │ Upper │
+├─────────────┼──────┼──┼───────────────┼───────────────┼─────────────────────┼────────────────────┤
+│Variable Name│-3.199│ 9│ .011│ -2.700│ -4.61│ -.79│
+╰─────────────┴──────┴──┴───────────────┴───────────────┴─────────────────────┴────────────────────╯
--- /dev/null
+ Excluded Variables[d]
+╭───────┬────────┬──────┬────┬───────────────────┬───────────────────────╮
+│ │ │ │ │ │Collinearity Statistics│
+│ │ │ │ │ ├───────────────────────┤
+│Model │ Beta In│ t │Sig.│Partial Correlation│ Tolerance │
+├───────┼────────┼──────┼────┼───────────────────┼───────────────────────┤
+│1 A│-.304[a]│-2.216│.032│ -.317│ .987│
+│ B│ .611[a]│ 5.532│.000│ .641│ .999│
+│ C│ .394[a]│ 2.964│.005│ .408│ .975│
+│ D│ .535[a]│ 4.415│.000│ .554│ .976│
+│ E│-.239[a]│-1.704│.095│ -.249│ .982│
+│ F│ .615[a]│ 5.558│.000│ .642│ .990│
+│ G│ .531[a]│ 4.390│.000│ .552│ .981│
+├───────┼────────┼──────┼────┼───────────────────┼───────────────────────┤
+│2 D│ .399[b]│ 1.908│.063│ .286│ .270│
+│ E│ .762[b]│ 1.598│.118│ .242│ .053│
+│ F│ .512[b]│ 1.713│.094│ .258│ .135│
+│ G│ .648[b]│ 2.154│.037│ .319│ .128│
+├───────┼────────┼──────┼────┼───────────────────┼───────────────────────┤
+│3 E│ .598[c]│ 1.250│.219│ .194│ .051│
+│ F│-.053[c]│ -.070│.945│ -.011│ .021│
+│ G│ .784[c]│ .964│.341│ .151│ .018│
+╰───────┴────────┴──────┴────┴───────────────────┴───────────────────────╯
+a. Footnote content a
+b. Footnote content b
+c. Footnote content c
+d. Footnote content d
--- /dev/null
+ Chi-Square Tests
+╭──────────────────┬─────────┬──┬─────────────────────╮
+│ │ Value │df│Asymp. Sig. (2-sided)│
+├──────────────────┼─────────┼──┼─────────────────────┤
+│Pearson Chi-Square│17,182[a]│ 4│ ,002│
+│Likelihood Ratio │ 16,820│ 4│ ,002│
+│N of Valid Cases │ 27│ │ │
+╰──────────────────┴─────────┴──┴─────────────────────╯
+a. 7 cells (77,8%) have expected count less than 5. The minimum expected count is ,04.
--- /dev/null
+ Statistics
+Variables: Finished
+╭───────┬───╮
+│Valid │159│
+│Missing│ 0│
+╰───────┴───╯
--- /dev/null
+ Notes
+Contents: Weight
+╭──────╮
+│<none>│
+╰──────╯
--- /dev/null
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see <http://www.gnu.org/licenses/>.
+
+use std::{
+ borrow::Cow,
+ io::{Cursor, Seek, Write},
+ iter::{repeat, repeat_n},
+};
+
+use binrw::{BinWrite, Endian};
+use chrono::Utc;
+use enum_map::EnumMap;
+use quick_xml::{
+ ElementWriter, Writer as XmlWriter,
+ events::{BytesText, attributes::Attribute},
+};
+use zip::{ZipWriter, write::SimpleFileOptions};
+
+use crate::{
+ data::{Datum, EncodedString},
+ format::{Format, Type},
+ output::{
+ Details, Item, Text,
+ page::{ChartSize, PageSetup},
+ pivot::{
+ Axis2, Axis3, Category, Dimension, Footnote, FootnoteMarkerPosition,
+ FootnoteMarkerType, Footnotes, Group, Leaf, PivotTable,
+ look::{
+ Area, AreaStyle, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle,
+ HeadingRegion, HorzAlign, LabelPosition, RowColBorder, RowParity, Stroke,
+ VertAlign,
+ },
+ value::{Value, ValueInner, ValueStyle},
+ },
+ },
+ settings::Show,
+ spv::{Error, html::Document},
+ util::ToSmallString,
+};
+
+/// SPSS viewer (SPV) file writer.
+pub struct Writer<W>
+where
+ W: Write + Seek,
+{
+ writer: ZipWriter<W>,
+ needs_page_break: bool,
+ next_table_id: u64,
+ next_heading_id: u64,
+ page_setup: Option<PageSetup>,
+}
+
+impl<W> Writer<W>
+where
+ W: Write + Seek,
+{
+ /// Creates a new `Writer` to write an SPV file to underlying stream
+ /// `writer`.
+ pub fn for_writer(writer: W) -> Result<Self, Error> {
+ let mut writer = ZipWriter::new(writer);
+ writer.start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default())?;
+ writer.write_all("allowPivoting=true".as_bytes())?;
+ Ok(Self {
+ writer,
+ needs_page_break: false,
+ next_table_id: 1,
+ next_heading_id: 1,
+ page_setup: None,
+ })
+ }
+
+ /// Returns this `Writer` with `page_setup` set up to be written with the
+ /// next call to [write](Writer::write).
+ ///
+ /// Page setup is only written if it is set before the first call to
+ /// [write](Writer::write).
+ pub fn with_page_setup(mut self, page_setup: PageSetup) -> Self {
+ self.set_page_setup(page_setup);
+ self
+ }
+
+ /// Sets `page_setup` to be written with the next call to
+ /// [write](Writer::write).
+ ///
+ /// Page setup is only written if it is set before the first call to
+ /// [write](Writer::write).
+ pub fn set_page_setup(&mut self, page_setup: PageSetup) {
+ self.page_setup = Some(page_setup);
+ }
+
+ /// Closes the underlying file and returns the inner writer and the final
+ /// I/O result.
+ pub fn close(mut self) -> Result<W, Error> {
+ self.writer
+ .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default())?;
+ write!(&mut self.writer, "allowPivoting=true")?;
+ Ok(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<X>(
+ &mut self,
+ item: &Item,
+ pivot_table: &PivotTable,
+ structure: &mut XmlWriter<X>,
+ ) -> Result<(), Error>
+ 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)?;
+
+ let table_name = format!("{table_id:011}_lightTableData.bin");
+ self.writer
+ .start_file(&table_name, SimpleFileOptions::default())?;
+ self.writer.write_all(&content)?;
+
+ self.container(structure, item, "vtb:table", |element| {
+ let subtype = pivot_table.subtype().display(pivot_table).to_string();
+ let type_ = match subtype.as_str() {
+ "Note" | "Notes" => "note",
+ "Warning" | "Warnings" => "warning",
+ _ => "table",
+ };
+ element
+ .with_attribute(("tableId", Cow::from(table_id.to_string())))
+ .with_attribute((
+ "commandName",
+ pivot_table
+ .metadata
+ .command_local
+ .as_ref()
+ .map_or("", |s| s.as_str()),
+ ))
+ .with_attribute(("type", type_))
+ .with_attribute(("subType", Cow::from(subtype)))
+ .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(())
+ })?;
+ Ok(())
+ })?;
+ Ok(())
+ }
+
+ fn write_text<X>(
+ &mut self,
+ item: &Item,
+ text: &Text,
+ structure: &mut XmlWriter<X>,
+ ) -> Result<(), Error>
+ 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()))?;
+ Ok(())
+ })
+ }
+
+ fn write_item<X>(&mut self, item: &Item, structure: &mut XmlWriter<X>) -> Result<(), Error>
+ where
+ X: Write,
+ {
+ match &item.details {
+ Details::Chart | Details::Image(_) => todo!(),
+ Details::Heading(children) => {
+ let mut attributes = Vec::<Attribute>::new();
+ if let Some(command_name) = &item.command_name {
+ attributes.push(("commandName", command_name.as_str()).into());
+ }
+ if !item.show {
+ attributes.push(("visibility", "collapsed").into());
+ }
+ structure
+ .create_element("heading")
+ .with_attributes(attributes)
+ .write_inner_content(|w| {
+ w.create_element("label")
+ .write_text_content(BytesText::new(&item.label()))?;
+ for child in &children.0 {
+ self.write_item(&child, w).map_err(std::io::Error::other)?;
+ }
+ Ok(())
+ })?;
+ Ok(())
+ }
+ Details::Message(diagnostic) => {
+ self.write_text(item, &Text::from(diagnostic.as_ref()), structure)
+ }
+ Details::PageBreak => {
+ self.needs_page_break = true;
+ Ok(())
+ }
+ Details::Table(pivot_table) => self.write_table(item, pivot_table, structure),
+ Details::Text(text) => self.write_text(item, text, structure),
+ }
+ }
+
+ fn container<X, F>(
+ &mut self,
+ writer: &mut XmlWriter<X>,
+ item: &Item,
+ inner_elem: &str,
+ closure: F,
+ ) -> Result<(), Error>
+ where
+ X: Write,
+ F: FnOnce(ElementWriter<X>) -> Result<(), Error>,
+ {
+ 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()))?
+ .create_element(inner_elem);
+ if let Some(command_name) = &item.command_name {
+ element = element.with_attribute(("commandName", command_name.as_str()));
+ };
+ closure(element).map_err(std::io::Error::other)?;
+ Ok(())
+ })?;
+ Ok(())
+ }
+}
+
+impl BinWrite for PivotTable {
+ type Args<'a> = ();
+
+ fn write_options<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ endian: Endian,
+ _args: (),
+ ) -> binrw::BinResult<()> {
+ // Header.
+ (
+ 1u8,
+ 0u8,
+ 3u32, // version
+ SpvBool(true), // x0
+ SpvBool(false), // x1
+ SpvBool(self.style.rotate_inner_column_labels),
+ SpvBool(self.style.rotate_outer_row_labels),
+ SpvBool(true), // x2
+ 0x15u32, // x3
+ *self.style.look.heading_widths[HeadingRegion::Columns].start() as i32,
+ *self.style.look.heading_widths[HeadingRegion::Columns].end() as i32,
+ *self.style.look.heading_widths[HeadingRegion::Rows].start() as i32,
+ *self.style.look.heading_widths[HeadingRegion::Rows].end() as i32,
+ 0u64,
+ )
+ .write_le(writer)?;
+
+ // Titles.
+ (
+ self.title(),
+ self.subtype(),
+ Optional(Some(self.title())),
+ Optional(self.metadata.corner_text.as_ref()),
+ Optional(self.metadata.caption.as_ref()),
+ )
+ .write_le(writer)?;
+
+ // Footnotes.
+ self.footnotes.write_le(writer)?;
+
+ // Areas.
+ static SPV_AREAS: [Area; 8] = [
+ Area::Title,
+ Area::Caption,
+ Area::Footer,
+ Area::Corner,
+ Area::Labels(Axis2::X),
+ Area::Labels(Axis2::Y),
+ Area::Data(RowParity::Even),
+ Area::Layers,
+ ];
+ for (index, area) in SPV_AREAS.into_iter().enumerate() {
+ let odd_data_style = if let Area::Data(_) = area {
+ Some(&self.style.look.areas[Area::Data(RowParity::Odd)])
+ } else {
+ None
+ };
+ self.style.look.areas[area].write_le_args(writer, (index, odd_data_style))?;
+ }
+
+ // Borders.
+ static SPV_BORDERS: [Border; 19] = [
+ Border::Title,
+ Border::OuterFrame(BoxBorder::Left),
+ Border::OuterFrame(BoxBorder::Top),
+ Border::OuterFrame(BoxBorder::Right),
+ Border::OuterFrame(BoxBorder::Bottom),
+ Border::InnerFrame(BoxBorder::Left),
+ Border::InnerFrame(BoxBorder::Top),
+ Border::InnerFrame(BoxBorder::Right),
+ Border::InnerFrame(BoxBorder::Bottom),
+ Border::DataLeft,
+ Border::DataTop,
+ Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::Y)),
+ Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::Y)),
+ Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::Y)),
+ Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::Y)),
+ ];
+ let borders_start = Count::new(writer)?;
+ (1, SPV_BORDERS.len() as u32).write_be(writer)?;
+ for (index, border) in SPV_BORDERS.into_iter().enumerate() {
+ self.style.look.borders[border].write_be_args(writer, index)?;
+ }
+ (SpvBool(self.style.show_grid_lines), 0u8, 0u16).write_le(writer)?;
+ borders_start.finish_le32(writer)?;
+
+ // Print Settings.
+ Counted::new((
+ 1u32,
+ SpvBool(self.style.look.print_all_layers),
+ SpvBool(self.style.look.paginate_layers),
+ SpvBool(self.style.look.shrink_to_fit[Axis2::X]),
+ SpvBool(self.style.look.shrink_to_fit[Axis2::Y]),
+ SpvBool(self.style.look.show_continuations[0]),
+ SpvBool(self.style.look.show_continuations[1]),
+ self.style.look.n_orphan_lines as u32,
+ SpvString(
+ self.style
+ .look
+ .continuation
+ .as_ref()
+ .map_or("", |s| s.as_str()),
+ ),
+ ))
+ .with_endian(Endian::Little)
+ .write_be(writer)?;
+
+ // Table Settings.
+ Counted::new((
+ 1u32,
+ 4u32,
+ self.spv_layer() as u32,
+ SpvBool(self.style.look.hide_empty),
+ SpvBool(self.style.look.row_label_position == LabelPosition::Corner),
+ SpvBool(self.style.look.footnote_marker_type == FootnoteMarkerType::Alphabetic),
+ SpvBool(
+ self.style.look.footnote_marker_position == FootnoteMarkerPosition::Superscript,
+ ),
+ 0u8,
+ Counted::new((
+ 0u32, // n-row-breaks
+ 0u32, // n-column-breaks
+ 0u32, // n-row-keeps
+ 0u32, // n-column-keeps
+ 0u32, // n-row-point-keeps
+ 0u32, // n-column-point-keeps
+ )),
+ SpvString::optional(&self.metadata.notes),
+ SpvString::optional(&self.style.look.name),
+ Zeros(82),
+ ))
+ .with_endian(Endian::Little)
+ .write_be(writer)?;
+
+ fn y0(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> {
+ (
+ pivot_table.style.settings.epoch.0 as u32,
+ u8::from(pivot_table.style.settings.decimal),
+ b',',
+ )
+ }
+
+ fn custom_currency(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> {
+ (
+ 5,
+ EnumMap::from_fn(|cc| {
+ SpvString(
+ pivot_table
+ .style
+ .settings
+ .number_style(Type::CC(cc))
+ .to_string(),
+ )
+ })
+ .into_array(),
+ )
+ }
+
+ fn x1(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> {
+ (
+ 0u8, // x14
+ if pivot_table.style.show_title {
+ 1u8
+ } else {
+ 10u8
+ },
+ 0u8, // x16
+ 0u8, // lang
+ Show::as_spv(&pivot_table.style.show_variables),
+ Show::as_spv(&pivot_table.style.show_values),
+ -1i32, // x18
+ -1i32, // x19
+ Zeros(17),
+ SpvBool(false), // x20
+ SpvBool(pivot_table.style.show_caption),
+ )
+ }
+
+ fn x2() -> impl for<'a> BinWrite<Args<'a> = ()> {
+ Counted::new((
+ 0u32, // n-row-heights
+ 0u32, // n-style-maps
+ 0u32, // n-styles,
+ 0u32,
+ ))
+ }
+
+ fn y1(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> + use<'_> {
+ (
+ SpvString::optional(&pivot_table.metadata.command_c),
+ SpvString::optional(&pivot_table.metadata.command_local),
+ SpvString::optional(&pivot_table.metadata.language),
+ SpvString("UTF-8"),
+ SpvString::optional(&pivot_table.metadata.locale),
+ SpvBool(false), // x10
+ SpvBool(pivot_table.style.settings.leading_zero),
+ SpvBool(true), // x12
+ SpvBool(true), // x13
+ y0(pivot_table),
+ )
+ }
+
+ fn y2(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> {
+ (custom_currency(pivot_table), b'.', SpvBool(false))
+ }
+
+ fn x3(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> + use<'_> {
+ Counted::new((
+ 1u8,
+ 0u8,
+ 4u8, // x21
+ 0u8,
+ 0u8,
+ 0u8,
+ y1(pivot_table),
+ pivot_table.style.small,
+ 1u8,
+ SpvString::optional(&pivot_table.metadata.dataset),
+ SpvString::optional(&pivot_table.metadata.datafile),
+ 0u32,
+ pivot_table
+ .metadata
+ .date
+ .map_or(0i64, |date| date.and_utc().timestamp()),
+ y2(pivot_table),
+ ))
+ }
+
+ // Formats.
+ (
+ 0u32,
+ SpvString("en_US.ISO_8859-1:1987"),
+ 0u32, // XXX current_layer
+ SpvBool(false), // x7
+ SpvBool(false), // x8
+ SpvBool(false), // x9
+ y0(self),
+ custom_currency(self),
+ Counted::new((Counted::new((x1(self), x2())), x3(self))),
+ )
+ .write_le(writer)?;
+
+ // Dimensions.
+ (self.dimensions().len() as u32).write_le(writer)?;
+
+ let x2 = repeat_n(2, self.axes()[Axis3::Z].dimensions.len())
+ .chain(repeat_n(0, self.axes()[Axis3::Y].dimensions.len()))
+ .chain(repeat(1));
+ for ((index, dimension), x2) in self.dimensions().iter().enumerate().zip(x2) {
+ dimension.write_options(writer, endian, (index, x2))?;
+ }
+
+ // Axes.
+ for axis in [Axis3::Z, Axis3::Y, Axis3::X] {
+ (self.axes()[axis].dimensions.len() as u32).write_le(writer)?;
+ }
+ for axis in [Axis3::Z, Axis3::Y, Axis3::X] {
+ for index in self.axes()[axis].dimensions.iter().copied() {
+ (index as u32).write_le(writer)?;
+ }
+ }
+
+ // Cells.
+ (self.cells().len() as u32).write_le(writer)?;
+ for (index, value) in self.cells() {
+ (*index as u64, value).write_le(writer)?;
+ }
+
+ Ok(())
+ }
+}
+
+impl PivotTable {
+ fn spv_layer(&self) -> usize {
+ let mut layer = 0;
+ for (dimension, layer_value) in self
+ .axis_dimensions(Axis3::Z)
+ .zip(self.layer().iter().copied())
+ .rev()
+ {
+ layer = layer * dimension.len() + layer_value;
+ }
+ layer
+ }
+}
+
+impl<W> Writer<W>
+where
+ W: Write + Seek,
+{
+ /// Writes `item` to the SPV file.
+ pub fn write(&mut self, item: &Item) -> Result<(), Error> {
+ if item.details.is_page_break() {
+ self.needs_page_break = true;
+ return Ok(());
+ }
+
+ 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).map_err(std::io::Error::other)?;
+ Ok(())
+ })?;
+
+ let headings = headings.into_inner().into_inner();
+ let heading_id = self.next_heading_id;
+ self.next_heading_id += 1;
+ self.writer.start_file(
+ format!(
+ "outputViewer{heading_id:010}{}.xml",
+ if item.details.is_heading() {
+ "_heading"
+ } else {
+ ""
+ }
+ ),
+ SimpleFileOptions::default(),
+ )?; // XXX
+ self.writer.write_all(&headings)?; // XXX
+ Ok(())
+ }
+}
+
+fn write_page_setup<X>(page_setup: &PageSetup, writer: &mut XmlWriter<X>) -> std::io::Result<()>
+where
+ X: Write,
+{
+ fn length(length: paper_sizes::Length) -> Cow<'static, str> {
+ Cow::from(length.to_string())
+ }
+
+ writer
+ .create_element("vps:pageSetup")
+ .with_attribute((
+ "initial-page-number",
+ Cow::from(format!("{}", page_setup.initial_page_number)),
+ ))
+ .with_attribute((
+ "chart-size",
+ match page_setup.chart_size {
+ ChartSize::AsIs => "as-is",
+ ChartSize::FullHeight => "full-height",
+ ChartSize::HalfHeight => "half-height",
+ ChartSize::QuarterHeight => "quarter-height",
+ },
+ ))
+ .with_attribute(("margin-left", length(page_setup.margins.0[Axis2::X][0])))
+ .with_attribute(("margin-right", length(page_setup.margins.0[Axis2::X][1])))
+ .with_attribute(("margin-top", length(page_setup.margins.0[Axis2::Y][0])))
+ .with_attribute(("margin-bottom", length(page_setup.margins.0[Axis2::Y][1])))
+ .with_attribute(("paper-height", length(page_setup.paper.height())))
+ .with_attribute(("paper-width", length(page_setup.paper.width())))
+ .with_attribute((
+ "reference-orientation",
+ match page_setup.orientation {
+ crate::output::page::Orientation::Portrait => "portrait",
+ crate::output::page::Orientation::Landscape => "landscape",
+ },
+ ))
+ .with_attribute(("space-after", length(page_setup.object_spacing)))
+ .write_inner_content(|w| {
+ write_page_heading(&page_setup.header, "vps:pageHeader", w)?;
+ write_page_heading(&page_setup.footer, "vps:pageFooter", w)?;
+ Ok(())
+ })?;
+ Ok(())
+}
+
+fn write_page_heading<X>(
+ heading: &Document,
+ name: &str,
+ writer: &mut XmlWriter<X>,
+) -> std::io::Result<()>
+where
+ X: Write,
+{
+ let element = writer.create_element(name);
+ if !heading.is_empty() {
+ element.write_inner_content(|w| {
+ w.create_element("vps:pageParagraph")
+ .write_inner_content(|w| {
+ w.create_element("vtx:text")
+ .with_attribute(("text", "title"))
+ .write_text_content(BytesText::new(&heading.to_html()))?;
+ Ok(())
+ })?;
+ Ok(())
+ })?;
+ }
+ Ok(())
+}
+
+impl BinWrite for Dimension {
+ type Args<'a> = (usize, u8);
+
+ fn write_options<W: Write + Seek>(
+ &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<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ D: Iterator<Item = usize>,
+ {
+ 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<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ D: Iterator<Item = usize>,
+ {
+ (
+ self.name(),
+ 0u8,
+ 0u8,
+ 0u8,
+ 2u32,
+ data_indexes
+ .next()
+ .expect("should have as many data indexes as leaves") as u32,
+ 0u32,
+ )
+ .write_le(writer)
+ }
+}
+
+impl Group {
+ fn write_le<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ D: Iterator<Item = usize>,
+ {
+ (
+ self.name(),
+ 0u8, // merge
+ 0u8,
+ 1u8,
+ 0u32, // x23
+ -1i32,
+ self.children.len() as u32,
+ )
+ .write_le(writer)?;
+
+ for child in &self.children {
+ child.write_le(writer, data_indexes)?;
+ }
+ Ok(())
+ }
+}
+
+impl BinWrite for Footnote {
+ type Args<'a> = ();
+
+ fn write_options<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ endian: Endian,
+ args: Self::Args<'_>,
+ ) -> binrw::BinResult<()> {
+ (self.len() as u32).write_options(writer, endian, args)?;
+ for footnote in self {
+ footnote.write_options(writer, endian, args)?;
+ }
+ Ok(())
+ }
+}
+
+impl BinWrite for AreaStyle {
+ type Args<'a> = (usize, Option<&'a AreaStyle>);
+
+ fn write_options<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ endian: Endian,
+ (index, odd_data_style): (usize, Option<&AreaStyle>),
+ ) -> binrw::BinResult<()> {
+ let typeface = if self.font_style.font.is_empty() {
+ "SansSerif"
+ } else {
+ self.font_style.font.as_str()
+ };
+ (
+ (index + 1) as u8,
+ 0x31u8,
+ SpvString(typeface),
+ self.font_style.size as f32 * 1.33,
+ self.font_style.bold as u32 + 2 * self.font_style.italic as u32,
+ SpvBool(self.font_style.underline),
+ self.cell_style
+ .horz_align
+ .map_or(64173, |horz_align| horz_align.as_spv(61453)),
+ self.cell_style.vert_align.as_spv(),
+ self.font_style.fg,
+ self.font_style.bg,
+ )
+ .write_options(writer, endian, ())?;
+
+ let alt_fg = odd_data_style.map_or(self.font_style.fg, |style| style.font_style.fg);
+ let alt_bg = odd_data_style.map_or(self.font_style.bg, |style| style.font_style.bg);
+ if self.font_style.fg != alt_fg || self.font_style.bg != alt_bg {
+ (SpvBool(true), alt_fg, alt_bg).write_options(writer, endian, ())?;
+ } else {
+ (SpvBool(false), SpvString(""), SpvString("")).write_options(writer, endian, ())?;
+ }
+
+ (
+ self.cell_style.margins[Axis2::X][0],
+ self.cell_style.margins[Axis2::X][1],
+ self.cell_style.margins[Axis2::Y][0],
+ self.cell_style.margins[Axis2::Y][1],
+ )
+ .write_options(writer, endian, ())
+ }
+}
+
+impl Stroke {
+ fn as_spv(&self) -> u32 {
+ match self {
+ Stroke::None => 0,
+ Stroke::Solid => 1,
+ Stroke::Dashed => 2,
+ Stroke::Thick => 3,
+ Stroke::Thin => 4,
+ Stroke::Double => 5,
+ }
+ }
+}
+
+impl Color {
+ fn as_spv(&self) -> u32 {
+ ((self.alpha as u32) << 24)
+ | ((self.r as u32) << 16)
+ | ((self.g as u32) << 8)
+ | (self.b as u32)
+ }
+}
+
+impl BinWrite for BorderStyle {
+ type Args<'a> = usize;
+
+ fn write_options<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ endian: binrw::Endian,
+ args: Self::Args<'_>,
+ ) -> binrw::BinResult<()> {
+ (self.0 as u8).write_options(writer, endian, args)
+ }
+}
+
+struct SpvString<T>(T);
+impl<'a> SpvString<&'a str> {
+ fn optional(s: &'a Option<String>) -> Self {
+ Self(s.as_ref().map_or("", |s| s.as_str()))
+ }
+}
+impl<T> BinWrite for SpvString<T>
+where
+ T: AsRef<str>,
+{
+ type Args<'a> = ();
+
+ fn write_options<W: Write + Seek>(
+ &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<Show>) -> u8 {
+ match this {
+ None => 0,
+ Some(Show::Value) => 1,
+ Some(Show::Label) => 2,
+ Some(Show::Both) => 3,
+ }
+ }
+}
+
+struct Count(u64);
+
+impl Count {
+ fn new<W>(writer: &mut W) -> binrw::BinResult<Self>
+ where
+ W: Write + Seek,
+ {
+ 0u32.write_le(writer)?;
+ Ok(Self(writer.stream_position()?))
+ }
+
+ fn finish<W>(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<W>(self, writer: &mut W) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ {
+ self.finish(writer, Endian::Little)
+ }
+}
+
+struct Counted<T> {
+ inner: T,
+ endian: Option<Endian>,
+}
+
+impl<T> Counted<T> {
+ fn new(inner: T) -> Self {
+ Self {
+ inner,
+ endian: None,
+ }
+ }
+ fn with_endian(self, endian: Endian) -> Self {
+ Self {
+ inner: self.inner,
+ endian: Some(endian),
+ }
+ }
+}
+
+impl<T> BinWrite for Counted<T>
+where
+ T: BinWrite,
+ for<'a> T: BinWrite<Args<'a> = ()>,
+{
+ type Args<'a> = T::Args<'a>;
+
+ fn write_options<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ endian: Endian,
+ args: Self::Args<'_>,
+ ) -> binrw::BinResult<()> {
+ let typeface = if self.font.is_empty() {
+ "SansSerif"
+ } else {
+ self.font.as_str()
+ };
+ (
+ SpvBool(self.bold),
+ SpvBool(self.italic),
+ SpvBool(self.underline),
+ SpvBool(true),
+ self.fg,
+ self.bg,
+ SpvString(typeface),
+ (self.size as f64 * 1.33).ceil() as u8,
+ )
+ .write_options(writer, endian, args)
+ }
+}
+
+impl HorzAlign {
+ fn as_spv(&self, decimal: u32) -> u32 {
+ match self {
+ HorzAlign::Right => 4,
+ HorzAlign::Left => 2,
+ HorzAlign::Center => 0,
+ HorzAlign::Decimal { .. } => decimal,
+ }
+ }
+
+ /// Returns the decimal offset for [HorzAlign::Decimal], or `None` for other
+ /// horizontal alignments.
+ pub fn decimal_offset(&self) -> Option<f64> {
+ 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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<T>(Option<T>);
+
+impl<T> BinWrite for Optional<T>
+where
+ T: BinWrite,
+{
+ type Args<'a> = T::Args<'a>;
+
+ fn write_options<W: Write + Seek>(
+ &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<Box<ValueStyle>>,
+ 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<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ endian: binrw::Endian,
+ args: Self::Args<'_>,
+ ) -> binrw::BinResult<()> {
+ if self.style.as_ref().is_some_and(|style| !style.is_empty()) || self.template.is_some() {
+ 0x31u8.write_options(writer, endian, args)?;
+ let default_style = Default::default();
+ let style = self.style.as_ref().unwrap_or(&default_style);
+
+ (style.footnotes.len() as u32).write_options(writer, endian, args)?;
+ for footnote in &style.footnotes {
+ (footnote.index() as u16).write_options(writer, endian, args)?;
+ }
+
+ (style.subscripts.len() as u32).write_options(writer, endian, args)?;
+ for subscript in &style.subscripts {
+ SpvString(subscript.as_str()).write_options(writer, endian, args)?;
+ }
+ let v3_start = Count::new(writer)?;
+ let template_string_start = Count::new(writer)?;
+ if let Some(template) = self.template {
+ Count::new(writer)?.finish_le32(writer)?;
+ (0x31u8, SpvString(template)).write_options(writer, endian, args)?;
+ }
+ template_string_start.finish_le32(writer)?;
+ StylePair {
+ font_style: style.font_style.as_ref(),
+ cell_style: style.cell_style.as_ref(),
+ }
+ .write_options(writer, endian, args)?;
+ v3_start.finish_le32(writer)
+ } else {
+ 0x58u8.write_options(writer, endian, args)
+ }
+ }
+}
+
+struct SpvFormat {
+ format: Format,
+ honor_small: bool,
+}
+
+impl BinWrite for SpvFormat {
+ type Args<'a> = ();
+
+ fn write_options<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ endian: binrw::Endian,
+ args: Self::Args<'_>,
+ ) -> binrw::BinResult<()> {
+ match &self.inner {
+ ValueInner::Datum(number_value) => match &number_value.datum {
+ Datum::Number(number) => {
+ let format = SpvFormat {
+ format: number_value.format,
+ honor_small: number_value.honor_small,
+ };
+ if number_value.variable.is_some() || number_value.value_label.is_some() {
+ (
+ 2u8,
+ ValueMod::new(self),
+ format,
+ number.unwrap_or(f64::MIN),
+ SpvString::optional(&number_value.variable),
+ SpvString::optional(&number_value.value_label),
+ Show::as_spv(&number_value.show),
+ )
+ .write_options(writer, endian, args)?;
+ } else {
+ (1u8, ValueMod::new(self), format, number.unwrap_or(f64::MIN))
+ .write_options(writer, endian, args)?;
+ }
+ }
+ Datum::String(s) => {
+ let hex;
+ let utf8;
+ let (s, format) = if number_value.format.type_() == Type::AHex {
+ hex = s.inner.to_hex();
+ (
+ hex.as_str(),
+ Format::new(Type::AHex, hex.len().min(65534) as u16, 0).unwrap(),
+ )
+ } else {
+ utf8 = s.as_str();
+ (
+ utf8.as_ref(),
+ Format::new(Type::A, utf8.len().min(32767) as u16, 0).unwrap(),
+ )
+ };
+ (
+ 4u8,
+ ValueMod::new(self),
+ SpvFormat {
+ format,
+ honor_small: false,
+ },
+ SpvString::optional(&number_value.value_label),
+ SpvString::optional(&number_value.variable),
+ Show::as_spv(&number_value.show),
+ SpvString(s),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ },
+ ValueInner::Variable(variable) => {
+ (
+ 5u8,
+ ValueMod::new(self),
+ SpvString(&variable.var_name),
+ SpvString::optional(&variable.variable_label),
+ Show::as_spv(&variable.show),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ ValueInner::Markup(markup) => {
+ let text = markup.to_string();
+ (
+ 3u8,
+ SpvString(&text), // XXX
+ ValueMod::new(self),
+ SpvString(&text),
+ SpvString(&text),
+ SpvBool(true),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ ValueInner::Text(text) => {
+ (
+ 3u8,
+ SpvString(&text.localized),
+ ValueMod::new(self),
+ SpvString(text.id()),
+ SpvString(text.c()),
+ SpvBool(true),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ ValueInner::Template(template) => {
+ (
+ 0u8,
+ ValueMod::new(self),
+ SpvString(&template.localized),
+ template.args.len() as u32,
+ )
+ .write_options(writer, endian, args)?;
+ for arg in &template.args {
+ if arg.len() > 1 {
+ (arg.len() as u32, 0u32).write_options(writer, endian, args)?;
+ for (index, value) in arg.iter().enumerate() {
+ if index > 0 {
+ 0u32.write_le(writer)?;
+ }
+ value.write_options(writer, endian, args)?;
+ }
+ } else {
+ (0u32, arg).write_options(writer, endian, args)?;
+ }
+ }
+ }
+ ValueInner::Empty => {
+ (
+ 3u8,
+ SpvString(""),
+ ValueMod::default(),
+ SpvString(""),
+ SpvString(""),
+ SpvBool(true),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ }
+ Ok(())
+ }
+}
};
use crate::{
- crypto::EncryptedFile,
+ crypto::EncryptedReader,
data::{ByteString, Case, Datum, MutRawString, RawString},
dictionary::{
DictIndexMultipleResponseSet, DictIndexVariableSet, Dictionary, MrSetError,
format::{Error as FormatError, Format, UncheckedFormat},
hexfloat::HexFloat,
identifier::{Error as IdError, Identifier},
- output::pivot::{Axis3, Dimension, Group, PivotTable, Value},
+ output::pivot::{Axis3, Dimension, Group, PivotTable, value::Value},
sys::{
raw::{
self, CaseDetails, DecodedRecord, RawCases, RawDatum, RawWidth, Reader, infer_encoding,
},
variable::{InvalidRole, MissingValues, MissingValuesError, VarType, VarWidth, Variable},
};
-use anyhow::{Error as AnyError, anyhow};
+use anyhow::Error as AnyError;
use binrw::{BinRead, BinWrite, Endian};
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use encoding_rs::{Encoding, UTF_8};
}
/// Causes the file to be read by decrypting it with the given `password` or
- /// without decrypting if `encoding` is None.
+ /// without decrypting if `password` is None.
pub fn with_password(self, password: Option<String>) -> Self {
Self { password, ..self }
}
F: FnMut(AnyError),
{
Self::open_reader_inner(
- EncryptedFile::new(reader)?
- .unlock(password.as_bytes())
- .map_err(|_| anyhow!("Incorrect password."))?,
+ EncryptedReader::open(reader, password)?,
self.encoding,
self.warn,
)
.decode(variable.width)
.as_encoded(variable.encoding())
.display(variable.print_format)
- .with_trimming()
+ .without_spaces()
.with_quoted_string()
.to_string()
})
let mut values = Vec::new();
group.push("Created");
- values.push(Value::new_date_time(self.creation));
+ values.push(Value::new_date(self.creation));
let mut product = Group::new("Writer");
product.push("Product");
identifier::{Error as IdError, Identifier},
output::{
Details, Item, Text,
- pivot::{Axis3, Dimension, Group, PivotTable, Value},
+ pivot::{Axis3, Dimension, Group, PivotTable, value::Value},
},
sys::{
encoding::{Error as EncodingError, default_encoding, get_encoding},
mem::take,
num::NonZeroU8,
ops::Range,
+ sync::Arc,
};
use thiserror::Error as ThisError;
/// An error encountered reading raw system file records.
///
/// Any error prevents reading further data from the system file.
-#[derive(Debug)]
+#[derive(Clone, Debug)]
pub struct Error<D> {
/// Range of file offsets where the error occurred.
pub offsets: Option<Range<u64>>,
}
/// Details of an [Error].
-#[derive(ThisError, Debug)]
+#[derive(Clone, ThisError, Debug)]
pub enum ErrorDetails {
/// Not an SPSS system file.
#[error("Not an SPSS system file")]
/// I/O error.
#[error("I/O error ({0})")]
- Io(#[from] IoError),
+ Io(Arc<IoError>),
/// Invalid SAV compression code.
#[error("Invalid SAV compression code {0}")]
),
}
+impl From<IoError> for ErrorDetails {
+ fn from(value: IoError) -> Self {
+ ErrorDetails::Io(Arc::new(value))
+ }
+}
+
/// A warning reading a raw system file record.
///
/// Warnings indicate that something may be amiss, but they do not prevent
/// An error reading a case from a system file.
///
/// Used for SPSS system files and SPSS/PC+ system files.
-#[derive(ThisError, Display, Debug)]
+#[derive(Clone, ThisError, Display, Debug)]
pub enum CaseDetails {
/// Unexpected end of file {case_ofs} bytes into case {case_number} with expected length {case_len} bytes.
EofInCase {
},
/// I/O error ({0})
- Io(#[from] IoError),
+ Io(Arc<IoError>),
+}
+
+impl From<IoError> for CaseDetails {
+ fn from(value: IoError) -> Self {
+ CaseDetails::Io(Arc::new(value))
+ }
}
impl Datum<ByteString> {
io::{Cursor, ErrorKind, Read, Seek, SeekFrom},
ops::Range,
str::from_utf8,
+ sync::Arc,
};
use crate::{
};
use binrw::{BinRead, BinWrite, Endian, Error as BinError, binrw};
-use clap::ValueEnum;
use encoding_rs::Encoding;
use itertools::Itertools;
-use serde::{Serialize, Serializer, ser::SerializeTuple};
+use serde::{Deserialize, Serialize, Serializer, ser::SerializeTuple};
use thiserror::Error as ThisError;
/// Type of compression in a system file.
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, ValueEnum)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
pub enum Compression {
/// Simple bytecode-based compression.
Simple,
/// [ZLIB] compression.
///
/// [ZLIB]: https://www.zlib.net/
- #[value(name = "zlib", help = "ZLIB space-efficient compression")]
ZLib,
}
}
/// Error reading a [ZHeader].
-#[derive(ThisError, Debug)]
+#[derive(Clone, ThisError, Debug)]
pub enum ZHeaderError {
/// I/O error via [mod@binrw].
#[error("{}", DisplayBinError(.0, "ZLIB header"))]
- BinError(#[from] BinError),
+ BinError(Arc<BinError>),
/// Impossible ztrailer_offset {0:#x}.
#[error("Impossible ztrailer_offset {0:#x}.")]
),
}
+impl From<BinError> for ZHeaderError {
+ fn from(value: BinError) -> Self {
+ ZHeaderError::BinError(Arc::new(value))
+ }
+}
+
/// A ZLIB trailer in a system file.
#[derive(Clone, Debug, Serialize)]
pub struct ZTrailer {
}
/// Error reading a [ZTrailer].
-#[derive(ThisError, Debug)]
+#[derive(Clone, ThisError, Debug)]
pub enum ZTrailerError {
/// I/O error via [mod@binrw].
#[error("{}", DisplayBinError(.0, "ZLIB trailer"))]
- BinError(#[from] BinError),
+ BinError(Arc<BinError>),
/// ZLIB trailer bias {actual} is not {} as expected from file header bias.
#[
},
}
+impl From<BinError> for ZTrailerError {
+ fn from(value: BinError) -> Self {
+ ZTrailerError::BinError(Arc::new(value))
+ }
+}
+
impl ZTrailer {
/// Reads a ZLIB trailer from `reader` using `endian`. `bias` is the
/// floating-point bias for confirmation against the trailer, and `zheader`
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Warning at file offsets 0xe0 to 0x100: In extension record: floating point record has bad count 4 instead of the expected 3.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0x104: In extension record: integer record has bad count 9 instead of the expected 8.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Integer format indicated by system file (3) differs from expected ({endian}).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Floating-point representation indicated by system file (2) differs from expected (1).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Ignoring long string value label for numeric variable NUM1.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
Warning at file offsets 0xe0 to 0xe5: In long variable name record: Missing `=` separator.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Variable with short name NUM1 listed in very long string record with width 256 requires string segments for 2 dictionary indexes starting at index 0, but the dictionary only contains 1 indexes.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Variable with short name STR1 listed in very long string record with width 256 has segment 1 of width 9 (expected 4).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Warning at file offsets 0x54 to 0x5c: In file header: Compression bias is 50 instead of the usual values of 0 or 100.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 1│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 1│
+╰────────────────┴────────────────────────╯
╭─────────┬───────────────────────────────────────────────────────╮
│Label │PSPP synthetic test file │
Warning at file offsets 0x10e to 0x12d: In file or variable attribute record: Duplicate attributes for variable FIRSTVAR: fred.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Duplicate long variable name LONGVARIABLENAME.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│4│
NUM1 has duplicate value labels for the following value(s): 1
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
Renaming variable with duplicate name VAR1 to VAR001.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 1│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 1│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Product 2 │Extra product info │
-│ │another line │
-│ │blah │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Product 2│Extra product info │
+│ │another line │
+│ │blah │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬──────────────╮
│Label │PSPP synthetic│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 5│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 5│
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
"VAR000\0\0" may not be used as an identifier because it contains disallowed character '\0'. Renaming variable to VAR001.
-╭──────────────────────┬────────────────────────────────────╮
-│ Created │ 30-JAN-2013 14:34:58│
-├──────────────────────┼────────────────────────────────────┤
-│Writer Product │MS Windows Release 12.0 spssio32.dll│
-│ Version │12.0.0 │
-├──────────────────────┼────────────────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│ 10│
-╰──────────────────────┴────────────────────────────────────╯
+╭────────────────┬────────────────────────────────────╮
+│Created │ 30-JAN-2013 14:34:58│
+├────────────────┼────────────────────────────────────┤
+│Writer Product │MS Windows Release 12.0 spssio32.dll│
+│ Version │12.0.0 │
+├────────────────┼────────────────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────────────────┤
+│Number of Cases │ 10│
+╰────────────────┴────────────────────────────────────╯
╭─────────┬─╮
│Variables│4│
Invalid long string missing value for 7-byte string variable STR4.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬──────────────────────────────╮
│Label │PSPP synthetic test file: ôõöø│
Substituting A4 for invalid write format on variable STR2. String variable with width 4 is not compatible with format AHEX4. Use format AHEX8 instead.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│4│
"GE" may not be used as an identifier because it is a reserved word. Renaming variable to VAR004.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│4│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Warning at file offsets 0xf6 to 0x109: In file or variable attribute record: Attribute for fred[2] lacks value.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Multiple response set $a has only one variable.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Variable index 0 is a 10-byte string that should be followed by long string continuation records through index 1 (inclusive), but index 1 is not a continuation
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
Multiple response set $a contains both string and numeric variables.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 5│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 5│
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─────────────────────╮
│Variables│ 2│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Warning at file offsets 0xe0 to 0xe5: In multiple response set record: Syntax error parsing counted string (missing trailing space).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Invalid multiple response set name. Multiple response set name "e" does not begin with required "$".
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Warning at file offsets 0xe0 to 0xe9: In multiple response set record: Syntax error parsing counted string (length 4 goes past end of input).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xe6: In multiple response set record: Syntax error parsing counted string (missing trailing space).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Multiple response set $a has only one variable.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xe5: In multiple response set record: Invalid multiple dichotomy label type.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xec: In multiple response set record: Syntax error (missing variable name delimiter).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xe5: In multiple response set record: Syntax error (missing space after multiple response type).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xea: In multiple response set record: Syntax error (missing space after multiple response type).
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xe5: In multiple response set record: Invalid multiple dichotomy label type.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xe6: In multiple response set record: Invalid multiple dichotomy label type.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
This system file does not indicate its own character encoding. For best results, specify an encoding explicitly. Use SYSFILE INFO with ENCODING="DETECT" to analyze the possible encodings.
-╭──────────────────────┬────────────────────────────────────╮
-│ Created │ 30-JAN-2013 14:34:58│
-├──────────────────────┼────────────────────────────────────┤
-│Writer Product │MS Windows Release 12.0 spssio32.dll│
-├──────────────────────┼────────────────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│ 10│
-╰──────────────────────┴────────────────────────────────────╯
+╭────────────────┬────────────────────────────────────╮
+│Created │ 30-JAN-2013 14:34:58│
+├────────────────┼────────────────────────────────────┤
+│Writer Product │MS Windows Release 12.0 spssio32.dll│
+├────────────────┼────────────────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────────────────┤
+│Number of Cases │ 10│
+╰────────────────┴────────────────────────────────────╯
╭─────────┬─╮
│Variables│4│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
-╭──────────────────────┬────────────────────────────────────────────╮
-│ Created │ 04-OCT-2013 19:13:09│
-├──────────────────────┼────────────────────────────────────────────┤
-│Writer Product │@(#) IBM SPSS STATISTICS MS Windows 22.0.0.0│
-│ Version │22.0.0 │
-├──────────────────────┼────────────────────────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│ 17│
-╰──────────────────────┴────────────────────────────────────────────╯
+╭────────────────┬────────────────────────────────────────────╮
+│Created │ 04-OCT-2013 19:13:09│
+├────────────────┼────────────────────────────────────────────┤
+│Writer Product │@(#) IBM SPSS STATISTICS MS Windows 22.0.0.0│
+│ Version │22.0.0 │
+├────────────────┼────────────────────────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────────────────────────┤
+│Number of Cases │ 17│
+╰────────────────┴────────────────────────────────────────────╯
╭─────────┬─╮
│Variables│5│
At offsets 0xf4...0x114, record types 3 and 4 may not add value labels to one or more long string variables: STR1
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Text string contains invalid bytes for UTF-8 encoding: "PSPP synthetic test file: ���� "
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬──────────────────────────────╮
│Label │PSPP synthetic test file: ����│
Unknown extension record with subtype 30 at offset 0xe0, consisting of 1 1-byte units. Please feel free to report this as a bug.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xfd to 0x10f: In file or variable attribute record: Attribute for fred[1] missing quotations.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 05-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 1│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 05-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 1│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Warning at file offsets 0x160 to 0x168: In value label record: One or more variable indexes were not in the valid range [1,2] or referred to string continuations: [7, 8]
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
Warning at file offsets 0x110 to 0x114: In value label record: One or more variable indexes were not in the valid range [1,2] or referred to string continuations: [2]
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
This system file does not indicate its own character encoding. For best results, specify an encoding explicitly. Use SYSFILE INFO with ENCODING="DETECT" to analyze the possible encodings.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 05-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 05-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 05-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 1│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 05-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 1│
+╰────────────────┴────────────────────────╯
╭─────────┬──────────────────────────────╮
│Label │PSPP synthetic test file: ôõöø│
Unknown role "6".
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Variable set "vs2" includes unknown variable foo.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 0│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 0│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Warning at file offsets 0x110 to 0x118: In value label record: First variable index is for a string variable but the following variable indexes are for numeric variables: [2]
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 1│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │1.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 1│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
File designates string variable STR1 (index 2) as weight variable, but weight variables must be numeric.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
File weight variable index 3 is invalid because it exceeds maximum variable index 2.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
File weight variable index 2 is invalid because it refers to long string continuation for variable STR1.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│2│
-╭──────────────────────┬────────────────────╮
-│ Created │30-JUL-2025 15:07:55│
-├──────────────────────┼────────────────────┤
-│Writer Product │PSPP TEST DATA FILE │
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│ 8│
-╰──────────────────────┴────────────────────╯
+╭────────────────┬────────────────────╮
+│Created │30-JUL-2025 15:07:55│
+├────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├────────────────┼────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────┤
+│Number of Cases │ 8│
+╰────────────────┴────────────────────╯
╭─────────┬─╮
│Variables│4│
-╭──────────────────────┬────────────────────╮
-│ Created │30-JUL-2025 15:07:55│
-├──────────────────────┼────────────────────┤
-│Writer Product │PSPP TEST DATA FILE │
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 8│
-╰──────────────────────┴────────────────────╯
+╭────────────────┬────────────────────╮
+│Created │30-JUL-2025 15:07:55│
+├────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├────────────────┼────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────┤
+│Number of Cases │ 8│
+╰────────────────┴────────────────────╯
╭─────────┬─╮
│Variables│4│
-╭──────────────────────┬────────────────────╮
-│ Created │30-JUL-2025 15:07:55│
-├──────────────────────┼────────────────────┤
-│Writer Product │PSPP TEST DATA FILE │
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────┤
-│ Compression │ZSAV │
-│ Number of Cases│ 8│
-╰──────────────────────┴────────────────────╯
+╭────────────────┬────────────────────╮
+│Created │30-JUL-2025 15:07:55│
+├────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├────────────────┼────────────────────┤
+│Compression │ZSAV │
+├────────────────┼────────────────────┤
+│Number of Cases │ 8│
+╰────────────────┴────────────────────╯
╭─────────┬─╮
│Variables│4│
-╭──────────────────────┬────────────────────╮
-│ Created │30-JUL-2025 15:07:55│
-├──────────────────────┼────────────────────┤
-│Writer Product │PSPP TEST DATA FILE │
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│ 2│
-╰──────────────────────┴────────────────────╯
+╭────────────────┬────────────────────╮
+│Created │30-JUL-2025 15:07:55│
+├────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├────────────────┼────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────┤
+│Number of Cases │ 2│
+╰────────────────┴────────────────────╯
╭─────────┬─╮
│Variables│5│
-╭──────────────────────┬────────────────────╮
-│ Created │30-JUL-2025 15:07:55│
-├──────────────────────┼────────────────────┤
-│Writer Product │PSPP TEST DATA FILE │
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 2│
-╰──────────────────────┴────────────────────╯
+╭────────────────┬────────────────────╮
+│Created │30-JUL-2025 15:07:55│
+├────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├────────────────┼────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────┤
+│Number of Cases │ 2│
+╰────────────────┴────────────────────╯
╭─────────┬─╮
│Variables│5│
-╭──────────────────────┬────────────────────╮
-│ Created │30-JUL-2025 15:07:55│
-├──────────────────────┼────────────────────┤
-│Writer Product │PSPP TEST DATA FILE │
-│ Version │1.2.3 │
-├──────────────────────┼────────────────────┤
-│ Compression │ZSAV │
-│ Number of Cases│ 2│
-╰──────────────────────┴────────────────────╯
+╭────────────────┬────────────────────╮
+│Created │30-JUL-2025 15:07:55│
+├────────────────┼────────────────────┤
+│Writer Product │PSPP TEST DATA FILE │
+│ Version │1.2.3 │
+├────────────────┼────────────────────┤
+│Compression │ZSAV │
+├────────────────┼────────────────────┤
+│Number of Cases │ 2│
+╰────────────────┴────────────────────╯
╭─────────┬─╮
│Variables│5│
Warning at file offsets 0xe0 to 0xe8: In variable display record: Invalid variable display alignment value 4294967295.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xe8: In variable display record: Invalid variable measurement level value 4.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xf0: In variable display record: Record contains 4 items but should contain either 2 or 3.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
Warning at file offsets 0xe0 to 0xf0: In extension record: variable display record has bad size 8 bytes instead of the expected 4.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
System file specifies value 2.0 (0x1.0p1) as LOWEST but -1.7976931348623157e308 (-0x1.fffffffffffffp1023) was expected.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
File header claims 2 variable positions but 1 were read from file.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 05-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-│ Version │13.2.3 │
-├──────────────────────┼────────────────────────┤
-│ Compression │None │
-│ Number of Cases│ 1│
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 05-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+│ Version │13.2.3 │
+├────────────────┼────────────────────────┤
+│Compression │None │
+├────────────────┼────────────────────────┤
+│Number of Cases │ 1│
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │ZSAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │ZSAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Warning at file offsets 0x1c4 to 0x1dc: In ZLIB trailer: Block descriptor 0 reported block size 0x400000, when at most 0x3ff000 was expected.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │ZSAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │ZSAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬────────────────────────╮
│Label │PSPP synthetic test file│
Multiple response set $b has no variables.
-╭──────────────────────┬────────────────────────╮
-│ Created │ 01-JAN-2011 20:53:52│
-├──────────────────────┼────────────────────────┤
-│Writer Product │PSPP synthetic test file│
-├──────────────────────┼────────────────────────┤
-│ Compression │SAV │
-│ Number of Cases│Unknown │
-╰──────────────────────┴────────────────────────╯
+╭────────────────┬────────────────────────╮
+│Created │ 01-JAN-2011 20:53:52│
+├────────────────┼────────────────────────┤
+│Writer Product │PSPP synthetic test file│
+├────────────────┼────────────────────────┤
+│Compression │SAV │
+├────────────────┼────────────────────────┤
+│Number of Cases │Unknown │
+╰────────────────┴────────────────────────╯
╭─────────┬─╮
│Variables│1│
fs::File,
io::{BufRead, BufReader, Cursor, Seek},
path::{Path, PathBuf},
- sync::Arc,
};
use binrw::Endian;
dictionary::Dictionary,
identifier::Identifier,
output::{
- Details, Item, Text,
- pivot::{Axis3, Dimension, Group, PivotTable, Value, tests::assert_lines_eq},
+ Item, Text,
+ pivot::{Axis3, Dimension, Group, PivotTable, tests::assert_lines_eq, value::Value},
},
sys::{
WriteOptions,
.with_extension("sav");
let sysfile = EncryptedFile::new(File::open(&input_filename).unwrap())
.unwrap()
- .unlock(password.as_bytes())
+ .unlock(password)
.unwrap();
let expected_filename = input_filename.with_extension("expected");
let expected = String::from_utf8(std::fs::read(&expected_filename).unwrap()).unwrap();
}
output.push(pt.into());
}
- Item::new(Details::Group(output.into_iter().map(Arc::new).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();
dictionary::{CategoryLabels, Dictionary, MultipleResponseType},
format::{DisplayPlain, Format},
identifier::Identifier,
- output::spv::Zeros,
sys::{
ProductVersion,
encoding::codepage_from_encoding,
}
}
+pub struct Zeros(pub usize);
+
+impl BinWrite for Zeros {
+ type Args<'a> = ();
+
+ fn write_options<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ _endian: Endian,
+ _args: Self::Args<'_>,
+ ) -> binrw::BinResult<()> {
+ for _ in 0..self.0 {
+ writer.write_all(&[0u8])?;
+ }
+ Ok(())
+ }
+}
+
#[cfg(test)]
mod tests {
use std::io::Cursor;
String,
}
+impl VarType {
+ pub fn is_numeric(&self) -> bool {
+ *self == Self::Numeric
+ }
+
+ pub fn is_string(&self) -> bool {
+ *self == Self::String
+ }
+}
+
impl Not for VarType {
type Output = Self;
Err(MissingValuesError::TooMany)
} else if value.var_type() != VarType::from(self.width) {
Err(MissingValuesError::MixedTypes)
- } else if value == Datum::Number(None) {
+ } else if value.is_sysmis() {
Err(MissingValuesError::SystemMissing)
} else if value.resize(self.width.min(VarWidth::String(8))).is_err() {
Err(MissingValuesError::TooWide)
--- /dev/null
+edition = "2024"
+style_edition = "2024"
struct spv_data_value
{
double index;
- int width;
+ int width; /* -1 for number, otherwise s's length. */
union
{
double d;