work on reading spv files
authorBen Pfaff <blp@cs.stanford.edu>
Wed, 24 Sep 2025 15:56:09 +0000 (08:56 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Sun, 30 Nov 2025 18:29:46 +0000 (10:29 -0800)
225 files changed:
rust/Cargo.lock
rust/doc/src/SUMMARY.md
rust/doc/src/commands/set.md
rust/doc/src/invoking/output.md [new file with mode: 0644]
rust/doc/src/invoking/pspp-convert.md
rust/doc/src/invoking/pspp-identify.md [new file with mode: 0644]
rust/doc/src/invoking/pspp-show-pc.md
rust/doc/src/invoking/pspp-show-por.md
rust/doc/src/invoking/pspp-show-spv.md [new file with mode: 0644]
rust/doc/src/invoking/pspp-show.md
rust/doc/src/spv/index.md
rust/doc/src/spv/legacy-detail-binary.md
rust/doc/src/spv/legacy-detail-xml.md
rust/doc/src/spv/light-detail.md
rust/doc/src/spv/structure.md
rust/doc/src/tablelook.md
rust/pspp/Cargo.toml
rust/pspp/src/calendar.rs
rust/pspp/src/cli.rs [new file with mode: 0644]
rust/pspp/src/cli/convert.rs [new file with mode: 0644]
rust/pspp/src/cli/decrypt.rs [new file with mode: 0644]
rust/pspp/src/cli/identify.rs [new file with mode: 0644]
rust/pspp/src/cli/show.rs [new file with mode: 0644]
rust/pspp/src/cli/show_pc.rs [new file with mode: 0644]
rust/pspp/src/cli/show_por.rs [new file with mode: 0644]
rust/pspp/src/cli/show_spv.rs [new file with mode: 0644]
rust/pspp/src/command.rs
rust/pspp/src/command/crosstabs.rs
rust/pspp/src/command/ctables.rs
rust/pspp/src/command/data_list.rs
rust/pspp/src/command/descriptives.rs
rust/pspp/src/convert.rs [deleted file]
rust/pspp/src/data.rs
rust/pspp/src/decrypt.rs [deleted file]
rust/pspp/src/file.rs
rust/pspp/src/format.rs
rust/pspp/src/format/display.rs
rust/pspp/src/lex/segment.rs
rust/pspp/src/main.rs
rust/pspp/src/message.rs
rust/pspp/src/output.rs
rust/pspp/src/output/cairo.rs [deleted file]
rust/pspp/src/output/cairo/driver.rs [deleted file]
rust/pspp/src/output/cairo/fsm.rs [deleted file]
rust/pspp/src/output/cairo/pager.rs [deleted file]
rust/pspp/src/output/csv.rs [deleted file]
rust/pspp/src/output/driver.rs [deleted file]
rust/pspp/src/output/drivers.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/cairo.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/cairo/driver.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/cairo/fsm.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/cairo/pager.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/csv.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/html.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/json.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/por.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/sav.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/spv.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/text.rs [new file with mode: 0644]
rust/pspp/src/output/drivers/text/text_line.rs [new file with mode: 0644]
rust/pspp/src/output/html.rs [deleted file]
rust/pspp/src/output/json.rs [deleted file]
rust/pspp/src/output/page.rs
rust/pspp/src/output/pivot.rs
rust/pspp/src/output/pivot/look_xml.rs
rust/pspp/src/output/pivot/output.rs
rust/pspp/src/output/pivot/testdata/caption.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_1.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_2.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_3.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/category_borders_1.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/category_borders_2.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d1_c.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d1_r.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_cc.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_cc_with_dim_labels.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_cl-all_layers.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_cl-layer0.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_cl-layer1.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_cr.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_cr_with_corner_dim_labels.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_cr_with_nested_dim_labels.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rc.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rc_with_corner_dim_labels.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rc_with_nested_dim_labels.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rl-all_layers.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rl-layer0.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rl-layer1.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rr.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rr_with_corner_dim_labels.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2_rr_with_nested_dim_labels.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2m_cc.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2m_cr.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2m_rc.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d2m_rr.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d3-layer0_0.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d3-layer0_1.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/d3-layer1_2.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/dimension_borders_1.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/dimension_borders_2.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/empty_groups.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/footnote_alphabetic_subscript.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/footnote_alphabetic_superscript.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/footnote_hidden.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/footnote_numeric_subscript.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/footnote_numeric_superscript.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/metadata_entry.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/no_dimension.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/no_title_or_caption.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/one_empty_dimension.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/small_numbers.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/three_dimensions_two_empty.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/title_and_caption.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/testdata/two_empty_dimensions.expected [new file with mode: 0644]
rust/pspp/src/output/pivot/tests.rs
rust/pspp/src/output/pivot/tlo.rs
rust/pspp/src/output/render.rs
rust/pspp/src/output/spv.rs
rust/pspp/src/output/spv/css.rs [new file with mode: 0644]
rust/pspp/src/output/spv/html.rs [new file with mode: 0644]
rust/pspp/src/output/spv/legacy_bin.rs [new file with mode: 0644]
rust/pspp/src/output/spv/legacy_xml.rs [new file with mode: 0644]
rust/pspp/src/output/spv/light.rs [new file with mode: 0644]
rust/pspp/src/output/table.rs
rust/pspp/src/output/text.rs [deleted file]
rust/pspp/src/output/text_line.rs [deleted file]
rust/pspp/src/pc.rs
rust/pspp/src/pc/tests.rs
rust/pspp/src/por/read.rs
rust/pspp/src/settings.rs
rust/pspp/src/show.rs [deleted file]
rust/pspp/src/show_pc.rs [deleted file]
rust/pspp/src/show_por.rs [deleted file]
rust/pspp/src/sys/cooked.rs
rust/pspp/src/sys/raw/records.rs
rust/pspp/src/sys/testdata/attributes.expected
rust/pspp/src/sys/testdata/bad_machine_float_info_size.expected
rust/pspp/src/sys/testdata/bad_machine_integer_info_count.expected
rust/pspp/src/sys/testdata/bad_machine_integer_info_endianness.expected
rust/pspp/src/sys/testdata/bad_machine_integer_info_float_format.expected
rust/pspp/src/sys/testdata/bad_variable_name_in_long_string_value_label.expected
rust/pspp/src/sys/testdata/bad_variable_name_in_variable_value_pair.expected
rust/pspp/src/sys/testdata/bad_very_long_string_length.expected
rust/pspp/src/sys/testdata/bad_very_long_string_segment_width.expected
rust/pspp/src/sys/testdata/compressed_data.expected
rust/pspp/src/sys/testdata/compressed_data_other_bias.expected
rust/pspp/src/sys/testdata/compressed_data_zero_bias.expected
rust/pspp/src/sys/testdata/documents.expected
rust/pspp/src/sys/testdata/duplicate_attribute_name.expected
rust/pspp/src/sys/testdata/duplicate_long_variable_name.expected
rust/pspp/src/sys/testdata/duplicate_value_labels_type.expected
rust/pspp/src/sys/testdata/duplicate_variable_name.expected
rust/pspp/src/sys/testdata/empty_document_record.expected
rust/pspp/src/sys/testdata/extra_product_info.expected
rust/pspp/src/sys/testdata/fewer_data_records_than_indicated_by_file_header.expected
rust/pspp/src/sys/testdata/integer_overflows_in_long_string_missing_values.expected
rust/pspp/src/sys/testdata/invalid_long_string_missing_values.expected
rust/pspp/src/sys/testdata/invalid_variable_format.expected
rust/pspp/src/sys/testdata/invalid_variable_name.expected
rust/pspp/src/sys/testdata/long_variable_names.expected
rust/pspp/src/sys/testdata/missing_attribute_value.expected
rust/pspp/src/sys/testdata/missing_newline_after_variable_name_in_mrsets.expected
rust/pspp/src/sys/testdata/missing_string_continuation.expected
rust/pspp/src/sys/testdata/mixed_variable_types_in_mrsets.expected
rust/pspp/src/sys/testdata/more_data_records_than_indicated_by_file_header.expected
rust/pspp/src/sys/testdata/multiple_documents_records.expected
rust/pspp/src/sys/testdata/multiple_response_sets.expected
rust/pspp/src/sys/testdata/multiple_response_sets_bad_counted_string.expected
rust/pspp/src/sys/testdata/multiple_response_sets_bad_name.expected
rust/pspp/src/sys/testdata/multiple_response_sets_counted_string_bad_length.expected
rust/pspp/src/sys/testdata/multiple_response_sets_counted_string_missing_space.expected
rust/pspp/src/sys/testdata/multiple_response_sets_duplicate_variable_name.expected
rust/pspp/src/sys/testdata/multiple_response_sets_missing_label_source.expected
rust/pspp/src/sys/testdata/multiple_response_sets_missing_newline_after_variable_name.expected
rust/pspp/src/sys/testdata/multiple_response_sets_missing_space_after_c.expected
rust/pspp/src/sys/testdata/multiple_response_sets_missing_space_after_counted_string.expected
rust/pspp/src/sys/testdata/multiple_response_sets_missing_space_after_e.expected
rust/pspp/src/sys/testdata/multiple_response_sets_unexpected_label_source.expected
rust/pspp/src/sys/testdata/no_variables.expected
rust/pspp/src/sys/testdata/null_dereference_skipping_bad_extension_record_18.expected
rust/pspp/src/sys/testdata/partial_compressed_data_record.expected
rust/pspp/src/sys/testdata/partial_data_record_between_variables.expected
rust/pspp/src/sys/testdata/partial_data_record_within_long_string.expected
rust/pspp/src/sys/testdata/test-encrypted.expected
rust/pspp/src/sys/testdata/type_4_record_names_long_string_variable.expected
rust/pspp/src/sys/testdata/unknown_encoding.expected
rust/pspp/src/sys/testdata/unknown_extension_record.expected
rust/pspp/src/sys/testdata/unquoted_attribute_value.expected
rust/pspp/src/sys/testdata/unspecified_number_of_variable_positions.expected
rust/pspp/src/sys/testdata/value_label_variable_indexes_must_be_in_correct_range.expected
rust/pspp/src/sys/testdata/value_label_variable_indexes_must_not_be_long_string_continuation.expected
rust/pspp/src/sys/testdata/value_label_with_no_associated_variables.expected
rust/pspp/src/sys/testdata/value_labels.expected
rust/pspp/src/sys/testdata/variable_display_with_width.expected
rust/pspp/src/sys/testdata/variable_display_without_width.expected
rust/pspp/src/sys/testdata/variable_labels_and_missing_values.expected
rust/pspp/src/sys/testdata/variable_roles.expected
rust/pspp/src/sys/testdata/variable_sets.expected
rust/pspp/src/sys/testdata/variable_sets_unknown_variable.expected
rust/pspp/src/sys/testdata/variables_for_value_label_must_all_be_same_type.expected
rust/pspp/src/sys/testdata/very_long_strings.expected
rust/pspp/src/sys/testdata/weight_must_be_numeric.expected
rust/pspp/src/sys/testdata/weight_variable_bad_index.expected
rust/pspp/src/sys/testdata/weight_variable_continuation.expected
rust/pspp/src/sys/testdata/write-numeric-simple.expected
rust/pspp/src/sys/testdata/write-numeric-uncompressed.expected
rust/pspp/src/sys/testdata/write-numeric-zlib.expected
rust/pspp/src/sys/testdata/write-string-simple.expected
rust/pspp/src/sys/testdata/write-string-uncompressed.expected
rust/pspp/src/sys/testdata/write-string-zlib.expected
rust/pspp/src/sys/testdata/wrong_display_alignment.expected
rust/pspp/src/sys/testdata/wrong_display_measurement_level.expected
rust/pspp/src/sys/testdata/wrong_display_parameter_count.expected
rust/pspp/src/sys/testdata/wrong_display_parameter_size.expected
rust/pspp/src/sys/testdata/wrong_special_floats.expected
rust/pspp/src/sys/testdata/wrong_variable_positions.expected
rust/pspp/src/sys/testdata/wrong_variable_positions_but_v13.expected
rust/pspp/src/sys/testdata/zcompressed_data.expected
rust/pspp/src/sys/testdata/zcompressed_data_uncompressed_size_block_size.expected
rust/pspp/src/sys/testdata/zero_or_one_variable_in_mrset.expected
rust/pspp/src/sys/tests.rs
rust/pspp/src/sys/write.rs
rust/pspp/src/variable.rs
rust/rustfmt.toml [new file with mode: 0644]
src/output/spv/spv-legacy-data.h

index 74ed653cff86c3a676994a90ec31261c432a6114..2a7afa685099167b6ceaf21e7cf83d947c4944fa 100644 (file)
@@ -227,6 +227,12 @@ dependencies = [
  "syn 1.0.109",
 ]
 
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
 [[package]]
 name = "bitflags"
 version = "1.3.2"
@@ -528,6 +534,40 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.101",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.101",
+]
+
 [[package]]
 name = "dashmap"
 version = "5.5.3"
@@ -625,6 +665,12 @@ dependencies = [
  "syn 2.0.101",
 ]
 
+[[package]]
+name = "doc-comment"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
+
 [[package]]
 name = "either"
 version = "1.15.0"
@@ -682,6 +728,27 @@ dependencies = [
  "syn 2.0.101",
 ]
 
+[[package]]
+name = "enumset"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634"
+dependencies = [
+ "enumset_derive",
+]
+
+[[package]]
+name = "enumset_derive"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.101",
+]
+
 [[package]]
 name = "env_filter"
 version = "0.1.3"
@@ -711,6 +778,17 @@ version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
 
+[[package]]
+name = "erased-serde"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
 [[package]]
 name = "errno"
 version = "0.3.12"
@@ -721,12 +799,6 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
-[[package]]
-name = "flagset"
-version = "0.4.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
-
 [[package]]
 name = "flate2"
 version = "1.1.1"
@@ -738,6 +810,12 @@ dependencies = [
  "miniz_oxide",
 ]
 
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
 [[package]]
 name = "foldhash"
 version = "0.1.5"
@@ -1014,6 +1092,21 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "html_parser"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f56db07b6612644f6f7719f8ef944f75fff9d6378fdf3d316fd32194184abd"
+dependencies = [
+ "doc-comment",
+ "pest",
+ "pest_derive",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+]
+
 [[package]]
 name = "httparse"
 version = "1.10.1"
@@ -1130,6 +1223,12 @@ dependencies = [
  "zerovec",
 ]
 
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
 [[package]]
 name = "idna"
 version = "1.0.3"
@@ -1619,6 +1718,49 @@ version = "2.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
 
+[[package]]
+name = "pest"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.101",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a"
+dependencies = [
+ "pest",
+ "sha2",
+]
+
 [[package]]
 name = "pin-project"
 version = "1.1.10"
@@ -1731,7 +1873,7 @@ dependencies = [
  "aes",
  "anyhow",
  "binrw",
- "bitflags 2.9.1",
+ "bit-vec",
  "cairo-rs",
  "chardetng",
  "chrono",
@@ -1747,10 +1889,12 @@ dependencies = [
  "encoding_rs",
  "enum-iterator",
  "enum-map",
- "flagset",
+ "enumset",
+ "erased-serde",
  "flate2",
  "hashbrown 0.15.5",
  "hexplay",
+ "html_parser",
  "indexmap",
  "itertools 0.14.0",
  "libc",
@@ -1760,12 +1904,14 @@ dependencies = [
  "ordered-float",
  "pango",
  "pangocairo",
+ "paper-sizes",
  "pspp-derive",
  "quick-xml",
  "rand",
  "readpass",
  "serde",
  "serde_json",
+ "serde_path_to_error",
  "smallstr",
  "smallvec",
  "thiserror",
@@ -1803,9 +1949,9 @@ dependencies = [
 
 [[package]]
 name = "quick-xml"
-version = "0.37.5"
+version = "0.38.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
+checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
 dependencies = [
  "memchr",
  "serde",
@@ -1996,6 +2142,17 @@ dependencies = [
  "serde_core",
 ]
 
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
 [[package]]
 name = "serde_repr"
 version = "0.1.20"
@@ -2036,6 +2193,17 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
 [[package]]
 name = "shlex"
 version = "1.3.0"
@@ -2435,12 +2603,24 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
 [[package]]
 name = "typenum"
 version = "1.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
 
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
 [[package]]
 name = "unicase"
 version = "2.8.1"
index 4c10ae8220f06dd3cbac268924623e13693d47a7..b35c29d723cf5698e2363824c637b99bd86501b6 100644 (file)
@@ -4,11 +4,14 @@
 [License](license.md)
 
 - [Running PSPP](invoking/index.md)
-  - [Converting Data](invoking/pspp-convert.md)
+  - [Converting Files](invoking/pspp-convert.md)
   - [Inspecting System Files](invoking/pspp-show.md)
   - [Inspecting Portable Files](invoking/pspp-show-por.md)
   - [Inspecting SPSS/PC+ Files](invoking/pspp-show-pc.md)
+  - [Inspecting SPSS Viewer Files](invoking/pspp-show-spv.md)
+  - [Identifying Files](invoking/pspp-identify.md)
   - [Decrypting Files](invoking/pspp-decrypt.md)
+  - [Output Driver Configuration](invoking/output.md)
 
 # Language Overview
 
index 0814ba8942d598e47843d85c99cf8f90c6dab1fd..774f57e1974d98d2403587dc2112483684cabd68 100644 (file)
@@ -24,7 +24,7 @@ SET
         /SCALEMIN=COUNT
 
 (data output)
-        /CC{A,B,C,D,E}={'NPRE,PRE,SUF,NSUF','NPRE.PRE.SUF.NSUF'}
+        /CC{A,B,C,D,E}='STRING'
         /DECIMAL={DOT,COMMA}
         /FORMAT=FMT_SPEC
         /LEADZERO={ON,OFF}
@@ -46,13 +46,13 @@ SET
         /TVARS={NAMES,LABELS,BOTH}
         /TLOOK={NONE,FILE}
 
-(logging)
+(journal)
         /JOURNAL={ON,OFF} ['FILE_NAME']
 
 (system files)
         /SCOMPRESSION={ON,OFF}
 
-(miscellaneous)
+(security)
         /SAFER=ON
         /LOCALE='STRING'
 
@@ -62,7 +62,7 @@ SET
         /MITERATE=NUMBER
         /MNEST=NUMBER
 
-(settings not yet implemented, but accepted and ignored)
+(not yet implemented)
         /BASETEXTDIRECTION={AUTOMATIC,RIGHTTOLEFT,LEFTTORIGHT}
         /BLOCK='C'
         /BOX={'XXX','XXXXXXXXXXX'}
@@ -80,8 +80,21 @@ subcommands are examined in groups.
 For subcommands that take boolean values, `ON` and `YES` are
 synonymous, as are `OFF` and `NO`, when used as subcommand values.
 
+<!-- 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
@@ -122,6 +135,15 @@ files.  The data input subcommands are
   default, is equivalent to `MSBFIRST` or `LSBFIRST` depending on the
   native format of the machine running PSPP.
 
+# Interaction
+
+```
+SET
+        /MXERRS=MAX_ERRS
+        /MXWARNS=MAX_WARNINGS
+        /WORKSPACE=WORKSPACE_SIZE
+```
+
 Interaction subcommands affect the way that PSPP interacts with an
 online user.  The interaction subcommands are
 
@@ -136,6 +158,18 @@ online user.  The interaction subcommands are
   are issued, except a single initial warning advising you that
   warnings will not be given.  The default value is 100.
 
+# Syntax Execution
+
+```
+SET
+        /LOCALE='LOCALE'
+        /MXLOOPS=MAX_LOOPS
+        /SEED={RANDOM,SEED_VALUE}
+        /UNDEFINED={WARN,NOWARN}
+        /FUZZBITS=FUZZBITS
+        /SCALEMIN=COUNT
+```
+
 Syntax execution subcommands control the way that PSPP commands
 execute.  The syntax execution subcommands are
 
@@ -184,6 +218,19 @@ execute.  The syntax execution subcommands are
   virtual memory management, setting a very large workspace may cause
   PSPP to abort.
 
+# Data Output
+
+```
+SET
+        /CC{A,B,C,D,E}='STRING'
+        /DECIMAL={DOT,COMMA}
+        /FORMAT=FMT_SPEC
+        /LEADZERO={ON,OFF}
+        /MDISPLAY={TEXT,TABLES}
+        /SMALL=NUMBER
+        /WIB={NATIVE,MSBFIRST,LSBFIRST}
+```
+
 Data output subcommands affect the format of output data.  These
 subcommands are
 
@@ -236,6 +283,16 @@ subcommands are
   default, is equivalent to `MSBFIRST` or `LSBFIRST` depending on the
   native format of the machine running PSPP.
 
+# Output Routing
+
+```
+SET
+        /ERRORS={ON,OFF,TERMINAL,LISTING,BOTH,NONE}
+        /MESSAGES={ON,OFF,TERMINAL,LISTING,BOTH,NONE}
+        /PRINTBACK={ON,OFF,TERMINAL,LISTING,BOTH,NONE}
+        /RESULTS={ON,OFF,TERMINAL,LISTING,BOTH,NONE}
+```
+
 In the PSPP text-based interface, the output routing subcommands
 affect where output is sent.  The following values are allowed for each
 of these subcommands:
@@ -275,6 +332,18 @@ These output routing subcommands are:
 These subcommands have no effect on output in the PSPP GUI
 environment.
 
+# Output Driver
+
+```
+SET
+        /HEADERS={NO,YES,BLANK}
+        /LENGTH={NONE,N_LINES}
+        /WIDTH={NARROW,WIDTH,N_CHARACTERS}
+        /TNUMBERS={VALUES,LABELS,BOTH}
+        /TVARS={NAMES,LABELS,BOTH}
+        /TLOOK={NONE,FILE}
+```
+
 Output driver option subcommands affect output drivers' settings.
 These subcommands are:
 
@@ -313,7 +382,14 @@ These subcommands are:
   `.tlo` file in the same way as specifying `--table-look=FILE` the
   PSPP command line (*note Main Options::).
 
-Logging subcommands affect logging of commands executed to external
+# Journal
+
+```
+SET
+        /JOURNAL={ON,OFF} ['FILE_NAME']
+```
+
+Journal subcommands affect logging of commands executed to external
 files.  These subcommands are
 
 * `JOURNAL`  
@@ -328,6 +404,13 @@ files.  These subcommands are
   The journal is named `pspp.jnl` by default.  A different name may
   be specified.
 
+# System Files
+
+```
+SET
+        /SCOMPRESSION={ON,OFF}
+```
+
 System file subcommands affect the default format of system files
 produced by PSPP.  These subcommands are
 
@@ -335,6 +418,14 @@ produced by PSPP.  These subcommands are
   Whether system files created by `SAVE` or `XSAVE` are compressed by
   default.  The default is `ON`.
 
+# Security
+
+```
+SET
+        /SAFER=ON
+        /LOCALE='STRING'
+```
+
 Security subcommands affect the operations that commands are allowed
 to perform.  The security subcommands are
 
@@ -377,6 +468,16 @@ to perform.  The security subcommands are
   Contrary to intuition, this command does not affect any aspect of
   the system's locale.
 
+# Macros
+
+```
+SET
+        /MEXPAND={ON,OFF}
+        /MPRINT={ON,OFF}
+        /MITERATE=NUMBER
+        /MNEST=NUMBER
+```
+
 The following subcommands affect the interpretation of macros.  For
 more information, see [Macro Settings](define.md#macro-settings).
 
@@ -399,6 +500,20 @@ more information, see [Macro Settings](define.md#macro-settings).
   Limits the number of levels of nested macro expansions.  This must
   be set to a positive integer.  The default is 50.
 
+# Not Yet Implemented
+
+```
+SET
+        /BASETEXTDIRECTION={AUTOMATIC,RIGHTTOLEFT,LEFTTORIGHT}
+        /BLOCK='C'
+        /BOX={'XXX','XXXXXXXXXXX'}
+        /CACHE={ON,OFF}
+        /CELLSBREAK=NUMBER
+        /COMPRESSION={ON,OFF}
+        /CMPTRANS={ON,OFF}
+        /HEADER={NO,YES,BLANK}
+```
+
 The following subcommands are not yet implemented, but PSPP accepts
 them and ignores the settings:
 
diff --git a/rust/doc/src/invoking/output.md b/rust/doc/src/invoking/output.md
new file mode 100644 (file)
index 0000000..920ffec
--- /dev/null
@@ -0,0 +1,273 @@
+# Output Drivers
+
+PSPP has output drivers for several formats.  This section documents
+the supported formats and how they can be configured:
+
+<!-- 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">&amp;[PageTitle]</p>'
+    footer = '<p align="right">Page &amp;[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.
index d1248cd67f9a2fbd79c29970ebc7466d46b96e6c..950dc0bebeab2b3a3dd482515b81348cb39165cb 100644 (file)
@@ -1,40 +1,54 @@
-# 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>`  
@@ -52,6 +66,12 @@ for unrecognized extensions.
 
   [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.
@@ -61,64 +81,23 @@ for unrecognized extensions.
   `--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
diff --git a/rust/doc/src/invoking/pspp-identify.md b/rust/doc/src/invoking/pspp-identify.md
new file mode 100644 (file)
index 0000000..4d4546a
--- /dev/null
@@ -0,0 +1,26 @@
+# 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.
index 6e85c11691d6dbecbab12b3aa0de440207204fdb..a933acee01bfcebc2075fb3477f5c542f4a59cb0 100644 (file)
@@ -45,7 +45,7 @@ The following `<MODE>`s are available:
 
 ## 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
@@ -53,26 +53,13 @@ The following options affect how `pspp show-pc` reads `<INPUT>`:
   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.
 
index bfe0f669a9a6f7f236472e367689f08ad715b5f3..83e0a3d80ee49b475f62f59d30687194c4743442 100644 (file)
@@ -85,7 +85,7 @@ The following `<MODE>`s are available:
 
 ## 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
@@ -93,26 +93,13 @@ The following options affect how `pspp show-por` reads `<INPUT>`:
   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.
 
diff --git a/rust/doc/src/invoking/pspp-show-spv.md b/rust/doc/src/invoking/pspp-show-spv.md
new file mode 100644 (file)
index 0000000..e080f10
--- /dev/null
@@ -0,0 +1,140 @@
+# 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.
index 25065438f7265ea267b7176029c90337202bdc08..db8c3fa5eff5aa201ea444c1ccf5be255eec315a 100644 (file)
@@ -60,7 +60,7 @@ The following `<MODE>`s are available:
 
 ## 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
@@ -81,26 +81,13 @@ The following options affect how `pspp show` reads `<INPUT>`:
   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.
 
index 100a531c41adf78f367b887e28c6abd5dbccb30c..38550d312fe3faa74bce83d502b9f58500351f0f 100644 (file)
@@ -19,9 +19,9 @@ SPV manifest contains the string `allowPivoting=true`, without a
 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
@@ -42,18 +42,19 @@ table, a heading, a block of text, etc.)  or a group of them.  The
 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`  
@@ -88,3 +89,8 @@ their exact names do not matter to readers as long as they are unique.
 
 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
index e5edf4227a917710c85a886d3b8eee13225711d5..f6e57271d5e7c61d01e41dfbe4e462459ce150ee 100644 (file)
@@ -31,13 +31,14 @@ of the other data in the member.  Versions 0xaf and 0xb0 are known.  We
 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 -->
 
@@ -54,24 +55,25 @@ A data source has `n-variables` variables, each with `n-values` data
 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.
 
@@ -82,14 +84,14 @@ Data => Variable*[n-variables]
 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
 
@@ -108,10 +110,10 @@ Label => int32[frequency] string[label]
 
 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.)
 
index 5f0fe3399af3f0ee49490a8a60c62344406f633a..1f8449e8dc2c3ed0bfd77d42ae4038cfcbf18970 100644 (file)
@@ -48,9 +48,7 @@ visualization
    (sourceVariable | derivedVariable)+
    categoricalDomain?
    graph
-   labelFrame[lf1]*
-   container?
-   labelFrame[lf2]*
+   (labelFrame | container)*
    style+
    layerController?
 
@@ -67,7 +65,9 @@ categoricalDomain => variableReference simpleSort
 
 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
 
@@ -111,6 +111,10 @@ the following attributes:
 
 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.
 
@@ -248,8 +252,7 @@ This element 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`  
@@ -296,7 +299,7 @@ expression.
 * `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)`  
@@ -566,8 +569,7 @@ Each `layer` element represents a dimension, e.g.:
 ## The `facetLayout` Element
 
 ```
-facetLayout => tableLayout setCellProperties[scp1]*
-               facetLevel+ setCellProperties[scp2]*
+facetLayout => tableLayout (setCellProperties | facetLevel)+
 
 tableLayout
    :verticalTitlesInCorner=bool
@@ -669,7 +671,7 @@ text
    :usesReference=int?
    :definesReference=int?
    :position=(subscript | superscript)?
-   :style=ref style
+   :style=ref style?
 => TEXT
 ```
 
@@ -1472,3 +1474,14 @@ printingProperties
 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
index 17337d0a8cf15996732ef27ceba5286259a328fd..bd335a5e6e501471cd7b3ec5570aed85e1ba61d5 100644 (file)
@@ -56,14 +56,14 @@ context-free grammar using the following conventions:
   `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
@@ -76,7 +76,7 @@ context-free grammar using the following conventions:
 
 * `(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)`  
@@ -104,8 +104,10 @@ name="px">"device-independent pixels" (px)</a>, at 96/inch.  To
 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 =>
@@ -154,15 +156,15 @@ whose values influence column widths.  For the purpose of interpreting
 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
@@ -231,7 +233,7 @@ Footnote => Value[text] (58 | 31 Value[marker]) int32[show]
 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
@@ -252,12 +254,21 @@ Area =>
     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
@@ -272,13 +283,25 @@ the corpus its values are always integers.
 
 `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
@@ -290,7 +313,7 @@ should be the same color.  When `alternate` is 1, `alt-fg-color` and
 are empty strings.
 
 `left-margin`, `right-margin`, `top-margin`, and `bottom-margin` are
-measured in px.
+measured in [px](#px).
 
 ## Borders
 
@@ -303,7 +326,7 @@ Borders =>
         00 00 00)
 
 Border =>
-    be32[border-type]
+    be32[index]
     be32[stroke-type]
     be32[color]
 ```
@@ -315,26 +338,30 @@ The fixed value of `endian` can be used to validate the endianness.
 `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
@@ -450,9 +477,11 @@ The `PointKeeps` seem to be generated automatically based on
 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.
@@ -473,8 +502,8 @@ Formats =>
     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]
 ```
@@ -484,7 +513,7 @@ widths as manually adjusted by the user.
 
 `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
@@ -507,12 +536,12 @@ Most commonly these are all `-,,,` but other strings occur.
 
 A writer may safely use false for `x7`, `x8`, and `x9`.
 
-### X0
+### N0
 
-X0 only appears, optionally, in version 1 members.
+`N0` only appears, optionally, in version 1 members.
 
 ```
-X0 => byte*14 Y1 Y2
+N0 => byte*14 Y1 Y2
 Y1 =>
     string[command] string[command-local]
     string[language] string[charset] string[locale]
@@ -538,12 +567,12 @@ missing value.  It is always observed as `.`.
 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]
@@ -556,19 +585,38 @@ X1 =>
     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.
 
@@ -577,12 +625,12 @@ to use a global default.
 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]
@@ -593,29 +641,29 @@ StyleMap => int64[cell-index] int16[style-index]
 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
@@ -628,10 +676,23 @@ scientific notation from being chosen.)
 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
@@ -640,27 +701,30 @@ a null byte (a valid string never will).
 
 `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.
@@ -708,32 +772,32 @@ many other values have been observed.  A writer may safely use 0 for
 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]
@@ -741,25 +805,25 @@ Group =>
 
 `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
@@ -767,12 +831,14 @@ and should not be displayed.  (Merged groups can be nested!)
 
 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
+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
@@ -808,11 +874,12 @@ Cells => int32[n-cells] Cell*[n-cells]
 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\\).  
@@ -822,7 +889,7 @@ the index \\(k\\) is calculated by the following algorithm:
 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
 
@@ -859,6 +926,9 @@ the first nonzero byte in the encoding.
   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
@@ -874,9 +944,14 @@ the first nonzero byte in the encoding.
   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
@@ -994,8 +1069,8 @@ the first nonzero byte in the encoding.
 
   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
@@ -1048,17 +1123,33 @@ the Value in which the `Template` is nested.  A writer may safely omit
 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).
index dbbecf280c0cc8625dce759e2196d63c93a713a2..a21e56ccf2376c47a42c517e4a25e195dd094e90 100644 (file)
@@ -29,6 +29,10 @@ or `container` elements (or a mix), forming a tree.  In turn,
 `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`
@@ -36,7 +40,7 @@ element:
 
 ```
 container
-   :visibility=(visible | hidden)
+   :visibility=(visible | hidden)?
    :page-break-before=(always)?
    :text-align=(left | center)?
    :width=dimension
@@ -58,18 +62,20 @@ value specifications are defined:
   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.
@@ -174,8 +180,6 @@ information, and the CSS from the embedded HTML:
 </heading>
 ```
 
-<!-- toc -->
-
 ## The `heading` Element
 
 ```
@@ -303,8 +307,8 @@ anyway.  The user cannot edit it.
 
 ```
 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)
