"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",
[[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"
[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
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)`
## 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
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
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
+ 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 corpus also includes localized names for units. A reader must
- understand these to properly interpret the dimension:
+ The corpus includes the following units, which includes localized
+ names for units. A reader must understand these to properly
+ interpret the dimensions:
- * inch: `์ธ์น`, `pol.`, `cala`, `cali`
- * point: `ะฟั`
- * centimeter: `ัะผ`
+ | Unit | Units per Inch | Names |
+ |:-------------------------|---------------:|:-------------------------------------|
+ | Inch | 1 | `in`, `์ธ์น`, `pol.`, `cala`, `cali` |
+ | Centimeter | 2.54 | `cm`, `ัะผ` |
+ | Point | 72 | `pt`, `ะฟั`, (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:
+### The `html` element
-* `color`
- In the form `RRGGBB`, e.g. `000000`, with no leading `#`.
+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.
-* `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.
-
-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, self-explanatory chart sizes, `quarter-height`,
+ or a localization (!) of one of these (e.g. `dimensione attuale`,
+ `Wie vorgegeben`).
* `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. Either `0deg`
+ (portrait) or `90deg` (landscape),
* `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).
-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:
+This element has the following attributes:
+
+* `type`
+ Always `text`.
+
+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"] }
serde = { version = "1.0.218", features = ["derive", "rc"] }
color = { version = "0.2.3", features = ["serde"] }
binrw = "0.14.1"
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"] }
(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, spv},
+ 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) = spv::ReadOptions::new()
+ .with_password(self.password.clone())
+ .open_file(&self.input)?
+ .into_parts();
+ 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.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(())
+ }
+}
--- /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 clap::{Args, ValueEnum};
+use pspp::output::{Criteria, Item, spv};
+use std::{fmt::Display, path::PathBuf};
+
+/// Show information about SPSS viewer files (SPV files).
+#[derive(Args, Clone, Debug)]
+pub struct ShowSpv {
+ /// What to show.
+ #[arg(value_enum)]
+ mode: Mode,
+
+ /// File to show.
+ ///
+ /// For most modes, this should be a `.spv` file. For `convert-table-look`,
+ /// this should be a `.tlo` or `.stt` file.
+ #[arg(required = true)]
+ input: PathBuf,
+
+ /// 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,
+
+ /// 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::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 = spv::ReadOptions::new()
+ .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 = spv::ReadOptions::new()
+ .with_password(self.password)
+ .open_file(&self.input)?
+ .into_items();
+ let items = self.criteria.apply(item);
+ for child in items {
+ println!("{child}");
+ }
+ 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(())
- }
-}
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(())
- }
-}
/// 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)]
}
}
-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,
+};
+
+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,
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(),
+ trim_spaces: true,
+ ..self
+ },
+ _ => self,
+ }
+ }
+}
+
#[cfg(test)]
mod tests;
}
}
- 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)
}
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 {
-/* 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,
+ message::{Diagnostic, Severity},
output::pivot::{Axis3, BorderStyle, Dimension, Group, Look},
};
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
+ }
+ }
}
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()
+ }
+}
+
+#[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::Group(children) => Some(children.as_slice()),
+ 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::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,
}
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> {
let Some(cur) = self.cur.take() else {
return;
};
- match cur.details {
- Details::Group(ref children) if !children.is_empty() => {
- self.cur = Some(children[0].clone());
- self.stack.push((cur, 1));
+ if let Some(first_child) = cur.details.children().first() {
+ self.cur = Some(first_child.clone());
+ self.stack.push((cur, 1));
+ } else {
+ while let Some((item, index)) = self.stack.pop() {
+ if let Some(child) = item.details.children().get(index) {
+ self.cur = Some(child.clone());
+ self.stack.push((item, index + 1));
+ return;
+ }
}
- _ => {
- 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;
+ }
+ }
+
+ // 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 detail members.
+ Light(
+ /// `.bin` member name.
+ String,
+ ),
+ /// Legacy detail members.
+ Legacy {
+ /// `.xml` member name.
+ xml: String,
+ /// `.bin` member name.
+ binary: String,
+ },
+ /// Image members.
+ Image(
+ /// `.png` file.
+ String,
+ ),
+ /// 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::Light(a) => (Some(a), None, None),
+ SpvMembers::Legacy { 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),
+ };
+ let mut classes = EnumSet::empty();
+ for name in s.split(',') {
+ if name == "all" {
+ classes = EnumSet::all();
+ } else {
+ classes.insert(
+ name.trim()
+ .parse()
+ .map_err(|_| anyhow!("unknown output class `{name}`"))?,
+ );
+ }
+ }
+ if invert {
+ classes = !classes;
+ }
+ Ok(classes)
+ }
+}
+
+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::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
+}
+
+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::{
+ Details, Item, ItemCursor, TextType,
+ drivers::{
+ Driver,
+ cairo::{
+ fsm::{CairoFsmStyle, parse_font_style},
+ pager::{CairoPageStyle, CairoPager},
+ },
+ },
+ page::PageSetup,
+ pivot::{Color, Coord2, 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) -> 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.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 Details::Text(text) = &item.details
+ && 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::drivers::cairo::{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::spv::html::Markup;
+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.
+ ///
+ /// - 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),
+ _ => {
+ eprintln!("{}", &self.item);
+ 0
+ }
+ };
+ 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.style.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(),
+ _ => true,
+ }
+ }
+}
+
+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.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
+}
+
+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, suffixes) = self.display().split_suffixes();
+ let horz_align = self.horz_align(&body);
+
+ let (mut body, mut attrs) = if let Some(markup) = body.markup() {
+ let (body, attrs) = Markup::to_pango(markup, self.substitutions);
+ (body, Some(attrs))
+ } else {
+ (avoid_decimal_split(body.to_string()), None)
+ };
+
+ match horz_align {
+ HorzAlign::Decimal { offset, decimal } if !self.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);
+ }
+ _ => (),
+ }
+
+ 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.max(0) as usize;
+
+ // Bound the adjustment by the width of the right margin.
+ let right_margin = px_to_xr(self.cell_style.margins[Axis2::X][1].max(0) as usize);
+ 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 == usize::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()
+ .saturating_sub(layout.size().1.max(0) as usize);
+ 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.max(0) as usize, height.max(0) as usize)
+ }
+
+ 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: 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
+ }
+ }
+
+ enum_map![
+ Extreme::Min => {
+ let bb = Rect2::new(0..1, 0..usize::MAX);
+ add_margins(cell, self.measure_cell(cell, bb).x())
+ }
+ Extreme::Max => {
+ let bb = Rect2::new(0..usize::MAX, 0..usize::MAX);
+ add_margins(cell, self.measure_cell(cell, bb).x())
+ },
+ ]
+ }
+
+ fn measure_cell_height(&self, cell: &DrawCell, width: usize) -> usize {
+ let margins = &cell.cell_style.margins;
+ let bb = Rect2::new(
+ 0..width.saturating_sub(px_to_xr(margins[Axis2::X].len())),
+ 0..usize::MAX,
+ );
+ self.measure_cell(cell, bb).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,
+ mut bb: Rect2,
+ valign_offset: usize,
+ spill: EnumMap<Axis2, [usize; 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].max(0) as usize);
+ bb[axis].end = bb[axis]
+ .end
+ .saturating_sub(draw_cell.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.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, CellStyle, FontStyle, Rect2, ValueOptions},
+ spv::html::{Document, Variable},
+ table::DrawCell,
+};
+
+#[derive(Clone, Debug)]
+pub struct CairoPageStyle {
+ pub margins: EnumMap<Axis2, [usize; 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: usize,
+ y_max: usize,
+}
+
+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.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;
+ }
+ }
+ }
+}
+
+struct RenderHeading<'a, F> {
+ heading: &'a Document,
+ fsm_style: &'a CairoFsmStyle,
+ page_number: i32,
+ width: usize,
+ 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) -> usize {
+ 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: usize) -> usize {
+ 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 paragraph in self.heading.to_values() {
+ // XXX substitute heading variables
+ let cell = DrawCell {
+ rotate: false,
+ inner: ¶graph.inner,
+ cell_style: paragraph.cell_style().unwrap_or(&default_cell_style),
+ font_style: paragraph.font_style().unwrap_or(&default_font_style),
+ subscripts: paragraph.subscripts(),
+ footnotes: paragraph.footnotes(),
+ value_options: &value_options,
+ substitutions,
+ };
+ let mut layout = Layout::new(&pangocairo_context);
+ let bb = Rect2::new(0..self.width, y + base_y..usize::MAX);
+ cell.layout(&bb, &mut layout, &self.fsm_style.font);
+ cell.draw(&bb, &layout, None, context);
+ y += layout.size().1 as usize;
+ }
+ 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, pivot::Coord2},
+ 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)
+ .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
+ | 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 = 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::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, 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.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(Coord2::new(0, 0));
+ self.put_cell(
+ DrawCell::new(cell.inner(), &title),
+ Rect2::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),
+ Rect2::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(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" };
+ 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(Coord2::new(0, 0)).inner(), &caption),
+ Rect2::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),
+ Rect2::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: Rect2,
+ 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[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 + '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 core::f64;
+use std::{
+ borrow::Cow,
+ fs::File,
+ io::{Cursor, Seek, Write},
+ iter::{repeat, repeat_n},
+ path::PathBuf,
+ sync::Arc,
+};
+
+use binrw::{BinWrite, Endian};
+use chrono::Utc;
+use enum_map::EnumMap;
+use paper_sizes::Length;
+use quick_xml::{
+ ElementWriter,
+ events::{BytesText, attributes::Attribute},
+ writer::Writer as XmlWriter,
+};
+use serde::{Deserialize, Serialize};
+use zip::{ZipWriter, result::ZipResult, write::SimpleFileOptions};
+
+use crate::{
+ format::{Format, Type},
+ output::{
+ Details, Item, Text,
+ drivers::Driver,
+ page::{ChartSize, PageSetup},
+ pivot::{
+ Area, AreaStyle, Axis2, Axis3, Border, BorderStyle, BoxBorder, Category, CellStyle,
+ Color, Dimension, FontStyle, Footnote, FootnoteMarkerPosition, FootnoteMarkerType,
+ Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Leaf, PivotTable,
+ RowColBorder, RowParity, Stroke, Value, ValueInner, ValueStyle, VertAlign,
+ },
+ spv::html::Document,
+ },
+ settings::Show,
+ util::ToSmallString,
+};
+
+fn light_table_name(table_id: u64) -> String {
+ format!("{table_id:011}_lightTableData.bin")
+}
+
+fn output_viewer_name(heading_id: u64, is_heading: bool) -> String {
+ format!(
+ "outputViewer{heading_id:010}{}.xml",
+ if is_heading { "_heading" } else { "" }
+ )
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct SpvConfig {
+ /// Output file name.
+ pub file: PathBuf,
+
+ /// Page setup.
+ pub page_setup: Option<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((
+ "commandName",
+ pivot_table
+ .metadata
+ .command_local
+ .as_ref()
+ .map_or("", |s| s.as_str()),
+ ))
+ .with_attribute(("type", "table" /*XXX*/))
+ .with_attribute((
+ "subType",
+ Cow::from(pivot_table.subtype().display(pivot_table).to_string()),
+ ))
+ .write_inner_content(|w| {
+ w.create_element("vtb:tableStructure")
+ .write_inner_content(|w| {
+ w.create_element("vtb:dataPath")
+ .write_text_content(BytesText::new(&table_name))?;
+ Ok(())
+ })?;
+ Ok(())
+ })
+ .unwrap();
+ });
+ }
+
+ fn write_text<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 {
+ 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);
+ }
+ Ok(())
+ })
+ .unwrap();
+ }
+ Details::Message(diagnostic) => {
+ self.write_text(item, &Text::from(diagnostic.as_ref()), structure)
+ }
+ Details::PageBreak => {
+ self.needs_page_break = true;
+ }
+ Details::Table(pivot_table) => self.write_table(item, pivot_table, structure),
+ Details::Text(text) => self.write_text(item, text, structure),
+ }
+ }
+
+ fn container<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.style.rotate_inner_column_labels),
+ SpvBool(self.style.rotate_outer_row_labels),
+ SpvBool(true), // x2
+ 0x15u32, // x3
+ *self.style.look.heading_widths[HeadingRegion::Columns].start() as i32,
+ *self.style.look.heading_widths[HeadingRegion::Columns].end() as i32,
+ *self.style.look.heading_widths[HeadingRegion::Rows].start() as i32,
+ *self.style.look.heading_widths[HeadingRegion::Rows].end() as i32,
+ 0u64,
+ )
+ .write_le(writer)?;
+
+ // Titles.
+ (
+ self.title(),
+ self.subtype(),
+ Optional(Some(self.title())),
+ Optional(self.metadata.corner_text.as_ref()),
+ Optional(self.metadata.caption.as_ref()),
+ )
+ .write_le(writer)?;
+
+ // Footnotes.
+ self.footnotes.write_le(writer)?;
+
+ // Areas.
+ static SPV_AREAS: [Area; 8] = [
+ Area::Title,
+ Area::Caption,
+ Area::Footer,
+ Area::Corner,
+ Area::Labels(Axis2::X),
+ Area::Labels(Axis2::Y),
+ Area::Data(RowParity::Even),
+ Area::Layers,
+ ];
+ for (index, area) in SPV_AREAS.into_iter().enumerate() {
+ let odd_data_style = if let Area::Data(_) = area {
+ Some(&self.style.look.areas[Area::Data(RowParity::Odd)])
+ } else {
+ None
+ };
+ self.style.look.areas[area].write_le_args(writer, (index, odd_data_style))?;
+ }
+
+ // Borders.
+ static SPV_BORDERS: [Border; 19] = [
+ Border::Title,
+ Border::OuterFrame(BoxBorder::Left),
+ Border::OuterFrame(BoxBorder::Top),
+ Border::OuterFrame(BoxBorder::Right),
+ Border::OuterFrame(BoxBorder::Bottom),
+ Border::InnerFrame(BoxBorder::Left),
+ Border::InnerFrame(BoxBorder::Top),
+ Border::InnerFrame(BoxBorder::Right),
+ Border::InnerFrame(BoxBorder::Bottom),
+ Border::DataLeft,
+ Border::DataTop,
+ Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::Y)),
+ Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::Y)),
+ Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::Y)),
+ Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::Y)),
+ ];
+ let borders_start = Count::new(writer)?;
+ (1, SPV_BORDERS.len() as u32).write_be(writer)?;
+ for (index, border) in SPV_BORDERS.into_iter().enumerate() {
+ self.style.look.borders[border].write_be_args(writer, index)?;
+ }
+ (SpvBool(self.style.show_grid_lines), 0u8, 0u16).write_le(writer)?;
+ borders_start.finish_le32(writer)?;
+
+ // Print Settings.
+ Counted::new((
+ 1u32,
+ SpvBool(self.style.look.print_all_layers),
+ SpvBool(self.style.look.paginate_layers),
+ SpvBool(self.style.look.shrink_to_fit[Axis2::X]),
+ SpvBool(self.style.look.shrink_to_fit[Axis2::Y]),
+ SpvBool(self.style.look.top_continuation),
+ SpvBool(self.style.look.bottom_continuation),
+ self.style.look.n_orphan_lines as u32,
+ SpvString(
+ self.style
+ .look
+ .continuation
+ .as_ref()
+ .map_or("", |s| s.as_str()),
+ ),
+ ))
+ .with_endian(Endian::Little)
+ .write_be(writer)?;
+
+ // Table Settings.
+ Counted::new((
+ 1u32,
+ 4u32,
+ self.spv_layer() as u32,
+ SpvBool(self.style.look.hide_empty),
+ SpvBool(self.style.look.row_label_position == LabelPosition::Corner),
+ SpvBool(self.style.look.footnote_marker_type == FootnoteMarkerType::Alphabetic),
+ SpvBool(
+ self.style.look.footnote_marker_position == FootnoteMarkerPosition::Superscript,
+ ),
+ 0u8,
+ Counted::new((
+ 0u32, // n-row-breaks
+ 0u32, // n-column-breaks
+ 0u32, // n-row-keeps
+ 0u32, // n-column-keeps
+ 0u32, // n-row-point-keeps
+ 0u32, // n-column-point-keeps
+ )),
+ SpvString::optional(&self.metadata.notes),
+ SpvString::optional(&self.style.look.name),
+ Zeros(82),
+ ))
+ .with_endian(Endian::Little)
+ .write_be(writer)?;
+
+ fn y0(pivot_table: &PivotTable) -> impl for<'a> BinWrite<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.current_layer.iter().copied())
+ .rev()
+ {
+ layer = layer * dimension.len() + layer_value;
+ }
+ layer
+ }
+}
+
+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>) {
+ if item.details.is_page_break() {
+ self.needs_page_break = true;
+ return;
+ }
+
+ let mut headings = XmlWriter::new(Cursor::new(Vec::new()));
+ let element = headings
+ .create_element("heading")
+ .with_attribute((
+ "creation-date-time",
+ Cow::from(Utc::now().format("%x %x").to_string()),
+ ))
+ .with_attribute((
+ "creator",
+ Cow::from(format!(
+ "{} {}",
+ env!("CARGO_PKG_NAME"),
+ env!("CARGO_PKG_VERSION")
+ )),
+ ))
+ .with_attribute(("creator-version", "21"))
+ .with_attribute(("xmlns", "http://xml.spss.com/spss/viewer/viewer-tree"))
+ .with_attribute((
+ "xmlns:vps",
+ "http://xml.spss.com/spss/viewer/viewer-pagesetup",
+ ))
+ .with_attribute(("xmlns:vtx", "http://xml.spss.com/spss/viewer/viewer-text"))
+ .with_attribute(("xmlns:vtb", "http://xml.spss.com/spss/viewer/viewer-table"));
+ element
+ .write_inner_content(|w| {
+ w.create_element("label")
+ .write_text_content(BytesText::new("Output"))?;
+ if let Some(page_setup) = self.page_setup.take() {
+ write_page_setup(&page_setup, w)?;
+ }
+ self.write_item(item, w);
+ Ok(())
+ })
+ .unwrap();
+
+ let headings = headings.into_inner().into_inner();
+ let heading_id = self.next_heading_id;
+ self.next_heading_id += 1;
+ self.writer
+ .start_file(
+ output_viewer_name(heading_id, item.details.is_heading()),
+ SimpleFileOptions::default(),
+ )
+ .unwrap(); // XXX
+ self.writer.write_all(&headings).unwrap(); // XXX
+ }
+
+ fn setup(&mut self, page_setup: &PageSetup) -> bool {
+ self.page_setup = Some(page_setup.clone());
+ true
+ }
+}
+
+fn write_page_setup<X>(page_setup: &PageSetup, writer: &mut XmlWriter<X>) -> std::io::Result<()>
+where
+ X: Write,
+{
+ fn length(length: Length) -> Cow<'static, str> {
+ Cow::from(length.to_string())
+ }
+
+ writer
+ .create_element("vps:pageSetup")
+ .with_attribute((
+ "initial-page-number",
+ Cow::from(format!("{}", page_setup.initial_page_number)),
+ ))
+ .with_attribute((
+ "chart-size",
+ match page_setup.chart_size {
+ ChartSize::AsIs => "as-is",
+ ChartSize::FullHeight => "full-height",
+ ChartSize::HalfHeight => "half-height",
+ ChartSize::QuarterHeight => "quarter-height",
+ },
+ ))
+ .with_attribute(("margin-left", length(page_setup.margins.0[Axis2::X][0])))
+ .with_attribute(("margin-right", length(page_setup.margins.0[Axis2::X][1])))
+ .with_attribute(("margin-top", length(page_setup.margins.0[Axis2::Y][0])))
+ .with_attribute(("margin-bottom", length(page_setup.margins.0[Axis2::Y][1])))
+ .with_attribute(("paper-height", length(page_setup.paper.height())))
+ .with_attribute(("paper-width", length(page_setup.paper.width())))
+ .with_attribute((
+ "reference-orientation",
+ match page_setup.orientation {
+ crate::output::page::Orientation::Portrait => "portrait",
+ crate::output::page::Orientation::Landscape => "landscape",
+ },
+ ))
+ .with_attribute(("space-after", length(page_setup.object_spacing)))
+ .write_inner_content(|w| {
+ write_page_heading(&page_setup.header, "vps:pageHeader", w)?;
+ write_page_heading(&page_setup.footer, "vps:pageFooter", w)?;
+ Ok(())
+ })?;
+ Ok(())
+}
+
+fn write_page_heading<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(())
+}
+
+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, // 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)
+ }
+
+ 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,
+ self.bg,
+ SpvString(typeface),
+ (self.size as f64 * 1.33).ceil() as u8,
+ )
+ .write_options(writer, endian, args)
+ }
+}
+
+impl HorzAlign {
+ fn as_spv(&self, decimal: u32) -> u32 {
+ match self {
+ HorzAlign::Right => 4,
+ HorzAlign::Left => 2,
+ HorzAlign::Center => 0,
+ HorzAlign::Decimal { .. } => decimal,
+ }
+ }
+
+ fn decimal_offset(&self) -> Option<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::Number(number) => {
+ let format = SpvFormat {
+ format: number.format,
+ honor_small: number.honor_small,
+ };
+ if number.variable.is_some() || number.value_label.is_some() {
+ (
+ 2u8,
+ ValueMod::new(self),
+ format,
+ number.value.unwrap_or(f64::MIN),
+ SpvString::optional(&number.variable),
+ SpvString::optional(&number.value_label),
+ Show::as_spv(&number.show),
+ )
+ .write_options(writer, endian, args)?;
+ } else {
+ (
+ 1u8,
+ ValueMod::new(self),
+ format,
+ number.value.unwrap_or(f64::MIN),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ }
+ ValueInner::String(string) => {
+ (
+ 4u8,
+ ValueMod::new(self),
+ SpvFormat {
+ format: if string.hex {
+ Format::new(Type::AHex, (string.s.len() * 2) as u16, 0).unwrap()
+ } else {
+ Format::new(Type::A, (string.s.len()) as u16, 0).unwrap()
+ },
+ honor_small: false,
+ },
+ SpvString::optional(&string.value_label),
+ SpvString::optional(&string.var_name),
+ Show::as_spv(&string.show),
+ SpvString(&string.s),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ ValueInner::Variable(variable) => {
+ (
+ 5u8,
+ ValueMod::new(self),
+ SpvString(&variable.var_name),
+ SpvString::optional(&variable.variable_label),
+ Show::as_spv(&variable.show),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ ValueInner::Markup(markup) => {
+ let text = markup.to_string();
+ (
+ 3u8,
+ SpvString(&text), // XXX
+ ValueMod::new(self),
+ SpvString(&text),
+ SpvString(&text),
+ SpvBool(true),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ ValueInner::Text(text) => {
+ (
+ 3u8,
+ SpvString(&text.localized),
+ ValueMod::new(self),
+ SpvString(text.id()),
+ SpvString(text.c()),
+ SpvBool(true),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ ValueInner::Template(template) => {
+ (
+ 0u8,
+ ValueMod::new(self),
+ SpvString(&template.localized),
+ template.args.len() as u32,
+ )
+ .write_options(writer, endian, args)?;
+ for arg in &template.args {
+ if arg.len() > 1 {
+ (arg.len() as u32, 0u32).write_options(writer, endian, args)?;
+ for (index, value) in arg.iter().enumerate() {
+ if index > 0 {
+ 0u32.write_le(writer)?;
+ }
+ value.write_options(writer, endian, args)?;
+ }
+ } else {
+ (0u32, arg).write_options(writer, endian, args)?;
+ }
+ }
+ }
+ ValueInner::Empty => {
+ (
+ 3u8,
+ SpvString(""),
+ ValueMod::default(),
+ SpvString(""),
+ SpvString(""),
+ SpvBool(true),
+ )
+ .write_options(writer, endian, args)?;
+ }
+ }
+ Ok(())
+ }
+}
--- /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::{render::Extreme, table::DrawCell};
+
+use crate::output::{
+ Details, Item,
+ drivers::Driver,
+ pivot::{Axis2, BorderStyle, Coord2, HorzAlign, PivotTable, Rect2, Stroke},
+ render::{Device, Pager, Params},
+ table::Content,
+};
+
+mod text_line;
+use text_line::{Emphasis, TextLine, clip_text};
+
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Boxes {
+ Ascii,
+ #[default]
+ Unicode,
+}
+
+impl Boxes {
+ fn box_chars(&self) -> &'static BoxChars {
+ match self {
+ Boxes::Ascii => &ASCII_BOX,
+ Boxes::Unicode => &UNICODE_BOX,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct TextConfig {
+ /// Output file name.
+ file: Option<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: 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,
+ {
+ match &item.details {
+ Details::Chart | Details::Image(_) => todo!(),
+ Details::Heading(children) => {
+ for (index, child) in children.0.iter().enumerate() {
+ if index > 0 {
+ writeln!(writer)?;
+ }
+ self.render(child, writer)?;
+ }
+ Ok(())
+ }
+ Details::Message(_diagnostic) => todo!(),
+ Details::PageBreak => Ok(()),
+ Details::Table(pivot_table) => self.render_table(pivot_table, writer),
+ Details::Text(text) => self.render_table(&PivotTable::from((**text).clone()), writer),
+ }
+ }
+
+ fn render_table<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,
+ 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.font_style).apply(text)
+ } else {
+ Cow::from(text)
+ };
+ self.get_line(y).put(x, &text);
+ }
+ }
+
+ fn scale(&mut self, _factor: f64) {
+ unimplemented!()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
+
+ use crate::output::drivers::text::new_line_breaks;
+
+ #[test]
+ fn unicode_width() {
+ // `\n` is a control character, so [UnicodeWidthChar] considers it to
+ // have no width.
+ assert_eq!('\n'.width(), None);
+
+ // But [UnicodeWidthStr] in unicode-width 0.1.14+ has a different idea.
+ assert_eq!("\n".width(), 1);
+ assert_eq!("\r\n".width(), 1);
+ }
+
+ #[track_caller]
+ fn test_line_breaks(input: &str, width: usize, expected: Vec<&str>) {
+ let actual = new_line_breaks(input, width).collect::<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:?}"
+ );
+ }
+ }
+ }
+}
+++ /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::output::spv::html::Document;
-use super::pivot::{Axis2, HorzAlign};
+use super::pivot::Axis2;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
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()
+ );
}
}
use color::{AlphaColor, Rgba8, Srgb, palette::css::TRANSPARENT};
use enum_iterator::Sequence;
use enum_map::{Enum, EnumMap, enum_map};
-use look_xml::TableProperties;
+use itertools::Itertools;
+pub use look_xml::{Length, TableProperties};
use quick_xml::{DeError, de::from_str};
use serde::{
Deserialize, Serialize, Serializer,
use smallstr::SmallString;
use smallvec::SmallVec;
use thiserror::Error as ThisError;
+pub use tlo::parse_bool;
use tlo::parse_tlo;
use crate::{
calendar::date_time_to_pspp,
- data::{ByteString, Datum, EncodedString, RawString},
- format::{Decimal, Format, Settings as FormatSettings, Type, UncheckedFormat},
+ data::{ByteString, Datum, EncodedString},
+ format::{
+ DATETIME40_0, Decimal, F8_2, F40, F40_2, F40_3, Format, PCT40_1,
+ Settings as FormatSettings, Type, UncheckedFormat,
+ },
+ output::spv::html::Markup,
settings::{Settings, Show},
util::ToSmallString,
variable::{VarType, Variable},
mod tlo;
/// Areas of a pivot table for styling purposes.
-#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
pub enum Area {
+ /// Title.
Title,
+
+ /// Caption.
Caption,
/// Footnotes,
// Top-left corner.
Corner,
- /// Labels for columns ([Axis2::X]) and rows ([Axis2::Y]).
- Labels(Axis2),
-
- #[default]
- Data,
+ /// Labels.
+ Labels(
+ /// - [Axis2::X]: Column labels, along the top of the table.
+ /// - [Axis2::Y]: Row labels, along the left side of the table.
+ Axis2,
+ ),
+
+ /// Data cells.
+ Data(
+ /// This allows styling for even rows and odd rows to differ
+ /// arbitrarily, but the SPV file format only distinguishes foreground
+ /// and background colors, so any other differences will be lost upon
+ /// save.
+ RowParity,
+ ),
/// Layer indication.
Layers,
}
+impl Default for Area {
+ fn default() -> Self {
+ Self::Data(RowParity::default())
+ }
+}
+
impl Display for Area {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Area::Footer => write!(f, "footer"),
Area::Corner => write!(f, "corner"),
Area::Labels(axis2) => write!(f, "labels({axis2})"),
- Area::Data => write!(f, "data"),
+ Area::Data(row) => write!(f, "data({row})"),
Area::Layers => write!(f, "layers"),
}
}
}
}
-impl Area {
- fn default_cell_style(self) -> CellStyle {
- use HorzAlign::*;
- use VertAlign::*;
- let (horz_align, vert_align, hmargins, vmargins) = match self {
- Area::Title => (Some(Center), Middle, [8, 11], [1, 8]),
- Area::Caption => (Some(Left), Top, [8, 11], [1, 1]),
- Area::Footer => (Some(Left), Top, [11, 8], [2, 3]),
- Area::Corner => (Some(Left), Bottom, [8, 11], [1, 1]),
- Area::Labels(Axis2::X) => (Some(Center), Top, [8, 11], [1, 3]),
- Area::Labels(Axis2::Y) => (Some(Left), Top, [8, 11], [1, 3]),
- Area::Data => (None, Top, [8, 11], [1, 1]),
- Area::Layers => (Some(Left), Bottom, [8, 11], [1, 3]),
- };
- CellStyle {
- horz_align,
- vert_align,
- margins: enum_map! { Axis2::X => hmargins, Axis2::Y => vmargins },
- }
- }
+/// Distinguishes [Area::Data] for even-numbered and odd-numbered rows.
+#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
+pub enum RowParity {
+ /// Even-numbered rows.
+ ///
+ /// The first row is row 0, hence even.
+ #[default]
+ Even,
+ /// Odd-numbered rows.
+ Odd,
+}
- fn default_font_style(self) -> FontStyle {
- FontStyle {
- bold: self == Area::Title,
- italic: false,
- underline: false,
- markup: false,
- font: String::from("Sans Serif"),
- fg: [Color::BLACK; 2],
- bg: [Color::WHITE; 2],
- size: 9,
+impl From<usize> for RowParity {
+ fn from(value: usize) -> Self {
+ if value % 2 == 1 {
+ Self::Odd
+ } else {
+ Self::Even
}
}
+}
- fn default_area_style(self) -> AreaStyle {
- AreaStyle {
- cell_style: self.default_cell_style(),
- font_style: self.default_font_style(),
+impl Display for RowParity {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RowParity::Even => write!(f, "even"),
+ RowParity::Odd => write!(f, "odd"),
}
}
}
Self::Dimension(row_col_border) => Self::Category(row_col_border),
}
}
+
+ pub fn default_borders() -> EnumMap<Border, BorderStyle> {
+ EnumMap::from_fn(Border::default_border_style)
+ }
}
impl Display for Border {
#[derive(Default, Clone, Debug, Serialize)]
pub struct Sizing {
/// Specific column widths, in 1/96" units.
- widths: Vec<i32>,
+ 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.
- breaks: Vec<usize>,
+ pub breaks: Vec<usize>,
/// Keeps: columns to keep together on a page if possible.
- keeps: Vec<Range<usize>>,
+ pub keeps: Vec<Range<usize>>,
}
#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Sequence, Serialize)]
}
impl PivotTable {
- pub fn with_look(mut self, look: Arc<Look>) -> Self {
- self.look = look;
- self
+ pub fn with_look(self, look: Arc<Look>) -> Self {
+ Self {
+ style: self.style.with_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
+ Class::Integer => F40,
+ Class::Correlations => F40_3,
+ Class::Significance => F40_3,
+ Class::Percent => PCT40_1,
+ Class::Residual => F40_2,
+ Class::Count => F40, // XXX
};
let value = Value::new(ValueInner::Number(NumberValue {
show: None,
leaf: &'a Leaf,
}
+pub type IndexVec = SmallVec<[usize; 4]>;
+
impl Dimension {
pub fn new(root: Group) -> Self {
Dimension {
self.root.leaf_path(index, SmallVec::new())
}
+ pub fn index_path(&self, index: usize) -> Option<IndexVec> {
+ self.root.index_path(index, SmallVec::new())
+ }
+
pub fn with_all_labels_hidden(self) -> Self {
Self {
hide_all_labels: true,
}
}
+/// Specifies a [Category] within a [Group].
+#[derive(Copy, Clone, Debug)]
+pub struct CategoryLocator {
+ /// The index of the leaf to start from.
+ pub leaf_index: usize,
+
+ /// The number of times to go up a level from the leaf. If this category is
+ /// a leaf, this is 0, otherwise it is positive.
+ pub level: usize,
+}
+
+impl CategoryLocator {
+ pub fn new_leaf(leaf_index: usize) -> Self {
+ Self {
+ leaf_index,
+ level: 0,
+ }
+ }
+
+ pub fn parent(&self) -> Self {
+ Self {
+ leaf_index: self.leaf_index,
+ level: self.level + 1,
+ }
+ }
+
+ pub fn as_leaf(&self) -> Option<usize> {
+ (self.level == 0).then_some(self.leaf_index)
+ }
+}
+
#[derive(Clone, Debug, Serialize)]
pub struct Group {
#[serde(skip)]
pub fn push(&mut self, child: impl Into<Category>) {
let mut child = child.into();
- if let Category::Group(group) = &mut child {
+ if let Some(group) = child.as_group_mut() {
group.show_label = true;
}
self.len += child.len();
None
}
+ 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
+ }
+
+ 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) or
+ /// if `locator` designates `self`.
+ pub fn category(&self, locator: CategoryLocator) -> Option<&Category> {
+ let path = self.locator_path(locator)?;
+ let mut this = &self.children[*path.get(0)?];
+ for index in path[1..].iter().copied() {
+ this = &this.as_group().unwrap().children[index];
+ }
+ Some(this)
+ }
+
+ pub fn category_mut(&mut self, locator: CategoryLocator) -> Option<&mut Category> {
+ let path = self.locator_path(locator)?;
+ let mut this = &mut self.children[*path.get(0)?];
+ for index in path[1..].iter().copied() {
+ this = &mut this.as_group_mut().unwrap().children[index];
+ }
+ Some(this)
+ }
+
pub fn len(&self) -> usize {
self.len
}
}
#[derive(Clone, Debug, Default, Serialize)]
-pub struct Footnotes(pub Vec<Arc<Footnote>>);
+pub struct Footnotes(Vec<Arc<Footnote>>);
impl Footnotes {
pub fn new() -> Self {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
+
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ pub fn get(&self, index: usize) -> Option<&Arc<Footnote>> {
+ self.0.get(index)
+ }
}
-#[derive(Clone, Debug)]
-pub struct Leaf {
- name: Box<Value>,
+impl Index<usize> for Footnotes {
+ type Output = Arc<Footnote>;
+
+ fn index(&self, index: usize) -> &Self::Output {
+ &self.0[index]
+ }
+}
+
+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()
+ }
+}
+
+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(),
+ )
+ }
}
+#[derive(Clone, Debug)]
+pub struct Leaf(Box<Value>);
+
impl Leaf {
pub fn new(name: Value) -> Self {
- Self {
- name: Box::new(name),
- }
+ Self(Box::new(name))
}
pub fn name(&self) -> &Value {
- &self.name
+ &self.0
}
}
where
S: serde::Serializer,
{
- self.name.serialize(serializer)
+ self.0.serialize(serializer)
}
}
Count,
}
-/// A pivot_category is a leaf (a category) or a group.
+/// A leaf category or a group of them.
#[derive(Clone, Debug, Serialize)]
pub enum Category {
Group(Group),
}
impl Category {
+ pub fn as_group(&self) -> Option<&Group> {
+ match self {
+ Category::Group(group) => Some(group),
+ Category::Leaf(_) => None,
+ }
+ }
+
+ pub fn as_group_mut(&mut self) -> Option<&mut Group> {
+ match self {
+ Category::Group(group) => Some(group),
+ Category::Leaf(_) => None,
+ }
+ }
+
+ pub fn as_leaf(&self) -> Option<&Leaf> {
+ match self {
+ Category::Leaf(leaf) => Some(leaf),
+ Category::Group(_) => None,
+ }
+ }
+
+ pub fn as_leaf_mut(&mut self) -> Option<&mut Leaf> {
+ match self {
+ Category::Leaf(leaf) => Some(leaf),
+ Category::Group(_) => None,
+ }
+ }
+
pub fn name(&self) -> &Value {
match self {
Category::Group(group) => &group.name,
- Category::Leaf(leaf) => &leaf.name,
+ Category::Leaf(leaf) => &leaf.0,
+ }
+ }
+
+ pub fn name_mut(&mut self) -> &mut Value {
+ match self {
+ Category::Group(group) => &mut group.name,
+ Category::Leaf(leaf) => &mut leaf.0,
}
}
pub fn nth_leaf(&self, index: usize) -> Option<&Leaf> {
match self {
Category::Group(group) => group.nth_leaf(index),
- Category::Leaf(leaf) => {
- if index == 0 {
- Some(leaf)
- } else {
- None
- }
- }
+ Category::Leaf(leaf) if index == 0 => Some(leaf),
+ _ => None,
}
}
pub fn leaf_path<'a>(&'a self, index: usize, groups: GroupVec<'a>) -> Option<Path<'a>> {
match self {
Category::Group(group) => group.leaf_path(index, groups),
- Category::Leaf(leaf) => {
- if index == 0 {
- Some(Path { groups, leaf })
- } else {
- None
- }
- }
+ Category::Leaf(leaf) if index == 0 => Some(Path { groups, leaf }),
+ _ => None,
+ }
+ }
+
+ fn index_path(&self, index: usize, path: IndexVec) -> Option<IndexVec> {
+ match self {
+ Category::Group(group) => group.index_path(index, path),
+ Category::Leaf(_) if index == 0 => Some(path),
+ _ => None,
+ }
+ }
+
+ fn locator_path(&self, locator: CategoryLocator) -> Option<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).
+ pub fn category(&self, locator: CategoryLocator) -> Option<&Category> {
+ let mut this = self;
+ for index in this.locator_path(locator)? {
+ this = &this.as_group().unwrap().children[index];
+ }
+ Some(this)
+ }
+
+ pub fn category_mut(&mut self, locator: CategoryLocator) -> Option<&mut Category> {
+ let mut this = self;
+ for index in this.locator_path(locator)? {
+ this = &mut this.as_group_mut().unwrap().children[index];
}
+ Some(this)
}
pub fn show_label(&self) -> bool {
/// The division between this and the style information in [PivotTable] seems
/// fairly arbitrary. The ultimate reason for the division is simply because
/// that's how SPSS documentation and file formats do it.
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Look {
pub name: Option<String>,
}),
footnote_marker_type: FootnoteMarkerType::default(),
footnote_marker_position: FootnoteMarkerPosition::default(),
- areas: EnumMap::from_fn(Area::default_area_style),
- borders: EnumMap::from_fn(Border::default_border_style),
+ areas: EnumMap::from_fn(AreaStyle::default_for_area),
+ borders: Border::default_borders(),
print_all_layers: false,
paginate_layers: false,
shrink_to_fit: EnumMap::from_fn(|_| false),
/// Position for group labels.
#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub enum LabelPosition {
- /// Hierarachically enclosing the categories.
+ /// Hierarchically enclosing the categories.
///
/// For column labels, group labels appear above the categories. For row
/// labels, group labels appear to the left of the categories.
}
}
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct AreaStyle {
pub cell_style: CellStyle,
pub font_style: FontStyle,
}
-#[derive(Clone, Debug, Serialize)]
+impl AreaStyle {
+ pub fn default_for_area(area: Area) -> Self {
+ Self {
+ cell_style: CellStyle::default_for_area(area),
+ font_style: FontStyle::default_for_area(area),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct CellStyle {
/// `None` means "mixed" alignment: align strings to the left, numbers to
/// the right.
pub margins: EnumMap<Axis2, [i32; 2]>,
}
+impl Default for CellStyle {
+ fn default() -> Self {
+ Self::default_for_area(Area::default())
+ }
+}
+
+impl CellStyle {
+ pub fn default_for_area(area: Area) -> Self {
+ use HorzAlign::*;
+ use VertAlign::*;
+ let (horz_align, vert_align, hmargins, vmargins) = match area {
+ Area::Title => (Some(Center), Middle, [8, 11], [1, 8]),
+ Area::Caption => (Some(Left), Top, [8, 11], [1, 1]),
+ Area::Footer => (Some(Left), Top, [11, 8], [2, 3]),
+ Area::Corner => (Some(Left), Bottom, [8, 11], [1, 1]),
+ Area::Labels(Axis2::X) => (Some(Center), Bottom, [8, 11], [1, 3]),
+ Area::Labels(Axis2::Y) => (Some(Left), Top, [8, 11], [1, 3]),
+ Area::Data(_) => (None, Top, [8, 11], [1, 1]),
+ Area::Layers => (Some(Left), Bottom, [8, 11], [1, 3]),
+ };
+ Self {
+ horz_align,
+ vert_align,
+ margins: enum_map! { Axis2::X => hmargins, Axis2::Y => vmargins },
+ }
+ }
+ pub fn with_horz_align(self, horz_align: Option<HorzAlign>) -> Self {
+ Self { horz_align, ..self }
+ }
+ pub fn with_vert_align(self, vert_align: VertAlign) -> Self {
+ Self { vert_align, ..self }
+ }
+ pub fn with_margins(self, margins: EnumMap<Axis2, [i32; 2]>) -> Self {
+ Self { margins, ..self }
+ }
+}
+
#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HorzAlign {
VarType::String => Self::Left,
}
}
+
+ pub fn as_str(&self) -> Option<&'static str> {
+ match self {
+ HorzAlign::Right => Some("right"),
+ HorzAlign::Left => Some("left"),
+ HorzAlign::Center => Some("center"),
+ HorzAlign::Decimal { .. } => None,
+ }
+ }
+}
+
+/// Unknown horizontal alignment.
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub struct UnknownHorzAlign;
+
+impl FromStr for HorzAlign {
+ type Err = UnknownHorzAlign;
+
+ fn from_str(s: &str) -> Result<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)
+ }
+ }
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
Bottom,
}
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct FontStyle {
pub bold: bool,
pub italic: bool,
pub underline: bool,
- pub markup: bool,
pub font: String,
-
- /// `fg[0]` is the usual foreground color.
- ///
- /// `fg[1]` is used only in [Area::Data] for odd-numbered rows.
- pub fg: [Color; 2],
-
- /// `bg[0]` is the usual background color.
- ///
- /// `bg[1]` is used only in [Area::Data] for odd-numbered rows.
- pub bg: [Color; 2],
+ pub fg: Color,
+ pub bg: Color,
/// In 1/72" units.
pub size: i32,
}
+impl Default for FontStyle {
+ fn default() -> Self {
+ FontStyle {
+ bold: false,
+ italic: false,
+ underline: false,
+ font: String::from("Sans Serif"),
+ fg: Color::BLACK,
+ bg: Color::WHITE,
+ size: 9,
+ }
+ }
+}
+
+impl FontStyle {
+ pub fn default_for_area(area: Area) -> Self {
+ Self::default().with_bold(area == Area::Title)
+ }
+ pub fn with_size(self, size: i32) -> Self {
+ Self { size, ..self }
+ }
+ pub fn with_bold(self, bold: bool) -> Self {
+ Self { bold, ..self }
+ }
+ pub fn with_italic(self, italic: bool) -> Self {
+ Self { italic, ..self }
+ }
+ pub fn with_underline(self, underline: bool) -> Self {
+ Self { underline, ..self }
+ }
+ pub fn with_font(self, font: impl Into<String>) -> Self {
+ Self {
+ font: font.into(),
+ ..self
+ }
+ }
+ pub fn with_fg(self, fg: Color) -> Self {
+ Self { fg, ..self }
+ }
+ pub fn with_bg(self, fg: Color) -> Self {
+ Self { fg, ..self }
+ }
+}
+
#[derive(Copy, Clone, PartialEq, Eq)]
pub struct Color {
pub alpha: u8,
pub fn display_css(&self) -> DisplayCss {
DisplayCss(*self)
}
+
+ pub fn into_rgb(&self) -> (u8, u8, u8) {
+ (self.r, self.g, self.b)
+ }
+
+ pub fn into_rgb16(&self) -> (u16, u16, u16) {
+ (
+ self.r as u16 * 257,
+ self.g as u16 * 257,
+ self.b as u16 * 257,
+ )
+ }
}
impl Debug for Color {
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)
- }
+ 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())
formatter.write_str("\"#rrggbb\" or \"rrggbb\" or web color name")
}
- 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,
{
}
}
-#[derive(Copy, Clone, Debug, Deserialize)]
+#[derive(Copy, Clone, Debug, PartialEq, Deserialize)]
pub struct BorderStyle {
#[serde(rename = "@borderStyleType")]
pub stroke: Stroke,
}
}
+impl From<Stroke> for BorderStyle {
+ fn from(value: Stroke) -> Self {
+ Self::new(value)
+ }
+}
+
impl BorderStyle {
- pub const fn none() -> Self {
+ pub const fn new(stroke: Stroke) -> Self {
Self {
- stroke: Stroke::None,
+ stroke,
color: Color::BLACK,
}
}
+ pub const fn none() -> Self {
+ Self::new(Stroke::None)
+ }
+
pub fn is_none(&self) -> bool {
self.stroke.is_none()
}
}
}
+/// 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),
+ }
+ }
+}
+
/// A 2-dimensional `(x,y)` pair.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Coord2(pub EnumMap<Axis2, usize>);
}
#[derive(Clone, Debug, Serialize)]
-pub struct PivotTable {
+pub struct PivotTableStyle {
pub look: Arc<Look>,
pub rotate_inner_column_labels: bool,
pub show_values: Option<Show>,
pub show_variables: Option<Show>,
-
- pub weight_format: Format,
-
- /// Current layer indexes, with `axes[Axis3::Z].dimensions.len()` elements.
- /// `current_layer[i]` is an offset into
- /// `axes[Axis3::Z].dimensions[i].data_leaves[]`, except that a dimension
- /// can have zero leaves, in which case `current_layer[i]` is zero and
- /// there's no corresponding leaf.
- pub current_layer: Vec<usize>,
-
- /// Column and row sizing and page breaks.
+ /// Column and row sizing and page breaks:
+ ///
+ /// - `sizing[Axis2::X]` is sizes for columns.
+ /// - `sizing[Axis2::Y]` is sizes for rows.
pub sizing: EnumMap<Axis2, Option<Box<Sizing>>>,
/// Format settings.
pub small: f64,
+ pub weight_format: Format,
+}
+
+impl Default for PivotTableStyle {
+ fn default() -> Self {
+ Self {
+ look: Look::shared_default(),
+ rotate_inner_column_labels: false,
+ rotate_outer_row_labels: false,
+ show_grid_lines: false,
+ show_title: true,
+ show_caption: true,
+ show_values: None,
+ show_variables: None,
+ sizing: EnumMap::default(),
+ settings: FormatSettings::default(), // XXX from settings
+ grouping: None,
+ small: 0.0001, // XXX from settings.
+ weight_format: F40,
+ }
+ }
+}
+
+impl PivotTableStyle {
+ fn with_look(self, look: Arc<Look>) -> Self {
+ Self { look, ..self }
+ }
+ fn with_show_values(self, show_values: Option<Show>) -> Self {
+ Self {
+ show_values,
+ ..self
+ }
+ }
+ fn with_show_variables(self, show_variables: Option<Show>) -> Self {
+ Self {
+ show_variables,
+ ..self
+ }
+ }
+ fn with_show_title(self, show_title: bool) -> Self {
+ Self { show_title, ..self }
+ }
+ fn with_show_caption(self, show_caption: bool) -> Self {
+ Self {
+ show_caption,
+ ..self
+ }
+ }
+ pub fn look_mut(&mut self) -> &mut Look {
+ Arc::make_mut(&mut self.look)
+ }
+}
+
+#[derive(Clone, Debug, Default, Serialize)]
+pub struct PivotTableMetadata {
pub command_local: Option<String>,
pub command_c: Option<String>,
pub language: 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>,
+}
+
+#[derive(Clone, Debug, Serialize)]
+pub struct PivotTable {
+ pub style: PivotTableStyle,
+
+ /// Current layer indexes, with `axes[Axis3::Z].dimensions.len()` elements.
+ /// `current_layer[i]` is an offset into
+ /// `axes[Axis3::Z].dimensions[i].data_leaves[]`, except that a dimension
+ /// can have zero leaves, in which case `current_layer[i]` is zero and
+ /// there's no corresponding leaf.
+ pub current_layer: Vec<usize>,
+
+ pub metadata: PivotTableMetadata,
+ pub footnotes: Footnotes,
+ dimensions: Vec<Dimension>,
+ axes: EnumMap<Axis3, Axis>,
+ cells: HashMap<usize, Value>,
+}
+
+impl PivotTableMetadata {
+ pub fn with_subtype(self, subtype: impl Into<Value>) -> Self {
+ Self {
+ subtype: Some(Box::new(subtype.into())),
+ ..self
+ }
+ }
}
impl PivotTable {
+ pub fn cells(&self) -> &HashMap<usize, Value> {
+ &self.cells
+ }
+ pub fn dimensions(&self) -> &[Dimension] {
+ &self.dimensions
+ }
+ pub fn axes(&self) -> &EnumMap<Axis3, Axis> {
+ &self.axes
+ }
+
pub fn with_title(mut self, title: impl Into<Value>) -> Self {
- self.title = Some(Box::new(title.into()));
- self.show_title = true;
+ self.metadata.title = Some(Box::new(title.into()));
+ self.style.show_title = true;
self
}
pub fn with_caption(mut self, caption: impl Into<Value>) -> Self {
- self.caption = Some(Box::new(caption.into()));
- self.show_caption = true;
+ self.metadata.caption = Some(Box::new(caption.into()));
+ self.style.show_caption = true;
self
}
pub fn with_corner_text(mut self, corner_text: impl Into<Value>) -> Self {
- self.corner_text = Some(Box::new(corner_text.into()));
+ self.metadata.corner_text = Some(Box::new(corner_text.into()));
self
}
pub fn with_subtype(self, subtype: impl Into<Value>) -> Self {
Self {
- subtype: Some(Box::new(subtype.into())),
+ metadata: self.metadata.with_subtype(subtype),
..self
}
}
- pub fn with_show_title(mut self, show_title: bool) -> Self {
- self.show_title = show_title;
- self
+ pub fn with_show_values(self, show_values: Option<Show>) -> Self {
+ Self {
+ style: self.style.with_show_values(show_values),
+ ..self
+ }
}
- pub fn with_show_caption(mut self, show_caption: bool) -> Self {
- self.show_caption = show_caption;
- self
+ pub fn with_show_variables(self, show_variables: Option<Show>) -> Self {
+ Self {
+ style: self.style.with_show_variables(show_variables),
+ ..self
+ }
+ }
+
+ pub fn with_show_title(self, show_title: bool) -> Self {
+ Self {
+ style: self.style.with_show_title(show_title),
+ ..self
+ }
+ }
+
+ pub fn with_show_caption(self, show_caption: bool) -> Self {
+ Self {
+ style: self.style.with_show_caption(show_caption),
+ ..self
+ }
}
pub fn with_layer(mut self, layer: &[usize]) -> Self {
debug_assert_eq!(layer.len(), self.current_layer.len());
- if self.look.print_all_layers {
- self.look_mut().print_all_layers = false;
+ if self.style.look.print_all_layers {
+ self.style.look_mut().print_all_layers = false;
}
self.current_layer.clear();
self.current_layer.extend_from_slice(layer);
}
pub fn with_all_layers(mut self) -> Self {
- if !self.look.print_all_layers {
+ if !self.style.look.print_all_layers {
self.look_mut().print_all_layers = true;
}
self
}
pub fn look_mut(&mut self) -> &mut Look {
- Arc::make_mut(&mut self.look)
+ self.style.look_mut()
}
pub fn with_show_empty(mut self) -> Self {
- if self.look.hide_empty {
+ if self.style.look.hide_empty {
self.look_mut().hide_empty = false;
}
self
}
pub fn with_hide_empty(mut self) -> Self {
- if !self.look.hide_empty {
+ if !self.style.look.hide_empty {
self.look_mut().hide_empty = true;
}
self
}
pub fn label(&self) -> String {
- match &self.title {
+ match &self.metadata.title {
Some(title) => title.display(self).to_string(),
None => String::from("Table"),
}
}
pub fn title(&self) -> &Value {
- match &self.title {
+ match &self.metadata.title {
Some(title) => title,
None => {
static EMPTY: Value = Value::empty();
}
pub fn subtype(&self) -> &Value {
- match &self.subtype {
+ match &self.metadata.subtype {
Some(subtype) => subtype,
None => {
static EMPTY: Value = Value::empty();
impl Default for PivotTable {
fn default() -> Self {
Self {
- look: Look::shared_default(),
- rotate_inner_column_labels: false,
- rotate_outer_row_labels: false,
- show_grid_lines: false,
- show_title: true,
- show_caption: true,
- show_values: None,
- show_variables: None,
- weight_format: Format::F40,
+ style: PivotTableStyle::default(),
+ metadata: PivotTableMetadata::default(),
current_layer: Vec::new(),
- sizing: EnumMap::default(),
- settings: FormatSettings::default(), // XXX from settings
- grouping: None,
- small: 0.0001, // XXX from settings.
- command_local: None,
- command_c: None, // XXX from current command name.
- language: None,
- locale: None,
- dataset: None,
- datafile: None,
- date: None,
footnotes: Footnotes::new(),
- subtype: None,
- title: None,
- corner_text: None,
- caption: None,
- notes: None,
dimensions: Vec::new(),
axes: EnumMap::default(),
cells: HashMap::new(),
}
}
+pub trait CellIndex {
+ fn cell_index<I>(self, dimensions: I) -> usize
+ where
+ I: ExactSizeIterator<Item = usize>;
+}
+
+impl<T> CellIndex for T
+where
+ T: AsRef<[usize]>,
+{
+ fn cell_index<I>(self, dimensions: I) -> usize
+ where
+ I: ExactSizeIterator<Item = usize>,
+ {
+ let data_indexes = self.as_ref();
+ let mut index = 0;
+ for (dimension, data_index) in dimensions.zip_eq(data_indexes.iter()) {
+ debug_assert!(*data_index < dimension);
+ index = dimension * index + data_index;
+ }
+ index
+ }
+}
+
+pub struct PrecomputedIndex(pub usize);
+
+impl CellIndex for PrecomputedIndex {
+ fn cell_index<I>(self, _dimensions: I) -> usize
+ where
+ I: ExactSizeIterator<Item = usize>,
+ {
+ self.0
+ }
+}
+
fn cell_index<I>(data_indexes: &[usize], dimensions: I) -> usize
where
I: ExactSizeIterator<Item = usize>,
dimensions.push(dimension);
}
Self {
- look: Settings::global().look.clone(),
+ style: PivotTableStyle::default().with_look(Settings::global().look.clone()),
current_layer: repeat_n(0, axes[Axis3::Z].dimensions.len()).collect(),
axes,
dimensions,
..Self::default()
}
}
- fn cell_index(&self, data_indexes: &[usize]) -> usize {
- cell_index(data_indexes, self.dimensions.iter().map(|d| d.len()))
+ fn cell_index<C>(&self, cell_index: C) -> usize
+ where
+ C: CellIndex,
+ {
+ cell_index.cell_index(self.dimensions.iter().map(|d| d.len()))
}
- pub fn insert(&mut self, data_indexes: &[usize], value: impl Into<Value>) {
- self.cells
- .insert(self.cell_index(data_indexes), value.into());
+ 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());
}
- pub fn get(&self, data_indexes: &[usize]) -> Option<&Value> {
- self.cells.get(&self.cell_index(data_indexes))
+ pub fn get<C>(&self, cell_index: C) -> Option<&Value>
+ where
+ C: CellIndex,
+ {
+ self.cells.get(&self.cell_index(cell_index))
}
- pub fn with_data<I>(mut self, iter: impl IntoIterator<Item = (I, Value)>) -> Self
+ pub fn with_data<C>(mut self, iter: impl IntoIterator<Item = (C, Value)>) -> Self
where
- I: AsRef<[usize]>,
+ C: CellIndex,
{
self.extend(iter);
self
}
+ pub fn with_style(self, style: PivotTableStyle) -> Self {
+ Self { style, ..self }
+ }
+ pub fn with_metadata(self, metadata: PivotTableMetadata) -> Self {
+ Self { metadata, ..self }
+ }
+
/// Converts per-axis presentation-order indexes in `presentation_indexes`,
/// into data indexes for each dimension.
fn convert_indexes_ptod(
///
/// - 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 {
+ if print && self.style.look.print_all_layers {
Box::new(self.axis_values(Axis3::Z))
} else {
Box::new(once(SmallVec::from_slice(&self.current_layer)))
pub fn value_options(&self) -> ValueOptions {
ValueOptions {
- show_values: self.show_values,
- show_variables: self.show_variables,
- small: self.small,
- footnote_marker_type: self.look.footnote_marker_type,
+ show_values: self.style.show_values,
+ show_variables: self.style.show_variables,
+ small: self.style.small,
+ footnote_marker_type: self.style.look.footnote_marker_type,
}
}
}
}
-impl<I> Extend<(I, Value)> for PivotTable
+impl<C> Extend<(C, Value)> for PivotTable
where
- I: AsRef<[usize]>,
+ C: CellIndex,
{
- fn extend<T: IntoIterator<Item = (I, Value)>>(&mut self, iter: T) {
- for (data_indexes, value) in iter {
- self.insert(data_indexes.as_ref(), value);
+ fn extend<T: IntoIterator<Item = (C, Value)>>(&mut self, iter: T) {
+ for (cell_index, value) in iter {
+ self.insert(cell_index, value);
}
}
}
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct Footnote {
#[serde(skip)]
index: usize,
show: true,
}
}
- pub fn with_marker(mut self, marker: impl Into<Value>) -> Self {
- self.marker = Some(Box::new(marker.into()));
- self
+ pub fn with_marker(self, marker: Option<Value>) -> Self {
+ Self {
+ marker: marker.map(Box::new),
+ ..self
+ }
+ }
+ pub fn with_some_marker(self, marker: impl Into<Value>) -> Self {
+ Self::with_marker(self, Some(marker.into()))
}
- pub fn with_show(mut self, show: bool) -> Self {
- self.show = show;
- self
+ pub fn with_show(self, show: bool) -> Self {
+ Self { show, ..self }
}
- pub fn with_index(mut self, index: usize) -> Self {
- self.index = index;
- self
+ pub fn with_index(self, index: usize) -> Self {
+ Self { index, ..self }
}
pub fn display_marker(&self, options: impl IntoValueOptions) -> DisplayMarker<'_> {
}
}
+impl Default for Footnote {
+ fn default() -> Self {
+ Footnote::new(Value::default())
+ }
+}
+
pub struct DisplayMarker<'a> {
footnote: &'a Footnote,
options: ValueOptions,
///
/// 5. A template. PSPP doesn't create these itself yet, but it can read and
/// interpret those created by SPSS.
-#[derive(Clone, Default)]
+#[derive(Clone, Default, PartialEq)]
pub struct Value {
pub inner: ValueInner,
pub styling: Option<Box<ValueStyle>>,
ValueInner::String(string_value) => string_value.s.serialize(serializer),
ValueInner::Variable(variable_value) => variable_value.var_name.serialize(serializer),
ValueInner::Text(text_value) => text_value.localized.serialize(serializer),
+ ValueInner::Markup(markup) => markup.serialize(serializer),
ValueInner::Template(template_value) => template_value.localized.serialize(serializer),
ValueInner::Empty => serializer.serialize_none(),
}
}
- fn new(inner: ValueInner) -> Self {
+ pub fn new(inner: ValueInner) -> Self {
Self {
inner,
styling: None,
}
}
pub fn new_date_time(date_time: NaiveDateTime) -> Self {
- Self::new_number_with_format(Some(date_time_to_pspp(date_time)), Format::DATETIME40_0)
+ Self::new_number_with_format(Some(date_time_to_pspp(date_time)), DATETIME40_0)
}
pub fn new_number_with_format(x: Option<f64>, format: Format) -> Self {
Self::new(ValueInner::Number(NumberValue {
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);
+ pub fn new_datum_with_format<B>(value: &Datum<B>, format: Format) -> Self
+ where
+ B: EncodedString,
+ {
match value {
Datum::Number(number) => Self::new(ValueInner::Number(NumberValue {
show: None,
- format: match variable.print_format.var_type() {
- VarType::Numeric => variable.print_format,
+ format: match format.var_type() {
+ VarType::Numeric => format,
VarType::String => {
#[cfg(debug_assertions)]
panic!("cannot create numeric pivot value with string format");
},
honor_small: false,
value: *number,
- variable: var_name,
- value_label,
+ variable: None,
+ value_label: None,
})),
Datum::String(string) => Self::new(ValueInner::String(StringValue {
show: None,
- hex: variable.print_format.type_() == Type::AHex,
- s: string
- .as_ref()
- .with_encoding(variable.encoding())
- .into_string(),
- var_name,
- value_label,
+ hex: format.type_() == Type::AHex,
+ s: string.as_str().into_owned(),
+ var_name: None,
+ value_label: None,
})),
}
}
+ pub fn new_variable_value(variable: &Variable, value: &Datum<ByteString>) -> Self {
+ Self::new_datum_with_format(
+ &value.as_encoded(variable.encoding()),
+ variable.print_format,
+ )
+ .with_variable_name(Some(variable.name.as_str().into()))
+ .with_value_label(variable.value_labels.get(value).map(String::from))
+ }
pub fn new_number(x: Option<f64>) -> Self {
- Self::new_number_with_format(x, Format::F8_2)
+ Self::new_number_with_format(x, F8_2)
}
pub fn new_integer(x: Option<f64>) -> Self {
- Self::new_number_with_format(x, Format::F40)
+ Self::new_number_with_format(x, F40)
}
pub fn new_text(s: impl Into<String>) -> Self {
Self::new_user_text(s)
}
+ pub fn new_general_text(localized: String, c: String, id: String, user_provided: bool) -> Self {
+ Self::new(ValueInner::Text(TextValue {
+ user_provided,
+ c: (c != localized).then_some(c),
+ id: (id != localized).then_some(id),
+ localized,
+ }))
+ }
+ pub fn new_markup(markup: Markup) -> Self {
+ Self::new(ValueInner::Markup(markup))
+ }
pub fn new_user_text(s: impl Into<String>) -> Self {
let s: String = s.into();
if s.is_empty() {
} else {
Self::new(ValueInner::Text(TextValue {
user_provided: true,
- localized: s.clone(),
+ localized: s,
c: None,
id: None,
}))
self
}
pub fn add_footnote(&mut self, footnote: &Arc<Footnote>) {
- let footnotes = &mut self.styling.get_or_insert_default().footnotes;
+ let footnotes = &mut self.styling_mut().footnotes;
footnotes.push(footnote.clone());
footnotes.sort_by_key(|f| f.index);
}
}
self
}
+ pub fn with_variable_name(mut self, variable_name: Option<String>) -> Self {
+ match &mut self.inner {
+ ValueInner::Number(NumberValue { variable, .. })
+ | ValueInner::String(StringValue {
+ var_name: variable, ..
+ }) => *variable = variable_name,
+ ValueInner::Variable(VariableValue {
+ var_name: variable, ..
+ }) => {
+ if let Some(name) = variable_name {
+ *variable = name;
+ }
+ }
+ _ => (),
+ }
+ self
+ }
+ pub fn styling_mut(&mut self) -> &mut ValueStyle {
+ self.styling.get_or_insert_default()
+ }
+ pub fn with_font_style(mut self, font_style: FontStyle) -> Self {
+ self.styling_mut().font_style = Some(font_style);
+ self
+ }
+ pub fn with_cell_style(mut self, cell_style: CellStyle) -> Self {
+ self.styling_mut().cell_style = Some(cell_style);
+ self
+ }
+ pub fn with_styling(self, styling: Option<Box<ValueStyle>>) -> Self {
+ Self { styling, ..self }
+ }
+ pub fn font_style(&self) -> Option<&FontStyle> {
+ self.styling
+ .as_ref()
+ .map(|styling| styling.font_style.as_ref())
+ .flatten()
+ }
+ pub fn cell_style(&self) -> Option<&CellStyle> {
+ self.styling
+ .as_ref()
+ .map(|styling| styling.cell_style.as_ref())
+ .flatten()
+ }
+ pub fn subscripts(&self) -> &[String] {
+ self.styling
+ .as_ref()
+ .map_or(&[], |styling| &styling.subscripts)
+ }
+ pub fn footnotes(&self) -> &[Arc<Footnote>] {
+ self.styling
+ .as_ref()
+ .map_or(&[], |styling| &styling.footnotes)
+ }
pub const fn empty() -> Self {
Value {
inner: ValueInner::Empty,
pub struct DisplayValue<'a> {
inner: &'a ValueInner,
- markup: bool,
subscripts: &'a [String],
footnotes: &'a [Arc<Footnote>],
options: ValueOptions,
}
}
+ pub fn markup(&self) -> Option<&Markup> {
+ self.inner.markup()
+ }
+
/// Returns this display split into `(body, suffixes)` where `suffixes` is
/// subscripts and footnotes and `body` is everything else.
pub fn split_suffixes(self) -> (Self, Self) {
}
pub fn with_styling(mut self, styling: &'a ValueStyle) -> Self {
- if let Some(area_style) = &styling.style {
- self.markup = area_style.font_style.markup;
- }
self.subscripts = styling.subscripts.as_slice();
self.footnotes = styling.footnotes.as_slice();
self
}
- pub fn with_font_style(self, font_style: &FontStyle) -> Self {
- Self {
- markup: font_style.markup,
- ..self
- }
- }
-
pub fn with_subscripts(self, subscripts: &'a [String]) -> Self {
Self { subscripts, ..self }
}
}
}
+ ValueInner::Markup(markup) => write!(f, "{markup}"),
+
ValueInner::Text(TextValue {
localized: local, ..
- }) => {
- /*
- if self
- .inner
- .styling
- .as_ref()
- .is_some_and(|styling| styling.style.font_style.markup)
- {
- todo!();
- }*/
- f.write_str(local)
- }
+ }) => f.write_str(local),
ValueInner::Template(TemplateValue {
args,
impl Debug for Value {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{:?}", self.display(()).to_string())
+ let name = match &self.inner {
+ ValueInner::Number(_) => "Number",
+ ValueInner::String(_) => "String",
+ ValueInner::Variable(_) => "Variable",
+ ValueInner::Text(_) => "Text",
+ ValueInner::Markup(_) => "Markup",
+ ValueInner::Template(_) => "Template",
+ ValueInner::Empty => "Empty",
+ };
+ write!(f, "{name}:{:?}", self.display(()).to_string())?;
+ if let Some(markup) = self.inner.markup() {
+ write!(f, " (markup: {markup:?})")?;
+ }
+ if let Some(styling) = &self.styling {
+ write!(f, " ({styling:?})")?;
+ }
+ Ok(())
}
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq)]
pub struct NumberValue {
/// The numerical value, or `None` if it is a missing value.
pub value: Option<f64>,
#[serde(serialize_with = "NumberValue::serialize_bare")] pub &'a NumberValue,
);
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct StringValue {
/// The string value.
///
pub value_label: Option<String>,
}
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct VariableValue {
pub show: Option<Show>,
pub var_name: String,
pub variable_label: Option<String>,
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq)]
pub struct TextValue {
pub user_provided: bool,
/// Localized.
}
}
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct TemplateValue {
pub args: Vec<Vec<Value>>,
pub localized: String,
- pub id: String,
+ pub id: Option<String>,
}
-#[derive(Clone, Debug, Default, Serialize)]
+#[derive(Clone, Debug, Default, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ValueInner {
Number(NumberValue),
String(StringValue),
Variable(VariableValue),
Text(TextValue),
+ Markup(Markup),
Template(TemplateValue),
#[default]
_ => None,
}
}
+
+ fn markup(&self) -> Option<&Markup> {
+ match self {
+ ValueInner::Markup(markup) => Some(markup),
+ _ => None,
+ }
+ }
}
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, PartialEq)]
pub struct ValueStyle {
- pub style: Option<AreaStyle>,
+ pub cell_style: Option<CellStyle>,
+ pub font_style: Option<FontStyle>,
pub subscripts: Vec<String>,
pub footnotes: Vec<Arc<Footnote>>,
}
impl ValueStyle {
pub fn is_empty(&self) -> bool {
- self.style.is_none() && self.subscripts.is_empty() && self.footnotes.is_empty()
+ self.font_style.is_none()
+ && self.cell_style.is_none()
+ && self.subscripts.is_empty()
+ && self.footnotes.is_empty()
}
}
};
DisplayValue {
inner: self,
- markup: false,
subscripts: &[],
footnotes: &[],
options,
#[cfg(test)]
mod test {
- use crate::output::pivot::{Display26Adic, MetadataEntry, MetadataValue, Value};
+ use std::str::FromStr;
+
+ use crate::output::pivot::{
+ Color, Display26Adic, MetadataEntry, MetadataValue, Value, tests::assert_rendering,
+ };
+
+ #[test]
+ fn parse_color() {
+ assert_eq!(Color::from_str("red"), Ok(Color::new(255, 0, 0)));
+ assert_eq!(Color::from_str("transparent"), Ok(Color::TRANSPARENT));
+ assert_eq!(Color::from_str("rgb(12,34,56)"), Ok(Color::new(12, 34, 56)));
+ assert_eq!(Color::from_str("#abcdef"), Ok(Color::new(0xab, 0xcd, 0xef)));
+ assert_eq!(Color::from_str("abcdef"), Ok(Color::new(0xab, 0xcd, 0xef)));
+ assert_eq!(Color::from_str("transparent"), Ok(Color::TRANSPARENT));
+ }
#[test]
fn display_26adic() {
}"#
);
- 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());
}
}
format::Decimal,
output::pivot::{
Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, Color, FootnoteMarkerPosition,
- FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, VertAlign,
+ FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, RowParity,
+ VertAlign,
},
};
use thiserror::Error as ThisError;
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct TableProperties {
#[serde(rename = "@name")]
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,
}
}
-#[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 {
horz_align: match self.text_alignment {
bold: self.font_weight == FontWeight::Bold,
italic: self.font_style == FontStyle::Italic,
underline: self.font_underline == FontUnderline::Underline,
- markup: false,
font: self.font_family.clone(),
- fg: [
- self.color.unwrap_or(Color::BLACK),
- self.alternating_text_color.unwrap_or(Color::BLACK),
- ],
- bg: [
- self.color2.unwrap_or(Color::BLACK),
- self.alternating_color.unwrap_or(Color::BLACK),
- ],
+ fg: match data_row {
+ RowParity::Even => self.color.unwrap_or(Color::BLACK),
+ RowParity::Odd => self.alternating_text_color.unwrap_or(Color::BLACK),
+ },
+ bg: match data_row {
+ RowParity::Even => self.color2.unwrap_or(Color::BLACK),
+ RowParity::Odd => self.alternating_color.unwrap_or(Color::BLACK),
+ },
size: self.font_size.as_pt_i32(),
},
}
}
}
-#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
enum FontStyle {
#[default]
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")]
}
#[derive(Copy, Clone, Default, PartialEq)]
-struct Dimension(
+pub struct Length(
/// In inches.
- f64,
+ pub f64,
);
-impl Dimension {
- fn as_px_f64(self) -> f64 {
+impl Length {
+ pub fn as_px_f64(self) -> f64 {
self.0 * 96.0
}
- fn as_px_i32(self) -> i32 {
+ pub fn as_px_i32(self) -> i32 {
num::cast(self.as_px_f64() + 0.5).unwrap_or_default()
}
- fn as_pt_f64(self) -> f64 {
+ pub fn as_pt_f64(self) -> f64 {
self.0 * 72.0
}
- fn as_pt_i32(self) -> i32 {
+ pub fn as_pt_i32(self) -> i32 {
num::cast(self.as_pt_f64() + 0.5).unwrap_or_default()
}
}
-impl Debug for Dimension {
+impl From<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)?;
+ .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::{
+ Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle,
+ FootnoteMarkerPosition, FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look,
+ RowColBorder, RowParity, Stroke, VertAlign,
+ look_xml::{Length, LengthParseError, TableProperties},
+ };
#[test]
fn dimension() {
- assert_eq!(Dimension::from_str("1"), Ok(Dimension(1.0 / 72.0)));
- assert_eq!(Dimension::from_str("1pt"), Ok(Dimension(1.0 / 72.0)));
- assert_eq!(Dimension::from_str("1ะฟั"), Ok(Dimension(1.0 / 72.0)));
- assert_eq!(Dimension::from_str("1.0"), Ok(Dimension(1.0 / 72.0)));
- assert_eq!(Dimension::from_str(" 1.0"), Ok(Dimension(1.0 / 72.0)));
- assert_eq!(Dimension::from_str(" 1.0 "), Ok(Dimension(1.0 / 72.0)));
- assert_eq!(Dimension::from_str("1.0 pt"), Ok(Dimension(1.0 / 72.0)));
- assert_eq!(Dimension::from_str("1.0pt "), Ok(Dimension(1.0 / 72.0)));
- assert_eq!(Dimension::from_str(" 1.0pt "), Ok(Dimension(1.0 / 72.0)));
+ assert_eq!(Length::from_str("1"), Ok(Length(1.0 / 72.0)));
+ assert_eq!(Length::from_str("1pt"), Ok(Length(1.0 / 72.0)));
+ assert_eq!(Length::from_str("1ะฟั"), Ok(Length(1.0 / 72.0)));
+ assert_eq!(Length::from_str("1.0"), Ok(Length(1.0 / 72.0)));
+ assert_eq!(Length::from_str(" 1.0"), Ok(Length(1.0 / 72.0)));
+ assert_eq!(Length::from_str(" 1.0 "), Ok(Length(1.0 / 72.0)));
+ assert_eq!(Length::from_str("1.0 pt"), Ok(Length(1.0 / 72.0)));
+ assert_eq!(Length::from_str("1.0pt "), Ok(Length(1.0 / 72.0)));
+ assert_eq!(Length::from_str(" 1.0pt "), Ok(Length(1.0 / 72.0)));
- assert_eq!(Dimension::from_str("1in"), Ok(Dimension(1.0)));
+ assert_eq!(Length::from_str("1in"), Ok(Length(1.0)));
- assert_eq!(Dimension::from_str("96px"), Ok(Dimension(1.0)));
+ assert_eq!(Length::from_str("96px"), Ok(Length(1.0)));
- assert_eq!(Dimension::from_str("2.54cm"), Ok(Dimension(1.0)));
+ assert_eq!(Length::from_str("2.54cm"), Ok(Length(1.0)));
assert_eq!(
- Dimension::from_str(""),
- Err(DimensionParseError::ParseFloatError(
+ Length::from_str(""),
+ Err(LengthParseError::ParseFloatError(
"".parse::<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::BLACK,
+ size: 9,
+ },
+ },
+ Area::Layers => AreaStyle {
+ cell_style: CellStyle {
+ horz_align: Some(
+ HorzAlign::Left,
+ ),
+ vert_align: VertAlign::Bottom,
+ margins: enum_map! {
+ Axis2::X => [
+ 8,
+ 11,
+ ],
+ Axis2::Y => [
+ 0,
+ 3,
+ ],
+ },
+ },
+ font_style: FontStyle {
+ bold: false,
+ italic: false,
+ underline: false,
+ font: String::from("Sans Serif"),
+ fg: Color::BLACK,
+ bg: Color::WHITE,
+ size: 9,
+ },
+ },
+ },
+ borders: enum_map! {
+ Border::Title => BorderStyle {
+ stroke: Stroke::None,
+ color: Color::BLACK,
+ },
+ Border::OuterFrame(
+ BoxBorder::Left,
+ )=>BorderStyle {
+ stroke: Stroke::None,
+ color: Color::BLACK,
+ },
+ Border::OuterFrame(
+ BoxBorder::Top,
+ ) =>BorderStyle {
+ stroke: Stroke::None,
+ color: Color::BLACK,
+ },
+ Border::OuterFrame(
+ BoxBorder::Right,
+ ) => BorderStyle {
+ stroke: Stroke::None,
+ color: Color::BLACK,
+ },
+ Border::OuterFrame(
+ BoxBorder::Bottom,
+ )=> BorderStyle {
+ stroke: Stroke::None,
+ color: Color::BLACK,
+ },
+ Border::InnerFrame(
+ BoxBorder::Left,
+ )=> BorderStyle {
+ stroke: Stroke::Thick,
+ color: Color::BLACK,
+ },
+ Border::InnerFrame(
+ BoxBorder::Top,
+ )=> BorderStyle {
+ stroke: Stroke::Thick,
+ color: Color::BLACK,
+ },
+ Border::InnerFrame(
+ BoxBorder::Right,
+ )=> BorderStyle {
+ stroke: Stroke::Thick,
+ color: Color::BLACK,
+ },
+ Border::InnerFrame(
+ BoxBorder::Bottom,
+ )=> BorderStyle {
+ stroke: Stroke::Thick,
+ color: Color::BLACK,
+ },
+ Border::Dimension(
+ RowColBorder(
+ HeadingRegion::Rows,
+ Axis2::X,
+ ),
+ )=> BorderStyle {
+ stroke: Stroke::Solid,
+ color: Color::BLACK,
+ },
+ Border::Dimension(
+ RowColBorder(
+ HeadingRegion::Columns,
+ Axis2::X,
+ ),
+ )=> BorderStyle {
+ stroke: Stroke::Solid,
+ color: Color::BLACK,
+ },
+ Border::Dimension(
+ RowColBorder(
+ HeadingRegion::Rows,
+ Axis2::Y,
+ ),
+ )=> BorderStyle {
+ stroke: Stroke::None,
+ color: Color::BLACK,
+ },
+ Border::Dimension(
+ RowColBorder(
+ HeadingRegion::Columns,
+ Axis2::Y,
+ ),
+ )=> BorderStyle {
+ stroke: Stroke::Solid,
+ color: Color::BLACK,
+ },
+ Border::Category(
+ RowColBorder(
+ HeadingRegion::Rows,
+ Axis2::X,
+ ),
+ )=> BorderStyle {
+ stroke: Stroke::None,
+ color: Color::BLACK,
+ },
+ Border::Category(
+ RowColBorder(
+ HeadingRegion::Columns,
+ Axis2::X,
+ ),
+ )=> BorderStyle {
+ stroke: Stroke::Solid,
+ color: Color::BLACK,
+ },
+ Border::Category(
+ RowColBorder(
+ HeadingRegion::Rows,
+ Axis2::Y,
+ ),
+ )=> BorderStyle {
+ stroke: Stroke::None,
+ color: Color::BLACK,
+ },
+ Border::Category(
+ RowColBorder(
+ HeadingRegion::Columns,
+ Axis2::Y,
+ ),
+ )=> BorderStyle {
+ stroke: Stroke::Solid,
+ color: Color::BLACK,
+ },
+ Border::DataLeft => BorderStyle {
+ stroke: Stroke::Thick,
+ color: Color::BLACK,
+ },
+ Border::DataTop => BorderStyle {
+ stroke: Stroke::Thick,
+ color: Color::BLACK,
+ },
+ },
+ print_all_layers: true,
+ paginate_layers: false,
+ shrink_to_fit: EnumMap::from_fn(|_| false),
+ top_continuation: false,
+ bottom_continuation: false,
+ continuation: None,
+ n_orphan_lines: 5,
+ };
+ assert_eq!(&look, &expected);
}
}
use itertools::Itertools;
use crate::output::{
- pivot::{HeadingRegion, LabelPosition, Path},
+ pivot::{HeadingRegion, LabelPosition, Path, RowParity},
table::{CellInner, Table},
};
};
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;
}
}
let mut table = Table::new(
Coord2::new(1, rows.len()),
Coord2::new(0, 0),
- self.look.areas.clone(),
+ self.style.look.areas.clone(),
self.borders(false),
self.into_value_options(),
);
fn borders(&self, printing: bool) -> EnumMap<Border, BorderStyle> {
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,
+ )
})
}
let mut body = Table::new(
Coord2::from_fn(|axis| data[axis] + stub[axis]),
stub,
- self.look.areas.clone(),
+ self.style.look.areas.clone(),
self.borders(printing),
self.into_value_options(),
);
Axis3::Z => layer_indexes,
};
let data_indexes = self.convert_indexes_ptod(presentation_indexes);
- let value = self.get(&data_indexes);
+ let value = self.get(&*data_indexes);
body.put(
Rect2::new(x..x + 1, y..y + 1),
- CellInner {
- rotate: false,
- area: Area::Data,
- value: Box::new(value.cloned().unwrap_or_default()),
- },
+ CellInner::new(
+ Area::Data(RowParity::from(y - stub[Axis2::Y])),
+ Box::new(value.cloned().unwrap_or_default()),
+ ),
);
}
}
// Insert corner text, but only if there's a stub and only if row labels
// are not in the corner.
- if self.corner_text.is_some()
- && self.look.row_label_position == LabelPosition::Nested
+ if self.metadata.corner_text.is_some()
+ && self.style.look.row_label_position == LabelPosition::Nested
&& stub.x() > 0
&& stub.y() > 0
{
body.put(
Rect2::new(0..stub.x(), 0..stub.y()),
- CellInner::new(Area::Corner, self.corner_text.clone().unwrap_or_default()),
+ CellInner::new(
+ Area::Corner,
+ self.metadata.corner_text.clone().unwrap_or_default(),
+ ),
);
}
}
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(),
+ ))
}
pub fn output_layers(&self, layer_indexes: &[usize]) -> Option<Table> {
layer_indexes,
) {
if !dimension.is_empty() {
- layers.push(dimension.nth_leaf(layer_index).unwrap().name.clone());
+ layers.push(dimension.nth_leaf(layer_index).unwrap().0.clone());
}
}
layers.reverse();
}
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(),
+ ))
}
pub fn output_footnotes(&self, footnotes: &[Arc<Footnote>]) -> Option<Table> {
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 = [
}
impl Path<'_> {
- pub fn get(&self, y: usize, height: usize) -> Option<&Value> {
- if y + 1 == height {
- Some(&self.leaf.name)
+ pub fn get(&self, y: usize, height: usize) -> (&Value, Range<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()),
- },
+ 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 {
table.put(
Rect2::new(v_ofs..v_ofs + 1, 0..h_ofs),
- CellInner {
- rotate: false,
- area: Area::Corner,
- value: self.dimension.root.name.clone(),
- },
+ CellInner::new(Area::Corner, self.dimension.root.name.clone()),
);
}
}
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
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ __
--- /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)
+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โ
+โฐโโโดโโโดโโโฏ
--- /dev/null
+Column x b1
+b1
+โญโโโฌโโโฌโโโฎ
+โa1โa2โa3โ
+โโโโผโโโผโโโค
+โ 0โ 1โ 2โ
+โฐโโโดโโโดโโโฏ
--- /dev/null
+Column x b2
+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)
+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โ
+โฐโโโดโโฏ
--- /dev/null
+Row x b1
+b1
+โญโโโฌโโฎ
+โa1โ0โ
+โa2โ1โ
+โa3โ2โ
+โฐโโโดโโฏ
--- /dev/null
+Row x b2
+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
+b1
+a1
+โญโโโฌโโโฌโโโฌโโโฌโโโฎ
+โc1โc2โc3โc4โc5โ
+โโโโผโโโผโโโผโโโผโโโค
+โ 0โ12โ24โ36โ48โ
+โฐโโโดโโโดโโโดโโโดโโโฏ
--- /dev/null
+Column x b2 x a1
+b2
+a1
+โญโโโฌโโโฌโโโฌโโโฌโโโฎ
+โc1โc2โc3โc4โc5โ
+โโโโผโโโผโโโผโโโผโโโค
+โ 3โ15โ27โ39โ51โ
+โฐโโโดโโโดโโโดโโโดโโโฏ
--- /dev/null
+Column x b3 x a2
+b3
+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,
},
- spv::SpvDriver,
};
use super::{Axis3, Value};
#[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 {
}
#[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);
}
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! {
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
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),
fn draw_cell(
&mut self,
draw_cell: &DrawCell,
- alternate_row: bool,
bb: Rect2,
valign_offset: usize,
spill: EnumMap<Axis2, [usize; 2]>,
usize::saturating_sub(bb[Y].len(), height)
}
fn draw_cell(&self, device: &mut dyn Device, ofs: Coord2, cell: &RenderCell) {
- use Axis2::*;
-
let mut bb = Rect2::from_fn(|a| {
self.cp[a][cell.rect[a].start * 2 + 1]..self.cp[a][cell.rect[a].end * 2]
})
bb.clone()
};
- // Header rows are never alternate rows.
- let alternate_row =
- usize::checked_sub(cell.rect[Y].start, self.h[Y]).is_some_and(|row| row % 2 == 1);
-
let draw_cell = DrawCell::new(cell.content.inner(), &self.table);
- let valign_offset = match draw_cell.style.cell_style.vert_align {
+ let valign_offset = match draw_cell.cell_style.vert_align {
VertAlign::Top => 0,
VertAlign::Middle => self.extra_height(device, &bb, &draw_cell) / 2,
VertAlign::Bottom => self.extra_height(device, &bb, &draw_cell),
};
- device.draw_cell(&draw_cell, alternate_row, bb, valign_offset, spill, &clip)
+ device.draw_cell(&draw_cell, bb, valign_offset, spill, &clip)
}
}
// Figure out the width of the body of the table. Use this to determine
// the base scale.
- let body_page = Page::new(Arc::new(output.body), device, 0, &pivot_table.look);
+ let body_page = Page::new(Arc::new(output.body), device, 0, &pivot_table.style.look);
let body_width = body_page.width(Axis2::X);
let mut scale = if body_width > device.params().size[Axis2::X]
- && pivot_table.look.shrink_to_fit[Axis2::X]
+ && pivot_table.style.look.shrink_to_fit[Axis2::X]
&& device.params().can_scale
{
device.params().size[Axis2::X] as f64 / body_width as f64
Arc::new(table),
device,
body_width,
- &pivot_table.look,
+ &pivot_table.style.look,
)));
}
pages.push(Arc::new(body_page));
Arc::new(table),
device,
0,
- &pivot_table.look,
+ &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))
// 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,
+ io::{BufReader, Cursor, Read, Seek},
+ path::Path,
};
-use binrw::{BinWrite, Endian};
-use chrono::Utc;
-use enum_map::EnumMap;
-use quick_xml::{
- ElementWriter,
- events::{BytesText, attributes::Attribute},
- writer::Writer as XmlWriter,
-};
-use serde::{Deserialize, Serialize};
-use zip::{ZipWriter, result::ZipResult, write::SimpleFileOptions};
+use anyhow::{Context, anyhow};
+use binrw::{BinRead, error::ContextExt};
+use cairo::ImageSurface;
+use displaydoc::Display;
+use paper_sizes::PaperSize;
+use serde::Deserialize;
+use zip::{ZipArchive, result::ZipError};
use crate::{
- format::{Format, Type},
+ crypto::EncryptedFile,
output::{
- Item, Text,
- driver::Driver,
- page::{Heading, PageSetup},
- pivot::{
- Area, AreaStyle, Axis2, Axis3, Border, BorderStyle, BoxBorder, Category, CellStyle,
- Color, Dimension, FontStyle, Footnote, FootnoteMarkerPosition, FootnoteMarkerType,
- Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Leaf, PivotTable,
- RowColBorder, Stroke, Value, ValueInner, ValueStyle, VertAlign,
+ Details, Item, SpvInfo, SpvMembers, Text,
+ page::{self},
+ pivot::{Axis2, Length, Look, TableProperties, Value},
+ spv::{
+ html::Document,
+ legacy_bin::LegacyBin,
+ legacy_xml::Visualization,
+ light::{LightError, LightTable},
},
},
- settings::Show,
- util::ToSmallString,
};
-fn light_table_name(table_id: u64) -> String {
- format!("{table_id:011}_lightTableData.bin")
-}
-
-fn output_viewer_name(heading_id: u64, is_heading: bool) -> String {
- format!(
- "outputViewer{heading_id:010}{}.xml",
- if is_heading { "_heading" } else { "" }
- )
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize)]
-pub struct SpvConfig {
- /// Output file name.
- pub file: PathBuf,
-
- /// Page setup.
- pub page_setup: Option<PageSetup>,
-}
+mod css;
+pub mod html;
+mod legacy_bin;
+mod legacy_xml;
+mod light;
-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>,
+/// Options for reading an SPV file.
+#[derive(Clone, Debug, Default)]
+pub struct ReadOptions {
+ /// Password to use to unlock an encrypted SPV file.
+ ///
+ /// For an encrypted SPV file, this must be set to the (encoded or
+ /// unencoded) password.
+ ///
+ /// For a plaintext SPV file, this must be None.
+ pub password: Option<String>,
}
-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 ReadOptions {
+ /// Construct a new [ReadOptions] without a password.
+ pub fn new() -> Self {
+ Self::default()
}
-}
-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,
- }
+ /// 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 }
}
- pub fn with_page_setup(self, page_setup: PageSetup) -> Self {
- Self {
- page_setup: Some(page_setup),
- ..self
+ /// Opens the file at `path`.
+ pub fn open_file<P>(mut self, path: P) -> Result<SpvFile, anyhow::Error>
+ where
+ P: AsRef<Path>,
+ {
+ let file = File::open(path)?;
+ if let Some(password) = self.password.take() {
+ self.open_reader_encrypted(file, password)
+ } else {
+ Self::open_reader_inner(file)
}
}
- pub fn close(mut self) -> ZipResult<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
+ /// Opens the file read from `reader`.
+ fn open_reader_encrypted<R>(self, reader: R, password: String) -> Result<SpvFile, anyhow::Error>
+ where
+ R: Read + Seek + 'static,
+ {
+ Self::open_reader_inner(
+ EncryptedFile::new(reader)?
+ .unlock(password.as_bytes())
+ .map_err(|_| anyhow!("Incorrect password."))?,
+ )
}
- fn write_table<X>(
- &mut self,
- item: &Item,
- pivot_table: &PivotTable,
- structure: &mut XmlWriter<X>,
- ) where
- X: Write,
+ /// Opens the file read from `reader`.
+ pub fn open_reader<R>(mut self, reader: R) -> Result<SpvFile, anyhow::Error>
+ where
+ R: Read + Seek + 'static,
{
- let table_id = self.next_table_id;
- self.next_table_id += 1;
-
- let mut content = Vec::new();
- let mut cursor = Cursor::new(&mut content);
- pivot_table.write_le(&mut cursor).unwrap();
-
- let table_name = light_table_name(table_id);
- self.writer
- .start_file(&table_name, SimpleFileOptions::default())
- .unwrap(); // XXX
- self.writer.write_all(&content).unwrap(); // XXX
-
- self.container(structure, item, "vtb:table", |element| {
- element
- .with_attribute(("tableId", Cow::from(table_id.to_string())))
- .with_attribute((
- "subType",
- Cow::from(pivot_table.subtype().display(pivot_table).to_string()),
- ))
- .write_inner_content(|w| {
- w.create_element("vtb:tableStructure")
- .write_inner_content(|w| {
- w.create_element("vtb:dataPath")
- .write_text_content(BytesText::new(&table_name))?;
- Ok(())
- })?;
- Ok(())
- })
- .unwrap();
- });
+ if let Some(password) = self.password.take() {
+ self.open_reader_encrypted(reader, password)
+ } else {
+ Self::open_reader_inner(reader)
+ }
}
- fn write_text<X>(&mut self, item: &Item, text: &Text, structure: &mut XmlWriter<X>)
+ fn open_reader_inner<R>(reader: R) -> Result<SpvFile, anyhow::Error>
where
- X: Write,
+ R: Read + Seek + 'static,
{
- self.container(structure, item, "vtx:text", |w| {
- w.with_attribute(("type", text.type_.as_xml_str()))
- .write_text_content(BytesText::new(&text.content.display(()).to_string()))
- .unwrap();
- });
+ // Open archive.
+ let mut archive = ZipArchive::new(reader).map_err(|error| match error {
+ ZipError::InvalidArchive(_) => Error::NotSpv,
+ other => other.into(),
+ })?;
+ Ok(Self::from_spv_zip_archive(&mut archive)?)
}
- fn write_item<X>(&mut self, item: &Item, structure: &mut XmlWriter<X>)
+ fn from_spv_zip_archive<R>(archive: &mut ZipArchive<R>) -> Result<SpvFile, Error>
where
- X: Write,
+ R: Read + Seek,
{
- match &item.details {
- super::Details::Chart => todo!(),
- super::Details::Image => todo!(),
- super::Details::Group(children) => {
- let mut attributes = Vec::<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)
+ // Check manifest.
+ let mut file = archive
+ .by_name("META-INF/MANIFEST.MF")
+ .map_err(|_| Error::NotSpv)?;
+ let mut string = String::new();
+ file.read_to_string(&mut string)?;
+ if string.trim() != "allowPivoting=true" {
+ return Err(Error::NotSpv);
+ }
+ drop(file);
+
+ let mut items = Vec::new();
+ let mut page_setup = None;
+ for i in 0..archive.len() {
+ let name = String::from(archive.name_for_index(i).unwrap());
+ if name.starts_with("outputViewer") && name.ends_with(".xml") {
+ let (mut new_items, ps) = read_heading(archive, i, &name)?;
+ items.append(&mut new_items);
+ page_setup = page_setup.or(ps);
}
- super::Details::PageBreak => {
- self.needs_page_break = true;
- }
- super::Details::Table(pivot_table) => self.write_table(item, pivot_table, structure),
- super::Details::Text(text) => self.write_text(item, text, structure),
}
- }
- fn container<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();
+ Ok(SpvFile {
+ item: items.into_iter().collect(),
+ page_setup,
+ })
}
}
-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),
- ))
- }
+pub struct SpvFile {
+ /// SPV file contents.
+ pub item: Vec<Item>,
- // Formats.
- (
- 0u32,
- SpvString("en_US.ISO_8859-1:1987"),
- 0u32, // XXX current_layer
- SpvBool(false), // x7
- SpvBool(false), // x8
- SpvBool(false), // x9
- y0(self),
- custom_currency(self),
- Counted::new((Counted::new((x1(self), x2())), x3(self))),
- )
- .write_le(writer)?;
-
- // Dimensions.
- (self.dimensions.len() as u32).write_le(writer)?;
-
- let x2 = repeat_n(2, self.axes[Axis3::Z].dimensions.len())
- .chain(repeat_n(0, self.axes[Axis3::Y].dimensions.len()))
- .chain(repeat(1));
- for ((index, dimension), x2) in self.dimensions.iter().enumerate().zip(x2) {
- dimension.write_options(writer, endian, (index, x2))?;
- }
-
- // Axes.
- for axis in [Axis3::Z, Axis3::Y, Axis3::X] {
- (self.axes[axis].dimensions.len() as u32).write_le(writer)?;
- }
- for axis in [Axis3::Z, Axis3::Y, Axis3::X] {
- for index in self.axes[axis].dimensions.iter().copied() {
- (index as u32).write_le(writer)?;
- }
- }
-
- // Cells.
- (self.cells.len() as u32).write_le(writer)?;
- for (index, value) in &self.cells {
- (*index as u64, value).write_le(writer)?;
- }
+ /// The page setup in the SPV file, if any.
+ pub page_setup: Option<page::PageSetup>,
+}
- Ok(())
+impl SpvFile {
+ pub fn into_parts(self) -> (Vec<Item>, Option<page::PageSetup>) {
+ (self.item, self.page_setup)
}
-}
-impl PivotTable {
- fn spv_layer(&self) -> usize {
- let mut layer = 0;
- for (dimension, layer_value) in self
- .axis_dimensions(Axis3::Z)
- .zip(self.current_layer.iter().copied())
- .rev()
- {
- layer = layer * dimension.len() + layer_value;
- }
- layer
+ pub fn into_items(self) -> Vec<Item> {
+ self.item
}
}
-impl<W> Driver for SpvDriver<W>
-where
- W: Write + Seek,
-{
- fn name(&self) -> Cow<'static, str> {
- Cow::from("spv")
- }
+#[derive(Debug, Display, thiserror::Error)]
+pub enum Error {
+ /// Not an SPV file.
+ NotSpv,
- fn write(&mut self, item: &Arc<Item>) {
- if item.details.is_page_break() {
- self.needs_page_break = true;
- return;
- }
+ /// {0}
+ ZipError(#[from] ZipError),
- let mut headings = XmlWriter::new(Cursor::new(Vec::new()));
- let element = headings
- .create_element("heading")
- .with_attribute((
- "creation-date-time",
- Cow::from(Utc::now().format("%x %x").to_string()),
- ))
- .with_attribute((
- "creator",
- Cow::from(format!(
- "{} {}",
- env!("CARGO_PKG_NAME"),
- env!("CARGO_PKG_VERSION")
- )),
- ))
- .with_attribute(("creator-version", "21"))
- .with_attribute(("xmlns", "http://xml.spss.com/spss/viewer/viewer-tree"))
- .with_attribute((
- "xmlns:vps",
- "http://xml.spss.com/spss/viewer/viewer-pagesetup",
- ))
- .with_attribute(("xmlns:vtx", "http://xml.spss.com/spss/viewer/viewer-text"))
- .with_attribute(("xmlns:vtb", "http://xml.spss.com/spss/viewer/viewer-table"));
- element
- .write_inner_content(|w| {
- w.create_element("label")
- .write_text_content(BytesText::new("Output"))?;
- if let Some(page_setup) = self.page_setup.take() {
- write_page_setup(&page_setup, w)?;
- }
- self.write_item(item, w);
- Ok(())
- })
- .unwrap();
-
- let headings = headings.into_inner().into_inner();
- let heading_id = self.next_heading_id;
- self.next_heading_id += 1;
- self.writer
- .start_file(
- output_viewer_name(heading_id, item.details.as_group().is_some()),
- SimpleFileOptions::default(),
- )
- .unwrap(); // XXX
- self.writer.write_all(&headings).unwrap(); // XXX
- }
-}
+ /// {0}
+ IoError(#[from] std::io::Error),
-fn write_page_setup<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"))
- }
+ /// {0}
+ DeError(#[from] quick_xml::DeError),
- writer
- .create_element("vps:pageSetup")
- .with_attribute((
- "initial-page-number",
- Cow::from(format!("{}", page_setup.initial_page_number)),
- ))
- .with_attribute((
- "chart-size",
- match page_setup.chart_size {
- super::page::ChartSize::AsIs => "as-is",
- super::page::ChartSize::FullHeight => "full-height",
- super::page::ChartSize::HalfHeight => "half-height",
- super::page::ChartSize::QuarterHeight => "quarter-height",
- },
- ))
- .with_attribute(("margin-left", inches(page_setup.margins[Axis2::X][0])))
- .with_attribute(("margin-right", inches(page_setup.margins[Axis2::X][1])))
- .with_attribute(("margin-top", inches(page_setup.margins[Axis2::Y][0])))
- .with_attribute(("margin-bottom", inches(page_setup.margins[Axis2::Y][1])))
- .with_attribute(("paper-height", inches(page_setup.paper[Axis2::Y])))
- .with_attribute(("paper-width", inches(page_setup.paper[Axis2::X])))
- .with_attribute((
- "reference-orientation",
- match page_setup.orientation {
- crate::output::page::Orientation::Portrait => "portrait",
- crate::output::page::Orientation::Landscape => "landscape",
- },
- ))
- .with_attribute((
- "space-after",
- Cow::from(format!("{:.1}pt", page_setup.object_spacing * 72.0)),
- ))
- .write_inner_content(|w| {
- write_page_heading(&page_setup.headings[0], "vps:pageHeader", w)?;
- write_page_heading(&page_setup.headings[1], "vps:pageFooter", w)?;
- Ok(())
- })?;
- Ok(())
-}
+ /// {0}
+ BinrwError(#[from] binrw::Error),
-fn write_page_heading<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(())
-}
+ /// {0}
+ LightError(#[from] LightError),
-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
- }
+ /// {0}
+ CairoError(#[from] cairo::IoError),
}
-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(())
- }
+fn new_error_item(message: impl Into<Value>) -> Item {
+ Text::new_log(message).into_item().with_label("Error")
}
-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>,
+fn read_heading<R>(
+ archive: &mut ZipArchive<R>,
+ file_number: usize,
+ structure_member: &str,
+) -> 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}"))
{
- match self {
- Category::Group(group) => group.write_le(writer, data_indexes),
- Category::Leaf(leaf) => leaf.write_le(writer, data_indexes),
- }
- }
-}
+ Ok(result) => result,
+ Err(error) => panic!("{error:?}"),
+ };
+ let _page_setup = heading.page_setup.take();
+ // XXX convert page_setup to the internal format
+ Ok((heading.decode(archive, structure_member)?, None))
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Heading {
+ #[serde(rename = "@visibility")]
+ visibility: Option<String>,
+ #[serde(rename = "@commandName")]
+ command_name: Option<String>,
+ label: Label,
+ page_setup: Option<PageSetup>,
-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)
- }
+ #[serde(rename = "$value")]
+ #[serde(default)]
+ children: Vec<HeadingContent>,
}
-impl Group {
- fn write_le<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
+impl Heading {
+ fn decode<R>(
+ self,
+ archive: &mut ZipArchive<R>,
+ structure_member: &str,
+ ) -> Result<Vec<Item>, Error>
where
- W: Write + Seek,
- D: Iterator<Item = usize>,
+ R: Read + Seek,
{
- (
- self.name(),
- 0u8,
- 0u8,
- 1u8,
- 0u32, // x23
- -1i32,
- )
- .write_le(writer)?;
-
- for child in &self.children {
- child.write_le(writer, data_indexes)?;
+ let mut items = Vec::new();
+ for child in self.children {
+ match child {
+ HeadingContent::Container(container) => {
+ if container.page_break_before == PageBreakBefore::Always {
+ items.push(
+ Details::PageBreak
+ .into_item()
+ .with_spv_info(SpvInfo::new(structure_member)),
+ );
+ }
+ let item = match container.content {
+ ContainerContent::Table(table) => {
+ table.decode(archive, structure_member).unwrap() /* XXX*/
+ }
+ ContainerContent::Graph(graph) => graph.decode(structure_member),
+ ContainerContent::Text(container_text) => Text::new(
+ match container_text.text_type {
+ TextType::Title => crate::output::TextType::Title,
+ TextType::Log | TextType::Text => crate::output::TextType::Log,
+ TextType::PageTitle => crate::output::TextType::PageTitle,
+ },
+ container_text.decode(),
+ )
+ .into_item()
+ .with_command_name(container_text.command_name)
+ .with_spv_info(SpvInfo::new(structure_member)),
+ ContainerContent::Image(image) => {
+ image.decode(archive, structure_member).unwrap()
+ } /*XXX*/,
+ ContainerContent::Object(object) => {
+ object.decode(archive, structure_member).unwrap()
+ } /*XXX*/,
+ ContainerContent::Model => new_error_item("models not yet implemented")
+ .with_spv_info(SpvInfo::new(structure_member).with_error()),
+ ContainerContent::Tree => new_error_item("trees not yet implemented")
+ .with_spv_info(SpvInfo::new(structure_member).with_error()),
+ };
+ items.push(item);
+ }
+ HeadingContent::Heading(mut heading) => {
+ let show = !heading.visibility.is_some();
+ let label = std::mem::take(&mut heading.label.text);
+ let command_name = heading.command_name.take();
+ items.push(
+ heading
+ .decode(archive, structure_member)?
+ .into_iter()
+ .collect::<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<ReferenceOrientation>,
+ #[serde(rename = "@space-after")]
+ pub space_after: Option<Length>,
+ pub page_header: PageHeader,
+ pub page_footer: PageFooter,
+}
+
+impl PageSetup {
+ fn decode(&self) -> page::PageSetup {
+ let mut setup = page::PageSetup::default();
+ if let Some(initial_page_number) = self.initial_page_number {
+ setup.initial_page_number = initial_page_number;
+ }
+ if let Some(chart_size) = self.chart_size {
+ setup.chart_size = chart_size.into();
+ }
+ if let Some(margin_left) = self.margin_left {
+ setup.margins.0[Axis2::X][0] = margin_left.into();
+ }
+ if let Some(margin_right) = self.margin_right {
+ setup.margins.0[Axis2::X][1] = margin_right.into();
+ }
+ if let Some(margin_top) = self.margin_top {
+ setup.margins.0[Axis2::Y][0] = margin_top.into();
+ }
+ if let Some(margin_bottom) = self.margin_bottom {
+ setup.margins.0[Axis2::Y][1] = margin_bottom.into();
+ }
+ match (self.paper_width, self.paper_height) {
+ (Some(width), Some(height)) => {
+ setup.paper = PaperSize::new(width.0, height.0, paper_sizes::Unit::Inch)
+ }
+ (Some(length), None) | (None, Some(length)) => {
+ setup.paper = PaperSize::new(length.0, length.0, paper_sizes::Unit::Inch)
+ }
+ (None, None) => (),
+ }
+ if let Some(reference_orientation) = self.reference_orientation {
+ setup.orientation = reference_orientation.into();
+ }
+ if let Some(space_after) = self.space_after {
+ setup.object_spacing = space_after.into();
+ }
+ if let Some(PageParagraph { text }) = &self.page_header.page_paragraph {
+ setup.header = text.decode();
}
- Ok(())
+ if let Some(PageParagraph { text }) = &self.page_footer.page_paragraph {
+ setup.footer = text.decode();
+ }
+ setup
}
}
-impl BinWrite for Footnote {
- type Args<'a> = ();
-
- fn write_options<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)
- }
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageHeader {
+ page_paragraph: Option<PageParagraph>,
}
-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(())
- }
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageFooter {
+ page_paragraph: Option<PageParagraph>,
}
-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, ())
- }
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageParagraph {
+ text: PageParagraphText,
}
-impl Stroke {
- fn as_spv(&self) -> u32 {
- match self {
- Stroke::None => 0,
- Stroke::Solid => 1,
- Stroke::Dashed => 2,
- Stroke::Thick => 3,
- Stroke::Thin => 4,
- Stroke::Double => 5,
- }
- }
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageParagraphText {
+ #[serde(default, rename = "$text")]
+ text: String,
}
-impl Color {
- fn as_spv(&self) -> u32 {
- ((self.alpha as u32) << 24)
- | ((self.r as u32) << 16)
- | ((self.g as u32) << 8)
- | (self.b as u32)
+impl PageParagraphText {
+ fn decode(&self) -> Document {
+ Document::from_html(&self.text)
}
}
-impl BinWrite for BorderStyle {
- type Args<'a> = usize;
+#[derive(Copy, Clone, Debug, Default, Deserialize)]
+#[serde(rename = "snake_case")]
+pub enum ReferenceOrientation {
+ #[serde(alias = "0")]
+ #[serde(alias = "0deg")]
+ #[serde(alias = "inherit")]
+ #[default]
+ Portrait,
- fn write_options<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)
- }
-}
+ #[serde(alias = "90")]
+ #[serde(alias = "90deg")]
+ #[serde(alias = "-270")]
+ #[serde(alias = "-270deg")]
+ Landscape,
-struct SpvBool(bool);
-impl BinWrite for SpvBool {
- type Args<'a> = ();
-
- fn write_options<W: Write + Seek>(
- &self,
- writer: &mut W,
- endian: binrw::Endian,
- args: Self::Args<'_>,
- ) -> binrw::BinResult<()> {
- (self.0 as u8).write_options(writer, endian, args)
- }
-}
+ #[serde(alias = "180")]
+ #[serde(alias = "180deg")]
+ #[serde(alias = "-1280")]
+ #[serde(alias = "-180deg")]
+ ReversePortrait,
-struct SpvString<T>(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)
- }
+ #[serde(alias = "270")]
+ #[serde(alias = "270deg")]
+ #[serde(alias = "-90")]
+ #[serde(alias = "-90deg")]
+ Seascape,
}
-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,
+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
+ }
}
}
}
-struct Count(u64);
+/// Chart size.
+#[derive(Copy, Clone, Debug, Default, Deserialize)]
+pub enum ChartSize {
+ #[default]
+ #[serde(rename = "as-is")]
+ AsIs,
-impl Count {
- fn new<W>(writer: &mut W) -> binrw::BinResult<Self>
- where
- W: Write + Seek,
- {
- 0u32.write_le(writer)?;
- Ok(Self(writer.stream_position()?))
- }
+ #[serde(rename = "full-height")]
+ FullHeight,
- 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)
- }
-}
+ #[serde(rename = "half-height")]
+ HalfHeight,
-struct Counted<T> {
- inner: T,
- endian: Option<Endian>,
+ #[serde(rename = "quarter-height")]
+ QuarterHeight,
}
-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 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)]
+#[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(),
+ }),
+ )
}
}
-impl<T> BinWrite for Counted<T>
+fn decode_image<R>(
+ archive: &mut ZipArchive<R>,
+ structure_member: &str,
+ command_name: &Option<String>,
+ image_name: &str,
+) -> Result<Item, Error>
where
- T: BinWrite,
- for<'a> T: BinWrite<Args<'a> = ()>,
+ R: Read + Seek,
{
- 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>,
+ let mut png = archive.by_name(image_name)?;
+ let image = ImageSurface::create_from_png(&mut png)?;
+ Ok(Details::Image(image)
+ .into_item()
+ .with_command_name(command_name.clone())
+ .with_spv_info(
+ SpvInfo::new(structure_member).with_members(SpvMembers::Image(image_name.into())),
+ ))
}
-impl BinWrite for Color {
- type Args<'a> = ();
-
- fn write_options<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)
- }
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Image {
+ #[serde(rename = "@commandName")]
+ command_name: Option<String>,
+ data_path: String,
}
-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,
+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,
)
- .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,
- }
- }
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Object {
+ #[serde(rename = "@commandName")]
+ command_name: Option<String>,
+ #[serde(rename = "@uri")]
+ uri: String,
}
-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 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)
}
}
-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)
- }
+#[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,
}
-struct Optional<T>(Option<T>);
+impl Table {
+ fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
+ where
+ R: Read + Seek,
+ {
+ match &self.table_structure.path {
+ None => {
+ let member_name = &self.table_structure.data_path;
+ let mut light = archive.by_name(member_name)?;
+ let mut data = Vec::with_capacity(light.size() as usize);
+ light.read_to_end(&mut data)?;
+ let mut cursor = Cursor::new(data);
+ let table = LightTable::read(&mut cursor).map_err(|e| {
+ e.with_message(format!(
+ "While parsing {member_name:?} as light binary SPV member"
+ ))
+ })?;
+ let pivot_table = table.decode()?;
+ Ok(pivot_table.into_item().with_spv_info(
+ SpvInfo::new(structure_member)
+ .with_members(SpvMembers::Light(self.table_structure.data_path.clone())),
+ ))
+ }
+ Some(xml_member_name) => {
+ let bin_member_name = &self.table_structure.data_path;
+ let mut bin_member = archive.by_name(bin_member_name)?;
+ let mut bin_data = Vec::with_capacity(bin_member.size() as usize);
+ bin_member.read_to_end(&mut bin_data)?;
+ let mut cursor = Cursor::new(bin_data);
+ let legacy_bin = LegacyBin::read(&mut cursor).map_err(|e| {
+ e.with_message(format!(
+ "While parsing {bin_member_name:?} as legacy binary SPV member"
+ ))
+ })?;
+ let data = legacy_bin.decode();
+ drop(bin_member);
-impl<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)
+ let member = BufReader::new(archive.by_name(&xml_member_name)?);
+ let visualization: Visualization = match serde_path_to_error::deserialize(
+ &mut quick_xml::de::Deserializer::from_reader(member),
+ )
+ .with_context(|| format!("Failed to parse {xml_member_name}"))
+ {
+ Ok(result) => result,
+ Err(error) => panic!("{error:?}"),
+ };
+ let pivot_table = visualization.decode(
+ data,
+ self.properties
+ .as_ref()
+ .map_or_else(Look::default, |properties| properties.clone().into()),
+ )?;
+
+ Ok(pivot_table.into_item().with_spv_info(
+ SpvInfo::new(structure_member).with_members(SpvMembers::Legacy {
+ xml: xml_member_name.clone(),
+ binary: bin_member_name.clone(),
+ }),
+ ))
}
- None => 0x58u8.write_le(writer),
}
}
}
-struct ValueMod<'a> {
- style: &'a Option<Box<ValueStyle>>,
- template: Option<&'a str>,
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum TableType {
+ Table,
+ Note,
+ Warning,
}
-impl<'a> ValueMod<'a> {
- fn new(value: &'a Value) -> Self {
- Self {
- style: &value.styling,
- template: None,
- }
- }
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct ContainerText {
+ #[serde(rename = "@type")]
+ text_type: TextType,
+ #[serde(rename = "@commandName")]
+ command_name: Option<String>,
+ html: String,
}
-impl<'a> Default for ValueMod<'a> {
- fn default() -> Self {
- Self {
- style: &None,
- template: None,
- }
+impl ContainerText {
+ fn decode(&self) -> Value {
+ html::Document::from_html(&self.html).into_value()
}
}
-impl<'a> BinWrite for ValueMod<'a> {
- type Args<'b> = ();
-
- fn write_options<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)
- }
- }
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum TextType {
+ Title,
+ Log,
+ Text,
+ #[serde(rename = "page-title")]
+ PageTitle,
}
-struct SpvFormat {
- format: Format,
- honor_small: bool,
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct TableStructure {
+ /// The `.xml` member name, for legacy members only.
+ path: Option<String>,
+ /// The `.bin` member name.
+ data_path: String,
+ /// Rarely used, not understood.
+ csv_path: Option<String>,
}
-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(())
+#[cfg(test)]
+#[test]
+fn test_spv() {
+ let items = ReadOptions::new()
+ .open_file("/home/blp/pspp/rust/tests/utilities/regress.spv")
+ .unwrap()
+ .into_items();
+ for item in items {
+ println!("{item}");
}
+ todo!()
}
--- /dev/null
+use std::{
+ borrow::Cow,
+ fmt::{Display, Write},
+ mem::discriminant,
+ ops::Not,
+};
+
+use itertools::Itertools;
+
+use crate::output::{
+ pivot::{FontStyle, HorzAlign},
+ spv::html::Style,
+};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum Token<'a> {
+ Id(Cow<'a, str>),
+ LeftCurly,
+ RightCurly,
+ Colon,
+ Semicolon,
+ Error,
+}
+
+struct Lexer<'a>(&'a str);
+
+impl<'a> Iterator for Lexer<'a> {
+ type Item = Token<'a>;
+
+ fn next(&mut self) -> Option<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 {
+ pub fn from_css(s: &str) -> Option<Self> {
+ let mut lexer = Lexer(s);
+ while let Some(token) = lexer.next() {
+ if let Token::Id(key) = token
+ && let Some(Token::Colon) = lexer.next()
+ && let Some(Token::Id(value)) = lexer.next()
+ && key.as_ref() == "text-align"
+ && let Ok(align) = value.parse()
+ {
+ return Some(align);
+ }
+ }
+ None
+ }
+}
+
+impl Style {
+ pub fn parse_css(styles: &mut Vec<Style>, s: &str) {
+ let mut lexer = Lexer(s);
+ while let Some(token) = lexer.next() {
+ if let Token::Id(key) = token
+ && let Some(Token::Colon) = lexer.next()
+ && let Some(Token::Id(value)) = lexer.next()
+ && 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
+ .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);
+ }
+ }
+ }
+ }
+}
+
+impl FontStyle {
+ pub fn parse_css(&mut self, s: &str) {
+ let mut lexer = Lexer(s);
+ while let Some(token) = lexer.next() {
+ if let Token::Id(key) = token
+ && let Some(Token::Colon) = lexer.next()
+ && let Some(Token::Id(value)) = lexer.next()
+ {
+ 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;
+ }
+ }
+ _ => (),
+ }
+ }
+ }
+ }
+
+ pub fn from_css(s: &str) -> Self {
+ let mut style = FontStyle::default();
+ style.parse_css(s);
+ style
+ }
+
+ 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::{Color, FontStyle, HorzAlign},
+ spv::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
+#![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::{CellStyle, Color, FontStyle, HorzAlign, 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)
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Markup {
+ Seq(Vec<Markup>),
+ Text(String),
+ Variable(Variable),
+ Style { style: Style, child: Box<Markup> },
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)]
+pub enum Variable {
+ Date,
+ Time,
+ Head(u8),
+ PageTitle,
+ Page,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
+#[error("Unknown variable")]
+pub struct UnknownVariable;
+
+impl FromStr for Variable {
+ type Err = UnknownVariable;
+
+ 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 {
+ fn is_empty(&self) -> bool {
+ match self {
+ Markup::Seq(seq) => seq.is_empty(),
+ _ => false,
+ }
+ }
+ fn is_style(&self) -> bool {
+ matches!(self, Markup::Style { .. })
+ }
+ fn into_style(self) -> Option<(Style, Markup)> {
+ match self {
+ Markup::Style { style, child } => Some((style, *child)),
+ _ => None,
+ }
+ }
+ fn is_text(&self) -> bool {
+ matches!(self, Markup::Text(_))
+ }
+ fn as_text(&self) -> Option<&str> {
+ match self {
+ Markup::Text(text) => Some(text.as_str()),
+ _ => None,
+ }
+ }
+ 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 } => {
+ match style {
+ Style::Bold => writer.create_element("b"),
+ Style::Italic => writer.create_element("i"),
+ Style::Underline => writer.create_element("u"),
+ Style::Strike => writer.create_element("strike"),
+ Style::Emphasis => writer.create_element("em"),
+ Style::Strong => writer.create_element("strong"),
+ Style::Face(face) => writer
+ .create_element("font")
+ .with_attribute(("face", face.as_str())),
+ Style::Color(color) => writer
+ .create_element("font")
+ .with_attribute(("color", color.display_css().to_string().as_str())),
+ Style::Size(points) => writer
+ .create_element("font")
+ .with_attribute(("size", format!("{points}pt").as_str())),
+ }
+ .write_inner_content(|w| child.write_html(w))?;
+ }
+ }
+ Ok(())
+ }
+
+ 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))
+ .unwrap();
+ String::from_utf8(writer.into_inner().into_inner()).unwrap()
+ }
+
+ pub fn to_pango<'a, F>(&self, substitutions: 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(&substitutions, &mut s, &mut attrs);
+ (s, attrs)
+ }
+
+ fn to_pango_inner<'a, F>(&self, substitutions: &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(substitutions, s, attrs);
+ }
+ }
+ Markup::Text(string) => s.push_str(&string),
+ Markup::Variable(variable) => match substitutions(*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(substitutions, 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)
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct Paragraph {
+ pub markup: Markup,
+ pub horz_align: HorzAlign,
+}
+
+impl Default for Paragraph {
+ fn default() -> Self {
+ Self {
+ markup: Markup::default(),
+ horz_align: HorzAlign::Left,
+ }
+ }
+}
+
+impl Paragraph {
+ 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 }
+ }
+
+ 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 markup = self.markup;
+ let mut strike = false;
+ while markup.is_style() {
+ let (style, child) = markup.into_style().unwrap();
+ 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;
+ }
+ if strike {
+ apply_style(&mut markup, Style::Strike);
+ }
+ if markup.is_text() {
+ Value::new_user_text(markup.into_text().unwrap())
+ } else {
+ Value::new_markup(markup)
+ }
+ .with_font_style(font_style)
+ .with_cell_style(cell_style)
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Document(pub Vec<Paragraph>);
+
+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 {
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ 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![Paragraph {
+ markup: Markup::Text(input.into()),
+ horz_align: HorzAlign::Left,
+ }]),
+ Err(_) => Self::default(),
+ }
+ }
+
+ pub fn into_value(self) -> Value {
+ self.0.into_iter().next().unwrap_or_default().into_value()
+ }
+
+ pub fn to_html(&self) -> String {
+ let mut writer = XmlWriter::new(Cursor::new(Vec::new()));
+ writer
+ .create_element("html")
+ .write_inner_content(|w| {
+ for paragraph in &self.0 {
+ w.create_element("p")
+ .with_attribute(("align", paragraph.horz_align.as_str().unwrap()))
+ .write_inner_content(|w| paragraph.markup.write_html(w))?;
+ }
+ Ok(())
+ })
+ .unwrap();
+
+ // Return the result with `<html>` and `</html>` stripped off.
+ str::from_utf8(&writer.into_inner().into_inner())
+ .unwrap()
+ .strip_prefix("<html>")
+ .unwrap()
+ .strip_suffix("</html>")
+ .unwrap()
+ .into()
+ }
+
+ pub fn to_values(&self) -> Vec<Value> {
+ self.0
+ .iter()
+ .map(|paragraph| paragraph.clone().into_value())
+ .collect()
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Style {
+ Bold,
+ Italic,
+ Underline,
+ Strike,
+ Emphasis,
+ Strong,
+ Face(String),
+ Color(Color),
+ Size(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),
+ };
+}
+
+pub fn parse_dom(dom: &Dom) -> Vec<Paragraph> {
+ // 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 css = 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);
+ Style::parse_css(&mut 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 paragraphs = 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)
+ };
+ paragraphs.push(Paragraph::new(parse_nodes(&body[start..end]), align, &css));
+ start = end;
+ }
+
+ paragraphs
+}
+
+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(mut expansion) = dst.last().unwrap().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()),
+ );
+ }
+ Node::Element(br) if br.name.eq_ignore_ascii_case("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));
+ }
+ 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::output::spv::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 size="9pt"><font color="#000000"><font face="Monospaced"><b>bold</b></font></font></font>ย <font size="9pt"><font color="#000000"><font face="Monospaced"><i>italic</i>ย <strike>strikeout</strike></font></font></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 size="13.5pt"><font color="#000000"><font face="Monospaced">centerย large</font></font></font></p><p align="right"><font size="9pt"><font color="#000000"><font face="Monospaced"><b><i>right</i></b></font></font></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"><font face="sans-serif">&[PageTitle]</font></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"><font face="sans-serif">Page &[Page]</font></font></p>"##
+ );
+ }
+}
--- /dev/null
+use std::{
+ collections::HashMap,
+ io::{Read, Seek, SeekFrom},
+};
+
+use binrw::{BinRead, BinResult, binread};
+use chrono::{NaiveDateTime, NaiveTime};
+use encoding_rs::UTF_8;
+
+use crate::{
+ calendar::{date_time_to_pspp, time_to_pspp},
+ data::Datum,
+ format::{Category, Format},
+ output::{
+ pivot::Value,
+ spv::light::{U32String, decode_format, parse_vec},
+ },
+};
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+pub struct LegacyBin {
+ #[br(magic(0u8))]
+ version: Version,
+ #[br(temp)]
+ n_sources: u16,
+ 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 {
+ pub fn decode(&self) -> HashMap<String, HashMap<String, Vec<DataValue>>> {
+ fn decode_asciiz(name: &[u8]) -> String {
+ let len = name.iter().position(|b| *b == 0).unwrap_or(name.len());
+ std::str::from_utf8(&name[..len]).unwrap().into() // XXX unwrap
+ }
+
+ let mut sources = HashMap::new();
+ for (metadata, data) in self.metadata.iter().zip(&self.data) {
+ let mut variables = HashMap::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
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct DataValue {
+ pub index: Option<f64>,
+ pub value: Datum<String>,
+}
+
+impl DataValue {
+ 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))
+ }
+
+ // 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),
+ }
+ }
+
+ 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_number_with_format(Some(date_time_to_pspp(date_time)), format)
+ } 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_number_with_format(Some(time_to_pspp(time)), format)
+ } else {
+ Value::new_datum_with_format(&self.value, 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 ordered_float::OrderedFloat;
+use serde::Deserialize;
+
+use crate::{
+ calendar::{date_time_to_pspp, time_to_pspp},
+ data::Datum,
+ format::{self, Decimal::Dot, F8_0, F40_2, Type, UncheckedFormat},
+ output::{
+ pivot::{
+ self, Area, AreaStyle, Axis2, Axis3, Category, CategoryLocator, CellStyle, Color,
+ Dimension, Group, HeadingRegion, HorzAlign, Leaf, Length, Look, NumberValue,
+ PivotTable, RowParity, Value, ValueInner, VertAlign,
+ },
+ spv::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: HashMap<String, HashMap<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 == 0 {
+ entry.content = text.text.strip_suffix('\n').unwrap_or(&text.text).into();
+ } else {
+ entry.marker =
+ Some(text.text.strip_suffix('.').unwrap_or(&text.text).into());
+ }
+ }
+ }
+ }
+ 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))),
+ );
+ }
+ 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]);
+ let caption = LabelFrame::decode_label(&labels[Purpose::SubTitle]);
+ 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 usize..=max_width.as_pt_f64() as usize;
+ }
+ }
+
+ 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_default())
+ } 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)]
+ struct CatBuilder {
+ /// The category we've built so far.
+ category: Category,
+
+ /// 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::new();
+ for (index, value) in variables[0].values.iter().enumerate() {
+ let Some(row) = value.category() else {
+ continue;
+ };
+ coordinate_to_index.insert(row, CategoryLocator::new_leaf(index));
+ let name = variables[0].new_name(value, footnotes);
+ cats.push(CatBuilder {
+ category: Category::from(Leaf::new(name)),
+ 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 j in 1..variables.len() {
+ let mut coordinate_to_index = HashMap::new();
+ let mut next_cats = Vec::with_capacity(cats.len());
+ let mut start = 0;
+ for end in 1..=cats.len() {
+ let dv1 = &variables[j].values[cats[start].leaves.start];
+ if end < cats.len()
+ && variables[j].values[cats[end].leaves.clone()]
+ .iter()
+ .all(|dv| &dv.value == &dv1.value)
+ {
+ } else {
+ let name = variables[j].map.lookup(dv1);
+ let next_cat = if end - start > 1 || name.is_number_or(|s| s.is_empty()) {
+ let name = variables[j].new_name(dv1, footnotes);
+ let mut group = Group::new(name);
+ for i in start..end {
+ group.push(cats[i].category.clone());
+ }
+ CatBuilder {
+ category: Category::from(group),
+ leaves: cats[start].leaves.start..cats[end - 1].leaves.end,
+ location: cats[start].location.parent(),
+ }
+ } else {
+ cats[start].clone()
+ };
+ coordinate_to_index
+ .insert(dv1.category().unwrap() /*XXX?*/, next_cat.location);
+ next_cats.push(next_cat);
+ start = end;
+ }
+ }
+ *variables[j].coordinate_to_index.borrow_mut() = coordinate_to_index;
+ cats = next_cats;
+ }
+
+ let dimension = Dimension::new(
+ Group::new(
+ variables[0]
+ .label
+ .as_ref()
+ .map_or_else(|| Value::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>>,
+ ) {
+ 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();
+ for var in variables {
+ if let Some((var, level)) = var {
+ dim_vars.push((var, level));
+ } else if !dim_vars.is_empty() {
+ 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() {
+ decode_dimension(
+ &dim_vars,
+ axes,
+ styles,
+ a,
+ look,
+ rotate_inner_column_labels,
+ rotate_outer_row_labels,
+ footnotes,
+ dims,
+ );
+ }
+ }
+
+ 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.children;
+ 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 mut level_ofs = columns.len() + rows.len() + 1;
+ for layers in [&graph.faceting.layers1, &graph.faceting.layers2] {
+ decode_dimensions(
+ layers.iter().map(|layer| layer.variable.as_str()),
+ &series,
+ &axes,
+ &styles,
+ Axis3::Y,
+ &mut look,
+ &mut rotate_inner_column_labels,
+ &mut rotate_outer_row_labels,
+ &footnotes,
+ level_ofs,
+ &mut dims,
+ );
+ level_ofs += layers.len();
+ }
+
+ 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
+ };
+ 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() {
+ 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)
+ {
+ value = value.with_footnote(footnote);
+ }
+ }
+ }
+ }
+ if let Value {
+ inner: ValueInner::Number(NumberValue { value: None, .. }),
+ styling: None,
+ } = &value
+ {
+ // 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_data(data);
+ 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 max_category(&self) -> Option<usize> {
+ self.values
+ .iter()
+ .filter_map(|value| value.category())
+ .max()
+ }
+
+ 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")]
+enum Variable {
+ SourceVariable(SourceVariable),
+ DerivedVariable(DerivedVariable),
+}
+
+#[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: &HashMap<String, HashMap<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")]
+ 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 {
+ match &mut value.inner {
+ ValueInner::Number(number) => {
+ number.format = format;
+ }
+ ValueInner::String(string) => {
+ if format.type_().category() == format::Category::Date
+ && let Ok(date_time) =
+ NaiveDateTime::parse_from_str(&string.s, "%Y-%m-%dT%H:%M:%S%.3f")
+ {
+ value.inner = ValueInner::Number(NumberValue {
+ show: None,
+ format,
+ honor_small: false,
+ value: Some(date_time_to_pspp(date_time)),
+ variable: None,
+ value_label: None,
+ })
+ } else if format.type_().category() == format::Category::Time
+ && let Ok(time) = NaiveTime::parse_from_str(&string.s, "%H:%M:%S%.3f")
+ {
+ value.inner = ValueInner::Number(NumberValue {
+ show: None,
+ format,
+ honor_small: false,
+ value: Some(time_to_pspp(time)),
+ variable: None,
+ value_label: None,
+ })
+ } else if let Ok(number) = string.s.parse::<f64>() {
+ value.inner = ValueInner::Number(NumberValue {
+ show: None,
+ format,
+ honor_small: false,
+ value: Some(number),
+ variable: None,
+ value_label: None,
+ })
+ }
+ }
+ _ => (),
+ }
+ }
+ }
+
+ 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 pivot::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(),
+ decimal: Dot,
+ }),
+ 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)]
+ layers1: Vec<Layer>,
+ cross: Cross,
+ #[serde(default)]
+ layers2: Vec<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(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,
+
+ 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]) -> Option<Value> {
+ if !labels.is_empty() {
+ let mut s = String::new();
+ for t in labels {
+ if let LabelChild::Text(text) = &t.child {
+ for t in text {
+ if let Some(_defines_reference) = t.defines_reference {
+ // XXX footnote
+ }
+ s += &t.text;
+ }
+ }
+ }
+ Some(Value::new_user_text(s))
+ } 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(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::{
+ fmt::Debug,
+ io::{Cursor, Read, Seek},
+ ops::Deref,
+ 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 crate::{
+ format::{
+ CC, Decimal, Decimals, Epoch, F40, Format, NumberStyle, Settings, Type, UncheckedFormat,
+ Width,
+ },
+ output::pivot::{
+ self, AreaStyle, Axis2, Axis3, BoxBorder, Color, FootnoteMarkerPosition,
+ FootnoteMarkerType, Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Look,
+ PivotTable, PivotTableMetadata, PivotTableStyle, PrecomputedIndex, RowColBorder, RowParity,
+ StringValue, Stroke, TemplateValue, ValueStyle, VariableValue, VertAlign, parse_bool,
+ },
+ settings::Show,
+};
+
+#[derive(Debug, Display, thiserror::Error)]
+pub enum LightError {
+ /// Expected {expected} dimensions along axes, found {actual} dimensions ({n_layers} layers + {n_rows} rows + {n_columns} columns).
+ WrongAxisCount {
+ expected: usize,
+ actual: usize,
+ n_layers: usize,
+ n_rows: usize,
+ 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),
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+pub struct LightTable {
+ header: Header,
+ #[br(args(header.version))]
+ titles: Titles,
+ #[br(parse_with(parse_vec), args(header.version))]
+ footnotes: Vec<Footnote>,
+ #[br(args(header.version))]
+ 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(header.version))]
+ formats: Formats,
+ #[br(parse_with(parse_vec), args(header.version))]
+ dimensions: Vec<Dimension>,
+ axes: Axes,
+ #[br(parse_with(parse_vec), args(header.version))]
+ 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 usize..=self.header.max_row_heading_width as usize,
+ HeadingRegion::Columns => self.header.min_column_heading_width as usize..=self.header.max_column_heading_width as usize,
+ },
+ 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,
+ },
+ top_continuation: self.print_settings.top_continuation,
+ bottom_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) -> Result<PivotTable, LightError> {
+ let encoding = self.formats.encoding();
+
+ 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 pivot_table = PivotTable::new(self.axes.decode(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: Settings {
+ epoch: self.formats.y0.epoch(),
+ decimal: self.formats.y0.decimal(),
+ leading_zero: y1.map_or(false, |y1| y1.include_leading_zero),
+ ccs: self.formats.custom_currency.decode(encoding),
+ },
+ 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),
+ })
+ .with_footnotes(footnotes)
+ .with_data(cells);
+ Ok(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,
+ 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(version: Version))]
+#[derive(Debug)]
+struct Titles {
+ #[br(args(version))]
+ title: Value,
+ #[br(temp)]
+ _1: Optional<One>,
+ #[br(args(version))]
+ subtype: Value,
+ #[br(temp)]
+ _2: Optional<One>,
+ #[br(magic = b'1')]
+ #[br(args(version))]
+ user_title: Value,
+ #[br(temp)]
+ _3: Optional<One>,
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ corner_text: Option<Value>,
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ 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<T, A>(inner: A, ...) -> BinResult<Vec<T>>
+where
+ for<'a> T: BinRead<Args<'a> = A>,
+ A: Clone,
+ T: 'static,
+{
+ let count = u32::read_options(reader, endian, ())? as usize;
+ <Vec<T>>::read_options(reader, endian, VecArgs { count, inner })
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Footnote {
+ #[br(args(version))]
+ text: Value,
+ #[br(parse_with(parse_explicit_optional))]
+ #[br(args(version))]
+ 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(version: Version))]
+#[derive(Debug)]
+struct Areas {
+ #[br(temp)]
+ _1: Optional<Zero>,
+ #[br(args(version))]
+ areas: [Area; 8],
+}
+
+impl Areas {
+ fn decode(&self, encoding: &'static Encoding) -> EnumMap<pivot::Area, AreaStyle> {
+ EnumMap::from_fn(|area| {
+ let index = match area {
+ pivot::Area::Title => 0,
+ pivot::Area::Caption => 1,
+ pivot::Area::Footer => 2,
+ pivot::Area::Corner => 3,
+ pivot::Area::Labels(Axis2::X) => 4,
+ pivot::Area::Labels(Axis2::Y) => 5,
+ pivot::Area::Data(_) => 6,
+ pivot::Area::Layers => 7,
+ };
+ let data_row = match area {
+ pivot::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(version: Version))]
+#[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(version == Version::V3))]
+ margins: Margins,
+}
+
+impl Area {
+ fn decode(&self, encoding: &'static Encoding, data_row: RowParity) -> AreaStyle {
+ AreaStyle {
+ cell_style: pivot::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: pivot::FontStyle {
+ bold: (self.style & 1) != 0,
+ italic: (self.style & 2) != 0,
+ underline: self.underline,
+ font: self.typeface.decode(encoding),
+ fg: match data_row {
+ RowParity::Even => self.fg,
+ RowParity::Odd => self.alt_fg,
+ },
+ bg: match data_row {
+ RowParity::Even => self.bg,
+ RowParity::Odd => self.alt_bg,
+ },
+ size: (self.size / 1.33) as i32,
+ },
+ }
+ }
+}
+
+#[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<pivot::Border, pivot::BorderStyle> {
+ let mut borders = pivot::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<(pivot::Border, pivot::BorderStyle)> {
+ let border = match self.index {
+ 0 => pivot::Border::Title,
+ 1 => pivot::Border::OuterFrame(BoxBorder::Left),
+ 2 => pivot::Border::OuterFrame(BoxBorder::Top),
+ 3 => pivot::Border::OuterFrame(BoxBorder::Right),
+ 4 => pivot::Border::OuterFrame(BoxBorder::Bottom),
+ 5 => pivot::Border::InnerFrame(BoxBorder::Left),
+ 6 => pivot::Border::InnerFrame(BoxBorder::Top),
+ 7 => pivot::Border::InnerFrame(BoxBorder::Right),
+ 8 => pivot::Border::InnerFrame(BoxBorder::Bottom),
+ 9 => pivot::Border::DataLeft,
+ 10 => pivot::Border::DataLeft,
+ 11 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ 12 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ 13 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ 14 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ 15 => pivot::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ 16 => pivot::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+ 17 => pivot::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ 18 => pivot::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+ _ => 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, pivot::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<pivot::Sizing>>> {
+ fn decode_axis(
+ widths: &[i32],
+ breaks: &[u32],
+ keeps: &[(i32, i32)],
+ ) -> Option<Box<pivot::Sizing>> {
+ if widths.is_empty() && breaks.is_empty() && keeps.is_empty() {
+ None
+ } else {
+ Some(Box::new(pivot::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:?}")
+ }
+}
+
+#[binread]
+struct CountedInner {
+ #[br(parse_with(parse_vec))]
+ data: Vec<u8>,
+}
+
+impl CountedInner {
+ fn cursor(self) -> Cursor<Vec<u8>> {
+ Cursor::new(self.data)
+ }
+}
+
+impl Debug for CountedInner {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{:?}", &self.data)
+ }
+}
+
+#[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(version: Version))]
+#[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(version == Version::V1))]
+ v1: Counted<Optional<N0>>,
+ #[br(if(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) -> &'static Encoding {
+ // XXX We should probably warn for unknown encodings below
+ if let Some(charset) = self.charset()
+ && let Some(encoding) = Encoding::for_label(&charset.string)
+ {
+ encoding
+ } else 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))]
+ show_variables: Option<Show>,
+ #[br(parse_with(parse_show))]
+ 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() -> BinResult<Option<Show>> {
+ match <u8>::read_options(reader, endian, ())? {
+ 0 => Ok(None),
+ 1 => Ok(Some(Show::Value)),
+ 2 => Ok(Some(Show::Label)),
+ 3 => Ok(Some(Show::Both)),
+ _ => {
+ // XXX warn about invalid value
+ 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) -> Decimal {
+ // XXX warn about bad decimal point?
+ Decimal::try_from(self.decimal as char).unwrap_or_default()
+ }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct CustomCurrency {
+ #[br(parse_with(parse_vec))]
+ ccs: Vec<U32String>,
+}
+
+impl CustomCurrency {
+ fn decode(&self, encoding: &'static Encoding) -> EnumMap<CC, Option<Box<NumberStyle>>> {
+ let mut ccs = EnumMap::default();
+ for (cc, string) in enum_iterator::all().zip(&self.ccs) {
+ if let Ok(style) = NumberStyle::from_str(&string.decode(encoding)) {
+ ccs[cc] = Some(Box::new(style));
+ } else {
+ // XXX warning
+ }
+ }
+ ccs
+ }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueNumber {
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ mods: Option<ValueMods>,
+ #[br(parse_with(parse_format))]
+ format: Format,
+ x: f64,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueVarNumber {
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ mods: Option<ValueMods>,
+ #[br(parse_with(parse_format))]
+ format: Format,
+ x: f64,
+ var_name: U32String,
+ value_label: U32String,
+ #[br(parse_with(parse_show))]
+ show: Option<Show>,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueText {
+ local: U32String,
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ mods: Option<ValueMods>,
+ id: U32String,
+ c: U32String,
+ #[br(parse_with(parse_bool))]
+ fixed: bool,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueString {
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ mods: Option<ValueMods>,
+ #[br(parse_with(parse_format))]
+ format: Format,
+ value_label: U32String,
+ var_name: U32String,
+ #[br(parse_with(parse_show))]
+ show: Option<Show>,
+ s: U32String,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueVarName {
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ mods: Option<ValueMods>,
+ var_name: U32String,
+ var_label: U32String,
+ #[br(parse_with(parse_show))]
+ show: Option<Show>,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueFixedText {
+ local: U32String,
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ mods: Option<ValueMods>,
+ id: U32String,
+ c: U32String,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueTemplate {
+ #[br(parse_with(parse_explicit_optional), args(version))]
+ mods: Option<ValueMods>,
+ template: U32String,
+ #[br(parse_with(parse_vec), args(version))]
+ 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> = (Version,);
+
+ 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) -> Format {
+ if raw == 0 || raw == 0x10000 || raw == 1 {
+ return Format::new(Type::F, 40, 2).unwrap();
+ }
+
+ 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 {
+ // XXX warn
+ 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() -> BinResult<Format> {
+ Ok(decode_format(u32::read_options(reader, endian, ())?))
+}
+
+impl ValueNumber {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+ pivot::Value::new_number_with_format((self.x != -f64::MAX).then_some(self.x), self.format)
+ .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+ }
+}
+
+impl ValueVarNumber {
+ fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+ pivot::Value::new_number_with_format((self.x != -f64::MAX).then_some(self.x), 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) -> pivot::Value {
+ pivot::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) -> pivot::Value {
+ pivot::Value::new(pivot::ValueInner::String(StringValue {
+ s: self.s.decode(encoding),
+ hex: self.format.type_() == Type::AHex,
+ show: self.show,
+ var_name: 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) -> pivot::Value {
+ pivot::Value::new(pivot::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) -> pivot::Value {
+ pivot::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) -> pivot::Value {
+ pivot::Value::new(pivot::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) -> pivot::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> = (Version,);
+
+ fn read_options<R: Read + Seek>(
+ reader: &mut R,
+ endian: Endian,
+ (version,): (Version,),
+ ) -> BinResult<Self> {
+ let count = u32::read_options(reader, endian, ())? as usize;
+ if count == 0 {
+ Ok(Self(vec![Value::read_options(reader, endian, (version,))?]))
+ } else {
+ let zero = u32::read_options(reader, endian, ())?;
+ assert_eq!(zero, 0);
+ let values = <Vec<_>>::read_options(
+ reader,
+ endian,
+ VecArgs {
+ count,
+ inner: (version,),
+ },
+ )?;
+ Ok(Self(values))
+ }
+ }
+}
+
+impl Argument {
+ fn decode(
+ &self,
+ encoding: &'static Encoding,
+ footnotes: &pivot::Footnotes,
+ ) -> Vec<pivot::Value> {
+ self.0
+ .iter()
+ .map(|value| value.decode(encoding, footnotes))
+ .collect()
+ }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueMods {
+ #[br(parse_with(parse_vec))]
+ refs: Vec<i16>,
+ #[br(parse_with(parse_vec))]
+ subscripts: Vec<U32String>,
+ #[br(if(version == Version::V1))]
+ v1: Option<ValueModsV1>,
+ #[br(if(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| pivot::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| {
+ pivot::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,
+ decimal: Decimal::Dot, /*XXX*/
+ }),
+ _ => 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::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(version: Version))]
+#[derive(Debug)]
+struct Dimension {
+ #[br(args(version))]
+ 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(version))]
+ categories: Vec<Category>,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Category {
+ #[br(args(version))]
+ name: Value,
+ #[br(args(version))]
+ 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(version: Version))]
+#[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(version))]
+ 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)>, LightError> {
+ let n = self.layers.len() + self.rows.len() + self.columns.len();
+ if n != dimensions.len() {
+ /* XXX warn
+ return Err(LightError::WrongAxisCount {
+ expected: dimensions.len(),
+ actual: n,
+ n_layers: self.layers.len(),
+ n_rows: self.rows.len(),
+ n_columns: self.columns.len(),
+ });*/
+ return Ok(dimensions
+ .into_iter()
+ .map(|dimension| (Axis3::Y, dimension))
+ .collect());
+ }
+
+ 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(LightError::InvalidDimensionIndex { index, n });
+ } else if axes[index].is_some() {
+ return Err(LightError::DuplicateDimensionIndex(index));
+ }
+ axes[index] = Some(axis);
+ }
+ Ok(axes
+ .into_iter()
+ .map(|axis| axis.unwrap())
+ .zip(dimensions)
+ .collect())
+ }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Cell {
+ index: u64,
+ #[br(if(version == Version::V1), temp)]
+ _zero: Optional<Zero>,
+ #[br(args(version))]
+ value: Value,
+}
//! Some drivers use tables as an implementation detail of rendering pivot
//! tables.
-use std::{ops::Range, sync::Arc};
+use std::{borrow::Cow, ops::Range, sync::Arc};
use enum_map::{EnumMap, enum_map};
use ndarray::{Array, Array2};
-use crate::output::pivot::{Coord2, DisplayValue, Footnote, HorzAlign, ValueInner};
+use crate::output::{
+ pivot::{CellStyle, Coord2, DisplayValue, FontStyle, Footnote, HorzAlign, ValueInner},
+ spv::html,
+};
use super::pivot::{
Area, AreaStyle, Axis2, Border, BorderStyle, HeadingRegion, Rect2, Value, ValueOptions,
}
}
+ pub fn with_rotate(self, rotate: bool) -> Self {
+ Self { rotate, ..self }
+ }
+
pub fn is_empty(&self) -> bool {
self.value.inner.is_empty()
}
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)]
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,
}
}
pub fn get_rule(&self, axis: Axis2, pos: Coord2) -> BorderStyle {
- self.borders[self.rules[axis][[pos.x(), pos.y()]]]
+ self.rules[axis][[pos.x(), pos.y()]].map_or(BorderStyle::none(), |b| self.borders[b])
}
pub fn put(&mut self, region: Rect2, inner: CellInner) {
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);
}
}
}
}
-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:?}"
- );
- }
- }
- }
-}
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();
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::{Format, Settings as FormatSettings, F8_2},
message::Severity,
output::pivot::Look,
};
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,
+++ /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,
-}
}
/// 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 }
}
};
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,
}
-โญโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโฎ
-โ 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,
+ Item, Text,
pivot::{Axis3, Dimension, Group, PivotTable, Value, tests::assert_lines_eq},
},
sys::{
}
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,
+ output::drivers::spv::Zeros,
sys::{
ProductVersion,
encoding::codepage_from_encoding,
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;