From d88840b47fc99d4f2a2ec2651d05351d37e7ab08 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sat, 31 Oct 2026 09:44:24 +0900 Subject: [PATCH 1/6] Add plain analysis report rendering --- src/report.rs | 2 + src/report/plain.rs | 168 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/report/plain.rs diff --git a/src/report.rs b/src/report.rs index 82cd5d7..5aa822f 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,3 +1,5 @@ +pub mod plain; + use std::{ io::{self, Write}, ops::Range, diff --git a/src/report/plain.rs b/src/report/plain.rs new file mode 100644 index 0000000..f64e566 --- /dev/null +++ b/src/report/plain.rs @@ -0,0 +1,168 @@ +use std::io::{self, Write}; + +use crate::{ + cli::LaunchTarget, + inspector::{ + InspectorDisplayMode, InspectorField, InspectorFieldId, InspectorSection, + UNAVAILABLE_ENCODING, + }, + unicode::{ + CodePoint, UnicodeDatabase, + representation::TextFormat, + text::{NormalizationForm, TextAnalysis, TextMetrics}, + }, +}; + +pub fn write(target: &LaunchTarget, mut writer: impl Write) -> io::Result<()> { + match target { + LaunchTarget::CodePoint(point) => write_code_point(*point, &mut writer), + LaunchTarget::Sequence(text) => { + write_sequence(&TextAnalysis::new(text.clone()), &mut writer) + } + } +} + +fn write_code_point(point: CodePoint, writer: &mut impl Write) -> io::Result<()> { + let character = point.to_char(); + let mut section = None; + for field in InspectorField::for_code_point(point, InspectorDisplayMode::Relevant) { + if section != Some(field.section()) { + if section.is_some() { + write!(writer, "\n\n")?; + } + // Relevant omits unavailable encoding fields; reports explain why. + if character.is_none() && field.section() == InspectorSection::Data { + write!(writer, "Encoding\n {UNAVAILABLE_ENCODING}\n\n")?; + } + write!(writer, "{}", field.section().label())?; + section = Some(field.section()); + } + + write!(writer, "\n {}:", field.label())?; + if field.id() == InspectorFieldId::Character { + write!(writer, " ")?; + if let Some(character) = character { + write_text(&character.to_string(), writer)?; + } else { + write!(writer, "{UNAVAILABLE_ENCODING}")?; + } + } else if field.values().len() == 1 && field.id() != InspectorFieldId::Decomposition { + write!(writer, " {}", field.values()[0])?; + } else { + for value in field.values() { + write!(writer, "\n {value}")?; + } + } + } + + write!(writer, "\n\nText Analysis\n ")?; + if let Some(character) = character { + let analysis = TextAnalysis::new(character.to_string()); + write_metrics(analysis.metrics(), writer)?; + write_normalization(&analysis, writer) + } else { + write!( + writer, + "{UNAVAILABLE_ENCODING}\n\nNormalization Forms\n {UNAVAILABLE_ENCODING}" + ) + } +} + +fn write_sequence(analysis: &TextAnalysis, writer: &mut impl Write) -> io::Result<()> { + write!(writer, "Input: ")?; + write_text(analysis.source(), writer)?; + writeln!(writer)?; + let metrics = analysis.metrics(); + write_metrics(metrics, writer)?; + write!( + writer, + "\n\nPositions: CP# / GC# are 1-based; byte / unit ranges are 0-based, end-exclusive.\n" + )?; + + let cp_width = metrics.code_points.to_string().len().max(3); + let gc_width = metrics.graphemes.to_string().len().max(3); + let utf8_width = (metrics.utf8_bytes.to_string().len() * 2 + 3).max(11); + let utf16_width = (metrics.utf16_units.to_string().len() * 2 + 3).max(12); + write!( + writer, + "{: io::Result<()> { + write!( + writer, + "CP: {} UTF-8 Bytes: {} UTF-16 Units: {} GC: {}", + metrics.code_points, metrics.utf8_bytes, metrics.utf16_units, metrics.graphemes + ) +} + +fn write_normalization(analysis: &TextAnalysis, writer: &mut impl Write) -> io::Result<()> { + let mut widths = [2, 11, 12, 2]; + for form in NormalizationForm::ALL { + let metrics = analysis.normalization().get(form).metrics(); + for (width, count) in widths.iter_mut().zip([ + metrics.code_points, + metrics.utf8_bytes, + metrics.utf16_units, + metrics.graphemes, + ]) { + *width = (*width).max(count.to_string().len()); + } + } + let [cp_width, utf8_width, utf16_width, gc_width] = widths; + write!( + writer, + "\n\nNormalization Forms\n {:<4} {:<9} {: io::Result<()> { + for atom in TextFormat::JsonString.atoms(text) { + writer.write_all(atom.as_bytes())?; + } + Ok(()) +} From 7a37f238cb63fc2dac5c2b9d800bf8f5deab8be9 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sat, 31 Oct 2026 09:45:17 +0900 Subject: [PATCH 2/6] Connect plain reports to noninteractive output --- src/output.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/output.rs b/src/output.rs index 201cf79..0265edc 100644 --- a/src/output.rs +++ b/src/output.rs @@ -15,6 +15,7 @@ use crate::{ #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum OutputFormat { + Plain, Json, JsonString, RustString, @@ -54,6 +55,7 @@ pub fn write( ) -> Result<(), OutputError> { let mut writer = BufWriter::new(writer); let result = match format { + OutputFormat::Plain => report::plain::write(target, &mut writer), OutputFormat::Json => report::write(target, &mut writer), OutputFormat::JsonString | OutputFormat::RustString => { let text = match target { @@ -68,7 +70,7 @@ pub fn write( let format = match format { OutputFormat::JsonString => TextFormat::JsonString, OutputFormat::RustString => TextFormat::RustString, - OutputFormat::Json => unreachable!(), + OutputFormat::Plain | OutputFormat::Json => unreachable!(), }; write_literal(format, &text, &mut writer) } From d4d8d3b68c7d8c3f44cf85c6d15ddafd82b6830d Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sat, 31 Oct 2026 09:46:30 +0900 Subject: [PATCH 3/6] Verify plain CLI reports with representative snapshots --- tests/cli_output.rs | 79 +++++++++++++++++-- .../snapshots/cli_output__plain_control.snap | 41 ++++++++++ .../snapshots/cli_output__plain_fraction.snap | 49 ++++++++++++ .../snapshots/cli_output__plain_sequence.snap | 21 +++++ .../cli_output__plain_surrogate.snap | 29 +++++++ .../cli_output__plain_unassigned.snap | 38 +++++++++ 6 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 tests/snapshots/cli_output__plain_control.snap create mode 100644 tests/snapshots/cli_output__plain_fraction.snap create mode 100644 tests/snapshots/cli_output__plain_sequence.snap create mode 100644 tests/snapshots/cli_output__plain_surrogate.snap create mode 100644 tests/snapshots/cli_output__plain_unassigned.snap diff --git a/tests/cli_output.rs b/tests/cli_output.rs index 75157c9..c7c6824 100644 --- a/tests/cli_output.rs +++ b/tests/cli_output.rs @@ -42,16 +42,50 @@ fn prints_complete_json_report_snapshots(#[case] name: &str, #[case] arguments: insta::assert_snapshot!(name, snapshot); } -#[test] -fn json_stdin_round_trips_controls_and_literal_escapes_without_normalizing() { +#[rstest] +#[case::fraction("plain_fraction", &["U+00BD"])] +#[case::sequence("plain_sequence", &["--text", "A\u{301}šŸ˜€"])] +#[case::surrogate("plain_surrogate", &["U+D800"])] +#[case::unassigned("plain_unassigned", &["U+2FE0"])] +#[case::control("plain_control", &["U+0000"])] +fn prints_complete_plain_report_snapshots(#[case] name: &str, #[case] arguments: &[&str]) { + let output = Command::new(env!("CARGO_BIN_EXE_sauva")) + .args(["--output", "plain"]) + .args(arguments) + .output() + .unwrap(); + + assert!(output.status.success(), "status: {}", output.status); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.ends_with('\n')); + assert!(!stdout.ends_with("\n\n")); + insta::assert_snapshot!(name, stdout); +} + +#[rstest] +#[case("json")] +#[case("plain")] +fn report_stdin_round_trips_controls_and_literal_escapes_without_normalizing(#[case] format: &str) { let source = "\u{feff} A\u{301}\r\nšŸ‘©ā€šŸ’»\0\u{1b}[31m\u{202e}\"\\u0041\n"; - let output = run_with_stdin(&["--output", "json", "--text", "-"], source.as_bytes()); + let output = run_with_stdin(&["--output", format, "--text", "-"], source.as_bytes()); assert!(output.status.success()); assert!(output.stderr.is_empty()); assert!(output.stdout.is_ascii()); - let report: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(report["text"], source); - assert_ne!(report["normalization"]["nfc"]["text"], source); + if format == "json" { + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["text"], source); + assert_ne!(report["normalization"]["nfc"]["text"], source); + } else { + let stdout = String::from_utf8(output.stdout).unwrap(); + let input = stdout + .lines() + .next() + .unwrap() + .strip_prefix("Input: ") + .unwrap(); + assert_eq!(serde_json::from_str::(input).unwrap(), source); + } } #[test] @@ -71,6 +105,37 @@ fn prints_long_json_reports_without_truncating_occurrences_or_normalized_text() } } +#[test] +fn prints_long_plain_reports_without_truncating_occurrences_or_normalized_text() { + let count = 4096; + let source = "恂".repeat(count); + let output = run_with_stdin(&["--output", "plain", "--text", "-"], source.as_bytes()); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let quoted = format!(r#""{}""#, r"\u3042".repeat(count)); + assert_eq!( + stdout + .lines() + .filter(|line| line.ends_with("ed)) + .count(), + 5 + ); + let rows: Vec<_> = stdout + .lines() + .filter(|line| line.split_whitespace().nth(2) == Some("U+3042")) + .collect(); + assert_eq!(rows.len(), count); + assert_eq!( + rows.last() + .unwrap() + .split_whitespace() + .take(5) + .collect::>(), + ["4096", "4096", "U+3042", "[12285,12288)", "[4095,4096)"] + ); +} + #[rstest] #[case("--help")] #[case("--version")] @@ -114,7 +179,7 @@ not valid TOML ) .unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_sauva")) - .args(["--output", "json", "A"]) + .args(["--output", "plain", "A"]) .env("SAUVA_CONFIG_FILE", config.path()) .output() .unwrap(); diff --git a/tests/snapshots/cli_output__plain_control.snap b/tests/snapshots/cli_output__plain_control.snap new file mode 100644 index 0000000..1852b8b --- /dev/null +++ b/tests/snapshots/cli_output__plain_control.snap @@ -0,0 +1,41 @@ +--- +source: tests/cli_output.rs +expression: stdout +--- +Identity + Character: "\u0000" + Code Point: U+0000 + Primary Name: No Primary Name + Aliases: + NULL — control + NUL — abbreviation + Plane: 0 — Basic Multilingual Plane + Block: Basic Latin + +Classification + General Category: Cc — Control + Script: Common + Age: 1.1 + East Asian Width: N — Neutral + Bidi Class: BN — Boundary Neutral + +Encoding + UTF-8: 00 + UTF-16: 0000 + HTML Decimal: � + HTML Hex: � + Rust char: '\u{0}' + Unicode Escape: \u0000 + +Data + Unicode Version: 17.0.0 + +Text Analysis + CP: 1 UTF-8 Bytes: 1 UTF-16 Units: 1 GC: 1 + +Normalization Forms + Form State CP UTF-8 Bytes UTF-16 Units GC Result + NFC unchanged 1 1 1 1 "\u0000" + NFD unchanged 1 1 1 1 "\u0000" + NFKC unchanged 1 1 1 1 "\u0000" + NFKD unchanged 1 1 1 1 "\u0000" diff --git a/tests/snapshots/cli_output__plain_fraction.snap b/tests/snapshots/cli_output__plain_fraction.snap new file mode 100644 index 0000000..e9abb4e --- /dev/null +++ b/tests/snapshots/cli_output__plain_fraction.snap @@ -0,0 +1,49 @@ +--- +source: tests/cli_output.rs +expression: stdout +--- +Identity + Character: "\u00BD" + Code Point: U+00BD + Primary Name: VULGAR FRACTION ONE HALF + Plane: 0 — Basic Multilingual Plane + Block: Latin-1 Supplement + +Classification + General Category: No — Other Number + Script: Common + Age: 1.1 + East Asian Width: A — Ambiguous + Bidi Class: ON — Other Neutral + +Numeric + Numeric Type: Numeric + Numeric Value: 1/2 + +Encoding + UTF-8: C2 BD + UTF-16: 00BD + HTML Decimal: ½ + HTML Hex: ½ + Rust char: '\u{BD}' + Unicode Escape: \u00BD + +Normalization + Decomposition Type: Compatibility — fraction + Decomposition: + U+0031 DIGIT ONE + U+2044 FRACTION SLASH + U+0032 DIGIT TWO + +Data + Unicode Version: 17.0.0 + +Text Analysis + CP: 1 UTF-8 Bytes: 2 UTF-16 Units: 1 GC: 1 + +Normalization Forms + Form State CP UTF-8 Bytes UTF-16 Units GC Result + NFC unchanged 1 2 1 1 "\u00BD" + NFD unchanged 1 2 1 1 "\u00BD" + NFKC changed 3 5 3 3 "1\u20442" + NFKD changed 3 5 3 3 "1\u20442" diff --git a/tests/snapshots/cli_output__plain_sequence.snap b/tests/snapshots/cli_output__plain_sequence.snap new file mode 100644 index 0000000..5c8865f --- /dev/null +++ b/tests/snapshots/cli_output__plain_sequence.snap @@ -0,0 +1,21 @@ +--- +source: tests/cli_output.rs +expression: stdout +--- +Input: "A\u0301\uD83D\uDE00" +CP: 3 UTF-8 Bytes: 7 UTF-16 Units: 4 GC: 2 + +Positions: CP# / GC# are 1-based; byte / unit ranges are 0-based, end-exclusive. +CP# GC# Code Point UTF-8 Bytes UTF-16 Units Name +1 1 U+0041 [0,1) [0,1) LATIN CAPITAL LETTER A +2 1 U+0301 [1,3) [1,2) COMBINING ACUTE ACCENT +3 2 U+1F600 [3,7) [2,4) GRINNING FACE + +Normalization Forms + Form State CP UTF-8 Bytes UTF-16 Units GC Result + NFC changed 2 6 3 2 "\u00C1\uD83D\uDE00" + NFD unchanged 3 7 4 2 "A\u0301\uD83D\uDE00" + NFKC changed 2 6 3 2 "\u00C1\uD83D\uDE00" + NFKD unchanged 3 7 4 2 "A\u0301\uD83D\uDE00" + +Unicode Version: 17.0.0 diff --git a/tests/snapshots/cli_output__plain_surrogate.snap b/tests/snapshots/cli_output__plain_surrogate.snap new file mode 100644 index 0000000..74076d7 --- /dev/null +++ b/tests/snapshots/cli_output__plain_surrogate.snap @@ -0,0 +1,29 @@ +--- +source: tests/cli_output.rs +expression: stdout +--- +Identity + Character: Not available — not a Unicode scalar value + Code Point: U+D800 + Primary Name: No Primary Name + Plane: 0 — Basic Multilingual Plane + Block: High Surrogates + +Classification + General Category: Cs — Surrogate + Script: Unknown + Age: 2.0 + East Asian Width: N — Neutral + Bidi Class: L — Left To Right + +Encoding + Not available — not a Unicode scalar value + +Data + Unicode Version: 17.0.0 + +Text Analysis + Not available — not a Unicode scalar value + +Normalization Forms + Not available — not a Unicode scalar value diff --git a/tests/snapshots/cli_output__plain_unassigned.snap b/tests/snapshots/cli_output__plain_unassigned.snap new file mode 100644 index 0000000..0137c61 --- /dev/null +++ b/tests/snapshots/cli_output__plain_unassigned.snap @@ -0,0 +1,38 @@ +--- +source: tests/cli_output.rs +expression: stdout +--- +Identity + Character: "\u2FE0" + Code Point: U+2FE0 + Primary Name: Unassigned + Plane: 0 — Basic Multilingual Plane + Block: No Block + +Classification + General Category: Cn — Unassigned + Script: Unknown + Age: Unassigned + East Asian Width: N — Neutral + Bidi Class: L — Left To Right + +Encoding + UTF-8: E2 BF A0 + UTF-16: 2FE0 + HTML Decimal: ⿠ + HTML Hex: ⿠ + Rust char: '\u{2FE0}' + Unicode Escape: \u2FE0 + +Data + Unicode Version: 17.0.0 + +Text Analysis + CP: 1 UTF-8 Bytes: 3 UTF-16 Units: 1 GC: 1 + +Normalization Forms + Form State CP UTF-8 Bytes UTF-16 Units GC Result + NFC unchanged 1 3 1 1 "\u2FE0" + NFD unchanged 1 3 1 1 "\u2FE0" + NFKC unchanged 1 3 1 1 "\u2FE0" + NFKD unchanged 1 3 1 1 "\u2FE0" From 8916ff3c5b9a20aa44cc65074efa99c1507c8bd5 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sat, 31 Oct 2026 09:48:12 +0900 Subject: [PATCH 4/6] Document plain analysis output --- README.md | 4 +- docs/src/SUMMARY.md | 1 + .../getting-started/command-line-options.md | 4 +- docs/src/getting-started/index.md | 2 +- .../getting-started/non-interactive-usage.md | 6 ++- docs/src/getting-started/plain-reports.md | 47 +++++++++++++++++++ docs/src/introduction/index.md | 2 +- 7 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 docs/src/getting-started/plain-reports.md diff --git a/README.md b/README.md index 879e60d..a5c5000 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,11 @@ Customize colors, fonts, and keybindings in the [configuration file](https://lus ## Non-interactive Output -Use `-o` / `--output` to print a JSON analysis report or a quoted JSON or Rust string literal and exit: +Use `-o` / `--output` to print a plain or JSON analysis report, or a quoted JSON or Rust string literal and exit: ```sh +sauva -o plain U+00BD sauva -o json --text 'AĢšŸ˜€' -sauva -o json-string --text 'AĢšŸ˜€' ``` See [Non-interactive Usage](https://lusingander.github.io/sauva/getting-started/non-interactive-usage.html) for formats and examples. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index eda93a8..e21897c 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -6,6 +6,7 @@ - [Installation](./getting-started/installation.md) - [Basic Usage](./getting-started/basic-usage.md) - [Non-interactive Usage](./getting-started/non-interactive-usage.md) + - [Plain Reports](./getting-started/plain-reports.md) - [JSON Reports](./getting-started/json-reports.md) - [Command Line Options](./getting-started/command-line-options.md) - [Compatibility](./getting-started/compatibility.md) diff --git a/docs/src/getting-started/command-line-options.md b/docs/src/getting-started/command-line-options.md index b751d48..73f657d 100644 --- a/docs/src/getting-started/command-line-options.md +++ b/docs/src/getting-started/command-line-options.md @@ -10,7 +10,7 @@ Arguments: Options: -t, --text Treat the input as literal text, without code point notation parsing; - reads stdin - -o, --output Print a result and exit [possible values: json, json-string, rust-string] + -o, --output Print a result and exit [possible values: plain, json, json-string, rust-string] -g, --graphics Control glyph preview graphics [default: auto] [possible values: auto, force, iterm2, off] --print-default-config Print the complete default configuration to standard output -h, --help Print help @@ -76,7 +76,7 @@ Since `-` selects stdin, use `sauva U+002D` to inspect the hyphen character itse ## `-o, --output ` -Print a JSON analysis report or a quoted JSON or Rust string literal and exit. See [Non-interactive Usage](./non-interactive-usage.md) for formats, examples, and exit codes. +Print a plain or JSON analysis report, or a quoted JSON or Rust string literal and exit. See [Non-interactive Usage](./non-interactive-usage.md) for formats, examples, and exit codes. ## `-g, --graphics ` diff --git a/docs/src/getting-started/index.md b/docs/src/getting-started/index.md index 20fc08f..d2ee38e 100644 --- a/docs/src/getting-started/index.md +++ b/docs/src/getting-started/index.md @@ -3,6 +3,6 @@ - [Requirements](./requirements.md): supported systems and terminal requirements. - [Installation](./installation.md): install with Cargo, Homebrew, or a release binary. - [Basic Usage](./basic-usage.md): inspect a character or analyze a string in the TUI. -- [Non-interactive Usage](./non-interactive-usage.md): print JSON reports or JSON and Rust string literals. +- [Non-interactive Usage](./non-interactive-usage.md): print plain or JSON reports, or JSON and Rust string literals. - [Command Line Options](./command-line-options.md): input formats and startup options. - [Compatibility](./compatibility.md): terminal graphics and system fonts. diff --git a/docs/src/getting-started/non-interactive-usage.md b/docs/src/getting-started/non-interactive-usage.md index e786c27..6bfad1b 100644 --- a/docs/src/getting-started/non-interactive-usage.md +++ b/docs/src/getting-started/non-interactive-usage.md @@ -1,10 +1,11 @@ # Non-interactive Usage -Use `-o, --output ` to print a JSON analysis report or a quoted JSON or Rust string literal and exit. This requires no interactive terminal, fonts, or clipboard and does not load user configuration. +Use `-o, --output ` to print a plain or JSON analysis report, or a quoted JSON or Rust string literal and exit. This requires no interactive terminal, fonts, or clipboard and does not load user configuration. ## Usage ```sh +sauva -o plain U+00BD sauva -o json U+00BD sauva -o json --text - < input.txt > report.json sauva -o json-string U+2192 @@ -15,12 +16,13 @@ Input is required and follows the shared [input interpretation](./command-line-o An explicit `--graphics` option cannot be combined with `--output`, including `--graphics auto`. Omitting `--output` starts the TUI, even when stdout is redirected. -Each result ends with one LF. Input line endings are preserved in the parsed text and escaped inside JSON strings or string literals. +Each result ends with one LF. Input line endings are preserved in the parsed text and escaped inside reports or string literals. ## Output Formats | Format | Output | | --- | --- | +| `plain` | A human-readable Unicode analysis report; see [Plain Reports](./plain-reports.md) for its contents and an example | | `json` | A Unicode analysis report; see [JSON Reports](./json-reports.md) for its contents and examples | | `json-string` | A quoted JSON string literal, such as `"A\u0301\uD83D\uDE00"` | | `rust-string` | A quoted Rust string literal, such as `"A\u{301}\u{1F600}"` | diff --git a/docs/src/getting-started/plain-reports.md b/docs/src/getting-started/plain-reports.md new file mode 100644 index 0000000..44cf984 --- /dev/null +++ b/docs/src/getting-started/plain-reports.md @@ -0,0 +1,47 @@ +# Plain Reports + +Use `--output plain` to read a Unicode analysis report without starting the TUI. See [Non-interactive Usage](./non-interactive-usage.md) for input, redirection, and exit codes. + +## Example + +The accent below is U+0301 following U+0041: + +```sh +sauva -o plain --text 'AĢšŸ˜€' +``` + +```text +Input: "A\u0301\uD83D\uDE00" +CP: 3 UTF-8 Bytes: 7 UTF-16 Units: 4 GC: 2 + +Positions: CP# / GC# are 1-based; byte / unit ranges are 0-based, end-exclusive. +CP# GC# Code Point UTF-8 Bytes UTF-16 Units Name +1 1 U+0041 [0,1) [0,1) LATIN CAPITAL LETTER A +2 1 U+0301 [1,3) [1,2) COMBINING ACUTE ACCENT +3 2 U+1F600 [3,7) [2,4) GRINNING FACE + +Normalization Forms + Form State CP UTF-8 Bytes UTF-16 Units GC Result + NFC changed 2 6 3 2 "\u00C1\uD83D\uDE00" + NFD unchanged 3 7 4 2 "A\u0301\uD83D\uDE00" + NFKC changed 2 6 3 2 "\u00C1\uD83D\uDE00" + NFKD unchanged 3 7 4 2 "A\u0301\uD83D\uDE00" + +Unicode Version: 17.0.0 +``` + +## Report Contents + +| Input | Contents | +| --- | --- | +| One code point | The [Inspector's Relevant properties](../features/inspector.md#properties), followed by text counts and normalization results | +| Multiple code points | The original input, text counts, an ordered list of code points with names and positions, and normalization results | +| A surrogate code point | Unicode properties, with reasons for unavailable character, encoding, and text-analysis values | + +CP counts code points; GC counts [extended grapheme clusters](../features/sequence.md#code-points-and-grapheme-clusters). UTF-8 counts bytes; UTF-16 counts 16-bit code units. In the code point list, CP# and GC# start at 1. Ranges start at 0, include the start, and exclude the end. + +Each [normalization form](../features/normalization.md#forms) includes its changed or unchanged state, complete result, and all four counts. A single character's Decomposition property is its direct mapping, separate from the whole-text normalization results. Numeric values preserve exact fractions such as `1/2`. + +Input, Character, and normalized text use the same quoted representation as `json-string`, escaping non-ASCII characters and controls. Reports use English labels, contain no colors or terminal control sequences, and retain all entries and text regardless of terminal width. + +Use [JSON Reports](./json-reports.md) for scripts or complete properties for every code point. Plain report alignment and explanatory wording are not a compatibility contract. diff --git a/docs/src/introduction/index.md b/docs/src/introduction/index.md index 3726d9b..962aa17 100644 --- a/docs/src/introduction/index.md +++ b/docs/src/introduction/index.md @@ -18,7 +18,7 @@ Sauva is a terminal application for searching and browsing Unicode code points. For example, use Sauva to identify an invisible character, find a symbol by name, or examine how an emoji or accented letter is represented in a string. -For scripts, Sauva also provides [non-interactive JSON and Rust string output](../getting-started/non-interactive-usage.md). +Sauva also provides [non-interactive reports and string literals](../getting-started/non-interactive-usage.md) for terminal output and scripts. ## Unicode Data From fb0ed389fabae00d08e40586baf17e72f790e79e Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sat, 31 Oct 2026 10:27:49 +0900 Subject: [PATCH 5/6] Show both plain report structures in collapsible examples --- docs/src/getting-started/plain-reports.md | 68 ++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/docs/src/getting-started/plain-reports.md b/docs/src/getting-started/plain-reports.md index 44cf984..7c5ff43 100644 --- a/docs/src/getting-started/plain-reports.md +++ b/docs/src/getting-started/plain-reports.md @@ -2,7 +2,68 @@ Use `--output plain` to read a Unicode analysis report without starting the TUI. See [Non-interactive Usage](./non-interactive-usage.md) for input, redirection, and exit codes. -## Example +## Examples + +### Single Code Point + +```sh +sauva -o plain U+00BD +``` + +
+Show the complete plain output + +```text +Identity + Character: "\u00BD" + Code Point: U+00BD + Primary Name: VULGAR FRACTION ONE HALF + Plane: 0 — Basic Multilingual Plane + Block: Latin-1 Supplement + +Classification + General Category: No — Other Number + Script: Common + Age: 1.1 + East Asian Width: A — Ambiguous + Bidi Class: ON — Other Neutral + +Numeric + Numeric Type: Numeric + Numeric Value: 1/2 + +Encoding + UTF-8: C2 BD + UTF-16: 00BD + HTML Decimal: ½ + HTML Hex: ½ + Rust char: '\u{BD}' + Unicode Escape: \u00BD + +Normalization + Decomposition Type: Compatibility — fraction + Decomposition: + U+0031 DIGIT ONE + U+2044 FRACTION SLASH + U+0032 DIGIT TWO + +Data + Unicode Version: 17.0.0 + +Text Analysis + CP: 1 UTF-8 Bytes: 2 UTF-16 Units: 1 GC: 1 + +Normalization Forms + Form State CP UTF-8 Bytes UTF-16 Units GC Result + NFC unchanged 1 2 1 1 "\u00BD" + NFD unchanged 1 2 1 1 "\u00BD" + NFKC changed 3 5 3 3 "1\u20442" + NFKD changed 3 5 3 3 "1\u20442" +``` + +
+ +### Text The accent below is U+0301 following U+0041: @@ -10,6 +71,9 @@ The accent below is U+0301 following U+0041: sauva -o plain --text 'AĢšŸ˜€' ``` +
+Show the complete plain output + ```text Input: "A\u0301\uD83D\uDE00" CP: 3 UTF-8 Bytes: 7 UTF-16 Units: 4 GC: 2 @@ -30,6 +94,8 @@ Normalization Forms Unicode Version: 17.0.0 ``` +
+ ## Report Contents | Input | Contents | From f49e54692d0590476c87b2715a89a40fe2e380cb Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sat, 31 Oct 2026 10:28:00 +0900 Subject: [PATCH 6/6] Trim redundant plain report explanations --- docs/src/getting-started/plain-reports.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/getting-started/plain-reports.md b/docs/src/getting-started/plain-reports.md index 7c5ff43..4955eef 100644 --- a/docs/src/getting-started/plain-reports.md +++ b/docs/src/getting-started/plain-reports.md @@ -108,6 +108,6 @@ CP counts code points; GC counts [extended grapheme clusters](../features/sequen Each [normalization form](../features/normalization.md#forms) includes its changed or unchanged state, complete result, and all four counts. A single character's Decomposition property is its direct mapping, separate from the whole-text normalization results. Numeric values preserve exact fractions such as `1/2`. -Input, Character, and normalized text use the same quoted representation as `json-string`, escaping non-ASCII characters and controls. Reports use English labels, contain no colors or terminal control sequences, and retain all entries and text regardless of terminal width. +Input, Character, and normalized text use the same quoted representation as `json-string`, escaping non-ASCII characters and controls. -Use [JSON Reports](./json-reports.md) for scripts or complete properties for every code point. Plain report alignment and explanatory wording are not a compatibility contract. +Use [JSON Reports](./json-reports.md) for scripts or complete properties for every code point.