@@ -317,7 +321,13 @@ This element has the following attributes.
 
 * `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
@@ -341,9 +351,11 @@ text[container_text]
   :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.
 
@@ -354,67 +366,62 @@ 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">&lt;head>&lt;style type="text/css">p{color:0;font-family:Monospaced;font-size:14pt;font-style:normal;font-weight:normal;text-decoration:none}&lt;/style>&lt;/head>&lt;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">&lt;head>&lt;style type="text/css">p{color:0;font-family:Monospaced;font-size:13pt;font-style:normal;font-weight:normal;text-decoration:none}&lt;/style>&lt;/head>&lt;BR>CROSSTABS&lt;BR>&amp;nbsp;&amp;nbsp;/TABLES=facrec&amp;nbsp;BY&amp;nbsp;nq1e&lt;BR>&amp;nbsp;&amp;nbsp;/FORMAT=AVALUE&amp;nbsp;TABLES&lt;BR>&amp;nbsp;&amp;nbsp;/CELLS=COUNT&amp;nbsp;ROW&lt;BR>&amp;nbsp;&amp;nbsp;/COUNT&amp;nbsp;ROUND&amp;nbsp;CELL.</html>
+> ```
+>
+> ```
+> <html xmlns="http://www.w3.org/1999/xhtml" lang="en">&lt;html>
+>   &lt;head>
+>     &lt;style type="text/css">
+>       &lt;!--
+>         p { font-style: normal; text-decoration: none; font-weight: bold; color: 000000; font-size: 14pt; font-family: Trebuchet MS }
+>       -->
+>     &lt;/style>
+>
+>   &lt;/head>
+>   &lt;body>
+>     &lt;b>&lt;font size="5" face="Times New Roman">ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย &lt;u>H&lt;/u>&lt;/font>&lt;u>&lt;font size="5" color="#000000" face="Times New Roman">ousehold
+>     Income (In Thousands)&lt;/font>&lt;/u>&lt;font size="5" color="#000000" face="Times New Roman">
+>     &lt;/font>&lt;/b>
+>   &lt;/body>
+> &lt;/html>
+> </html>
+> ```
+
 ## The `table` Element
 
 ```
@@ -429,7 +436,7 @@ table
    :orphanTolerance=int?
    :rowBreakNumber=int?
    :subType
-   :tableId
+   :tableId?
    :tableLookId?
    :type[table_type]=(table | note | warning)
 => tableProperties? tableStructure
@@ -455,13 +462,31 @@ This element has the following attributes.
 * `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
 
@@ -625,46 +650,53 @@ pageParagraph => pageParagraph_text
 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 `&amp;`, that is, these are
+    not XML entities, since XML entity names can't begin with `[`.
 
 * `&[Date]`  
   `&[Time]`  
@@ -674,30 +706,269 @@ variables.  The following variables are supported:
   `&[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:
+>
+> ```
+> &lt;html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+>   &lt;head>
+>
+>   &lt;/head>
+>   &lt;body>
+>     &lt;p style="text-align:center; margin-top: 0">
+>       &amp;[PageTitle]
+>     &lt;/p>
+>   &lt;/body>
+> &lt;/html>
+> ```
+>
+> and footers:
+>
+> ```
+> &lt;html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+>   &lt;head>
+>
+>   &lt;/head>
+>   &lt;body>
+>     &lt;p style="text-align:right; margin-top: 0">
+>       Page &amp;[Page]
+>     &lt;/p>
+>   &lt;/body>
+> &lt;/html>
+> ```
+>
+> Sometimes CSS is present (the original was indented much deeper), with
+> header:
+>
+> ```
+> &lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+>   &lt;head>
+>           &lt;style type="text/css">
+>                   p { font-family: sans-serif;
+>                        font-size: 10pt; text-align: center;
+>                        font-weight: normal;
+>                        color: #000000;
+>                        }
+>           &lt;/style>
+>   &lt;/head>
+>   &lt;body>
+>           &lt;p>&amp;amp;[PageTitle]&lt;/p>
+>   &lt;/body>
+> &lt;/html>
+> ```
+>
+> and footer:
+>
+> ```
+> &lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+>   &lt;head>
+>           &lt;style type="text/css">
+>                   p { font-family: sans-serif;
+>                        font-size: 10pt; text-align: right;
+>                        font-weight: normal;
+>                        color: #000000;
+>                        }
+>           &lt;/style>
+>   &lt;/head>
+>   &lt;body>
+>           &lt;p>Page &amp;amp;[Page]&lt;/p>
+>   &lt;/body>
+> &lt;/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 `&#160;` or `&nbsp;`.  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`]:
+
+```
+&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p>
+      plain&amp;#160;&lt;font color="#000000" size="3" face="Monospaced">&lt;b>bold&lt;/b>&lt;/font>&amp;#160;&lt;font color="#000000" size="3" face="Monospaced">&lt;i>italic&lt;/i>&amp;#160;&lt;strike>strikeout&lt;/strike>&lt;/font>
+    &lt;/p>
+  &lt;/body>
+&lt;/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:
+
 ```
+&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
 
-This element has the following attributes.
+  &lt;/head>
+  &lt;body>
+    &lt;p>left&lt;/p>
+    &lt;p align="center">&lt;font color="#000000" size="5" face="Monospaced">center&amp;#160;large&lt;/font>&lt;/p>
+    &lt;p align="right">&lt;font color="#000000" size="3" face="Monospaced">&lt;b>&lt;i>right&lt;/i>&lt;/b>&lt;/font>&lt;/p>
+  &lt;/body>
+&lt;/html>
+```
 
-* `type`  
-  Always `text`.
+[inside `pageParagraph`]: #the-text-element-inside-pageparagraph
+[inside `container`]: #the-text-element-inside-container
 
index 153c3e2b27df823960fd72fad50ef8c17ad65db2..91b7b44f0775b84d6739b9c562515978d4dfedca 100644 (file)
@@ -206,12 +206,26 @@ AreaStyle =>
 
 `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.
index 18969a98c75fa1b161e625d231bdd830d22a912b..82a0c51624fd2b6b5e27b7ccc7d63c95ada2d987 100644 (file)
@@ -19,11 +19,9 @@ chrono = { version = "0.4.40", features = ["serde"] }
 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"
@@ -32,7 +30,7 @@ libm = "0.2.11"
 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"
@@ -55,6 +53,12 @@ toml = "0.9.5"
 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"] }
index 8da918863e572d7fb7c14b3a8569b857d90d64c4..808a0d3bc03781f7fdbf03ae73f224e6369ea364 100644 (file)
@@ -27,6 +27,10 @@ pub fn date_time_to_pspp(date_time: NaiveDateTime) -> f64 {
     (date_time - EPOCH_DATETIME).as_seconds_f64()
 }
 
+pub fn time_to_pspp(time: NaiveTime) -> f64 {
+    (time - NaiveTime::MIN).as_seconds_f64()
+}
+
 /// Takes a count of days from 14 Oct 1582 and translates it into a Gregorian
 /// calendar date, if possible.  Positive and negative offsets are supported.
 pub fn calendar_offset_to_gregorian(offset: f64) -> Option<NaiveDate> {
diff --git a/rust/pspp/src/cli.rs b/rust/pspp/src/cli.rs
new file mode 100644 (file)
index 0000000..3a58686
--- /dev/null
@@ -0,0 +1,80 @@
+// 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())),
+    }
+}
diff --git a/rust/pspp/src/cli/convert.rs b/rust/pspp/src/cli/convert.rs
new file mode 100644 (file)
index 0000000..26dc202
--- /dev/null
@@ -0,0 +1,165 @@
+// 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()
+            ),
+        }
+    }
+}
diff --git a/rust/pspp/src/cli/decrypt.rs b/rust/pspp/src/cli/decrypt.rs
new file mode 100644 (file)
index 0000000..50e0629
--- /dev/null
@@ -0,0 +1,57 @@
+/* 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(())
+    }
+}
diff --git a/rust/pspp/src/cli/identify.rs b/rust/pspp/src/cli/identify.rs
new file mode 100644 (file)
index 0000000..917e863
--- /dev/null
@@ -0,0 +1,46 @@
+// 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(())
+    }
+}
diff --git a/rust/pspp/src/cli/show.rs b/rust/pspp/src/cli/show.rs
new file mode 100644 (file)
index 0000000..038eac0
--- /dev/null
@@ -0,0 +1,250 @@
+// 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())
+    }
+}
diff --git a/rust/pspp/src/cli/show_pc.rs b/rust/pspp/src/cli/show_pc.rs
new file mode 100644 (file)
index 0000000..cb23fdc
--- /dev/null
@@ -0,0 +1,179 @@
+// 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())
+    }
+}
diff --git a/rust/pspp/src/cli/show_por.rs b/rust/pspp/src/cli/show_por.rs
new file mode 100644 (file)
index 0000000..96a5117
--- /dev/null
@@ -0,0 +1,207 @@
+// 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())
+    }
+}
diff --git a/rust/pspp/src/cli/show_spv.rs b/rust/pspp/src/cli/show_spv.rs
new file mode 100644 (file)
index 0000000..631c317
--- /dev/null
@@ -0,0 +1,155 @@
+// 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);
+    }
+}
index 471bb84485659e66afc1de75f4a7b012c13ea899..78f88f708b8422ebecc9d73da75af0342705e8bb 100644 (file)
@@ -26,7 +26,7 @@ use ctables::ctables_command;
 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::{
@@ -46,30 +46,29 @@ pub mod ctables;
 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,
@@ -817,7 +816,7 @@ fn commands() -> &'static [Command] {
             ctables_command(),
             data_list_command(),
             Command {
-                allowed_states: FlagSet::full(),
+                allowed_states: EnumSet::all(),
                 enhanced_only: false,
                 testing_only: false,
                 no_abbrev: false,
index 38079857b86f7719c35a60a6cfd0d3e81082cf3e..53344af1493cd64069d90a5b1e067695e84a65e8 100644 (file)
@@ -14,7 +14,7 @@
 // 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::{
@@ -24,7 +24,7 @@ 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,
index a42e9fcd2d2b8c7acf435a3a10e8c1cba552ecfe..2556bdd8cc98d5c7357898613835fe6a4c839bec 100644 (file)
@@ -17,7 +17,7 @@
 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,
@@ -31,7 +31,7 @@ use crate::{
 
 pub(super) fn ctables_command() -> Command {
     Command {
-        allowed_states: FlagSet::full(),
+        allowed_states: EnumSet::all(),
         enhanced_only: false,
         testing_only: false,
         no_abbrev: false,
index ea52f708a671700b18b5f27afb1bca4594af9f7f..c5becd3657178ace6341ea3f49402fe5ef609a86 100644 (file)
@@ -15,7 +15,7 @@
 // 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::{
@@ -25,7 +25,7 @@ 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,
index 9f8fb2d8b68b625d685c9360dbd9ab09c752d36f..656274dffab301ae5c816c00a18415e716a053be 100644 (file)
@@ -14,7 +14,7 @@
 // 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::{
@@ -24,7 +24,7 @@ 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,
diff --git a/rust/pspp/src/convert.rs b/rust/pspp/src/convert.rs
deleted file mode 100644 (file)
index b38ccb3..0000000
+++ /dev/null
@@ -1,402 +0,0 @@
-/* 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(())
-    }
-}
index 5552b813f5b74ecedeb74d2f9104a96a4b436d3f..c2edc22f126e3343e2d3fe213d7314801882739f 100644 (file)
@@ -574,6 +574,50 @@ impl<B> Datum<B> {
         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>> {
diff --git a/rust/pspp/src/decrypt.rs b/rust/pspp/src/decrypt.rs
deleted file mode 100644 (file)
index 50e0629..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-/* 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(())
-    }
-}
index 7c9cf4fe5270cffa8e2a4d98e70e749528d770c1..d4d077076bfcd591a92243601b9de632776a00fd 100644 (file)
@@ -42,7 +42,7 @@ pub enum FileType {
     /// 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,
@@ -152,7 +152,7 @@ impl FileType {
         }
 
         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)
@@ -170,6 +170,21 @@ impl FileType {
 
         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)]
index e9d26895e0b3d91b88fcf64530d808b7eeeccfc9..744dedbdcab9852b3ff268cf676ff50059d67787 100644 (file)
@@ -503,49 +503,55 @@ impl Serialize for Format {
     }
 }
 
-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_
     }
@@ -575,6 +581,14 @@ impl Format {
         }
     }
 
+    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,
index 93cb275c9ea9aeb33855c207c097d3bf36bab5b0..5c619e1705d7ba03a9c50484c2d60ecd71b5aaba 100644 (file)
@@ -53,6 +53,22 @@ pub struct DisplayDatum<'b, B> {
     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;
 
@@ -494,12 +510,6 @@ where
             }
         }
 
-        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)
     }
index 625fbd9a1a34749c6acf337df7651b62ed0fd74e..37840308459a0b3766a0d146c4d6277caace6338 100644 (file)
@@ -42,7 +42,7 @@ use crate::{
     identifier::{IdentifierChar, id_match, id_match_n},
     prompt::PromptStyle,
 };
-use bitflags::bitflags;
+use enumset::{EnumSet, EnumSetType};
 
 use super::command_name::{COMMAND_NAMES, command_match};
 
@@ -184,12 +184,10 @@ pub enum Segment {
     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.
@@ -199,7 +197,7 @@ pub struct Incomplete;
 /// Labels syntax input with [Segment]s.
 #[derive(Copy, Clone)]
 pub struct Segmenter {
-    state: (State, Substate),
+    state: (State, EnumSet<Substate>),
     nest: u8,
     syntax: Syntax,
 }
@@ -219,9 +217,9 @@ impl Segmenter {
     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,
@@ -234,11 +232,11 @@ impl Segmenter {
     }
 
     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
@@ -557,14 +555,14 @@ impl Segmenter {
         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)
     }
@@ -590,29 +588,29 @@ impl Segmenter {
         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>(
@@ -621,13 +619,13 @@ impl Segmenter {
         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,
@@ -638,7 +636,7 @@ impl Segmenter {
                     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)))
                 }
             }
@@ -657,16 +655,16 @@ impl Segmenter {
                     }
                     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)
@@ -676,7 +674,7 @@ impl Segmenter {
             '>' => 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)? {
@@ -698,11 +696,11 @@ impl Segmenter {
             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)))
             }
         }
@@ -719,7 +717,7 @@ impl Segmenter {
                 _ 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;
@@ -728,7 +726,7 @@ impl Segmenter {
                 _ => input = rest,
             }
         }
-        self.state.1 = Substate::empty();
+        self.state.1 = EnumSet::empty();
         Ok(Some((input, Segment::ExpectedQuote)))
     }
     fn maybe_parse_string<'a>(
@@ -803,23 +801,23 @@ impl Segmenter {
         };
         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) {
@@ -839,7 +837,7 @@ impl Segmenter {
                             } else {
                                 State::BeginData2
                             },
-                            Substate::empty(),
+                            EnumSet::empty(),
                         );
                         return Ok(Some((rest, Segment::Identifier)));
                     }
@@ -847,7 +845,7 @@ impl Segmenter {
             }
         }
 
-        self.state.1 = Substate::empty();
+        self.state.1 = EnumSet::empty();
         Ok(Some((
             rest,
             if identifier != "!" {
@@ -864,7 +862,7 @@ impl Segmenter {
         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,
@@ -889,12 +887,12 @@ impl Segmenter {
             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>(
@@ -911,7 +909,7 @@ impl Segmenter {
         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 {
@@ -920,17 +918,17 @@ impl Segmenter {
                     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)));
                         }
                     }
@@ -956,10 +954,10 @@ impl Segmenter {
         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)))
     }
@@ -971,7 +969,7 @@ impl Segmenter {
         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 {
@@ -996,7 +994,7 @@ impl Segmenter {
         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>(
@@ -1006,7 +1004,7 @@ impl Segmenter {
     ) -> 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)))
     }
@@ -1046,7 +1044,7 @@ impl Segmenter {
         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>(
@@ -1059,7 +1057,7 @@ impl Segmenter {
             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!(),
@@ -1194,7 +1192,8 @@ impl Segmenter {
                     // 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);
                 }
@@ -1271,7 +1270,7 @@ impl Segmenter {
             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());
                 }
             }
             _ => (),
@@ -1312,7 +1311,7 @@ impl Segmenter {
         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`.
@@ -1411,7 +1410,7 @@ impl Segmenter {
         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 {
index 4fa6f80b557396ed11fcd2f1c08096f4358ebc20..a1f72129d957c62847e76e6b072ff8a81fd315d8 100644 (file)
@@ -1,72 +1,25 @@
-/* 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()
 }
index 28d0a9911ab8d4f7e9eba969798049ad0d0ef1bf..125de74687ac7ed5e1c457b8cd68ccbe56505dcf 100644 (file)
@@ -178,13 +178,13 @@ pub enum Category {
     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 {
@@ -193,7 +193,7 @@ impl From<Diagnostic> for Diagnostics {
     }
 }
 
-#[derive(Serialize)]
+#[derive(Clone, Serialize)]
 pub struct Diagnostic {
     pub severity: Severity,
     pub category: Category,
index c1e061ed9bb7c9475efc5fccedbf0b87f4128dd8..a794de3d37f82b6df9f82e907c0b82c9b2fdec3a 100644 (file)
 #![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 {
@@ -77,6 +87,7 @@ impl Item {
             command_name: details.command_name().cloned(),
             show: true,
             details,
+            spv_info: None,
         }
     }
 
@@ -86,6 +97,41 @@ impl Item {
             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
@@ -97,11 +143,78 @@ where
     }
 }
 
-#[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>),
@@ -109,9 +222,48 @@ pub enum Details {
 }
 
 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,
         }
     }
@@ -119,20 +271,20 @@ impl Details {
     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()),
@@ -140,9 +292,37 @@ impl Details {
         }
     }
 
+    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
@@ -153,7 +333,9 @@ where
     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(),
+        ))
     }
 }
 
@@ -193,6 +375,15 @@ impl From<Box<Text>> for Details {
     }
 }
 
+#[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,
@@ -201,12 +392,19 @@ pub struct Text {
 }
 
 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> {
@@ -297,21 +495,801 @@ impl ItemCursor {
         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")
     }
 }
diff --git a/rust/pspp/src/output/cairo.rs b/rust/pspp/src/output/cairo.rs
deleted file mode 100644 (file)
index 260e5c3..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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();
-    }
-}
diff --git a/rust/pspp/src/output/cairo/driver.rs b/rust/pspp/src/output/cairo/driver.rs
deleted file mode 100644 (file)
index e239298..0000000
+++ /dev/null
@@ -1,162 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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();
-        }
-    }
-}
diff --git a/rust/pspp/src/output/cairo/fsm.rs b/rust/pspp/src/output/cairo/fsm.rs
deleted file mode 100644 (file)
index 13597f3..0000000
+++ /dev/null
@@ -1,762 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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: &params,
-            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);
-    }
-}
diff --git a/rust/pspp/src/output/cairo/pager.rs b/rust/pspp/src/output/cairo/pager.rs
deleted file mode 100644 (file)
index 6106eb6..0000000
+++ /dev/null
@@ -1,237 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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(&paragraph.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
-}
diff --git a/rust/pspp/src/output/csv.rs b/rust/pspp/src/output/csv.rs
deleted file mode 100644 (file)
index 0eded20..0000000
+++ /dev/null
@@ -1,230 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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();
-    }
-}
diff --git a/rust/pspp/src/output/driver.rs b/rust/pspp/src/output/driver.rs
deleted file mode 100644 (file)
index 6015ed8..0000000
+++ /dev/null
@@ -1,164 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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()
-        );
-    }
-}
diff --git a/rust/pspp/src/output/drivers.rs b/rust/pspp/src/output/drivers.rs
new file mode 100644 (file)
index 0000000..7e22879
--- /dev/null
@@ -0,0 +1,273 @@
+// 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()
+        );
+    }
+}
diff --git a/rust/pspp/src/output/drivers/cairo.rs b/rust/pspp/src/output/drivers/cairo.rs
new file mode 100644 (file)
index 0000000..be8e528
--- /dev/null
@@ -0,0 +1,54 @@
+// 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();
+    }
+}
diff --git a/rust/pspp/src/output/drivers/cairo/driver.rs b/rust/pspp/src/output/drivers/cairo/driver.rs
new file mode 100644 (file)
index 0000000..eb04616
--- /dev/null
@@ -0,0 +1,174 @@
+// 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();
+        }
+    }
+}
diff --git a/rust/pspp/src/output/drivers/cairo/fsm.rs b/rust/pspp/src/output/drivers/cairo/fsm.rs
new file mode 100644 (file)
index 0000000..41e8d40
--- /dev/null
@@ -0,0 +1,764 @@
+// 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: &params,
+            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);
+    }
+}
diff --git a/rust/pspp/src/output/drivers/cairo/pager.rs b/rust/pspp/src/output/drivers/cairo/pager.rs
new file mode 100644 (file)
index 0000000..1f099f5
--- /dev/null
@@ -0,0 +1,222 @@
+// 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: &paragraph.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
+        }
+    }
+}
diff --git a/rust/pspp/src/output/drivers/csv.rs b/rust/pspp/src/output/drivers/csv.rs
new file mode 100644 (file)
index 0000000..d0963e3
--- /dev/null
@@ -0,0 +1,430 @@
+// 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(())
+    }
+}
diff --git a/rust/pspp/src/output/drivers/html.rs b/rust/pspp/src/output/drivers/html.rs
new file mode 100644 (file)
index 0000000..a4c797a
--- /dev/null
@@ -0,0 +1,489 @@
+// 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("&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("&nbsp;")
+                        .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("&nbsp;")
+                        .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: "&quot;",
+            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("&amp;")?,
+                '<' => f.write_str("&lt;")?,
+                '>' => f.write_str("&gt;")?,
+                '"' => f.write_str(self.quote)?,
+                '\'' => f.write_str(self.apos)?,
+                _ => f.write_char(c)?,
+            }
+        }
+        Ok(())
+    }
+}
diff --git a/rust/pspp/src/output/drivers/json.rs b/rust/pspp/src/output/drivers/json.rs
new file mode 100644 (file)
index 0000000..d121a9a
--- /dev/null
@@ -0,0 +1,108 @@
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <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();
+    }
+}
diff --git a/rust/pspp/src/output/drivers/por.rs b/rust/pspp/src/output/drivers/por.rs
new file mode 100644 (file)
index 0000000..723d0e2
--- /dev/null
@@ -0,0 +1,78 @@
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <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(())
+    }
+}
diff --git a/rust/pspp/src/output/drivers/sav.rs b/rust/pspp/src/output/drivers/sav.rs
new file mode 100644 (file)
index 0000000..75e1c61
--- /dev/null
@@ -0,0 +1,83 @@
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <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(())
+    }
+}
diff --git a/rust/pspp/src/output/drivers/spv.rs b/rust/pspp/src/output/drivers/spv.rs
new file mode 100644 (file)
index 0000000..b2238ba
--- /dev/null
@@ -0,0 +1,1413 @@
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <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(())
+    }
+}
diff --git a/rust/pspp/src/output/drivers/text.rs b/rust/pspp/src/output/drivers/text.rs
new file mode 100644 (file)
index 0000000..42b0eac
--- /dev/null
@@ -0,0 +1,707 @@
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <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"]);
+    }
+}
diff --git a/rust/pspp/src/output/drivers/text/text_line.rs b/rust/pspp/src/output/drivers/text/text_line.rs
new file mode 100644 (file)
index 0000000..e4d7c5c
--- /dev/null
@@ -0,0 +1,610 @@
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <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:?}"
+                );
+            }
+        }
+    }
+}
diff --git a/rust/pspp/src/output/html.rs b/rust/pspp/src/output/html.rs
deleted file mode 100644 (file)
index 949724b..0000000
+++ /dev/null
@@ -1,500 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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("&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("&nbsp;")
-                        .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("&nbsp;")
-                        .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: "&quot;",
-            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("&amp;")?,
-                '<' => f.write_str("&lt;")?,
-                '>' => f.write_str("&gt;")?,
-                '"' => f.write_str(self.quote)?,
-                '\'' => f.write_str(self.apos)?,
-                _ => f.write_char(c)?,
-            }
-        }
-        Ok(())
-    }
-}
diff --git a/rust/pspp/src/output/json.rs b/rust/pspp/src/output/json.rs
deleted file mode 100644 (file)
index c7f52bd..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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();
-    }
-}
index 6872a6aeabf87808fdfbb406543ab4cb301eeaf4..d8ea2e67d9ca98281bba3443a7970302314207c2 100644 (file)
 // 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")]
@@ -45,65 +50,212 @@ pub enum ChartSize {
     QuarterHeight,
 }
 
-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
-pub struct Paragraph {
-    pub markup: String,
-    pub horz_align: HorzAlign,
-}
-
-impl Default for Paragraph {
-    fn default() -> Self {
-        Self {
-            markup: Default::default(),
-            horz_align: HorzAlign::Left,
-        }
-    }
-}
-
-#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
-pub struct Heading(pub Vec<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()
+        );
     }
 }
index 13392f8ea6bdd8331fe4e0fb9519cc121e9c6c3a..6da0d028d1782daef59114a89300a3da3f5abb77 100644 (file)
@@ -58,7 +58,8 @@ pub use color::ParseError as ParseColorError;
 use color::{AlphaColor, Rgba8, Srgb, palette::css::TRANSPARENT};
 use enum_iterator::Sequence;
 use enum_map::{Enum, EnumMap, enum_map};
-use look_xml::TableProperties;
+use itertools::Itertools;
+pub use look_xml::{Length, TableProperties};
 use quick_xml::{DeError, de::from_str};
 use serde::{
     Deserialize, Serialize, Serializer,
@@ -68,12 +69,17 @@ use serde::{
 use smallstr::SmallString;
 use smallvec::SmallVec;
 use thiserror::Error as ThisError;
+pub use tlo::parse_bool;
 use tlo::parse_tlo;
 
 use crate::{
     calendar::date_time_to_pspp,
-    data::{ByteString, Datum, EncodedString, RawString},
-    format::{Decimal, Format, Settings as FormatSettings, Type, UncheckedFormat},
+    data::{ByteString, Datum, EncodedString},
+    format::{
+        DATETIME40_0, Decimal, F8_2, F40, F40_2, F40_3, Format, PCT40_1,
+        Settings as FormatSettings, Type, UncheckedFormat,
+    },
+    output::spv::html::Markup,
     settings::{Settings, Show},
     util::ToSmallString,
     variable::{VarType, Variable},
@@ -87,9 +93,12 @@ pub mod tests;
 mod tlo;
 
 /// Areas of a pivot table for styling purposes.
-#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
 pub enum Area {
+    /// Title.
     Title,
+
+    /// Caption.
     Caption,
 
     /// Footnotes,
@@ -98,16 +107,32 @@ pub enum Area {
     // Top-left corner.
     Corner,
 
-    /// Labels for columns ([Axis2::X]) and rows ([Axis2::Y]).
-    Labels(Axis2),
-
-    #[default]
-    Data,
+    /// Labels.
+    Labels(
+        /// - [Axis2::X]: Column labels, along the top of the table.
+        /// - [Axis2::Y]: Row labels, along the left side of the table.
+        Axis2,
+    ),
+
+    /// Data cells.
+    Data(
+        /// This allows styling for even rows and odd rows to differ
+        /// arbitrarily, but the SPV file format only distinguishes foreground
+        /// and background colors, so any other differences will be lost upon
+        /// save.
+        RowParity,
+    ),
 
     /// Layer indication.
     Layers,
 }
 
+impl Default for Area {
+    fn default() -> Self {
+        Self::Data(RowParity::default())
+    }
+}
+
 impl Display for Area {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
@@ -116,7 +141,7 @@ impl Display for Area {
             Area::Footer => write!(f, "footer"),
             Area::Corner => write!(f, "corner"),
             Area::Labels(axis2) => write!(f, "labels({axis2})"),
-            Area::Data => write!(f, "data"),
+            Area::Data(row) => write!(f, "data({row})"),
             Area::Layers => write!(f, "layers"),
         }
     }
@@ -131,44 +156,33 @@ impl Serialize for Area {
     }
 }
 
-impl Area {
-    fn default_cell_style(self) -> CellStyle {
-        use HorzAlign::*;
-        use VertAlign::*;
-        let (horz_align, vert_align, hmargins, vmargins) = match self {
-            Area::Title => (Some(Center), Middle, [8, 11], [1, 8]),
-            Area::Caption => (Some(Left), Top, [8, 11], [1, 1]),
-            Area::Footer => (Some(Left), Top, [11, 8], [2, 3]),
-            Area::Corner => (Some(Left), Bottom, [8, 11], [1, 1]),
-            Area::Labels(Axis2::X) => (Some(Center), Top, [8, 11], [1, 3]),
-            Area::Labels(Axis2::Y) => (Some(Left), Top, [8, 11], [1, 3]),
-            Area::Data => (None, Top, [8, 11], [1, 1]),
-            Area::Layers => (Some(Left), Bottom, [8, 11], [1, 3]),
-        };
-        CellStyle {
-            horz_align,
-            vert_align,
-            margins: enum_map! { Axis2::X => hmargins, Axis2::Y => vmargins },
-        }
-    }
+/// Distinguishes [Area::Data] for even-numbered and odd-numbered rows.
+#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
+pub enum RowParity {
+    /// Even-numbered rows.
+    ///
+    /// The first row is row 0, hence even.
+    #[default]
+    Even,
+    /// Odd-numbered rows.
+    Odd,
+}
 
-    fn default_font_style(self) -> FontStyle {
-        FontStyle {
-            bold: self == Area::Title,
-            italic: false,
-            underline: false,
-            markup: false,
-            font: String::from("Sans Serif"),
-            fg: [Color::BLACK; 2],
-            bg: [Color::WHITE; 2],
-            size: 9,
+impl From<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"),
         }
     }
 }
@@ -214,6 +228,10 @@ impl Border {
             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 {
@@ -289,14 +307,14 @@ impl Display for RowColBorder {
 #[derive(Default, Clone, Debug, Serialize)]
 pub struct Sizing {
     /// Specific column widths, in 1/96" units.
-    widths: Vec<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)]
@@ -362,19 +380,21 @@ impl Iterator for AxisIterator {
 }
 
 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,
@@ -443,6 +463,8 @@ pub struct Path<'a> {
     leaf: &'a Leaf,
 }
 
+pub type IndexVec = SmallVec<[usize; 4]>;
+
 impl Dimension {
     pub fn new(root: Group) -> Self {
         Dimension {
@@ -469,6 +491,10 @@ impl Dimension {
         self.root.leaf_path(index, SmallVec::new())
     }
 
+    pub fn index_path(&self, index: usize) -> Option<IndexVec> {
+        self.root.index_path(index, SmallVec::new())
+    }
+
     pub fn with_all_labels_hidden(self) -> Self {
         Self {
             hide_all_labels: true,
@@ -477,6 +503,37 @@ impl Dimension {
     }
 }
 
+/// Specifies a [Category] within a [Group].
+#[derive(Copy, Clone, Debug)]
+pub struct CategoryLocator {
+    /// The index of the leaf to start from.
+    pub leaf_index: usize,
+
+    /// The number of times to go up a level from the leaf.  If this category is
+    /// a leaf, this is 0, otherwise it is positive.
+    pub level: usize,
+}
+
+impl CategoryLocator {
+    pub fn new_leaf(leaf_index: usize) -> Self {
+        Self {
+            leaf_index,
+            level: 0,
+        }
+    }
+
+    pub fn parent(&self) -> Self {
+        Self {
+            leaf_index: self.leaf_index,
+            level: self.level + 1,
+        }
+    }
+
+    pub fn as_leaf(&self) -> Option<usize> {
+        (self.level == 0).then_some(self.leaf_index)
+    }
+}
+
 #[derive(Clone, Debug, Serialize)]
 pub struct Group {
     #[serde(skip)]
@@ -509,7 +566,7 @@ impl Group {
 
     pub fn push(&mut self, child: impl Into<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();
@@ -561,6 +618,45 @@ impl Group {
         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
     }
@@ -588,7 +684,7 @@ where
 }
 
 #[derive(Clone, Debug, Default, Serialize)]
-pub struct Footnotes(pub Vec<Arc<Footnote>>);
+pub struct Footnotes(Vec<Arc<Footnote>>);
 
 impl Footnotes {
     pub fn new() -> Self {
@@ -604,21 +700,54 @@ impl Footnotes {
     pub fn is_empty(&self) -> bool {
         self.0.is_empty()
     }
+
+    pub fn len(&self) -> usize {
+        self.0.len()
+    }
+
+    pub fn get(&self, index: usize) -> Option<&Arc<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
     }
 }
 
@@ -627,7 +756,7 @@ impl Serialize for Leaf {
     where
         S: serde::Serializer,
     {
-        self.name.serialize(serializer)
+        self.0.serialize(serializer)
     }
 }
 
@@ -646,7 +775,7 @@ pub enum Class {
     Count,
 }
 
-/// A pivot_category is a leaf (a category) or a group.
+/// A leaf category or a group of them.
 #[derive(Clone, Debug, Serialize)]
 pub enum Category {
     Group(Group),
@@ -654,10 +783,45 @@ pub enum Category {
 }
 
 impl Category {
+    pub fn as_group(&self) -> Option<&Group> {
+        match self {
+            Category::Group(group) => Some(group),
+            Category::Leaf(_) => None,
+        }
+    }
+
+    pub fn as_group_mut(&mut self) -> Option<&mut Group> {
+        match self {
+            Category::Group(group) => Some(group),
+            Category::Leaf(_) => None,
+        }
+    }
+
+    pub fn as_leaf(&self) -> Option<&Leaf> {
+        match self {
+            Category::Leaf(leaf) => Some(leaf),
+            Category::Group(_) => None,
+        }
+    }
+
+    pub fn as_leaf_mut(&mut self) -> Option<&mut Leaf> {
+        match self {
+            Category::Leaf(leaf) => Some(leaf),
+            Category::Group(_) => None,
+        }
+    }
+
     pub fn name(&self) -> &Value {
         match self {
             Category::Group(group) => &group.name,
-            Category::Leaf(leaf) => &leaf.name,
+            Category::Leaf(leaf) => &leaf.0,
+        }
+    }
+
+    pub fn name_mut(&mut self) -> &mut Value {
+        match self {
+            Category::Group(group) => &mut group.name,
+            Category::Leaf(leaf) => &mut leaf.0,
         }
     }
 
@@ -675,27 +839,49 @@ impl Category {
     pub fn nth_leaf(&self, index: usize) -> Option<&Leaf> {
         match self {
             Category::Group(group) => group.nth_leaf(index),
-            Category::Leaf(leaf) => {
-                if index == 0 {
-                    Some(leaf)
-                } else {
-                    None
-                }
-            }
+            Category::Leaf(leaf) if index == 0 => Some(leaf),
+            _ => None,
         }
     }
 
     pub fn leaf_path<'a>(&'a self, index: usize, groups: GroupVec<'a>) -> Option<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 {
@@ -753,7 +939,7 @@ impl From<&String> for Category {
 /// The division between this and the style information in [PivotTable] seems
 /// fairly arbitrary.  The ultimate reason for the division is simply because
 /// that's how SPSS documentation and file formats do it.
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, PartialEq, Serialize)]
 pub struct Look {
     pub name: Option<String>,
 
@@ -819,8 +1005,8 @@ impl Default for Look {
             }),
             footnote_marker_type: FootnoteMarkerType::default(),
             footnote_marker_position: FootnoteMarkerPosition::default(),
-            areas: EnumMap::from_fn(Area::default_area_style),
-            borders: EnumMap::from_fn(Border::default_border_style),
+            areas: EnumMap::from_fn(AreaStyle::default_for_area),
+            borders: Border::default_borders(),
             print_all_layers: false,
             paginate_layers: false,
             shrink_to_fit: EnumMap::from_fn(|_| false),
@@ -886,7 +1072,7 @@ impl Look {
 /// Position for group labels.
 #[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
 pub enum LabelPosition {
-    /// Hierarachically enclosing the categories.
+    /// Hierarchically enclosing the categories.
     ///
     /// For column labels, group labels appear above the categories.  For row
     /// labels, group labels appear to the left of the categories.
@@ -966,13 +1152,22 @@ impl From<Axis2> for HeadingRegion {
     }
 }
 
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, PartialEq, Serialize)]
 pub struct AreaStyle {
     pub cell_style: CellStyle,
     pub font_style: FontStyle,
 }
 
-#[derive(Clone, Debug, Serialize)]
+impl AreaStyle {
+    pub fn default_for_area(area: Area) -> Self {
+        Self {
+            cell_style: CellStyle::default_for_area(area),
+            font_style: FontStyle::default_for_area(area),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Serialize, PartialEq)]
 pub struct CellStyle {
     /// `None` means "mixed" alignment: align strings to the left, numbers to
     /// the right.
@@ -988,6 +1183,43 @@ pub struct CellStyle {
     pub margins: EnumMap<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 {
@@ -1017,6 +1249,35 @@ impl HorzAlign {
             VarType::String => Self::Left,
         }
     }
+
+    pub fn as_str(&self) -> Option<&'static str> {
+        match self {
+            HorzAlign::Right => Some("right"),
+            HorzAlign::Left => Some("left"),
+            HorzAlign::Center => Some("center"),
+            HorzAlign::Decimal { .. } => None,
+        }
+    }
+}
+
+/// Unknown horizontal alignment.
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub struct UnknownHorzAlign;
+
+impl FromStr for HorzAlign {
+    type Err = UnknownHorzAlign;
+
+    fn from_str(s: &str) -> Result<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)]
@@ -1032,28 +1293,63 @@ pub enum VertAlign {
     Bottom,
 }
 
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
 pub struct FontStyle {
     pub bold: bool,
     pub italic: bool,
     pub underline: bool,
-    pub markup: bool,
     pub font: String,
-
-    /// `fg[0]` is the usual foreground color.
-    ///
-    /// `fg[1]` is used only in [Area::Data] for odd-numbered rows.
-    pub fg: [Color; 2],
-
-    /// `bg[0]` is the usual background color.
-    ///
-    /// `bg[1]` is used only in [Area::Data] for odd-numbered rows.
-    pub bg: [Color; 2],
+    pub fg: Color,
+    pub bg: Color,
 
     /// In 1/72" units.
     pub size: i32,
 }
 
+impl Default for FontStyle {
+    fn default() -> Self {
+        FontStyle {
+            bold: false,
+            italic: false,
+            underline: false,
+            font: String::from("Sans Serif"),
+            fg: Color::BLACK,
+            bg: Color::WHITE,
+            size: 9,
+        }
+    }
+}
+
+impl FontStyle {
+    pub fn default_for_area(area: Area) -> Self {
+        Self::default().with_bold(area == Area::Title)
+    }
+    pub fn with_size(self, size: i32) -> Self {
+        Self { size, ..self }
+    }
+    pub fn with_bold(self, bold: bool) -> Self {
+        Self { bold, ..self }
+    }
+    pub fn with_italic(self, italic: bool) -> Self {
+        Self { italic, ..self }
+    }
+    pub fn with_underline(self, underline: bool) -> Self {
+        Self { underline, ..self }
+    }
+    pub fn with_font(self, font: impl Into<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,
@@ -1089,6 +1385,18 @@ impl Color {
     pub fn display_css(&self) -> DisplayCss {
         DisplayCss(*self)
     }
+
+    pub fn into_rgb(&self) -> (u8, u8, u8) {
+        (self.r, self.g, self.b)
+    }
+
+    pub fn into_rgb16(&self) -> (u16, u16, u16) {
+        (
+            self.r as u16 * 257,
+            self.g as u16 * 257,
+            self.b as u16 * 257,
+        )
+    }
 }
 
 impl Debug for Color {
@@ -1112,14 +1420,8 @@ impl FromStr for Color {
             s.chars().count() == 6 && s.chars().all(|c| c.is_ascii_hexdigit())
         }
         let color: AlphaColor<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())
@@ -1149,7 +1451,7 @@ impl<'de> Deserialize<'de> for Color {
                 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,
             {
@@ -1173,7 +1475,7 @@ impl Display for DisplayCss {
     }
 }
 
-#[derive(Copy, Clone, Debug, Deserialize)]
+#[derive(Copy, Clone, Debug, PartialEq, Deserialize)]
 pub struct BorderStyle {
     #[serde(rename = "@borderStyleType")]
     pub stroke: Stroke,
@@ -1194,14 +1496,24 @@ impl Serialize for BorderStyle {
     }
 }
 
+impl From<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()
     }
@@ -1279,6 +1591,21 @@ impl Not for Axis2 {
     }
 }
 
+/// 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>);
@@ -1471,7 +1798,7 @@ impl IntoValueOptions for ValueOptions {
 }
 
 #[derive(Clone, Debug, Serialize)]
-pub struct PivotTable {
+pub struct PivotTableStyle {
     pub look: Arc<Look>,
 
     pub rotate_inner_column_labels: bool,
@@ -1487,17 +1814,10 @@ pub struct PivotTable {
     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.
@@ -1508,6 +1828,61 @@ pub struct PivotTable {
 
     pub small: f64,
 
+    pub weight_format: Format,
+}
+
+impl Default for PivotTableStyle {
+    fn default() -> Self {
+        Self {
+            look: Look::shared_default(),
+            rotate_inner_column_labels: false,
+            rotate_outer_row_labels: false,
+            show_grid_lines: false,
+            show_title: true,
+            show_caption: true,
+            show_values: None,
+            show_variables: None,
+            sizing: EnumMap::default(),
+            settings: FormatSettings::default(), // XXX from settings
+            grouping: None,
+            small: 0.0001, // XXX from settings.
+            weight_format: F40,
+        }
+    }
+}
+
+impl PivotTableStyle {
+    fn with_look(self, look: Arc<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>,
@@ -1515,56 +1890,107 @@ pub struct PivotTable {
     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);
@@ -1572,39 +1998,39 @@ impl PivotTable {
     }
 
     pub fn with_all_layers(mut self) -> Self {
-        if !self.look.print_all_layers {
+        if !self.style.look.print_all_layers {
             self.look_mut().print_all_layers = true;
         }
         self
     }
 
     pub fn look_mut(&mut self) -> &mut Look {
-        Arc::make_mut(&mut self.look)
+        self.style.look_mut()
     }
 
     pub fn with_show_empty(mut self) -> Self {
-        if self.look.hide_empty {
+        if self.style.look.hide_empty {
             self.look_mut().hide_empty = false;
         }
         self
     }
 
     pub fn with_hide_empty(mut self) -> Self {
-        if !self.look.hide_empty {
+        if !self.style.look.hide_empty {
             self.look_mut().hide_empty = true;
         }
         self
     }
 
     pub fn label(&self) -> String {
-        match &self.title {
+        match &self.metadata.title {
             Some(title) => title.display(self).to_string(),
             None => String::from("Table"),
         }
     }
 
     pub fn title(&self) -> &Value {
-        match &self.title {
+        match &self.metadata.title {
             Some(title) => title,
             None => {
                 static EMPTY: Value = Value::empty();
@@ -1614,7 +2040,7 @@ impl PivotTable {
     }
 
     pub fn subtype(&self) -> &Value {
-        match &self.subtype {
+        match &self.metadata.subtype {
             Some(subtype) => subtype,
             None => {
                 static EMPTY: Value = Value::empty();
@@ -1627,33 +2053,10 @@ impl PivotTable {
 impl Default for PivotTable {
     fn default() -> Self {
         Self {
-            look: Look::shared_default(),
-            rotate_inner_column_labels: false,
-            rotate_outer_row_labels: false,
-            show_grid_lines: false,
-            show_title: true,
-            show_caption: true,
-            show_values: None,
-            show_variables: None,
-            weight_format: Format::F40,
+            style: PivotTableStyle::default(),
+            metadata: PivotTableMetadata::default(),
             current_layer: Vec::new(),
-            sizing: EnumMap::default(),
-            settings: FormatSettings::default(), // XXX from settings
-            grouping: None,
-            small: 0.0001, // XXX from settings.
-            command_local: None,
-            command_c: None, // XXX from current command name.
-            language: None,
-            locale: None,
-            dataset: None,
-            datafile: None,
-            date: None,
             footnotes: Footnotes::new(),
-            subtype: None,
-            title: None,
-            corner_text: None,
-            caption: None,
-            notes: None,
             dimensions: Vec::new(),
             axes: EnumMap::default(),
             cells: HashMap::new(),
@@ -1661,6 +2064,41 @@ impl Default for PivotTable {
     }
 }
 
+pub trait CellIndex {
+    fn cell_index<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>,
@@ -1683,34 +2121,49 @@ impl PivotTable {
             dimensions.push(dimension);
         }
         Self {
-            look: Settings::global().look.clone(),
+            style: PivotTableStyle::default().with_look(Settings::global().look.clone()),
             current_layer: repeat_n(0, axes[Axis3::Z].dimensions.len()).collect(),
             axes,
             dimensions,
             ..Self::default()
         }
     }
-    fn cell_index(&self, data_indexes: &[usize]) -> usize {
-        cell_index(data_indexes, self.dimensions.iter().map(|d| d.len()))
+    fn cell_index<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(
@@ -1737,7 +2190,7 @@ impl PivotTable {
     ///
     /// - 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)))
@@ -1746,10 +2199,10 @@ impl PivotTable {
 
     pub fn value_options(&self) -> ValueOptions {
         ValueOptions {
-            show_values: self.show_values,
-            show_variables: self.show_variables,
-            small: self.small,
-            footnote_marker_type: self.look.footnote_marker_type,
+            show_values: self.style.show_values,
+            show_variables: self.style.show_variables,
+            small: self.style.small,
+            footnote_marker_type: self.style.look.footnote_marker_type,
         }
     }
 
@@ -1814,18 +2267,18 @@ impl PivotTable {
     }
 }
 
-impl<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,
@@ -1843,19 +2296,22 @@ impl Footnote {
             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<'_> {
@@ -1874,6 +2330,12 @@ impl Footnote {
     }
 }
 
+impl Default for Footnote {
+    fn default() -> Self {
+        Footnote::new(Value::default())
+    }
+}
+
 pub struct DisplayMarker<'a> {
     footnote: &'a Footnote,
     options: ValueOptions,
@@ -1968,7 +2430,7 @@ impl Display for Display26Adic {
 ///
 /// 5. A template. PSPP doesn't create these itself yet, but it can read and
 ///    interpret those created by SPSS.
-#[derive(Clone, Default)]
+#[derive(Clone, Default, PartialEq)]
 pub struct Value {
     pub inner: ValueInner,
     pub styling: Option<Box<ValueStyle>>,
@@ -1997,19 +2459,20 @@ impl Value {
             ValueInner::String(string_value) => string_value.s.serialize(serializer),
             ValueInner::Variable(variable_value) => variable_value.var_name.serialize(serializer),
             ValueInner::Text(text_value) => text_value.localized.serialize(serializer),
+            ValueInner::Markup(markup) => markup.serialize(serializer),
             ValueInner::Template(template_value) => template_value.localized.serialize(serializer),
             ValueInner::Empty => serializer.serialize_none(),
         }
     }
 
-    fn new(inner: ValueInner) -> Self {
+    pub fn new(inner: ValueInner) -> Self {
         Self {
             inner,
             styling: None,
         }
     }
     pub fn new_date_time(date_time: NaiveDateTime) -> Self {
-        Self::new_number_with_format(Some(date_time_to_pspp(date_time)), Format::DATETIME40_0)
+        Self::new_number_with_format(Some(date_time_to_pspp(date_time)), DATETIME40_0)
     }
     pub fn new_number_with_format(x: Option<f64>, format: Format) -> Self {
         Self::new(ValueInner::Number(NumberValue {
@@ -2037,14 +2500,15 @@ impl Value {
             Datum::String(string) => Self::new_user_text(string.as_str()),
         }
     }
-    pub fn new_variable_value(variable: &Variable, value: &Datum<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");
@@ -2055,30 +2519,46 @@ impl Value {
                 },
                 honor_small: false,
                 value: *number,
-                variable: var_name,
-                value_label,
+                variable: None,
+                value_label: None,
             })),
             Datum::String(string) => Self::new(ValueInner::String(StringValue {
                 show: None,
-                hex: variable.print_format.type_() == Type::AHex,
-                s: string
-                    .as_ref()
-                    .with_encoding(variable.encoding())
-                    .into_string(),
-                var_name,
-                value_label,
+                hex: format.type_() == Type::AHex,
+                s: string.as_str().into_owned(),
+                var_name: None,
+                value_label: None,
             })),
         }
     }
+    pub fn new_variable_value(variable: &Variable, value: &Datum<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() {
@@ -2086,7 +2566,7 @@ impl Value {
         } else {
             Self::new(ValueInner::Text(TextValue {
                 user_provided: true,
-                localized: s.clone(),
+                localized: s,
                 c: None,
                 id: None,
             }))
@@ -2097,7 +2577,7 @@ impl Value {
         self
     }
     pub fn add_footnote(&mut self, footnote: &Arc<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);
     }
@@ -2126,6 +2606,59 @@ impl Value {
         }
         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,
@@ -2157,7 +2690,6 @@ impl From<&Variable> for Value {
 
 pub struct DisplayValue<'a> {
     inner: &'a ValueInner,
-    markup: bool,
     subscripts: &'a [String],
     footnotes: &'a [Arc<Footnote>],
     options: ValueOptions,
@@ -2193,6 +2725,10 @@ impl<'a> DisplayValue<'a> {
         }
     }
 
+    pub fn markup(&self) -> Option<&Markup> {
+        self.inner.markup()
+    }
+
     /// Returns this display split into `(body, suffixes)` where `suffixes` is
     /// subscripts and footnotes and `body` is everything else.
     pub fn split_suffixes(self) -> (Self, Self) {
@@ -2204,21 +2740,11 @@ impl<'a> DisplayValue<'a> {
     }
 
     pub fn with_styling(mut self, styling: &'a ValueStyle) -> Self {
-        if let Some(area_style) = &styling.style {
-            self.markup = area_style.font_style.markup;
-        }
         self.subscripts = styling.subscripts.as_slice();
         self.footnotes = styling.footnotes.as_slice();
         self
     }
 
-    pub fn with_font_style(self, font_style: &FontStyle) -> Self {
-        Self {
-            markup: font_style.markup,
-            ..self
-        }
-    }
-
     pub fn with_subscripts(self, subscripts: &'a [String]) -> Self {
         Self { subscripts, ..self }
     }
@@ -2407,20 +2933,11 @@ impl Display for DisplayValue<'_> {
                 }
             }
 
+            ValueInner::Markup(markup) => write!(f, "{markup}"),
+
             ValueInner::Text(TextValue {
                 localized: local, ..
-            }) => {
-                /*
-                if self
-                    .inner
-                    .styling
-                    .as_ref()
-                    .is_some_and(|styling| styling.style.font_style.markup)
-                {
-                    todo!();
-                }*/
-                f.write_str(local)
-            }
+            }) => f.write_str(local),
 
             ValueInner::Template(TemplateValue {
                 args,
@@ -2458,11 +2975,27 @@ impl Value {
 
 impl Debug for Value {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{:?}", self.display(()).to_string())
+        let name = match &self.inner {
+            ValueInner::Number(_) => "Number",
+            ValueInner::String(_) => "String",
+            ValueInner::Variable(_) => "Variable",
+            ValueInner::Text(_) => "Text",
+            ValueInner::Markup(_) => "Markup",
+            ValueInner::Template(_) => "Template",
+            ValueInner::Empty => "Empty",
+        };
+        write!(f, "{name}:{:?}", self.display(()).to_string())?;
+        if let Some(markup) = self.inner.markup() {
+            write!(f, " (markup: {markup:?})")?;
+        }
+        if let Some(styling) = &self.styling {
+            write!(f, " ({styling:?})")?;
+        }
+        Ok(())
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq)]
 pub struct NumberValue {
     /// The numerical value, or `None` if it is a missing value.
     pub value: Option<f64>,
@@ -2523,7 +3056,7 @@ pub struct BareNumberValue<'a>(
     #[serde(serialize_with = "NumberValue::serialize_bare")] pub &'a NumberValue,
 );
 
-#[derive(Clone, Debug, Serialize)]
+#[derive(Clone, Debug, Serialize, PartialEq)]
 pub struct StringValue {
     /// The string value.
     ///
@@ -2540,14 +3073,14 @@ pub struct StringValue {
     pub value_label: Option<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.
@@ -2595,20 +3128,21 @@ impl TextValue {
     }
 }
 
-#[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]
@@ -2650,18 +3184,29 @@ impl ValueInner {
             _ => None,
         }
     }
+
+    fn markup(&self) -> Option<&Markup> {
+        match self {
+            ValueInner::Markup(markup) => Some(markup),
+            _ => None,
+        }
+    }
 }
 
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, PartialEq)]
 pub struct ValueStyle {
-    pub style: Option<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()
     }
 }
 
@@ -2689,7 +3234,6 @@ impl ValueInner {
         };
         DisplayValue {
             inner: self,
-            markup: false,
             subscripts: &[],
             footnotes: &[],
             options,
@@ -2792,7 +3336,21 @@ impl Serialize for MetadataEntry {
 
 #[cfg(test)]
 mod test {
-    use crate::output::pivot::{Display26Adic, MetadataEntry, MetadataValue, Value};
+    use std::str::FromStr;
+
+    use crate::output::pivot::{
+        Color, Display26Adic, MetadataEntry, MetadataValue, Value, tests::assert_rendering,
+    };
+
+    #[test]
+    fn parse_color() {
+        assert_eq!(Color::from_str("red"), Ok(Color::new(255, 0, 0)));
+        assert_eq!(Color::from_str("transparent"), Ok(Color::TRANSPARENT));
+        assert_eq!(Color::from_str("rgb(12,34,56)"), Ok(Color::new(12, 34, 56)));
+        assert_eq!(Color::from_str("#abcdef"), Ok(Color::new(0xab, 0xcd, 0xef)));
+        assert_eq!(Color::from_str("abcdef"), Ok(Color::new(0xab, 0xcd, 0xef)));
+        assert_eq!(Color::from_str("transparent"), Ok(Color::TRANSPARENT));
+    }
 
     #[test]
     fn display_26adic() {
@@ -2858,18 +3416,6 @@ mod test {
 }"#
         );
 
-        assert_eq!(
-            tree.into_pivot_table().to_string(),
-            r#"โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚           Name 1   โ”‚Value 1   โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚Subgroup 1 Subname 1โ”‚Subvalue 1โ”‚
-โ”‚           Subname 2โ”‚Subvalue 2โ”‚
-โ”‚           Subname 3โ”‚         3โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚           Name 2   โ”‚Value 2   โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
-"#
-        );
+        assert_rendering("metadata_entry", &tree.into_pivot_table());
     }
 }
index a9e1264f55e567a2fdf164539711149174771081..fcefe65c355e5e12e05b3918237d881037271f5c 100644 (file)
@@ -23,12 +23,13 @@ use crate::{
     format::Decimal,
     output::pivot::{
         Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, Color, FootnoteMarkerPosition,
-        FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, VertAlign,
+        FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, RowParity,
+        VertAlign,
     },
 };
 use thiserror::Error as ThisError;
 
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 pub struct TableProperties {
     #[serde(rename = "@name")]
@@ -53,14 +54,14 @@ impl From<TableProperties> for Look {
                 footnote_marker_type: table_properties.footnote_properties.marker_type,
                 footnote_marker_position: table_properties.footnote_properties.marker_position,
                 areas: enum_map! {
-                    Area::Title => table_properties.cell_format_properties.title.style.as_area_style(),
-                    Area::Caption => table_properties.cell_format_properties.caption.style.as_area_style(),
-                    Area::Footer => table_properties.cell_format_properties.footnotes.style.as_area_style(),
-                    Area::Corner => table_properties.cell_format_properties.corner_labels.style.as_area_style(),
-                    Area::Labels(Axis2::X) => table_properties.cell_format_properties.column_labels.style.as_area_style(),
-                    Area::Labels(Axis2::Y) => table_properties.cell_format_properties.row_labels.style.as_area_style(),
-                    Area::Data => table_properties.cell_format_properties.data.style.as_area_style(),
-                    Area::Layers => table_properties.cell_format_properties.layers.style.as_area_style(),
+                    Area::Title => table_properties.cell_format_properties.title.style.as_area_style(RowParity::Even),
+                    Area::Caption => table_properties.cell_format_properties.caption.style.as_area_style(RowParity::Even),
+                    Area::Footer => table_properties.cell_format_properties.footnotes.style.as_area_style(RowParity::Even),
+                    Area::Corner => table_properties.cell_format_properties.corner_labels.style.as_area_style(RowParity::Even),
+                    Area::Labels(Axis2::X) => table_properties.cell_format_properties.column_labels.style.as_area_style(RowParity::Even),
+                    Area::Labels(Axis2::Y) => table_properties.cell_format_properties.row_labels.style.as_area_style(RowParity::Even),
+                    Area::Data(row) => table_properties.cell_format_properties.data.style.as_area_style(row),
+                    Area::Layers => table_properties.cell_format_properties.layers.style.as_area_style(RowParity::Even),
                 },
                 borders: enum_map!  {
                     Border::Title => table_properties.border_properties.title_layer_separator,
@@ -114,7 +115,7 @@ impl From<TableProperties> for Look {
     }
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Debug, Deserialize)]
 struct GeneralProperties {
     #[serde(rename = "@hideEmptyRows")]
     hide_empty_rows: bool,
@@ -135,7 +136,7 @@ struct GeneralProperties {
     row_label_position: LabelPosition,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 struct FootnoteProperties {
     #[serde(rename = "@markerPosition")]
@@ -145,7 +146,7 @@ struct FootnoteProperties {
     marker_type: FootnoteMarkerType,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Debug, Deserialize)]
 #[serde(rename_all = "camelCase")]
 struct CellFormatProperties {
     caption: CellStyleHolder,
@@ -158,13 +159,13 @@ struct CellFormatProperties {
     title: CellStyleHolder,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Debug, Deserialize)]
 #[serde(rename_all = "camelCase")]
 struct CellStyleHolder {
     style: CellStyle,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Clone, Debug, Default, Deserialize)]
 #[serde(default)]
 struct CellStyle {
     #[serde(rename = "@alternatingColor")]
@@ -178,7 +179,7 @@ struct CellStyle {
     #[serde(rename = "@font-family")]
     font_family: String,
     #[serde(rename = "@font-size")]
-    font_size: Dimension,
+    font_size: Length,
     #[serde(rename = "@font-style")]
     font_style: FontStyle,
     #[serde(rename = "@font-weight")]
@@ -188,21 +189,21 @@ struct CellStyle {
     #[serde(rename = "@labelLocationVertical")]
     label_location_vertical: LabelLocationVertical,
     #[serde(rename = "@margin-bottom")]
-    margin_bottom: Dimension,
+    margin_bottom: Length,
     #[serde(rename = "@margin-left")]
-    margin_left: Dimension,
+    margin_left: Length,
     #[serde(rename = "@margin-right")]
-    margin_right: Dimension,
+    margin_right: Length,
     #[serde(rename = "@margin-top")]
-    margin_top: Dimension,
+    margin_top: Length,
     #[serde(rename = "@textAlignment", default)]
     text_alignment: TextAlignment,
     #[serde(rename = "@decimal-offset")]
-    decimal_offset: Dimension,
+    decimal_offset: Length,
 }
 
 impl CellStyle {
-    fn as_area_style(&self) -> AreaStyle {
+    fn as_area_style(&self, data_row: RowParity) -> AreaStyle {
         AreaStyle {
             cell_style: super::CellStyle {
                 horz_align: match self.text_alignment {
@@ -229,23 +230,22 @@ impl CellStyle {
                 bold: self.font_weight == FontWeight::Bold,
                 italic: self.font_style == FontStyle::Italic,
                 underline: self.font_underline == FontUnderline::Underline,
-                markup: false,
                 font: self.font_family.clone(),
-                fg: [
-                    self.color.unwrap_or(Color::BLACK),
-                    self.alternating_text_color.unwrap_or(Color::BLACK),
-                ],
-                bg: [
-                    self.color2.unwrap_or(Color::BLACK),
-                    self.alternating_color.unwrap_or(Color::BLACK),
-                ],
+                fg: match data_row {
+                    RowParity::Even => self.color.unwrap_or(Color::BLACK),
+                    RowParity::Odd => self.alternating_text_color.unwrap_or(Color::BLACK),
+                },
+                bg: match data_row {
+                    RowParity::Even => self.color2.unwrap_or(Color::BLACK),
+                    RowParity::Odd => self.alternating_color.unwrap_or(Color::BLACK),
+                },
                 size: self.font_size.as_pt_i32(),
             },
         }
     }
 }
 
-#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum FontStyle {
     #[default]
@@ -253,7 +253,7 @@ enum FontStyle {
     Italic,
 }
 
-#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum FontWeight {
     #[default]
@@ -261,7 +261,7 @@ enum FontWeight {
     Bold,
 }
 
-#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum FontUnderline {
     #[default]
@@ -269,7 +269,7 @@ enum FontUnderline {
     Underline,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Clone, Debug, Default, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum TextAlignment {
     Left,
@@ -280,7 +280,7 @@ enum TextAlignment {
     Mixed,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Clone, Debug, Default, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum LabelLocationVertical {
     /// Top.
@@ -294,7 +294,7 @@ enum LabelLocationVertical {
     Center,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Debug, Deserialize)]
 #[serde(rename_all = "camelCase")]
 struct BorderProperties {
     bottom_inner_frame: BorderStyle,
@@ -318,7 +318,7 @@ struct BorderProperties {
     vertical_dimension_border_columns: BorderStyle,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Clone, Debug, Default, Deserialize)]
 #[serde(rename_all = "camelCase", default)]
 struct PrintingProperties {
     #[serde(rename = "@printAllLayers")]
@@ -347,41 +347,47 @@ struct PrintingProperties {
 }
 
 #[derive(Copy, Clone, Default, PartialEq)]
-struct Dimension(
+pub struct Length(
     /// In inches.
-    f64,
+    pub f64,
 );
 
-impl Dimension {
-    fn as_px_f64(self) -> f64 {
+impl Length {
+    pub fn as_px_f64(self) -> f64 {
         self.0 * 96.0
     }
-    fn as_px_i32(self) -> i32 {
+    pub fn as_px_i32(self) -> i32 {
         num::cast(self.as_px_f64() + 0.5).unwrap_or_default()
     }
-    fn as_pt_f64(self) -> f64 {
+    pub fn as_pt_f64(self) -> f64 {
         self.0 * 72.0
     }
-    fn as_pt_i32(self) -> i32 {
+    pub fn as_pt_i32(self) -> i32 {
         num::cast(self.as_pt_f64() + 0.5).unwrap_or_default()
     }
 }
 
-impl Debug for Dimension {
+impl From<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,
@@ -395,14 +401,14 @@ impl FromStr for Dimension {
             // Centimeters.
             "cm" | "ัะผ" => 2.54,
 
-            other => return Err(DimensionParseError::InvalidUnit(other.into())),
+            other => return Err(LengthParseError::InvalidUnit(other.into())),
         };
-        Ok(Dimension(number / divisor))
+        Ok(Length(number / divisor))
     }
 }
 
 #[derive(ThisError, Debug, PartialEq, Eq)]
-enum DimensionParseError {
+pub enum LengthParseError {
     /// Invalid number.
     #[error(transparent)]
     ParseFloatError(ParseFloatError),
@@ -412,7 +418,7 @@ enum DimensionParseError {
     InvalidUnit(String),
 }
 
-impl<'de> Deserialize<'de> for Dimension {
+impl<'de> Deserialize<'de> for Length {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
         D: serde::Deserializer<'de>,
@@ -420,13 +426,13 @@ impl<'de> Deserialize<'de> for Dimension {
         struct DimensionVisitor;
 
         impl<'de> Visitor<'de> for DimensionVisitor {
-            type Value = Dimension;
+            type Value = Length;
 
             fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
-                formatter.write_str("a string")
+                formatter.write_str("a dimension expressed as a string, e.g. \"1.0 cm\"")
             }
 
-            fn visit_borrowed_str<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,
             {
@@ -442,43 +448,49 @@ impl<'de> Deserialize<'de> for Dimension {
 mod tests {
     use std::str::FromStr;
 
+    use enum_map::{EnumMap, enum_map};
     use quick_xml::de::from_str;
 
-    use crate::output::pivot::look_xml::{Dimension, DimensionParseError, TableProperties};
+    use crate::output::pivot::{
+        Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle,
+        FootnoteMarkerPosition, FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look,
+        RowColBorder, RowParity, Stroke, VertAlign,
+        look_xml::{Length, LengthParseError, TableProperties},
+    };
 
     #[test]
     fn dimension() {
-        assert_eq!(Dimension::from_str("1"), Ok(Dimension(1.0 / 72.0)));
-        assert_eq!(Dimension::from_str("1pt"), Ok(Dimension(1.0 / 72.0)));
-        assert_eq!(Dimension::from_str("1ะฟั‚"), Ok(Dimension(1.0 / 72.0)));
-        assert_eq!(Dimension::from_str("1.0"), Ok(Dimension(1.0 / 72.0)));
-        assert_eq!(Dimension::from_str(" 1.0"), Ok(Dimension(1.0 / 72.0)));
-        assert_eq!(Dimension::from_str(" 1.0 "), Ok(Dimension(1.0 / 72.0)));
-        assert_eq!(Dimension::from_str("1.0 pt"), Ok(Dimension(1.0 / 72.0)));
-        assert_eq!(Dimension::from_str("1.0pt "), Ok(Dimension(1.0 / 72.0)));
-        assert_eq!(Dimension::from_str(" 1.0pt "), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Length::from_str("1"), Ok(Length(1.0 / 72.0)));
+        assert_eq!(Length::from_str("1pt"), Ok(Length(1.0 / 72.0)));
+        assert_eq!(Length::from_str("1ะฟั‚"), Ok(Length(1.0 / 72.0)));
+        assert_eq!(Length::from_str("1.0"), Ok(Length(1.0 / 72.0)));
+        assert_eq!(Length::from_str(" 1.0"), Ok(Length(1.0 / 72.0)));
+        assert_eq!(Length::from_str(" 1.0 "), Ok(Length(1.0 / 72.0)));
+        assert_eq!(Length::from_str("1.0 pt"), Ok(Length(1.0 / 72.0)));
+        assert_eq!(Length::from_str("1.0pt "), Ok(Length(1.0 / 72.0)));
+        assert_eq!(Length::from_str(" 1.0pt "), Ok(Length(1.0 / 72.0)));
 
-        assert_eq!(Dimension::from_str("1in"), Ok(Dimension(1.0)));
+        assert_eq!(Length::from_str("1in"), Ok(Length(1.0)));
 
-        assert_eq!(Dimension::from_str("96px"), Ok(Dimension(1.0)));
+        assert_eq!(Length::from_str("96px"), Ok(Length(1.0)));
 
-        assert_eq!(Dimension::from_str("2.54cm"), Ok(Dimension(1.0)));
+        assert_eq!(Length::from_str("2.54cm"), Ok(Length(1.0)));
 
         assert_eq!(
-            Dimension::from_str(""),
-            Err(DimensionParseError::ParseFloatError(
+            Length::from_str(""),
+            Err(LengthParseError::ParseFloatError(
                 "".parse::<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()))
         );
     }
 
@@ -540,6 +552,409 @@ mod tests {
 </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);
     }
 }
index 8df1ae536929d7c0e2873969c6d5c031c833c149..a2427b0d35dffee5985f8a5539bbd1065f085c9e 100644 (file)
@@ -20,7 +20,7 @@ use enum_map::{EnumMap, enum_map};
 use itertools::Itertools;
 
 use crate::output::{
-    pivot::{HeadingRegion, LabelPosition, Path},
+    pivot::{HeadingRegion, LabelPosition, Path, RowParity},
     table::{CellInner, Table},
 };
 
@@ -92,7 +92,7 @@ impl PivotTable {
             };
             presentation_indexes[vary_axis] = &vary_indexes;
             let data_indexes = self.convert_indexes_ptod(presentation_indexes);
-            if self.get(&data_indexes).is_some() {
+            if self.get(&*data_indexes).is_some() {
                 return false;
             }
         }
@@ -141,7 +141,7 @@ impl PivotTable {
         let mut table = Table::new(
             Coord2::new(1, rows.len()),
             Coord2::new(0, 0),
-            self.look.areas.clone(),
+            self.style.look.areas.clone(),
             self.borders(false),
             self.into_value_options(),
         );
@@ -167,7 +167,11 @@ impl PivotTable {
 
     fn borders(&self, printing: bool) -> EnumMap<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,
+            )
         })
     }
 
@@ -182,7 +186,7 @@ impl PivotTable {
         let mut body = Table::new(
             Coord2::from_fn(|axis| data[axis] + stub[axis]),
             stub,
-            self.look.areas.clone(),
+            self.style.look.areas.clone(),
             self.borders(printing),
             self.into_value_options(),
         );
@@ -199,28 +203,30 @@ impl PivotTable {
                     Axis3::Z => layer_indexes,
                 };
                 let data_indexes = self.convert_indexes_ptod(presentation_indexes);
-                let value = self.get(&data_indexes);
+                let value = self.get(&*data_indexes);
                 body.put(
                     Rect2::new(x..x + 1, y..y + 1),
-                    CellInner {
-                        rotate: false,
-                        area: Area::Data,
-                        value: Box::new(value.cloned().unwrap_or_default()),
-                    },
+                    CellInner::new(
+                        Area::Data(RowParity::from(y - stub[Axis2::Y])),
+                        Box::new(value.cloned().unwrap_or_default()),
+                    ),
                 );
             }
         }
 
         // Insert corner text, but only if there's a stub and only if row labels
         // are not in the corner.
-        if self.corner_text.is_some()
-            && self.look.row_label_position == LabelPosition::Nested
+        if self.metadata.corner_text.is_some()
+            && self.style.look.row_label_position == LabelPosition::Nested
             && stub.x() > 0
             && stub.y() > 0
         {
             body.put(
                 Rect2::new(0..stub.x(), 0..stub.y()),
-                CellInner::new(Area::Corner, self.corner_text.clone().unwrap_or_default()),
+                CellInner::new(
+                    Area::Corner,
+                    self.metadata.corner_text.clone().unwrap_or_default(),
+                ),
             );
         }
 
@@ -245,7 +251,10 @@ impl PivotTable {
     }
 
     pub fn output_title(&self) -> Option<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> {
@@ -258,7 +267,7 @@ impl PivotTable {
             layer_indexes,
         ) {
             if !dimension.is_empty() {
-                layers.push(dimension.nth_leaf(layer_index).unwrap().name.clone());
+                layers.push(dimension.nth_leaf(layer_index).unwrap().0.clone());
             }
         }
         layers.reverse();
@@ -267,7 +276,10 @@ impl PivotTable {
     }
 
     pub fn output_caption(&self) -> Option<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> {
@@ -285,10 +297,14 @@ impl PivotTable {
 
     pub fn output(&self, layer_indexes: &[usize], printing: bool) -> OutputTables {
         // Produce most of the tables.
-        let title = self.show_title.then(|| self.output_title()).flatten();
+        let title = self.style.show_title.then(|| self.output_title()).flatten();
         let layers = self.output_layers(layer_indexes);
         let body = self.output_body(layer_indexes, printing);
-        let caption = self.show_caption.then(|| self.output_caption()).flatten();
+        let caption = self
+            .style
+            .show_caption
+            .then(|| self.output_caption())
+            .flatten();
 
         // Then collect the footnotes from those tables.
         let tables = [
@@ -352,11 +368,12 @@ pub struct OutputTables {
 }
 
 impl Path<'_> {
-    pub fn get(&self, y: usize, height: usize) -> Option<&Value> {
-        if y + 1 == height {
-            Some(&self.leaf.name)
+    pub fn get(&self, y: usize, height: usize) -> (&Value, Range<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)
         }
     }
 }
@@ -422,44 +439,47 @@ impl<'a> Heading<'a> {
     ) {
         let v = !h;
 
+        // Go through the heading row by row.
         for row in 0..self.height {
             // Find all the categories, dropping columns without a category.
             let categories = self.columns.iter().enumerate().filter_map(|(x, column)| {
-                column.get(row, self.height).map(|name| (x..x + 1, name))
+                let (name, y_range) = column.get(row, self.height);
+                (y_range.start == row).then_some((x..x + 1, y_range, name))
             });
 
             // Merge adjacent identical categories (but don't merge across a vertical rule).
             let categories = categories
-                .coalesce(|(a_r, a), (b_r, b)| {
+                .coalesce(|(a_r, a_yr, a), (b_r, b_yr, b)| {
                     if a_r.end == b_r.start && !vrules[b_r.start] && std::ptr::eq(a, b) {
-                        Ok((a_r.start..b_r.end, a))
+                        Ok((a_r.start..b_r.end, a_yr, a))
                     } else {
-                        Err(((a_r, a), (b_r, b)))
+                        Err(((a_r, a_yr, a), (b_r, b_yr, b)))
                     }
                 })
                 .collect::<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
                 // โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
@@ -485,11 +505,15 @@ impl<'a> Heading<'a> {
                     }
                 }
 
-                // Draws the horizontal lines within a dimension, that is, those
+                // Draw the horizontal lines within a dimension, that is, those
                 // that separate a category (or group) from its parent group or
-                // dimension's label.  Our running example doesn't have groups
-                // but the `โ•โ•โ•โ•โ•` lines below show the separators between
-                // categories and their dimension label:
+                // dimension's label.
+                //
+                // # Example
+                //
+                // Our running example doesn't have groups but the `โ•โ•โ•โ•โ•` lines
+                // below show the separators between categories and their
+                // dimension label:
                 //
                 // ```text
                 // โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
@@ -523,11 +547,7 @@ impl<'a> Heading<'a> {
         if dimension_label_position == LabelPosition::Corner {
             table.put(
                 Rect2::new(v_ofs..v_ofs + 1, 0..h_ofs),
-                CellInner {
-                    rotate: false,
-                    area: Area::Corner,
-                    value: self.dimension.root.name.clone(),
-                },
+                CellInner::new(Area::Corner, self.dimension.root.name.clone()),
             );
         }
     }
@@ -542,7 +562,8 @@ struct Headings<'a> {
 
 impl<'a> Headings<'a> {
     fn new(pt: &'a PivotTable, h: Axis2, layer_indexes: &[usize]) -> Self {
-        let column_enumeration = pt.enumerate_axis(h.into(), layer_indexes, pt.look.hide_empty);
+        let column_enumeration =
+            pt.enumerate_axis(h.into(), layer_indexes, pt.style.look.hide_empty);
 
         let mut headings = pt.axes[h.into()]
             .dimensions
@@ -556,7 +577,7 @@ impl<'a> Headings<'a> {
             .collect::<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())
@@ -620,8 +641,13 @@ impl<'a> Headings<'a> {
             );
             v_ofs += heading.height;
             if !inner {
-                // Draw the horizontal line between dimensions, e.g. the `=====`
-                // line here:
+                // Draw the horizontal line between dimensions.
+                //
+                // # Example
+                //
+                // Suppose we have two dimensions `aaaa` and `bbbb`, each with
+                // three numbered categories.  This code draws the `=====` line
+                // here:
                 //
                 // ```text
                 // โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” __
diff --git a/rust/pspp/src/output/pivot/testdata/caption.expected b/rust/pspp/src/output/pivot/testdata/caption.expected
new file mode 100644 (file)
index 0000000..73ff1fd
--- /dev/null
@@ -0,0 +1,8 @@
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚  โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
+Caption
diff --git a/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_1.expected b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_1.expected
new file mode 100644 (file)
index 0000000..8957417
--- /dev/null
@@ -0,0 +1,24 @@
+Category and Dimension Borders 1
+                           b
+                     bg1       โ”‚
+                 b1   โ”‚   b2   โ”‚   b3
+                  a   โ”‚    a   โ”‚    a
+                โ”‚ ag1 โ”‚  โ”‚ ag1 โ”‚  โ”‚ ag1
+d      c      a1โ”‚a2โ”Ša3โ”‚a1โ”‚a2โ”Ša3โ”‚a1โ”‚a2โ”Ša3
+dg1 d1 c1      0โ”‚ 1โ”Š 2โ”‚ 3โ”‚ 4โ”Š 5โ”‚ 6โ”‚ 7โ”Š 8
+      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+       cg1 c2  9โ”‚10โ”Š11โ”‚12โ”‚13โ”Š14โ”‚15โ”‚16โ”Š17
+          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
+           c3 18โ”‚19โ”Š20โ”‚21โ”‚22โ”Š23โ”‚24โ”‚25โ”Š26
+   โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+    d2 c1     27โ”‚28โ”Š29โ”‚30โ”‚31โ”Š32โ”‚33โ”‚34โ”Š35
+      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+       cg1 c2 36โ”‚37โ”Š38โ”‚39โ”‚40โ”Š41โ”‚42โ”‚43โ”Š44
+          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
+           c3 45โ”‚46โ”Š47โ”‚48โ”‚49โ”Š50โ”‚51โ”‚52โ”Š53
+โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+d3     c1     54โ”‚55โ”Š56โ”‚57โ”‚58โ”Š59โ”‚60โ”‚61โ”Š62
+      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+       cg1 c2 63โ”‚64โ”Š65โ”‚66โ”‚67โ”Š68โ”‚69โ”‚70โ”Š71
+          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
+           c3 72โ”‚73โ”Š74โ”‚75โ”‚76โ”Š77โ”‚78โ”‚79โ”Š80
diff --git a/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_2.expected b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_2.expected
new file mode 100644 (file)
index 0000000..6cb2aa3
--- /dev/null
@@ -0,0 +1,21 @@
+Category and Dimension Borders 2
+                           b
+             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+                     bg1
+             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+                 b1       b2       b3
+             โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+                  a        a        a
+             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+                  ag1      ag1      ag1
+                โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ  โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ  โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+d      c      a1 a2 a3 a1 a2 a3 a1 a2 a3
+dg1โ”Šd1โ”‚c1      0  1  2  3  4  5  6  7  8
+   โ”Š  โ”‚cg1โ”Šc2  9 10 11 12 13 14 15 16 17
+   โ”Š  โ”‚   โ”Šc3 18 19 20 21 22 23 24 25 26
+   โ”Šd2โ”‚c1     27 28 29 30 31 32 33 34 35
+   โ”Š  โ”‚cg1โ”Šc2 36 37 38 39 40 41 42 43 44
+   โ”Š  โ”‚   โ”Šc3 45 46 47 48 49 50 51 52 53
+d3    โ”‚c1     54 55 56 57 58 59 60 61 62
+      โ”‚cg1โ”Šc2 63 64 65 66 67 68 69 70 71
+      โ”‚   โ”Šc3 72 73 74 75 76 77 78 79 80
diff --git a/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_3.expected b/rust/pspp/src/output/pivot/testdata/category_and_dimension_borders_3.expected
new file mode 100644 (file)
index 0000000..fdf05f0
--- /dev/null
@@ -0,0 +1,25 @@
+Category and Dimension Borders 3
+                     bg1       โ”‚
+             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ฌโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ค
+                 b1   โ”‚   b2   โ”‚   b3
+             โ•ถโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€
+                โ”‚ ag1 โ”‚  โ”‚ ag1 โ”‚  โ”‚ ag1
+                โ”œโ•Œโ•Œโ”ฌโ•Œโ•Œโ”ค  โ”œโ•Œโ•Œโ”ฌโ•Œโ•Œโ”ค  โ”œโ•Œโ•Œโ”ฌโ•Œโ•Œ
+              a1โ”‚a2โ”Ša3โ”‚a1โ”‚a2โ”Ša3โ”‚a1โ”‚a2โ”Ša3
+dg1โ”Šd1โ”‚c1      0โ”‚ 1โ”Š 2โ”‚ 3โ”‚ 4โ”Š 5โ”‚ 6โ”‚ 7โ”Š 8
+   โ”Š  โ”œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+   โ”Š  โ”‚cg1โ”Šc2  9โ”‚10โ”Š11โ”‚12โ”‚13โ”Š14โ”‚15โ”‚16โ”Š17
+   โ”Š  โ”‚   โ”œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
+   โ”Š  โ”‚   โ”Šc3 18โ”‚19โ”Š20โ”‚21โ”‚22โ”Š23โ”‚24โ”‚25โ”Š26
+   โ”œโ”€โ”€โ”ผโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+   โ”Šd2โ”‚c1     27โ”‚28โ”Š29โ”‚30โ”‚31โ”Š32โ”‚33โ”‚34โ”Š35
+   โ”Š  โ”œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+   โ”Š  โ”‚cg1โ”Šc2 36โ”‚37โ”Š38โ”‚39โ”‚40โ”Š41โ”‚42โ”‚43โ”Š44
+   โ”Š  โ”‚   โ”œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
+   โ”Š  โ”‚   โ”Šc3 45โ”‚46โ”Š47โ”‚48โ”‚49โ”Š50โ”‚51โ”‚52โ”Š53
+โ”€โ”€โ”€โ”ดโ”€โ”€โ”ผโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+d3    โ”‚c1     54โ”‚55โ”Š56โ”‚57โ”‚58โ”Š59โ”‚60โ”‚61โ”Š62
+      โ”œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
+      โ”‚cg1โ”Šc2 63โ”‚64โ”Š65โ”‚66โ”‚67โ”Š68โ”‚69โ”‚70โ”Š71
+      โ”‚   โ”œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
+      โ”‚   โ”Šc3 72โ”‚73โ”Š74โ”‚75โ”‚76โ”Š77โ”‚78โ”‚79โ”Š80
diff --git a/rust/pspp/src/output/pivot/testdata/category_borders_1.expected b/rust/pspp/src/output/pivot/testdata/category_borders_1.expected
new file mode 100644 (file)
index 0000000..30412f9
--- /dev/null
@@ -0,0 +1,24 @@
+Category Borders 1
+                           b
+                     bg1       โ”Š
+                 b1   โ”Š   b2   โ”Š   b3
+                  a   โ”Š    a   โ”Š    a
+                โ”Š ag1 โ”Š  โ”Š ag1 โ”Š  โ”Š ag1
+d      c      a1โ”Ša2โ”Ša3โ”Ša1โ”Ša2โ”Ša3โ”Ša1โ”Ša2โ”Ša3
+dg1 d1 c1      0โ”Š 1โ”Š 2โ”Š 3โ”Š 4โ”Š 5โ”Š 6โ”Š 7โ”Š 8
+      โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
+       cg1 c2  9โ”Š10โ”Š11โ”Š12โ”Š13โ”Š14โ”Š15โ”Š16โ”Š17
+          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
+           c3 18โ”Š19โ”Š20โ”Š21โ”Š22โ”Š23โ”Š24โ”Š25โ”Š26
+   โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
+    d2 c1     27โ”Š28โ”Š29โ”Š30โ”Š31โ”Š32โ”Š33โ”Š34โ”Š35
+      โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
+       cg1 c2 36โ”Š37โ”Š38โ”Š39โ”Š40โ”Š41โ”Š42โ”Š43โ”Š44
+          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
+           c3 45โ”Š46โ”Š47โ”Š48โ”Š49โ”Š50โ”Š51โ”Š52โ”Š53
+โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
+d3     c1     54โ”Š55โ”Š56โ”Š57โ”Š58โ”Š59โ”Š60โ”Š61โ”Š62
+      โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
+       cg1 c2 63โ”Š64โ”Š65โ”Š66โ”Š67โ”Š68โ”Š69โ”Š70โ”Š71
+          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
+           c3 72โ”Š73โ”Š74โ”Š75โ”Š76โ”Š77โ”Š78โ”Š79โ”Š80
diff --git a/rust/pspp/src/output/pivot/testdata/category_borders_2.expected b/rust/pspp/src/output/pivot/testdata/category_borders_2.expected
new file mode 100644 (file)
index 0000000..33d3afc
--- /dev/null
@@ -0,0 +1,21 @@
+Category Borders 2
+                           b
+             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+                     bg1
+             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+                 b1       b2       b3
+             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+                  a        a        a
+             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+                  ag1      ag1      ag1
+                โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ  โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ  โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
+d      c      a1 a2 a3 a1 a2 a3 a1 a2 a3
+dg1โ”Šd1โ”Šc1      0  1  2  3  4  5  6  7  8
+   โ”Š  โ”Šcg1โ”Šc2  9 10 11 12 13 14 15 16 17
+   โ”Š  โ”Š   โ”Šc3 18 19 20 21 22 23 24 25 26
+   โ”Šd2โ”Šc1     27 28 29 30 31 32 33 34 35
+   โ”Š  โ”Šcg1โ”Šc2 36 37 38 39 40 41 42 43 44
+   โ”Š  โ”Š   โ”Šc3 45 46 47 48 49 50 51 52 53
+d3    โ”Šc1     54 55 56 57 58 59 60 61 62
+      โ”Šcg1โ”Šc2 63 64 65 66 67 68 69 70 71
+      โ”Š   โ”Šc3 72 73 74 75 76 77 78 79 80
diff --git a/rust/pspp/src/output/pivot/testdata/d1_c.expected b/rust/pspp/src/output/pivot/testdata/d1_c.expected
new file mode 100644 (file)
index 0000000..d07377f
--- /dev/null
@@ -0,0 +1,8 @@
+Columns
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚    a   โ”‚
+โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
+โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d1_r.expected b/rust/pspp/src/output/pivot/testdata/d1_r.expected
new file mode 100644 (file)
index 0000000..70dc402
--- /dev/null
@@ -0,0 +1,8 @@
+Rows
+โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚a โ”‚ โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”ค
+โ”‚a1โ”‚0โ”‚
+โ”‚a2โ”‚1โ”‚
+โ”‚a3โ”‚2โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_cc.expected b/rust/pspp/src/output/pivot/testdata/d2_cc.expected
new file mode 100644 (file)
index 0000000..593ffa9
--- /dev/null
@@ -0,0 +1,8 @@
+Columns
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚   b1   โ”‚   b2   โ”‚   b3   โ”‚
+โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
+โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 0โ”‚ 1โ”‚ 2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_cc_with_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_cc_with_dim_labels.expected
new file mode 100644 (file)
index 0000000..e2acdbc
--- /dev/null
@@ -0,0 +1,12 @@
+Columns
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚             b            โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚   b1   โ”‚   b2   โ”‚   b3   โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚    a   โ”‚    a   โ”‚    a   โ”‚
+โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
+โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 0โ”‚ 1โ”‚ 2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_cl-all_layers.expected b/rust/pspp/src/output/pivot/testdata/d2_cl-all_layers.expected
new file mode 100644 (file)
index 0000000..cdb9255
--- /dev/null
@@ -0,0 +1,23 @@
+Column (All Layers)
+b1
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
+
+Column (All Layers)
+b2
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
+
+Column (All Layers)
+b3
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_cl-layer0.expected b/rust/pspp/src/output/pivot/testdata/d2_cl-layer0.expected
new file mode 100644 (file)
index 0000000..48a28c4
--- /dev/null
@@ -0,0 +1,7 @@
+Column x b1
+b1
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_cl-layer1.expected b/rust/pspp/src/output/pivot/testdata/d2_cl-layer1.expected
new file mode 100644 (file)
index 0000000..9e9323c
--- /dev/null
@@ -0,0 +1,7 @@
+Column x b2
+b2
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_cr.expected b/rust/pspp/src/output/pivot/testdata/d2_cr.expected
new file mode 100644 (file)
index 0000000..12d2be6
--- /dev/null
@@ -0,0 +1,8 @@
+Column x Row
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚  โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_cr_with_corner_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_cr_with_corner_dim_labels.expected
new file mode 100644 (file)
index 0000000..8538b2d
--- /dev/null
@@ -0,0 +1,10 @@
+Column x Row - Corner
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚  โ”‚    a   โ”‚
+โ”‚  โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
+โ”‚b โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_cr_with_nested_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_cr_with_nested_dim_labels.expected
new file mode 100644 (file)
index 0000000..79b99dd
--- /dev/null
@@ -0,0 +1,10 @@
+Column x Row - Nested
+โ•ญโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚    โ”‚    a   โ”‚
+โ”‚    โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
+โ”‚    โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚b b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ”‚  b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ”‚  b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rc.expected b/rust/pspp/src/output/pivot/testdata/d2_rc.expected
new file mode 100644 (file)
index 0000000..2f337af
--- /dev/null
@@ -0,0 +1,8 @@
+Row x Column
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚  โ”‚b1โ”‚b2โ”‚b3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚a1โ”‚ 0โ”‚ 3โ”‚ 6โ”‚
+โ”‚a2โ”‚ 1โ”‚ 4โ”‚ 7โ”‚
+โ”‚a3โ”‚ 2โ”‚ 5โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rc_with_corner_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_rc_with_corner_dim_labels.expected
new file mode 100644 (file)
index 0000000..2df469a
--- /dev/null
@@ -0,0 +1,10 @@
+Row x Column - Corner
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚  โ”‚    b   โ”‚
+โ”‚  โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
+โ”‚a โ”‚b1โ”‚b2โ”‚b3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚a1โ”‚ 0โ”‚ 3โ”‚ 6โ”‚
+โ”‚a2โ”‚ 1โ”‚ 4โ”‚ 7โ”‚
+โ”‚a3โ”‚ 2โ”‚ 5โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rc_with_nested_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_rc_with_nested_dim_labels.expected
new file mode 100644 (file)
index 0000000..37cc93f
--- /dev/null
@@ -0,0 +1,10 @@
+Row x Column - Nested
+โ•ญโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚    โ”‚    b   โ”‚
+โ”‚    โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
+โ”‚    โ”‚b1โ”‚b2โ”‚b3โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚a a1โ”‚ 0โ”‚ 3โ”‚ 6โ”‚
+โ”‚  a2โ”‚ 1โ”‚ 4โ”‚ 7โ”‚
+โ”‚  a3โ”‚ 2โ”‚ 5โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rl-all_layers.expected b/rust/pspp/src/output/pivot/testdata/d2_rl-all_layers.expected
new file mode 100644 (file)
index 0000000..0aa4682
--- /dev/null
@@ -0,0 +1,23 @@
+Row (All Layers)
+b1
+โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚a1โ”‚0โ”‚
+โ”‚a2โ”‚1โ”‚
+โ”‚a3โ”‚2โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
+
+Row (All Layers)
+b2
+โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚a1โ”‚3โ”‚
+โ”‚a2โ”‚4โ”‚
+โ”‚a3โ”‚5โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
+
+Row (All Layers)
+b3
+โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚a1โ”‚6โ”‚
+โ”‚a2โ”‚7โ”‚
+โ”‚a3โ”‚8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rl-layer0.expected b/rust/pspp/src/output/pivot/testdata/d2_rl-layer0.expected
new file mode 100644 (file)
index 0000000..1f76675
--- /dev/null
@@ -0,0 +1,7 @@
+Row x b1
+b1
+โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚a1โ”‚0โ”‚
+โ”‚a2โ”‚1โ”‚
+โ”‚a3โ”‚2โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rl-layer1.expected b/rust/pspp/src/output/pivot/testdata/d2_rl-layer1.expected
new file mode 100644 (file)
index 0000000..051797d
--- /dev/null
@@ -0,0 +1,7 @@
+Row x b2
+b2
+โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚a1โ”‚3โ”‚
+โ”‚a2โ”‚4โ”‚
+โ”‚a3โ”‚5โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rr.expected b/rust/pspp/src/output/pivot/testdata/d2_rr.expected
new file mode 100644 (file)
index 0000000..ff69fe4
--- /dev/null
@@ -0,0 +1,14 @@
+Rows
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚b1 a1โ”‚0โ”‚
+โ”‚   a2โ”‚1โ”‚
+โ”‚   a3โ”‚2โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
+โ”‚b2 a1โ”‚3โ”‚
+โ”‚   a2โ”‚4โ”‚
+โ”‚   a3โ”‚5โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
+โ”‚b3 a1โ”‚6โ”‚
+โ”‚   a2โ”‚7โ”‚
+โ”‚   a3โ”‚8โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rr_with_corner_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_rr_with_corner_dim_labels.expected
new file mode 100644 (file)
index 0000000..a1662c5
--- /dev/null
@@ -0,0 +1,16 @@
+Rows - Corner
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚b  a โ”‚ โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
+โ”‚b1 a1โ”‚0โ”‚
+โ”‚   a2โ”‚1โ”‚
+โ”‚   a3โ”‚2โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
+โ”‚b2 a1โ”‚3โ”‚
+โ”‚   a2โ”‚4โ”‚
+โ”‚   a3โ”‚5โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
+โ”‚b3 a1โ”‚6โ”‚
+โ”‚   a2โ”‚7โ”‚
+โ”‚   a3โ”‚8โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2_rr_with_nested_dim_labels.expected b/rust/pspp/src/output/pivot/testdata/d2_rr_with_nested_dim_labels.expected
new file mode 100644 (file)
index 0000000..f401eb1
--- /dev/null
@@ -0,0 +1,14 @@
+Rows - Nested
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ•ฎ
+โ”‚b b1 a a1โ”‚0โ”‚
+โ”‚       a2โ”‚1โ”‚
+โ”‚       a3โ”‚2โ”‚
+โ”‚ โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
+โ”‚  b2 a a1โ”‚3โ”‚
+โ”‚       a2โ”‚4โ”‚
+โ”‚       a3โ”‚5โ”‚
+โ”‚ โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
+โ”‚  b3 a a1โ”‚6โ”‚
+โ”‚       a2โ”‚7โ”‚
+โ”‚       a3โ”‚8โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2m_cc.expected b/rust/pspp/src/output/pivot/testdata/d2m_cc.expected
new file mode 100644 (file)
index 0000000..eaa9ee3
--- /dev/null
@@ -0,0 +1,12 @@
+Columns
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚        b        โ”‚    c   โ”‚        โ”‚        โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค        โ”‚        โ”‚
+โ”‚   b1   โ”‚        โ”‚        โ”‚        โ”‚        โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค        โ”‚        โ”‚        โ”‚        โ”‚
+โ”‚   b2   โ”‚   b3   โ”‚   c1   โ”‚    d   โ”‚    e   โ”‚
+โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
+โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 0โ”‚ 1โ”‚ 2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚ 6โ”‚ 7โ”‚ 8โ”‚ 9โ”‚10โ”‚11โ”‚12โ”‚13โ”‚14โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2m_cr.expected b/rust/pspp/src/output/pivot/testdata/d2m_cr.expected
new file mode 100644 (file)
index 0000000..914989b
--- /dev/null
@@ -0,0 +1,14 @@
+Column x Row
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚       โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚b b1 b2โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ”‚ โ•ถโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚  b3   โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚c c1   โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚d      โ”‚ 9โ”‚10โ”‚11โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚e      โ”‚12โ”‚13โ”‚14โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2m_rc.expected b/rust/pspp/src/output/pivot/testdata/d2m_rc.expected
new file mode 100644 (file)
index 0000000..d5e05ce
--- /dev/null
@@ -0,0 +1,12 @@
+Row x Column
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚  โ”‚  b  โ”‚ cโ”‚  โ”‚  โ”‚
+โ”‚  โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ค  โ”‚  โ”‚
+โ”‚  โ”‚b1โ”‚  โ”‚  โ”‚  โ”‚  โ”‚
+โ”‚  โ”œโ”€โ”€โ”ค  โ”‚  โ”‚  โ”‚  โ”‚
+โ”‚  โ”‚b2โ”‚b3โ”‚c1โ”‚ dโ”‚ eโ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚a1โ”‚ 0โ”‚ 3โ”‚ 6โ”‚ 9โ”‚12โ”‚
+โ”‚a2โ”‚ 1โ”‚ 4โ”‚ 7โ”‚10โ”‚13โ”‚
+โ”‚a3โ”‚ 2โ”‚ 5โ”‚ 8โ”‚11โ”‚14โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d2m_rr.expected b/rust/pspp/src/output/pivot/testdata/d2m_rr.expected
new file mode 100644 (file)
index 0000000..737976b
--- /dev/null
@@ -0,0 +1,22 @@
+Rows
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚b b1 b2 a1โ”‚ 0โ”‚
+โ”‚        a2โ”‚ 1โ”‚
+โ”‚        a3โ”‚ 2โ”‚
+โ”‚ โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚  b3    a1โ”‚ 3โ”‚
+โ”‚        a2โ”‚ 4โ”‚
+โ”‚        a3โ”‚ 5โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚c c1    a1โ”‚ 6โ”‚
+โ”‚        a2โ”‚ 7โ”‚
+โ”‚        a3โ”‚ 8โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚d       a1โ”‚ 9โ”‚
+โ”‚        a2โ”‚10โ”‚
+โ”‚        a3โ”‚11โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚e       a1โ”‚12โ”‚
+โ”‚        a2โ”‚13โ”‚
+โ”‚        a3โ”‚14โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d3-layer0_0.expected b/rust/pspp/src/output/pivot/testdata/d3-layer0_0.expected
new file mode 100644 (file)
index 0000000..c2eefe0
--- /dev/null
@@ -0,0 +1,8 @@
+Column x b1 x a1
+b1
+a1
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚c1โ”‚c2โ”‚c3โ”‚c4โ”‚c5โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 0โ”‚12โ”‚24โ”‚36โ”‚48โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d3-layer0_1.expected b/rust/pspp/src/output/pivot/testdata/d3-layer0_1.expected
new file mode 100644 (file)
index 0000000..aaa4395
--- /dev/null
@@ -0,0 +1,8 @@
+Column x b2 x a1
+b2
+a1
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚c1โ”‚c2โ”‚c3โ”‚c4โ”‚c5โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 3โ”‚15โ”‚27โ”‚39โ”‚51โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/d3-layer1_2.expected b/rust/pspp/src/output/pivot/testdata/d3-layer1_2.expected
new file mode 100644 (file)
index 0000000..55fb152
--- /dev/null
@@ -0,0 +1,8 @@
+Column x b3 x a2
+b3
+a2
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚c1โ”‚c2โ”‚c3โ”‚c4โ”‚c5โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚ 7โ”‚19โ”‚31โ”‚43โ”‚55โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/dimension_borders_1.expected b/rust/pspp/src/output/pivot/testdata/dimension_borders_1.expected
new file mode 100644 (file)
index 0000000..34419da
--- /dev/null
@@ -0,0 +1,21 @@
+Dimension Borders 1
+                           b
+                     bg1       โ”‚
+                 b1   โ”‚   b2   โ”‚   b3
+                  a   โ”‚    a   โ”‚    a
+                โ”‚ ag1 โ”‚  โ”‚ ag1 โ”‚  โ”‚ ag1
+d      c      a1โ”‚a2 a3โ”‚a1โ”‚a2 a3โ”‚a1โ”‚a2 a3
+dg1 d1 c1      0โ”‚ 1  2โ”‚ 3โ”‚ 4  5โ”‚ 6โ”‚ 7  8
+      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
+       cg1 c2  9โ”‚10 11โ”‚12โ”‚13 14โ”‚15โ”‚16 17
+           c3 18โ”‚19 20โ”‚21โ”‚22 23โ”‚24โ”‚25 26
+   โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
+    d2 c1     27โ”‚28 29โ”‚30โ”‚31 32โ”‚33โ”‚34 35
+      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
+       cg1 c2 36โ”‚37 38โ”‚39โ”‚40 41โ”‚42โ”‚43 44
+           c3 45โ”‚46 47โ”‚48โ”‚49 50โ”‚51โ”‚52 53
+โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
+d3     c1     54โ”‚55 56โ”‚57โ”‚58 59โ”‚60โ”‚61 62
+      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
+       cg1 c2 63โ”‚64 65โ”‚66โ”‚67 68โ”‚69โ”‚70 71
+           c3 72โ”‚73 74โ”‚75โ”‚76 77โ”‚78โ”‚79 80
diff --git a/rust/pspp/src/output/pivot/testdata/dimension_borders_2.expected b/rust/pspp/src/output/pivot/testdata/dimension_borders_2.expected
new file mode 100644 (file)
index 0000000..5d3ba61
--- /dev/null
@@ -0,0 +1,17 @@
+Dimension Borders 2
+                           b
+                     bg1
+                 b1       b2       b3
+             โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+                  a        a        a
+                  ag1      ag1      ag1
+d      c      a1 a2 a3 a1 a2 a3 a1 a2 a3
+dg1 d1โ”‚c1      0  1  2  3  4  5  6  7  8
+      โ”‚cg1 c2  9 10 11 12 13 14 15 16 17
+      โ”‚    c3 18 19 20 21 22 23 24 25 26
+    d2โ”‚c1     27 28 29 30 31 32 33 34 35
+      โ”‚cg1 c2 36 37 38 39 40 41 42 43 44
+      โ”‚    c3 45 46 47 48 49 50 51 52 53
+d3    โ”‚c1     54 55 56 57 58 59 60 61 62
+      โ”‚cg1 c2 63 64 65 66 67 68 69 70 71
+      โ”‚    c3 72 73 74 75 76 77 78 79 80
diff --git a/rust/pspp/src/output/pivot/testdata/empty_groups.expected b/rust/pspp/src/output/pivot/testdata/empty_groups.expected
new file mode 100644 (file)
index 0000000..46cebd3
--- /dev/null
@@ -0,0 +1,7 @@
+Empty Groups
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚  โ”‚a1โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚b2โ”‚ 0โ”‚ 1โ”‚
+โ”‚b3โ”‚ 2โ”‚ 3โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_subscript.expected b/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_subscript.expected
new file mode 100644 (file)
index 0000000..4878aa0
--- /dev/null
@@ -0,0 +1,12 @@
+Pivot Table with Alphabetic Subscript Footnotes[*]
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚            โ”‚       A[*]       โ”‚
+โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚Corner[*][b]โ”‚  B[b] โ”‚  C[*][b] โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚D[b] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
+โ”‚     F[*][b]โ”‚2.00[b]โ”‚3.00[*][b]โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
+Caption[*]
+*. First footnote
+b. Second footnote
diff --git a/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_superscript.expected b/rust/pspp/src/output/pivot/testdata/footnote_alphabetic_superscript.expected
new file mode 100644 (file)
index 0000000..c989f62
--- /dev/null
@@ -0,0 +1,12 @@
+Pivot Table with Alphabetic Superscript Footnotes[*]
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚            โ”‚       A[*]       โ”‚
+โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚Corner[*][b]โ”‚  B[b] โ”‚  C[*][b] โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚D[b] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
+โ”‚     F[*][b]โ”‚2.00[b]โ”‚3.00[*][b]โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
+Caption[*]
+*. First footnote
+b. Second footnote
diff --git a/rust/pspp/src/output/pivot/testdata/footnote_hidden.expected b/rust/pspp/src/output/pivot/testdata/footnote_hidden.expected
new file mode 100644 (file)
index 0000000..ce76a3f
--- /dev/null
@@ -0,0 +1,11 @@
+Pivot Table with Alphabetic Subscript Footnotes[*]
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚            โ”‚       A[*]       โ”‚
+โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚Corner[*][b]โ”‚  B[b] โ”‚  C[*][b] โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚D[b] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
+โ”‚     F[*][b]โ”‚2.00[b]โ”‚3.00[*][b]โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
+Caption[*]
+b. Second footnote
diff --git a/rust/pspp/src/output/pivot/testdata/footnote_numeric_subscript.expected b/rust/pspp/src/output/pivot/testdata/footnote_numeric_subscript.expected
new file mode 100644 (file)
index 0000000..82c1ccf
--- /dev/null
@@ -0,0 +1,12 @@
+Pivot Table with Numeric Subscript Footnotes[*]
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚            โ”‚       A[*]       โ”‚
+โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚Corner[*][2]โ”‚  B[2] โ”‚  C[*][2] โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚D[2] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
+โ”‚     F[*][2]โ”‚2.00[2]โ”‚3.00[*][2]โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
+Caption[*]
+*. First footnote
+2. Second footnote
diff --git a/rust/pspp/src/output/pivot/testdata/footnote_numeric_superscript.expected b/rust/pspp/src/output/pivot/testdata/footnote_numeric_superscript.expected
new file mode 100644 (file)
index 0000000..c649862
--- /dev/null
@@ -0,0 +1,12 @@
+Pivot Table with Numeric Superscript Footnotes[*]
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚            โ”‚       A[*]       โ”‚
+โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚Corner[*][2]โ”‚  B[2] โ”‚  C[*][2] โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚D[2] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
+โ”‚     F[*][2]โ”‚2.00[2]โ”‚3.00[*][2]โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
+Caption[*]
+*. First footnote
+2. Second footnote
diff --git a/rust/pspp/src/output/pivot/testdata/metadata_entry.expected b/rust/pspp/src/output/pivot/testdata/metadata_entry.expected
new file mode 100644 (file)
index 0000000..9c2f712
--- /dev/null
@@ -0,0 +1,9 @@
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚Name 1              โ”‚Value 1   โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚Subgroup 1 Subname 1โ”‚Subvalue 1โ”‚
+โ”‚           Subname 2โ”‚Subvalue 2โ”‚
+โ”‚           Subname 3โ”‚         3โ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚Name 2              โ”‚Value 2   โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/no_dimension.expected b/rust/pspp/src/output/pivot/testdata/no_dimension.expected
new file mode 100644 (file)
index 0000000..86fbb7e
--- /dev/null
@@ -0,0 +1,3 @@
+No Dimensions
+โ•ญโ•ฎ
+โ•ฐโ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/no_title_or_caption.expected b/rust/pspp/src/output/pivot/testdata/no_title_or_caption.expected
new file mode 100644 (file)
index 0000000..e61aeb1
--- /dev/null
@@ -0,0 +1,7 @@
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚  โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/one_empty_dimension.expected b/rust/pspp/src/output/pivot/testdata/one_empty_dimension.expected
new file mode 100644 (file)
index 0000000..e992c1d
--- /dev/null
@@ -0,0 +1 @@
+One Empty Dimension
diff --git a/rust/pspp/src/output/pivot/testdata/small_numbers.expected b/rust/pspp/src/output/pivot/testdata/small_numbers.expected
new file mode 100644 (file)
index 0000000..22ad04e
--- /dev/null
@@ -0,0 +1,21 @@
+small numbers
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚        โ”‚             result class            โ”‚
+โ”‚        โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚        โ”‚      general      โ”‚     specific    โ”‚
+โ”‚        โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚        โ”‚        sign       โ”‚       sign      โ”‚
+โ”‚        โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚exponentโ”‚ positiveโ”‚ negativeโ”‚positiveโ”‚negativeโ”‚
+โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+โ”‚0       โ”‚     1.00โ”‚     1.00โ”‚   -1.00โ”‚   -1.00โ”‚
+โ”‚-1      โ”‚      .10โ”‚      .10โ”‚    -.10โ”‚    -.10โ”‚
+โ”‚-2      โ”‚      .01โ”‚      .01โ”‚    -.01โ”‚    -.01โ”‚
+โ”‚-3      โ”‚      .00โ”‚      .00โ”‚     .00โ”‚     .00โ”‚
+โ”‚-4      โ”‚      .00โ”‚      .00โ”‚     .00โ”‚     .00โ”‚
+โ”‚-5      โ”‚1.00E-005โ”‚1.00E-005โ”‚     .00โ”‚     .00โ”‚
+โ”‚-6      โ”‚1.00E-006โ”‚1.00E-006โ”‚     .00โ”‚     .00โ”‚
+โ”‚-7      โ”‚1.00E-007โ”‚1.00E-007โ”‚     .00โ”‚     .00โ”‚
+โ”‚-8      โ”‚1.00E-008โ”‚1.00E-008โ”‚     .00โ”‚     .00โ”‚
+โ”‚-9      โ”‚1.00E-009โ”‚1.00E-009โ”‚     .00โ”‚     .00โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
diff --git a/rust/pspp/src/output/pivot/testdata/three_dimensions_two_empty.expected b/rust/pspp/src/output/pivot/testdata/three_dimensions_two_empty.expected
new file mode 100644 (file)
index 0000000..63fd2c2
--- /dev/null
@@ -0,0 +1 @@
+Three Dimensions, Two Empty
diff --git a/rust/pspp/src/output/pivot/testdata/title_and_caption.expected b/rust/pspp/src/output/pivot/testdata/title_and_caption.expected
new file mode 100644 (file)
index 0000000..270bb08
--- /dev/null
@@ -0,0 +1,9 @@
+Title
+โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
+โ”‚  โ”‚a1โ”‚a2โ”‚a3โ”‚
+โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
+โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
+โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
+โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
+โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
+Caption
diff --git a/rust/pspp/src/output/pivot/testdata/two_empty_dimensions.expected b/rust/pspp/src/output/pivot/testdata/two_empty_dimensions.expected
new file mode 100644 (file)
index 0000000..1da8f2b
--- /dev/null
@@ -0,0 +1 @@
+Two Empty Dimensions
index 27c3f1975de018f9d4f6fafa6cfc46ba2b3cde2c..23df42aca4b1edb5ab1e89c500875e3af9124d64 100644 (file)
@@ -19,16 +19,18 @@ use std::{fmt::Display, fs::File, path::Path, sync::Arc};
 use enum_map::EnumMap;
 
 use crate::output::{
-    Details, Item,
-    cairo::{CairoConfig, CairoDriver},
-    driver::Driver,
-    html::HtmlDriver,
+    Text,
+    drivers::{
+        Driver,
+        cairo::{CairoConfig, CairoDriver},
+        html::HtmlDriver,
+        spv::SpvDriver,
+    },
     pivot::{
         Area, Axis2, Border, BorderStyle, Class, Color, Dimension, Footnote,
         FootnoteMarkerPosition, FootnoteMarkerType, Footnotes, Group, HeadingRegion, LabelPosition,
         Look, PivotTable, RowColBorder, Stroke,
     },
-    spv::SpvDriver,
 };
 
 use super::{Axis3, Value};
@@ -65,38 +67,12 @@ fn d1(title: &str, axis: Axis3) -> PivotTable {
 
 #[test]
 fn d1_c() {
-    assert_rendering(
-        "d1_c",
-        &d1("Columns", Axis3::X),
-        "\
-Columns
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚    a   โ”‚
-โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
-โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d1_c", &d1("Columns", Axis3::X));
 }
 
 #[test]
 fn d1_r() {
-    assert_rendering(
-        "d1_r",
-        &d1("Rows", Axis3::Y),
-        "\
-Rows
-โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚a โ”‚ โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”ค
-โ”‚a1โ”‚0โ”‚
-โ”‚a2โ”‚1โ”‚
-โ”‚a3โ”‚2โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
-",
-    );
+    assert_rendering("d1_r", &d1("Rows", Axis3::Y));
 }
 
 fn test_look() -> Look {
@@ -164,48 +140,56 @@ where
 }
 
 #[track_caller]
-pub fn assert_rendering(name: &str, pivot_table: &PivotTable, expected: &str) {
-    assert_lines_eq(
-        expected,
-        format!("{name} expected"),
-        &pivot_table.to_string(),
-        format!("{name} actual"),
-    );
-
-    let item = Arc::new(Item::new(Details::Table(Box::new(pivot_table.clone()))));
+pub fn assert_rendering(name: &str, pivot_table: &PivotTable) {
+    let item = Arc::new(pivot_table.clone().into_item());
     if let Some(dir) = std::env::var_os("PSPP_TEST_HTML_DIR") {
         let writer = File::create(Path::new(&dir).join(name).with_extension("html")).unwrap();
         HtmlDriver::for_writer(writer).write(&item);
     }
 
-    let item = Arc::new(Item::new(Details::Table(Box::new(pivot_table.clone()))));
+    let item = Arc::new(pivot_table.clone().into_item());
     if let Some(dir) = std::env::var_os("PSPP_TEST_PDF_DIR") {
         let config = CairoConfig::new(Path::new(&dir).join(name).with_extension("pdf"));
-        CairoDriver::new(&config).unwrap().write(&item);
+        let mut pdf_driver = CairoDriver::new(&config).unwrap();
+        pdf_driver.write(&Arc::new(
+            Text::new(crate::output::TextType::PageTitle, "page title").into_item(),
+        ));
+        pdf_driver.write(&item);
     }
 
     if let Some(dir) = std::env::var_os("PSPP_TEST_SPV_DIR") {
         let writer = File::create(Path::new(&dir).join(name).with_extension("spv")).unwrap();
-        SpvDriver::for_writer(writer).write(&item);
+        let mut spv_driver = SpvDriver::for_writer(writer);
+        spv_driver.write(&Arc::new(
+            Text::new(crate::output::TextType::PageTitle, "page title").into_item(),
+        ));
+        spv_driver.write(&item);
     }
+
+    let expected_filename = Path::new("src/output/pivot/testdata")
+        .join(name)
+        .with_extension("expected");
+    let actual = pivot_table.to_string();
+    let expected = std::fs::read_to_string(&expected_filename).unwrap();
+    if expected != actual {
+        if std::env::var("PSPP_REFRESH_EXPECTED").is_ok() {
+            std::fs::write(&expected_filename, actual).unwrap();
+            panic!("{}: refreshed output", expected_filename.display());
+        } else {
+            eprintln!("note: rerun with PSPP_REFRESH_EXPECTED=1 to refresh expected output");
+        }
+    }
+    assert_lines_eq(
+        &expected,
+        expected_filename.display(),
+        &actual,
+        format!("actual"),
+    );
 }
 
 #[test]
 fn d2_cc() {
-    assert_rendering(
-        "d2_cc",
-        &d2("Columns", [Axis3::X, Axis3::X], None),
-        "\
-Columns
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚   b1   โ”‚   b2   โ”‚   b3   โ”‚
-โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
-โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 0โ”‚ 1โ”‚ 2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_cc", &d2("Columns", [Axis3::X, Axis3::X], None));
 }
 
 #[test]
@@ -213,45 +197,12 @@ fn d2_cc_with_dim_labels() {
     assert_rendering(
         "d2_cc_with_dim_labels",
         &d2("Columns", [Axis3::X, Axis3::X], Some(LabelPosition::Corner)),
-        "\
-Columns
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚             b            โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚   b1   โ”‚   b2   โ”‚   b3   โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚    a   โ”‚    a   โ”‚    a   โ”‚
-โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
-โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 0โ”‚ 1โ”‚ 2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
     );
 }
 
 #[test]
 fn d2_rr() {
-    assert_rendering(
-        "d2_rr",
-        &d2("Rows", [Axis3::Y, Axis3::Y], None),
-        "\
-Rows
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚b1 a1โ”‚0โ”‚
-โ”‚   a2โ”‚1โ”‚
-โ”‚   a3โ”‚2โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
-โ”‚b2 a1โ”‚3โ”‚
-โ”‚   a2โ”‚4โ”‚
-โ”‚   a3โ”‚5โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
-โ”‚b3 a1โ”‚6โ”‚
-โ”‚   a2โ”‚7โ”‚
-โ”‚   a3โ”‚8โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_rr", &d2("Rows", [Axis3::Y, Axis3::Y], None));
 }
 
 #[test]
@@ -263,24 +214,6 @@ fn d2_rr_with_corner_dim_labels() {
             [Axis3::Y, Axis3::Y],
             Some(LabelPosition::Corner),
         ),
-        "\
-Rows - Corner
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚b  a โ”‚ โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
-โ”‚b1 a1โ”‚0โ”‚
-โ”‚   a2โ”‚1โ”‚
-โ”‚   a3โ”‚2โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
-โ”‚b2 a1โ”‚3โ”‚
-โ”‚   a2โ”‚4โ”‚
-โ”‚   a3โ”‚5โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
-โ”‚b3 a1โ”‚6โ”‚
-โ”‚   a2โ”‚7โ”‚
-โ”‚   a3โ”‚8โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ•ฏ
-",
     );
 }
 
@@ -293,41 +226,12 @@ fn d2_rr_with_nested_dim_labels() {
             [Axis3::Y, Axis3::Y],
             Some(LabelPosition::Nested),
         ),
-        "\
-Rows - Nested
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚b b1 a a1โ”‚0โ”‚
-โ”‚       a2โ”‚1โ”‚
-โ”‚       a3โ”‚2โ”‚
-โ”‚ โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
-โ”‚  b2 a a1โ”‚3โ”‚
-โ”‚       a2โ”‚4โ”‚
-โ”‚       a3โ”‚5โ”‚
-โ”‚ โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”ค
-โ”‚  b3 a a1โ”‚6โ”‚
-โ”‚       a2โ”‚7โ”‚
-โ”‚       a3โ”‚8โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ•ฏ
-",
     );
 }
 
 #[test]
 fn d2_cr() {
-    assert_rendering(
-        "d2_cr",
-        &d2("Column x Row", [Axis3::X, Axis3::Y], None),
-        "\
-Column x Row
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚  โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
-โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_cr", &d2("Column x Row", [Axis3::X, Axis3::Y], None));
 }
 
 #[test]
@@ -339,18 +243,6 @@ fn d2_cr_with_corner_dim_labels() {
             [Axis3::X, Axis3::Y],
             Some(LabelPosition::Corner),
         ),
-        "\
-Column x Row - Corner
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚  โ”‚    a   โ”‚
-โ”‚  โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
-โ”‚b โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
-โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
     );
 }
 
@@ -363,37 +255,12 @@ fn d2_cr_with_nested_dim_labels() {
             [Axis3::X, Axis3::Y],
             Some(LabelPosition::Nested),
         ),
-        "\
-Column x Row - Nested
-โ•ญโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚    โ”‚    a   โ”‚
-โ”‚    โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
-โ”‚    โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚b b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ”‚  b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
-โ”‚  b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
     );
 }
 
 #[test]
 fn d2_rc() {
-    assert_rendering(
-        "d2_rc",
-        &d2("Row x Column", [Axis3::Y, Axis3::X], None),
-        "\
-Row x Column
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚  โ”‚b1โ”‚b2โ”‚b3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚a1โ”‚ 0โ”‚ 3โ”‚ 6โ”‚
-โ”‚a2โ”‚ 1โ”‚ 4โ”‚ 7โ”‚
-โ”‚a3โ”‚ 2โ”‚ 5โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_rc", &d2("Row x Column", [Axis3::Y, Axis3::X], None));
 }
 
 #[test]
@@ -405,18 +272,6 @@ fn d2_rc_with_corner_dim_labels() {
             [Axis3::Y, Axis3::X],
             Some(LabelPosition::Corner),
         ),
-        "\
-Row x Column - Corner
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚  โ”‚    b   โ”‚
-โ”‚  โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
-โ”‚a โ”‚b1โ”‚b2โ”‚b3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚a1โ”‚ 0โ”‚ 3โ”‚ 6โ”‚
-โ”‚a2โ”‚ 1โ”‚ 4โ”‚ 7โ”‚
-โ”‚a3โ”‚ 2โ”‚ 5โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
     );
 }
 
@@ -429,155 +284,92 @@ fn d2_rc_with_nested_dim_labels() {
             [Axis3::Y, Axis3::X],
             Some(LabelPosition::Nested),
         ),
-        "\
-Row x Column - Nested
-โ•ญโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚    โ”‚    b   โ”‚
-โ”‚    โ”œโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ค
-โ”‚    โ”‚b1โ”‚b2โ”‚b3โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚a a1โ”‚ 0โ”‚ 3โ”‚ 6โ”‚
-โ”‚  a2โ”‚ 1โ”‚ 4โ”‚ 7โ”‚
-โ”‚  a3โ”‚ 2โ”‚ 5โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
     );
 }
 
 #[test]
 fn d2_cl() {
     let pivot_table = d2("Column x b1", [Axis3::X, Axis3::Z], None);
-    assert_rendering(
-        "d2_cl-layer0",
-        &pivot_table,
-        "\
-Column x b1
-b1
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_cl-layer0", &pivot_table);
 
     let pivot_table = pivot_table
         .with_layer(&[1])
         .with_title(Value::new_text("Column x b2"));
-    assert_rendering(
-        "d2_cl-layer1",
-        &pivot_table,
-        "\
-Column x b2
-b2
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_cl-layer1", &pivot_table);
 
     let pivot_table = pivot_table
         .with_all_layers()
         .with_title(Value::new_text("Column (All Layers)"));
-    assert_rendering(
-        "d2_cl-all_layers",
-        &pivot_table,
-        "\
-Column (All Layers)
-b1
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-
-Column (All Layers)
-b2
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-
-Column (All Layers)
-b3
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_cl-all_layers", &pivot_table);
 }
 
 #[test]
 fn d2_rl() {
     let pivot_table = d2("Row x b1", [Axis3::Y, Axis3::Z], None);
-    assert_rendering(
-        "d2_rl-layer0",
-        &pivot_table,
-        "\
-Row x b1
-b1
-โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚a1โ”‚0โ”‚
-โ”‚a2โ”‚1โ”‚
-โ”‚a3โ”‚2โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_rl-layer0", &pivot_table);
 
     let pivot_table = pivot_table
         .with_layer(&[1])
         .with_title(Value::new_text("Row x b2"));
-    assert_rendering(
-        "d2_rl-layer1",
-        &pivot_table,
-        "\
-Row x b2
-b2
-โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚a1โ”‚3โ”‚
-โ”‚a2โ”‚4โ”‚
-โ”‚a3โ”‚5โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
-",
-    );
+    assert_rendering("d2_rl-layer1", &pivot_table);
 
     let pivot_table = pivot_table
         .with_all_layers()
         .with_title(Value::new_text("Row (All Layers)"));
-    assert_rendering(
-        "d2_rl-all_layers",
-        &pivot_table,
-        "\
-Row (All Layers)
-b1
-โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚a1โ”‚0โ”‚
-โ”‚a2โ”‚1โ”‚
-โ”‚a3โ”‚2โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
-
-Row (All Layers)
-b2
-โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚a1โ”‚3โ”‚
-โ”‚a2โ”‚4โ”‚
-โ”‚a3โ”‚5โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
-
-Row (All Layers)
-b3
-โ•ญโ”€โ”€โ”ฌโ”€โ•ฎ
-โ”‚a1โ”‚6โ”‚
-โ”‚a2โ”‚7โ”‚
-โ”‚a3โ”‚8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ•ฏ
-",
+    assert_rendering("d2_rl-all_layers", &pivot_table);
+}
+
+fn d2m(title: &str, axes: [Axis3; 2], dimension_labels: Option<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]
@@ -613,111 +405,33 @@ fn d3() {
             }
         }
     }
-    assert_rendering(
-        "d3-layer0_0",
-        &pt,
-        "\
-Column x b1 x a1
-b1
-a1
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚c1โ”‚c2โ”‚c3โ”‚c4โ”‚c5โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 0โ”‚12โ”‚24โ”‚36โ”‚48โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d3-layer0_0", &pt);
 
     let pt = pt.with_layer(&[0, 1]).with_title("Column x b2 x a1");
-    assert_rendering(
-        "d3-layer0_1",
-        &pt,
-        "\
-Column x b2 x a1
-b2
-a1
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚c1โ”‚c2โ”‚c3โ”‚c4โ”‚c5โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 3โ”‚15โ”‚27โ”‚39โ”‚51โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d3-layer0_1", &pt);
 
     let pt = pt.with_layer(&[1, 2]).with_title("Column x b3 x a2");
-    assert_rendering(
-        "d3-layer1_2",
-        &pt,
-        "\
-Column x b3 x a2
-b3
-a2
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚c1โ”‚c2โ”‚c3โ”‚c4โ”‚c5โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚ 7โ”‚19โ”‚31โ”‚43โ”‚55โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("d3-layer1_2", &pt);
 }
 
 #[test]
 fn title_and_caption() {
     let pivot_table =
         d2("Title", [Axis3::X, Axis3::Y], None).with_caption(Value::new_text("Caption"));
-    assert_rendering(
-        "title_and_caption",
-        &pivot_table,
-        "\
-Title
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚  โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
-โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-Caption
-",
-    );
+    assert_rendering("title_and_caption", &pivot_table);
 
     let pivot_table = pivot_table.with_show_title(false);
-    assert_rendering(
-        "caption",
-        &pivot_table,
-        "\
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚  โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
-โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-Caption
-",
-    );
+    assert_rendering("caption", &pivot_table);
 
     let pivot_table = pivot_table.with_show_caption(false);
-    assert_rendering(
-        "no_title_or_caption",
-        &pivot_table,
-        "\
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚  โ”‚a1โ”‚a2โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚b1โ”‚ 0โ”‚ 1โ”‚ 2โ”‚
-โ”‚b2โ”‚ 3โ”‚ 4โ”‚ 5โ”‚
-โ”‚b3โ”‚ 6โ”‚ 7โ”‚ 8โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("no_title_or_caption", &pivot_table);
 }
 
 fn footnote_table(show_f0: bool) -> PivotTable {
     let mut footnotes = Footnotes::new();
     let f0 = footnotes.push(
         Footnote::new("First footnote")
-            .with_marker("*")
+            .with_some_marker("*")
             .with_show(show_f0),
     );
     let f1 = footnotes.push(Footnote::new("Second footnote"));
@@ -764,24 +478,7 @@ fn footnote_table(show_f0: bool) -> PivotTable {
 
 #[test]
 fn footnote_alphabetic_subscript() {
-    assert_rendering(
-        "footnote_alphabetic_subscript",
-        &footnote_table(true),
-        "\
-Pivot Table with Alphabetic Subscript Footnotes[*]
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚            โ”‚       A[*]       โ”‚
-โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚Corner[*][b]โ”‚  B[b] โ”‚  C[*][b] โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚D[b] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
-โ”‚     F[*][b]โ”‚2.00[b]โ”‚3.00[*][b]โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
-Caption[*]
-*. First footnote
-b. Second footnote
-",
-    );
+    assert_rendering("footnote_alphabetic_subscript", &footnote_table(true));
 }
 
 #[test]
@@ -792,24 +489,7 @@ fn footnote_alphabetic_superscript() {
         Value::new_text("Pivot Table with Alphabetic Superscript Footnotes").with_footnote(&f0),
     );
     pt.look_mut().footnote_marker_position = FootnoteMarkerPosition::Superscript;
-    assert_rendering(
-        "footnote_alphabetic_superscript",
-        &pt,
-        "\
-Pivot Table with Alphabetic Superscript Footnotes[*]
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚            โ”‚       A[*]       โ”‚
-โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚Corner[*][b]โ”‚  B[b] โ”‚  C[*][b] โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚D[b] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
-โ”‚     F[*][b]โ”‚2.00[b]โ”‚3.00[*][b]โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
-Caption[*]
-*. First footnote
-b. Second footnote
-",
-    );
+    assert_rendering("footnote_alphabetic_superscript", &pt);
 }
 
 #[test]
@@ -820,24 +500,7 @@ fn footnote_numeric_subscript() {
         Value::new_text("Pivot Table with Numeric Subscript Footnotes").with_footnote(&f0),
     );
     pt.look_mut().footnote_marker_type = FootnoteMarkerType::Numeric;
-    assert_rendering(
-        "footnote_numeric_subscript",
-        &pt,
-        "\
-Pivot Table with Numeric Subscript Footnotes[*]
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚            โ”‚       A[*]       โ”‚
-โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚Corner[*][2]โ”‚  B[2] โ”‚  C[*][2] โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚D[2] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
-โ”‚     F[*][2]โ”‚2.00[2]โ”‚3.00[*][2]โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
-Caption[*]
-*. First footnote
-2. Second footnote
-",
-    );
+    assert_rendering("footnote_numeric_subscript", &pt);
 }
 
 #[test]
@@ -849,45 +512,12 @@ fn footnote_numeric_superscript() {
     );
     pt.look_mut().footnote_marker_type = FootnoteMarkerType::Numeric;
     pt.look_mut().footnote_marker_position = FootnoteMarkerPosition::Superscript;
-    assert_rendering(
-        "footnote_numeric_superscript",
-        &pt,
-        "\
-Pivot Table with Numeric Superscript Footnotes[*]
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚            โ”‚       A[*]       โ”‚
-โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚Corner[*][2]โ”‚  B[2] โ”‚  C[*][2] โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚D[2] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
-โ”‚     F[*][2]โ”‚2.00[2]โ”‚3.00[*][2]โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
-Caption[*]
-*. First footnote
-2. Second footnote
-",
-    );
+    assert_rendering("footnote_numeric_superscript", &pt);
 }
 
 #[test]
 fn footnote_hidden() {
-    assert_rendering(
-        "footnote_hidden",
-        &footnote_table(false),
-        "\
-Pivot Table with Alphabetic Subscript Footnotes[*]
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚            โ”‚       A[*]       โ”‚
-โ”‚            โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚Corner[*][b]โ”‚  B[b] โ”‚  C[*][b] โ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚D[b] E[*]   โ”‚    .00โ”‚   1.00[*]โ”‚
-โ”‚     F[*][b]โ”‚2.00[b]โ”‚3.00[*][b]โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
-Caption[*]
-b. Second footnote
-",
-    );
+    assert_rendering("footnote_hidden", &footnote_table(false));
 }
 
 #[test]
@@ -895,14 +525,7 @@ fn no_dimension() {
     let pivot_table = PivotTable::new([])
         .with_title("No Dimensions")
         .with_look(Arc::new(test_look()));
-    assert_rendering(
-        "no_dimension",
-        &pivot_table,
-        "No Dimensions
-โ•ญโ•ฎ
-โ•ฐโ•ฏ
-",
-    );
+    assert_rendering("no_dimension", &pivot_table);
 }
 
 #[test]
@@ -913,18 +536,14 @@ fn empty_dimensions() {
     let pivot_table = PivotTable::new([d1])
         .with_title("One Empty Dimension")
         .with_look(look.clone());
-    assert_rendering("one_empty_dimension", &pivot_table, "One Empty Dimension\n");
+    assert_rendering("one_empty_dimension", &pivot_table);
 
     let d1 = (Axis3::X, Dimension::new(Group::new("a")));
     let d2 = (Axis3::X, Dimension::new(Group::new("b").with_label_shown()));
     let pivot_table = PivotTable::new([d1, d2])
         .with_title("Two Empty Dimensions")
         .with_look(look.clone());
-    assert_rendering(
-        "two_empty_dimensions",
-        &pivot_table,
-        "Two Empty Dimensions\n",
-    );
+    assert_rendering("two_empty_dimensions", &pivot_table);
 
     let d1 = (Axis3::X, Dimension::new(Group::new("a")));
     let d2 = (Axis3::X, Dimension::new(Group::new("b").with_label_shown()));
@@ -935,11 +554,7 @@ fn empty_dimensions() {
     let pivot_table = PivotTable::new([d1, d2, d3])
         .with_title("Three Dimensions, Two Empty")
         .with_look(look.clone());
-    assert_rendering(
-        "three_dimensions_two_empty",
-        &pivot_table,
-        "Three Dimensions, Two Empty\n",
-    );
+    assert_rendering("three_dimensions_two_empty", &pivot_table);
 }
 
 #[test]
@@ -963,19 +578,7 @@ fn empty_groups() {
         }
     }
     let pivot_table = pt.with_look(Arc::new(test_look().with_omit_empty(false)));
-    assert_rendering(
-        "empty_groups",
-        &pivot_table,
-        "\
-Empty Groups
-โ•ญโ”€โ”€โ”ฌโ”€โ”€โ”ฌโ”€โ”€โ•ฎ
-โ”‚  โ”‚a1โ”‚a3โ”‚
-โ”œโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ค
-โ”‚b2โ”‚ 0โ”‚ 1โ”‚
-โ”‚b3โ”‚ 2โ”‚ 3โ”‚
-โ•ฐโ”€โ”€โ”ดโ”€โ”€โ”ดโ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("empty_groups", &pivot_table);
 }
 
 fn d4(
@@ -1047,33 +650,7 @@ fn dimension_borders_1() {
         }),
         true,
     );
-    assert_rendering(
-        "dimension_borders_1",
-        &pivot_table,
-        "\
-Dimension Borders 1
-                           b
-                     bg1       โ”‚
-                 b1   โ”‚   b2   โ”‚   b3
-                  a   โ”‚    a   โ”‚    a
-                โ”‚ ag1 โ”‚  โ”‚ ag1 โ”‚  โ”‚ ag1
-d      c      a1โ”‚a2 a3โ”‚a1โ”‚a2 a3โ”‚a1โ”‚a2 a3
-dg1 d1     c1  0โ”‚ 1  2โ”‚ 3โ”‚ 4  5โ”‚ 6โ”‚ 7  8
-      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
-       cg1 c2  9โ”‚10 11โ”‚12โ”‚13 14โ”‚15โ”‚16 17
-           c3 18โ”‚19 20โ”‚21โ”‚22 23โ”‚24โ”‚25 26
-   โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
-    d2     c1 27โ”‚28 29โ”‚30โ”‚31 32โ”‚33โ”‚34 35
-      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
-       cg1 c2 36โ”‚37 38โ”‚39โ”‚40 41โ”‚42โ”‚43 44
-           c3 45โ”‚46 47โ”‚48โ”‚49 50โ”‚51โ”‚52 53
-โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
-    d3     c1 54โ”‚55 56โ”‚57โ”‚58 59โ”‚60โ”‚61 62
-      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€
-       cg1 c2 63โ”‚64 65โ”‚66โ”‚67 68โ”‚69โ”‚70 71
-           c3 72โ”‚73 74โ”‚75โ”‚76 77โ”‚78โ”‚79 80
-",
-    );
+    assert_rendering("dimension_borders_1", &pivot_table);
 }
 
 #[test]
@@ -1087,29 +664,7 @@ fn dimension_borders_2() {
         }),
         true,
     );
-    assert_rendering(
-        "dimension_borders_2",
-        &pivot_table,
-        "\
-Dimension Borders 2
-                           b
-                     bg1
-                 b1       b2       b3
-             โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
-                  a        a        a
-                  ag1      ag1      ag1
-d      c      a1 a2 a3 a1 a2 a3 a1 a2 a3
-dg1 d1โ”‚    c1  0  1  2  3  4  5  6  7  8
-      โ”‚cg1 c2  9 10 11 12 13 14 15 16 17
-      โ”‚    c3 18 19 20 21 22 23 24 25 26
-    d2โ”‚    c1 27 28 29 30 31 32 33 34 35
-      โ”‚cg1 c2 36 37 38 39 40 41 42 43 44
-      โ”‚    c3 45 46 47 48 49 50 51 52 53
-    d3โ”‚    c1 54 55 56 57 58 59 60 61 62
-      โ”‚cg1 c2 63 64 65 66 67 68 69 70 71
-      โ”‚    c3 72 73 74 75 76 77 78 79 80
-",
-    );
+    assert_rendering("dimension_borders_2", &pivot_table);
 }
 
 #[test]
@@ -1123,36 +678,7 @@ fn category_borders_1() {
         }),
         true,
     );
-    assert_rendering(
-        "category_borders_1",
-        &pivot_table,
-        "\
-Category Borders 1
-                           b
-                     bg1       โ”Š
-                 b1   โ”Š   b2   โ”Š   b3
-                  a   โ”Š    a   โ”Š    a
-                โ”Š ag1 โ”Š  โ”Š ag1 โ”Š  โ”Š ag1
-d      c      a1โ”Ša2โ”Ša3โ”Ša1โ”Ša2โ”Ša3โ”Ša1โ”Ša2โ”Ša3
-dg1 d1     c1  0โ”Š 1โ”Š 2โ”Š 3โ”Š 4โ”Š 5โ”Š 6โ”Š 7โ”Š 8
-      โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
-       cg1 c2  9โ”Š10โ”Š11โ”Š12โ”Š13โ”Š14โ”Š15โ”Š16โ”Š17
-          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
-           c3 18โ”Š19โ”Š20โ”Š21โ”Š22โ”Š23โ”Š24โ”Š25โ”Š26
-   โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
-    d2     c1 27โ”Š28โ”Š29โ”Š30โ”Š31โ”Š32โ”Š33โ”Š34โ”Š35
-      โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
-       cg1 c2 36โ”Š37โ”Š38โ”Š39โ”Š40โ”Š41โ”Š42โ”Š43โ”Š44
-          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
-           c3 45โ”Š46โ”Š47โ”Š48โ”Š49โ”Š50โ”Š51โ”Š52โ”Š53
-โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
-    d3     c1 54โ”Š55โ”Š56โ”Š57โ”Š58โ”Š59โ”Š60โ”Š61โ”Š62
-      โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
-       cg1 c2 63โ”Š64โ”Š65โ”Š66โ”Š67โ”Š68โ”Š69โ”Š70โ”Š71
-          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ+โ•Œโ•Œ
-           c3 72โ”Š73โ”Š74โ”Š75โ”Š76โ”Š77โ”Š78โ”Š79โ”Š80
-",
-    );
+    assert_rendering("category_borders_1", &pivot_table);
 }
 
 #[test]
@@ -1166,33 +692,7 @@ fn category_borders_2() {
         }),
         true,
     );
-    assert_rendering(
-        "category_borders_2",
-        &pivot_table,
-        "\
-Category Borders 2
-                           b
-             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-                     bg1
-             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-                 b1       b2       b3
-             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-                  a        a        a
-             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-                  ag1      ag1      ag1
-                โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ  โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ  โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-d      c      a1 a2 a3 a1 a2 a3 a1 a2 a3
-dg1โ”Šd1โ”Š    c1  0  1  2  3  4  5  6  7  8
-   โ”Š  โ”Šcg1โ”Šc2  9 10 11 12 13 14 15 16 17
-   โ”Š  โ”Š   โ”Šc3 18 19 20 21 22 23 24 25 26
-   โ”Šd2โ”Š    c1 27 28 29 30 31 32 33 34 35
-   โ”Š  โ”Šcg1โ”Šc2 36 37 38 39 40 41 42 43 44
-   โ”Š  โ”Š   โ”Šc3 45 46 47 48 49 50 51 52 53
-    d3โ”Š    c1 54 55 56 57 58 59 60 61 62
-      โ”Šcg1โ”Šc2 63 64 65 66 67 68 69 70 71
-      โ”Š   โ”Šc3 72 73 74 75 76 77 78 79 80
-",
-    );
+    assert_rendering("category_borders_2", &pivot_table);
 }
 
 #[test]
@@ -1208,36 +708,7 @@ fn category_and_dimension_borders_1() {
         }),
         true,
     );
-    assert_rendering(
-        "category_and_dimension_borders_1",
-        &pivot_table,
-        "\
-Category and Dimension Borders 1
-                           b
-                     bg1       โ”‚
-                 b1   โ”‚   b2   โ”‚   b3
-                  a   โ”‚    a   โ”‚    a
-                โ”‚ ag1 โ”‚  โ”‚ ag1 โ”‚  โ”‚ ag1
-d      c      a1โ”‚a2โ”Ša3โ”‚a1โ”‚a2โ”Ša3โ”‚a1โ”‚a2โ”Ša3
-dg1 d1     c1  0โ”‚ 1โ”Š 2โ”‚ 3โ”‚ 4โ”Š 5โ”‚ 6โ”‚ 7โ”Š 8
-      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-       cg1 c2  9โ”‚10โ”Š11โ”‚12โ”‚13โ”Š14โ”‚15โ”‚16โ”Š17
-          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
-           c3 18โ”‚19โ”Š20โ”‚21โ”‚22โ”Š23โ”‚24โ”‚25โ”Š26
-   โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-    d2     c1 27โ”‚28โ”Š29โ”‚30โ”‚31โ”Š32โ”‚33โ”‚34โ”Š35
-      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-       cg1 c2 36โ”‚37โ”Š38โ”‚39โ”‚40โ”Š41โ”‚42โ”‚43โ”Š44
-          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
-           c3 45โ”‚46โ”Š47โ”‚48โ”‚49โ”Š50โ”‚51โ”‚52โ”Š53
-โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-    d3     c1 54โ”‚55โ”Š56โ”‚57โ”‚58โ”Š59โ”‚60โ”‚61โ”Š62
-      โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-       cg1 c2 63โ”‚64โ”Š65โ”‚66โ”‚67โ”Š68โ”‚69โ”‚70โ”Š71
-          โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
-           c3 72โ”‚73โ”Š74โ”‚75โ”‚76โ”Š77โ”‚78โ”‚79โ”Š80
-",
-    );
+    assert_rendering("category_and_dimension_borders_1", &pivot_table);
 }
 
 #[test]
@@ -1253,33 +724,7 @@ fn category_and_dimension_borders_2() {
         }),
         true,
     );
-    assert_rendering(
-        "category_and_dimension_borders_2",
-        &pivot_table,
-        "\
-Category and Dimension Borders 2
-                           b
-             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-                     bg1
-             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-                 b1       b2       b3
-             โ•ถโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
-                  a        a        a
-             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-                  ag1      ag1      ag1
-                โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ  โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ  โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-d      c      a1 a2 a3 a1 a2 a3 a1 a2 a3
-dg1โ”Šd1โ”‚    c1  0  1  2  3  4  5  6  7  8
-   โ”Š  โ”‚cg1โ”Šc2  9 10 11 12 13 14 15 16 17
-   โ”Š  โ”‚   โ”Šc3 18 19 20 21 22 23 24 25 26
-   โ”Šd2โ”‚    c1 27 28 29 30 31 32 33 34 35
-   โ”Š  โ”‚cg1โ”Šc2 36 37 38 39 40 41 42 43 44
-   โ”Š  โ”‚   โ”Šc3 45 46 47 48 49 50 51 52 53
-    d3โ”‚    c1 54 55 56 57 58 59 60 61 62
-      โ”‚cg1โ”Šc2 63 64 65 66 67 68 69 70 71
-      โ”‚   โ”Šc3 72 73 74 75 76 77 78 79 80
-",
-    );
+    assert_rendering("category_and_dimension_borders_2", &pivot_table);
 }
 
 const SOLID_BLUE: BorderStyle = BorderStyle {
@@ -1303,37 +748,7 @@ fn category_and_dimension_borders_3() {
         }),
         false,
     );
-    assert_rendering(
-        "category_and_dimension_borders_3",
-        &pivot_table,
-        "\
-Category and Dimension Borders 3
-                     bg1       โ”‚
-             โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ฌโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ค
-                 b1   โ”‚   b2   โ”‚   b3
-             โ•ถโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€
-                โ”‚ ag1 โ”‚  โ”‚ ag1 โ”‚  โ”‚ ag1
-                โ”œโ•Œโ•Œโ”ฌโ•Œโ•Œโ”ค  โ”œโ•Œโ•Œโ”ฌโ•Œโ•Œโ”ค  โ”œโ•Œโ•Œโ”ฌโ•Œโ•Œ
-              a1โ”‚a2โ”Ša3โ”‚a1โ”‚a2โ”Ša3โ”‚a1โ”‚a2โ”Ša3
-dg1โ”Šd1โ”‚    c1  0โ”‚ 1โ”Š 2โ”‚ 3โ”‚ 4โ”Š 5โ”‚ 6โ”‚ 7โ”Š 8
-   โ”Š  โ”œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-   โ”Š  โ”‚cg1โ”Šc2  9โ”‚10โ”Š11โ”‚12โ”‚13โ”Š14โ”‚15โ”‚16โ”Š17
-   โ”Š  โ”‚   โ”œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
-   โ”Š  โ”‚   โ”Šc3 18โ”‚19โ”Š20โ”‚21โ”‚22โ”Š23โ”‚24โ”‚25โ”Š26
-   โ”œโ”€โ”€โ”ผโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-   โ”Šd2โ”‚    c1 27โ”‚28โ”Š29โ”‚30โ”‚31โ”Š32โ”‚33โ”‚34โ”Š35
-   โ”Š  โ”œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-   โ”Š  โ”‚cg1โ”Šc2 36โ”‚37โ”Š38โ”‚39โ”‚40โ”Š41โ”‚42โ”‚43โ”Š44
-   โ”Š  โ”‚   โ”œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
-   โ”Š  โ”‚   โ”Šc3 45โ”‚46โ”Š47โ”‚48โ”‚49โ”Š50โ”‚51โ”‚52โ”Š53
-โ”€โ”€โ”€โ”ดโ”€โ”€โ”ผโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-    d3โ”‚    c1 54โ”‚55โ”Š56โ”‚57โ”‚58โ”Š59โ”‚60โ”‚61โ”Š62
-      โ”œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€โ”ผโ”€โ”€
-      โ”‚cg1โ”Šc2 63โ”‚64โ”Š65โ”‚66โ”‚67โ”Š68โ”‚69โ”‚70โ”Š71
-      โ”‚   โ”œโ•Œโ•Œโ•Œโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œโ”ผโ•Œโ•Œโ”ผโ•Œโ•Œ+โ•Œโ•Œ
-      โ”‚   โ”Šc3 72โ”‚73โ”Š74โ”‚75โ”‚76โ”Š77โ”‚78โ”‚79โ”Š80
-",
-    );
+    assert_rendering("category_and_dimension_borders_3", &pivot_table);
 }
 
 #[test]
@@ -1415,31 +830,5 @@ fn small_numbers() {
     pt.insert_number(&[8, 1, 1], Some(-0.00000001), Class::Residual);
     pt.insert_number(&[9, 1, 1], Some(-0.000000001), Class::Residual);
     let pivot_table = pt.with_look(Arc::new(test_look()));
-    assert_rendering(
-        "small_numbers",
-        &pivot_table,
-        "\
-small numbers
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚        โ”‚             result class            โ”‚
-โ”‚        โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚        โ”‚      general      โ”‚     specific    โ”‚
-โ”‚        โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚        โ”‚        sign       โ”‚       sign      โ”‚
-โ”‚        โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚exponentโ”‚ positiveโ”‚ negativeโ”‚positiveโ”‚negativeโ”‚
-โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
-โ”‚0       โ”‚     1.00โ”‚     1.00โ”‚   -1.00โ”‚   -1.00โ”‚
-โ”‚-1      โ”‚      .10โ”‚      .10โ”‚    -.10โ”‚    -.10โ”‚
-โ”‚-2      โ”‚      .01โ”‚      .01โ”‚    -.01โ”‚    -.01โ”‚
-โ”‚-3      โ”‚      .00โ”‚      .00โ”‚     .00โ”‚     .00โ”‚
-โ”‚-4      โ”‚      .00โ”‚      .00โ”‚     .00โ”‚     .00โ”‚
-โ”‚-5      โ”‚1.00E-005โ”‚1.00E-005โ”‚     .00โ”‚     .00โ”‚
-โ”‚-6      โ”‚1.00E-006โ”‚1.00E-006โ”‚     .00โ”‚     .00โ”‚
-โ”‚-7      โ”‚1.00E-007โ”‚1.00E-007โ”‚     .00โ”‚     .00โ”‚
-โ”‚-8      โ”‚1.00E-008โ”‚1.00E-008โ”‚     .00โ”‚     .00โ”‚
-โ”‚-9      โ”‚1.00E-009โ”‚1.00E-009โ”‚     .00โ”‚     .00โ”‚
-โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
-",
-    );
+    assert_rendering("small_numbers", &pivot_table);
 }
index e8577848091e6afafc0d18e08f6a6e5666c526e6..b83526186ac23875563f3b448e2b8c8843738ccb 100644 (file)
@@ -103,7 +103,7 @@ impl From<TableLook> for Look {
                     Area::Corner => (&look.pv_text_style.corner).into(),
                     Area::Labels(Axis2::X) => (&look.pv_text_style.column_labels).into(),
                     Area::Labels(Axis2::Y) => (&look.pv_text_style.row_labels).into(),
-                    Area::Data => (&look.pv_text_style.data).into(),
+                    Area::Data(_) => (&look.pv_text_style.data).into(),
                     Area::Layers => (&look.pv_text_style.layers).into(),
             },
                 borders: enum_map!  {
@@ -218,6 +218,7 @@ enum Separator {
     None,
     #[br(magic = 1u16)]
     Some {
+        #[br(parse_with(parse_tlo_color))]
         color: Color,
         style: u16,
         width: u16,
@@ -249,17 +250,10 @@ impl From<Separator> for BorderStyle {
     }
 }
 
-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]
@@ -277,8 +271,9 @@ struct PvCellStyle {
 #[br(little)]
 #[derive(Debug)]
 struct AreaColor {
-    #[br(magic = b"\0\x01\0")]
+    #[br(magic(b"\0\x01\0"), parse_with(parse_tlo_color))]
     color10: Color,
+    #[br(parse_with(parse_tlo_color))]
     color0: Color,
     shading: u8,
     #[br(temp, magic = 0u8)]
@@ -290,18 +285,8 @@ impl From<AreaColor> for Color {
         match area_color.shading {
             0 => area_color.color0,
             x1 @ 1..=9 => {
-                let Color {
-                    r: r0,
-                    g: g0,
-                    b: b0,
-                    ..
-                } = area_color.color0;
-                let Color {
-                    r: r1,
-                    g: g1,
-                    b: b1,
-                    ..
-                } = area_color.color10;
+                let (r0, g0, b0) = area_color.color0.into_rgb();
+                let (r1, g1, b1) = area_color.color10.into_rgb();
                 fn mix(c0: u32, c1: u32, x1: u32) -> u8 {
                     let x0 = 10 - x1;
                     ((c0 * x0 + c1 * x1) / 10) as u8
@@ -386,13 +371,9 @@ impl super::AreaStyle {
                 bold: style.weight > 400,
                 italic: style.italic,
                 underline: style.underline,
-                markup: false,
                 font: style.font_name.string.clone(),
-                fg: {
-                    let fg = style.text_color;
-                    [fg, fg]
-                },
-                bg: [bg, bg],
+                fg: style.text_color,
+                bg,
                 size: -style.font_size * 3 / 4,
             },
         }
@@ -427,6 +408,7 @@ struct AreaStyle {
     rtf_charset_number: u32,
     x: u8,
     font_name: U8String,
+    #[br(parse_with(parse_tlo_color))]
     text_color: Color,
     #[br(temp, magic = 0u16)]
     _tmp: (),
@@ -490,7 +472,7 @@ impl Default for V2Styles {
 }
 
 #[binrw::parser(reader, endian)]
-fn parse_bool() -> BinResult<bool> {
+pub fn parse_bool() -> BinResult<bool> {
     let byte = <u8>::read_options(reader, endian, ())?;
     match byte {
         0 => Ok(false),
index 61ac68af473169cd6342e1818501fa3d16aec751..e8f900d97140f9b67c20e71967c374ff3922c8c7 100644 (file)
@@ -151,7 +151,6 @@ pub trait Device {
     fn draw_cell(
         &mut self,
         draw_cell: &DrawCell,
-        alternate_row: bool,
         bb: Rect2,
         valign_offset: usize,
         spill: EnumMap<Axis2, [usize; 2]>,
@@ -931,8 +930,6 @@ impl Page {
         usize::saturating_sub(bb[Y].len(), height)
     }
     fn draw_cell(&self, device: &mut dyn Device, ofs: Coord2, cell: &RenderCell) {
-        use Axis2::*;
-
         let mut bb = Rect2::from_fn(|a| {
             self.cp[a][cell.rect[a].start * 2 + 1]..self.cp[a][cell.rect[a].end * 2]
         })
@@ -969,17 +966,13 @@ impl Page {
             bb.clone()
         };
 
-        // Header rows are never alternate rows.
-        let alternate_row =
-            usize::checked_sub(cell.rect[Y].start, self.h[Y]).is_some_and(|row| row % 2 == 1);
-
         let draw_cell = DrawCell::new(cell.content.inner(), &self.table);
-        let valign_offset = match draw_cell.style.cell_style.vert_align {
+        let valign_offset = match draw_cell.cell_style.vert_align {
             VertAlign::Top => 0,
             VertAlign::Middle => self.extra_height(device, &bb, &draw_cell) / 2,
             VertAlign::Bottom => self.extra_height(device, &bb, &draw_cell),
         };
-        device.draw_cell(&draw_cell, alternate_row, bb, valign_offset, spill, &clip)
+        device.draw_cell(&draw_cell, bb, valign_offset, spill, &clip)
     }
 }
 
@@ -1370,10 +1363,10 @@ impl Pager {
 
         // Figure out the width of the body of the table. Use this to determine
         // the base scale.
-        let body_page = Page::new(Arc::new(output.body), device, 0, &pivot_table.look);
+        let body_page = Page::new(Arc::new(output.body), device, 0, &pivot_table.style.look);
         let body_width = body_page.width(Axis2::X);
         let mut scale = if body_width > device.params().size[Axis2::X]
-            && pivot_table.look.shrink_to_fit[Axis2::X]
+            && pivot_table.style.look.shrink_to_fit[Axis2::X]
             && device.params().can_scale
         {
             device.params().size[Axis2::X] as f64 / body_width as f64
@@ -1387,7 +1380,7 @@ impl Pager {
                 Arc::new(table),
                 device,
                 body_width,
-                &pivot_table.look,
+                &pivot_table.style.look,
             )));
         }
         pages.push(Arc::new(body_page));
@@ -1396,7 +1389,7 @@ impl Pager {
                 Arc::new(table),
                 device,
                 0,
-                &pivot_table.look,
+                &pivot_table.style.look,
             )));
         }
         pages.reverse();
@@ -1410,7 +1403,7 @@ impl Pager {
         // shrinking the table vertically more than the scale would imply.
         // Shrinking only as much as necessary would require an iterative
         // search.
-        if pivot_table.look.shrink_to_fit[Axis2::Y] && device.params().can_scale {
+        if pivot_table.style.look.shrink_to_fit[Axis2::Y] && device.params().can_scale {
             let total_height = pages
                 .iter()
                 .map(|page: &Arc<Page>| page.total_size(Axis2::Y))
index 9f7290f1b3f6b922b44482d231b8de76c48af19d..d87c608669111a20791faf603081ada667f8a41c 100644 (file)
 // 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(&paragraph.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!()
 }
diff --git a/rust/pspp/src/output/spv/css.rs b/rust/pspp/src/output/spv/css.rs
new file mode 100644 (file)
index 0000000..17fbe92
--- /dev/null
@@ -0,0 +1,377 @@
+use std::{
+    borrow::Cow,
+    fmt::{Display, Write},
+    mem::discriminant,
+    ops::Not,
+};
+
+use itertools::Itertools;
+
+use crate::output::{
+    pivot::{FontStyle, HorzAlign},
+    spv::html::Style,
+};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum Token<'a> {
+    Id(Cow<'a, str>),
+    LeftCurly,
+    RightCurly,
+    Colon,
+    Semicolon,
+    Error,
+}
+
+struct Lexer<'a>(&'a str);
+
+impl<'a> Iterator for Lexer<'a> {
+    type Item = Token<'a>;
+
+    fn next(&mut self) -> Option<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())
+        );
+    }
+}
diff --git a/rust/pspp/src/output/spv/html.rs b/rust/pspp/src/output/spv/html.rs
new file mode 100644 (file)
index 0000000..6515b07
--- /dev/null
@@ -0,0 +1,917 @@
+#![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>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p>
+      plain&amp;#160;&lt;font color="#000000" size="3" face="Monospaced">&lt;b>bold&lt;/b>&lt;/font>&amp;#160;&lt;font color="#000000" size="3" face="Monospaced">&lt;i>italic&lt;/i>&amp;#160;&lt;strike>strikeout&lt;/strike>&lt;/font>
+    &lt;/p>
+  &lt;/body>
+&lt;/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>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p>left&lt;/p>
+    &lt;p align="center">&lt;font color="#000000" size="5" face="Monospaced">center&amp;#160;large&lt;/font>&lt;/p>
+    &lt;p align="right">&lt;font color="#000000" size="3" face="Monospaced">&lt;b>&lt;i>right&lt;/i>&lt;/b>&lt;/font>&lt;/p>
+  &lt;/body>
+&lt;/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>&lt;html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p style="text-align:center; margin-top: 0">
+      &amp;[PageTitle]
+    &lt;/p>
+  &lt;/body>
+&lt;/html></xml>"##;
+        let content = quick_xml::de::from_str::<String>(text).unwrap();
+        assert_eq!(
+            Document::from_html(&content).to_html(),
+            r##"<p align="center">&amp;[PageTitle]</p>"##
+        );
+    }
+
+    /// From the corpus (also included in the documentation).
+    #[test]
+    fn footer1() {
+        let text = r##"<xml>&lt;html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p style="text-align:right; margin-top: 0">
+      Page &amp;[Page]
+    &lt;/p>
+  &lt;/body>
+&lt;/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 &amp;[Page]</p>"##
+        );
+    }
+
+    /// From the corpus (also included in the documentation).
+    #[test]
+    fn header2() {
+        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+          &lt;style type="text/css">
+                  p { font-family: sans-serif;
+                       font-size: 10pt; text-align: center;
+                       font-weight: normal;
+                       color: #000000;
+                       }
+          &lt;/style>
+  &lt;/head>
+  &lt;body>
+          &lt;p>&amp;amp;[PageTitle]&lt;/p>
+  &lt;/body>
+&lt;/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">&amp;[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>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+          &lt;style type="text/css">
+                  p { font-family: sans-serif;
+                       font-size: 10pt; text-align: right;
+                       font-weight: normal;
+                       color: #000000;
+                       }
+          &lt;/style>
+  &lt;/head>
+  &lt;body>
+          &lt;p>Page &amp;amp;[Page]&lt;/p>
+  &lt;/body>
+&lt;/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 &amp;[Page]</font></font></p>"##
+        );
+    }
+}
diff --git a/rust/pspp/src/output/spv/legacy_bin.rs b/rust/pspp/src/output/spv/legacy_bin.rs
new file mode 100644 (file)
index 0000000..f1e0e46
--- /dev/null
@@ -0,0 +1,280 @@
+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
+    )
+}
diff --git a/rust/pspp/src/output/spv/legacy_xml.rs b/rust/pspp/src/output/spv/legacy_xml.rs
new file mode 100644 (file)
index 0000000..1e218ef
--- /dev/null
@@ -0,0 +1,2694 @@
+// 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>>,
+}
diff --git a/rust/pspp/src/output/spv/light.rs b/rust/pspp/src/output/spv/light.rs
new file mode 100644 (file)
index 0000000..d2f2696
--- /dev/null
@@ -0,0 +1,1681 @@
+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,
+}
index 98d5a0b16f4b7269c138d8a106c9464bf91034ec..77c3fa0fa2422fdb4bb3d8bcab40b2a76d17f60e 100644 (file)
 //! 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,
@@ -176,6 +179,10 @@ impl CellInner {
         }
     }
 
+    pub fn with_rotate(self, rotate: bool) -> Self {
+        Self { rotate, ..self }
+    }
+
     pub fn is_empty(&self) -> bool {
         self.value.inner.is_empty()
     }
@@ -201,7 +208,7 @@ pub struct Table {
     pub borders: EnumMap<Border, BorderStyle>,
 
     /// Horizontal ([Axis2::Y]) and vertical ([Axis2::X]) rules.
-    pub rules: EnumMap<Axis2, Array2<Border>>,
+    pub rules: EnumMap<Axis2, Array2<Option<Border>>>,
 
     /// How to present values.
     #[debug(skip)]
@@ -223,8 +230,8 @@ impl Table {
             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,
         }
@@ -238,7 +245,7 @@ impl Table {
     }
 
     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) {
@@ -257,13 +264,13 @@ impl Table {
 
     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);
         }
     }
 
@@ -378,48 +385,46 @@ impl<'a> Iterator for Cells<'a> {
     }
 }
 
-pub struct DrawCell<'a> {
+pub struct DrawCell<'a, 'b> {
     pub rotate: bool,
     pub inner: &'a ValueInner,
-    pub style: &'a AreaStyle,
+    pub cell_style: &'a CellStyle,
+    pub font_style: &'a FontStyle,
     pub subscripts: &'a [String],
     pub footnotes: &'a [Arc<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()))
     }
diff --git a/rust/pspp/src/output/text.rs b/rust/pspp/src/output/text.rs
deleted file mode 100644 (file)
index 990f1fa..0000000
+++ /dev/null
@@ -1,707 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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"]);
-    }
-}
diff --git a/rust/pspp/src/output/text_line.rs b/rust/pspp/src/output/text_line.rs
deleted file mode 100644 (file)
index e4d7c5c..0000000
+++ /dev/null
@@ -1,610 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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:?}"
-                );
-            }
-        }
-    }
-}
index ce484496a7877e656b267e94c8312c0ace2b6dc7..9627f29aa6850ff72b4fdfd74bbdd85aeaa4957b 100644 (file)
@@ -211,9 +211,7 @@ impl<R> Cases<R> {
         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);
                     }
                 }
index 25dab4f51318731e7fa9eba4da7e3cbcf5b3af39..1a573f448dc96ec0d85585246ba18f61cec22e55 100644 (file)
@@ -5,7 +5,7 @@ use itertools::Itertools;
 use crate::{
     data::cases_to_output,
     output::{
-        Details, Item, Text,
+        Item, Text,
         pivot::{PivotTable, tests::assert_lines_eq},
     },
     pc::PcFile,
@@ -30,9 +30,9 @@ fn test_pcfile(name: &str) {
             output.push(PivotTable::from(&metadata).into());
             output.extend(dictionary.all_pivot_tables().into_iter().map_into());
             output.extend(cases_to_output(&dictionary, cases));
-            Item::new(Details::Group(output.into_iter().map_into().collect()))
+            output.into_iter().collect()
         }
-        Err(error) => Item::new(Details::Text(Box::new(Text::new_log(error.to_string())))),
+        Err(error) => Text::new_log(error.to_string()).into_item(),
     };
 
     let actual = output.to_string();
index 51ccfbef8e38f165e49babb1a9cacbc3d6f75011..1bb4c809946154e16dd928d153f1aa1e21e6a2b4 100644 (file)
@@ -1158,7 +1158,7 @@ mod tests {
     use crate::{
         data::cases_to_output,
         output::{
-            Details, Item, Text,
+            Item, Text,
             pivot::{PivotTable, tests::assert_lines_eq},
         },
         por::{PortableFile, ReadPad},
@@ -1196,9 +1196,9 @@ mod tests {
                 output.push(PivotTable::from(&metadata).into());
                 output.extend(dictionary.all_pivot_tables().into_iter().map_into());
                 output.extend(cases_to_output(&dictionary, cases));
-                Item::new(Details::Group(output.into_iter().map_into().collect()))
+                output.into_iter().collect()
             }
-            Err(error) => Item::new(Details::Text(Box::new(Text::new_log(error.to_string())))),
+            Err(error) => Text::new_log(error.to_string()).into_item(),
         };
 
         let actual = output.to_string();
index aac4a4c8a83953db1dbff2ed9e0d9d9f27fcd923..d53a8c29a1206a62150847d7ffd7b786a663a92b 100644 (file)
@@ -21,7 +21,7 @@ use enum_map::EnumMap;
 use serde::Serialize;
 
 use crate::{
-    format::{Format, Settings as FormatSettings},
+    format::{Format, Settings as FormatSettings, F8_2},
     message::Severity,
     output::pivot::Look,
 };
@@ -136,7 +136,7 @@ impl Default for Settings {
             macros: MacroSettings::default(),
             max_loops: 40,
             workspace: 64 * 1024 * 1024,
-            default_format: Format::F8_2,
+            default_format: F8_2,
             testing: false,
             fuzz_bits: 6,
             scale_min: 24,
diff --git a/rust/pspp/src/show.rs b/rust/pspp/src/show.rs
deleted file mode 100644 (file)
index 2a866c5..0000000
+++ /dev/null
@@ -1,383 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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,
-}
diff --git a/rust/pspp/src/show_pc.rs b/rust/pspp/src/show_pc.rs
deleted file mode 100644 (file)
index 385f877..0000000
+++ /dev/null
@@ -1,300 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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,
-}
diff --git a/rust/pspp/src/show_por.rs b/rust/pspp/src/show_por.rs
deleted file mode 100644 (file)
index d0ba365..0000000
+++ /dev/null
@@ -1,327 +0,0 @@
-// PSPP - a program for statistical analysis.
-// Copyright (C) 2025 Free Software Foundation, Inc.
-//
-// This program is free software: you can redistribute it and/or modify it under
-// the terms of the GNU General Public License as published by the Free Software
-// Foundation, either version 3 of the License, or (at your option) any later
-// version.
-//
-// This program is distributed in the hope that it will be useful, but WITHOUT
-// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
-// details.
-//
-// You should have received a copy of the GNU General Public License along with
-// this program.  If not, see <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,
-}
index d3e43b6b157e35d67332821d0f36b8d744ba7a1d..6b09440ac8a9f8e9852abb4b5ce41e22f248e10d 100644 (file)
@@ -515,7 +515,7 @@ impl<F> ReadOptions<F> {
     }
 
     /// 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 }
     }
index 1bc79ced89fb2b09f4551e98323271d93d44a57c..3fae0faa2a07ef367a8d74c7f423480fd792ec77 100644 (file)
@@ -32,21 +32,20 @@ use crate::{
 };
 
 use binrw::{BinRead, BinWrite, Endian, Error as BinError, binrw};
-use clap::ValueEnum;
 use encoding_rs::Encoding;
 use itertools::Itertools;
-use serde::{Serialize, Serializer, ser::SerializeTuple};
+use serde::{Deserialize, Serialize, Serializer, ser::SerializeTuple};
 use thiserror::Error as ThisError;
 
 /// Type of compression in a system file.
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, ValueEnum)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
 pub enum Compression {
     /// Simple bytecode-based compression.
     Simple,
     /// [ZLIB] compression.
     ///
     /// [ZLIB]: https://www.zlib.net/
-    #[value(name = "zlib", help = "ZLIB space-efficient compression")]
     ZLib,
 }
 
index 577bea6123b31d7a90e0206f1448601a354aa8f4..552bd19d30ae9d2ed16f4271a709e0e7289744ca 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index b09ef9aa98954baaf51502402f48716822e4ebc8..8d0c1bc48976d574375f8efa027290a429de3ff9 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index c6e81da6886691222d0f37896466552322c0565e..f719eb560757d3a21cc4690876361fef4b51bbcf 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index ca0229447c4a1710b29252c91d2a188cc38f4d45..b3caf8fc99e454c3ffa52b35c4da8a15153613d0 100644 (file)
@@ -1,14 +1,15 @@
 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โ”‚
index f04025570532f9a830be688a15816436e0a8730c..645328e60bd367fcea937592523322d2cc629ca9 100644 (file)
@@ -1,14 +1,15 @@
 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โ”‚
index acdbb3f5a38c67bde1db53b9bbde2e3424b41e95..4bf3dd788ab0d7225aa3d03a9ca3976360f72306 100644 (file)
@@ -2,14 +2,15 @@ Ignoring long string value label for unknown variable STR9.
 
 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โ”‚
index 673e178210a6abd50991d590e278eaaabf25625f..b5a3f1aa8ffbbee3cdb49d818f2fba6a58c27746 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 7d391c03235ab1402d05c2656025800d2df8d9b3..1774a5ef820eff2c0b22151ef9410a572681a094 100644 (file)
@@ -4,14 +4,15 @@ Variable with short name NUM1 listed in very long string record with width 255,
 
 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โ”‚
index 6ba24735bd7410e09deb534e80a9b058682f3540..843a5b8fb789c73e45c6d917e9c1d49f3a6a6558 100644 (file)
@@ -2,14 +2,15 @@ File header claims 1 variable positions but 34 were read from file.
 
 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โ”‚
index 1cf5d48e0fb75d77d59cf33650102bfa9c52485a..8ad02aa36cd58d2a62411309a042e7a8af49bf76 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 372a7f965447bb59addf2e756431c408244d0c8b..f4c927a6e16af785114ce5ea59e9c9a8e5a2555e 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 9d6989cdf77b9adaae594535c3a3fd250bc5e4a0..21c7de66915f423ab4ad6248fb252416d46f1089 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 762e20406943a7fb017b65701be94dc5618e6af2..d70739ff26c57a45c8511d5b9d9653150b0fb96b 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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                               โ”‚
index 78d1ec0b8361a882ea5377e4260471430546138b..8e2410888694737b25a5fbf6f3c4d10a7854ba5c 100644 (file)
@@ -2,14 +2,15 @@ Warning at file offsets 0xe0 to 0xfe: In file or variable attribute record: Dupl
 
 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โ”‚
index a169e66ead137341b1c770e707c13510adb979a2..4f40124294275b9acc8ba4144b5846515a984f91 100644 (file)
@@ -6,14 +6,15 @@ Warning at file offsets 0x140 to 0x1aa: In long variable name record: Invalid lo
 
 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โ”‚
index 010bb05806efc9c7c105598279decc9283fd04b2..7da70489ce385e448a8d0ed7f24c5d672b466d9c 100644 (file)
@@ -2,14 +2,15 @@ STR1 has duplicate value labels for the following value(s): "xyzzy"
 
 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โ”‚
index 581bcf61d9e4c2d886b268f6840134480a53c398..f6781422f61632b3812542f704688933e72b2080 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 0bf233ca67a84146a3c9e8682de67579ee5c03dc..c1e88b37ea0a72594f8b982e675bc2d4d8938755 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index f5d25a70ddb18c6bd1762eeb67ba41d63a6329f9..8800d553e9880ddfb2777bbedd1fe87c09c3d1a4 100644 (file)
@@ -1,14 +1,15 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 70c4d64598815702848bfb5ac6d50b99f8720c2c..c895ed4c14885d6782aa3a64b7b0e8beb9e25f1e 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 8b1faeb1aaeda98a84e36b887c6ce84e3a33230c..7692f631f763a37f83cba024e928fd0f7734f54a 100644 (file)
@@ -4,15 +4,16 @@ This system file does not indicate its own character encoding.  For best results
 
 "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โ”‚
index 086d49847e99097e4733002af5dfcf6f456c3ba5..d773e0a5e88eca06db1c99ad09c850010dbe5d4a 100644 (file)
@@ -10,15 +10,16 @@ Invalid long string missing value for numeric variable NUM1.
 
 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: รดรตรถรธโ”‚
index 05d55df674b9bc8c1271979fa82c167c33f09bbf..cb49d2699fdc53d056d0031742c42e3ccd69e0dc 100644 (file)
@@ -14,14 +14,15 @@ Substituting A4 for invalid print format on variable STR2.  String variable with
 
 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โ”‚
index ee97fa67064089c9ca66b123bd3163af5c3abcdf..90925e3c19d6475cc2bb74e5ed435b1f557b7698 100644 (file)
@@ -6,14 +6,15 @@
 
 "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โ”‚
index 88dde68c5235909750f82f1b0f214b2e8e77d8dc..ce67c85e47207b88bedc0a0c5e4c5482c8e63aff 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 90adcda9974495222add83432c6bdb5b1c502df8..555fe4ab9e94f36028188014c9918ce7d83496fd 100644 (file)
@@ -2,14 +2,15 @@ Warning at file offsets 0xe0 to 0xe6: In file or variable attribute record: Attr
 
 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โ”‚
index 7aef1fdfece7f31dafc99257c83e20eec2feb579..e16df378949f0cba9fff4cadb6a7985f108dd565 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 6630473a187a61ffbac9b916ce61964d05a7e29e..f673aa1d7994f315fb45883fffe0272af1a40a30 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 4c86e6f7caf31b21f18ebe09feb9147ad8f98ed9..a2b9316920eb2996c2d59b53cd2e358fefa794a1 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 16d81d3cc7243ab93ea5e00363299b04d027e9b8..e3851c28bc3bde33c4d6bceb8e08189fb372de9a 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 2f4533163742e843bf4ef14d3a5a021ba33d8526..32c604a068f7516dbff4f8ea53acbf908fd64e80 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 6bacc195f37399a16009a9aeb1d049f42d1bdd9c..63d1902fea7c5b415ff62749cd51f0681aaccad5 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index e418bef4cc992a9af42c9997b6febc8a1a319ebd..705dd1d5f424086e5d2827ab746f0af466df52ee 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 0e7f37317d7cbf70b565b836f856f1e2931b13a9..7909e1af195f369bfbd92979f1feb709f67f082f 100644 (file)
@@ -2,15 +2,16 @@ Invalid multiple response set name.  Multiple response set name "b" does not beg
 
 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โ”‚
index 9d0a063f518274f3b906c1919948c94e38385bf7..632d653f60aa3b7deb3b7810691ac5cacb2b3756 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 8cbd13cc55ae1108d888bc16084c322d508f3f44..8816eee94707367445359920ea2933a80ac0782e 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index a43645718e5c3b71ebc2ebb0aed69e428020de8c..3a87a32434c2c3e3ad1afe8e089f686428890b3a 100644 (file)
@@ -2,14 +2,15 @@ Multiple response set $a includes variable NUM1 more than once.
 
 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โ”‚
index 9212037f06b9fd01d9f2ae5ba6f602f0b663a418..de7277f3a5d6baf7bbf4fd04088b6cfaaee189d9 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index a4535ccd209792d1217cd0ab2d8c517db408b49d..d412498ab6c550a86bea03af78f6dd7d5363db09 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index beca7dd9f0b579bc865e45185a53c519ea3adcc5..c3cb2776ee07e292137745fcf43eb4be87acd4ab 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index bdaee73a02e0b289eb0bab14ba6f1debdb675744..02add88f3bba90604c99fada2c8fdb02398a2b8e 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 9212037f06b9fd01d9f2ae5ba6f602f0b663a418..de7277f3a5d6baf7bbf4fd04088b6cfaaee189d9 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index c71d6c89492ef67863f44434d1c96adf0d0589e1..e242aa8faf0553258a32837f669c90a601530e7f 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 3580af6a4b2dbddf8672ced9731e5eae688b1b7b..75638b7f64b42e00b56c4f34b30c3e074bb0cad7 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 1cad5bffd3a341cfe4f10b7e4714c5f008ff2b66..9fab62375f2193a897d88a34fdf723deda193b36 100644 (file)
@@ -2,14 +2,15 @@ Warning at file offsets 0x1c8 to 0x1e8: In extension record: variable attributes
 
 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โ”‚
index d7b0a07ee794de5bbda0a31f2c32859591d27784..d7920d31803f45627dd79975b725cd64a1d6b607 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 021b2b3ff688b7fb5e85167c2c7761cd8616013c..a8528692115c566591e87d9240675d351b35e624 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index b0754f43a02cf093c7f0264c6a465fc9550f992f..c8eb632c4d12d5c0246b1996804e4f43a046c236 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 840189423ba1a4c521e0ce0ddb7c5cb2511f8b0a..9f3b99c20210d4c6fc88fb23e3c86707fb3a00cc 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index c91be624b3ddd5f332de69e7980173451f26109b..92893de8dc66556668b3eeac300010b3fad1355f 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 3d620f7241a77e5912197d4635f0cb0732845510..5d5780ec9f48dfa795a520db173a8ee2fcaa692c 100644 (file)
@@ -2,14 +2,15 @@ This system file does not indicate its own character encoding.  For best results
 
 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: ๏ฟฝ๏ฟฝ๏ฟฝ๏ฟฝโ”‚
index bf775efd479cec714056fb7fdb6340b655c39acc..e5553f3573dd216fc8f88ff40e1a7b8b6a8a45de 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 22a368ee068c5f3e935f50caea05f246fba6482d..0818af6f957fb2429f8b6adc31561eeadabc7e72 100644 (file)
@@ -2,14 +2,15 @@ Warning at file offsets 0xe0 to 0xed: In file or variable attribute record: Attr
 
 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โ”‚
index ed9a324b8f0a427ae24eb3a86cdd70ee4e17d99a..4887a92ec6b721de193147b8f52aa062324a5e72 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index ceb9b71ee67df2baec8756cf151c01364a568205..71239f97f0b48932029a65a7669dfccf16b53a51 100644 (file)
@@ -4,14 +4,15 @@ Warning at file offsets 0x138 to 0x140: In value label record: One or more varia
 
 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โ”‚
index 67c64a991e011bec1a49f7e13043b108432eb45f..3e2e93e63ba4e33f2415e579a8370e2af4154605 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 91ea4380dd7a9ff63d968be66138da96f2eefb7f..f589de6a6021f49524f5b204488c021cb984853b 100644 (file)
@@ -2,14 +2,15 @@ Warning at file offsets 0xec to 0xf0: In value label record: At least one valid
 
 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โ”‚
index 50932daee198b29c843d87e1841fb325af7ca198..16d3c3e28ba3efd655a362f5a2ed5ec6a2aff2e8 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 898c8350f250b63b092cf6ed7b9ce50b5f93dd3d..1c24450e150493dd66a3e993d6bf964f140c6284 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index e810dcd53f3f31a8345edfbd1176c48c1ec81955..a215257b14712b11b5fbcf969bac3c7b766690fd 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 6d6b7a751da55f5d7e6976461ae1e641134b7bcd..ea8895f26ba6a0a002d12683f77e7e61ad9beb03 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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: รดรตรถรธโ”‚
index 5825b90c08a300aeba179ec7310d9e04b3db5713..c8a21b0c09d9125aef7de728631293c50e66abac 100644 (file)
@@ -1,14 +1,15 @@
 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โ”‚
index 4832fda1797c8f6713eee37b374d7faefaee1088..c2058c1dadb6b5c6f7b093c50036e70a8ab35714 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 48f024822ed12274e4407adf922eeaca98b483d6..58b80d60af16a0b3faa2ae10144fc6ec1120dd4a 100644 (file)
@@ -2,15 +2,16 @@ Variable set "Variable Set 1" includes unknown variable xyzzy.
 
 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โ”‚
index 4302cc3c62d6983b24e268c3aebe0f1295d0a1a0..d63baca3c168797885db4ee7a070d2af0a488868 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 35b06625cb95187f6dcb55885ac3288d0ee7a68a..fba47dda145627177b93be9d2474fe0cabd8eb7e 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 4e1bf58cad142e5505a9ac18f87b2e005590fed3..b82e90af79c8acb4f9f495bb7a73978a2cf16ef3 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index dae8fb9619e538c81ea98a1c89a1a0b364139d4e..bb4c5df8fdf73f1a7260efe8ae1a78d8ea5f332d 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index da6403f16b1e963ce5a91128f540e54219bef318..1e362b02ace4187b2d57de1e4c6e3aec6c6bcba6 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 4dafcc388fd53a38907f76754cc51e9bcad5c15c..5176fac17c254a9b036ec030c66a6b8ac0149d97 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index ac6eae1832c52f808e0e3d23128c7730f5c05cd3..a5e1d5b6a877ddb1ce3dfbca2f43bb15886ad3f4 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 971572b57670ddfa44cd29f8ff700ef9f206bc58..6604a5d423273b86825e2091509797a278f417c4 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 34eab53234fdaaf4e63a299a5c2b1e70f3885801..d6c6fb22d794ec0b2d1303583dedcc00ff9fe6a8 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index aaa9214564050f6fa892ea97d0e97a0acb5160ae..6383cb33294d8c8cb457b973a23ed15ab096bd11 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 55aea6b552ca864fb591dbdc35ef79eba440cf63..dd2566a2265a90e7b5ec1eb2bfe102aa396fdc56 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index fa6fa29ac00ee9d4457df1e07c2fa2a513d826ee..91ce27067889229e9688aa5b078eb14742419879 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 5d76ed0661872ff9883aa7091bc44ffbc12483fb..32e9b530465fc9c0cc4ca8d8fc1d75cde617304a 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 3617b645db7fb85a748d7d1a4144d048e1c35fa9..eb0efddf158c1f63610204b2413a086a7168b226 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index e8fda3180c8d0c12efee8d1e82cb08a7087480fe..d56cab89f96ebb4308060dcece92e5c69a6af483 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index b73def632a42fa2a1823eaf85f9f41c5690dd494..05f92769ac72aa4f055bef601573341f756f3829 100644 (file)
@@ -4,14 +4,15 @@ System file specifies value 1.0 (0x1.0p0) as HIGHEST but 1.7976931348623157e308
 
 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โ”‚
index 34858dc9219b31a116d83aa6c9c7e0175e8861f4..8584783afe7dfc718e80c9f50809b5628a5276c7 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index 463a44e39b819e56535d0fcd768d66333718fbcf..ac2ef8a9c76764f4bdb291ed9bacc436085672a7 100644 (file)
@@ -1,12 +1,13 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index 66f4355225645887a59d5ddcbecbc5d44c20e5ae..34178bfb2caa0364eab9cf79efb4e093f9720fbf 100644 (file)
@@ -1,11 +1,12 @@
-โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
-โ”‚       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โ”‚
index b5fabe9da74b618720bd571f6da40b8282a7829e..f517017abaa507ff0d7f407e412aad0eb5927eee 100644 (file)
@@ -1,13 +1,14 @@
 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โ”‚
index a7a05a9cce1d382cb23dbc6e77fdf6d754d4d689..445817aaf2354214f1530a2a25243396457d7254 100644 (file)
@@ -2,14 +2,15 @@ Multiple response set $a has only one variable.
 
 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โ”‚
index 857e12ce8ea10bcda795b8388c06f7a83f21b1bd..d394c0a9f1290eaad61b3faa584e6c9b78392588 100644 (file)
@@ -18,7 +18,6 @@ use std::{
     fs::File,
     io::{BufRead, BufReader, Cursor, Seek},
     path::{Path, PathBuf},
-    sync::Arc,
 };
 
 use binrw::Endian;
@@ -31,7 +30,7 @@ use crate::{
     dictionary::Dictionary,
     identifier::Identifier,
     output::{
-        Details, Item, Text,
+        Item, Text,
         pivot::{Axis3, Dimension, Group, PivotTable, Value, tests::assert_lines_eq},
     },
     sys::{
@@ -809,9 +808,9 @@ where
                 }
                 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();
index 741a9dd42328bdda40c6fdbbadaab65d7123a802..350f7d072066c1ea632e268c2e9cf6aa873d7e4e 100644 (file)
@@ -37,7 +37,7 @@ use crate::{
     dictionary::{CategoryLabels, Dictionary, MultipleResponseType},
     format::{DisplayPlain, Format},
     identifier::Identifier,
-    output::spv::Zeros,
+    output::drivers::spv::Zeros,
     sys::{
         ProductVersion,
         encoding::codepage_from_encoding,
index 1ebad2cbc33461ea0c7aff7b7d1a0514c49ace2f..fca523b9750792bd88dc8d7052fc7e448b918d57 100644 (file)
@@ -54,6 +54,16 @@ pub enum VarType {
     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;
 
@@ -733,7 +743,7 @@ impl<'a> MissingValuesMut<'a> {
             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)
diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml
new file mode 100644 (file)
index 0000000..f3e454b
--- /dev/null
@@ -0,0 +1,2 @@
+edition = "2024"
+style_edition = "2024"
index 3323123e7e4cea5de5cdb43eb1bc1580b28aa70b..611e2bfb034a763ef5e3fb4459af372818066dbd 100644 (file)
@@ -70,7 +70,7 @@ void spv_data_variable_dump (const struct spv_data_variable *, FILE *);
 struct spv_data_value
   {
     double index;
-    int width;
+    int width;                  /* -1 for number, otherwise s's length. */
     union
       {
         double d;