refactor(edit): one superscript/subscript toggle, in the engine

The four character match blocks become two const tables in a new engine
module, text_script, keeping the minus sign's mapping. One toggle_script in
Edit text replaces the three copies (paragraph editor, added-text draft and
the Format text button state).

A range is now 'already in the script' when at least one character is and
none of the others has a form in it. Before, one character with no Unicode
form (a space, q, H in H2O) kept the range from ever counting as converted,
so the second click converted again, changed nothing, and the text could not
be toggled back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FredApps
2026-11-01 12:34:54 +01:00
co-authored by Claude Opus 5.5
parent 2ef192b7cd
commit f01823f6fa
5 changed files with 277 additions and 355 deletions
+1
View File
@@ -54,6 +54,7 @@ mod stamp_preview;
mod test_hooks;
pub mod text_fonts;
mod text_preview;
pub mod text_script;
pub mod text_style_bookmarks;
pub mod word_count;
pub mod xfa;
+240
View File
@@ -0,0 +1,240 @@
//! Superscript and subscript as Unicode characters (Edit a PDF ▸ Format text): `2` ↔ `²` and
//! `2` ↔ `₂`.
//!
//! The text keeps its font, size and baseline; its characters change to the forms Unicode encodes
//! for raised and lowered text, so the font must have those glyphs, and extracted text contains
//! them. Characters Unicode has no such form for are left as they are.
use std::ops::Range;
/// Raised or lowered text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Script {
Super,
Sub,
}
/// Each character Unicode raises, and its superscript form.
const SUPERSCRIPT: &[(char, char)] = &[
('0', '⁰'),
('1', '¹'),
('2', '²'),
('3', '³'),
('4', '⁴'),
('5', '⁵'),
('6', '⁶'),
('7', '⁷'),
('8', '⁸'),
('9', '⁹'),
('+', '⁺'),
('-', '⁻'),
('=', '⁼'),
('(', '⁽'),
(')', '⁾'),
('a', 'ᵃ'),
('b', 'ᵇ'),
('c', 'ᶜ'),
('d', 'ᵈ'),
('e', 'ᵉ'),
('f', 'ᶠ'),
('g', 'ᵍ'),
('h', 'ʰ'),
('i', 'ⁱ'),
('j', 'ʲ'),
('k', 'ᵏ'),
('l', 'ˡ'),
('m', 'ᵐ'),
('n', 'ⁿ'),
('o', 'ᵒ'),
('p', 'ᵖ'),
('r', 'ʳ'),
('s', 'ˢ'),
('t', 'ᵗ'),
('u', 'ᵘ'),
('v', 'ᵛ'),
('w', 'ʷ'),
('x', 'ˣ'),
('y', 'ʸ'),
('z', 'ᶻ'),
('A', 'ᴬ'),
('B', 'ᴮ'),
('D', 'ᴰ'),
('E', 'ᴱ'),
('G', 'ᴳ'),
('H', 'ᴴ'),
('I', 'ᴵ'),
('J', 'ᴶ'),
('K', 'ᴷ'),
('L', 'ᴸ'),
('M', 'ᴹ'),
('N', 'ᴺ'),
('O', 'ᴼ'),
('P', 'ᴾ'),
('R', 'ᴿ'),
('T', 'ᵀ'),
('U', 'ᵁ'),
('V', 'ⱽ'),
('W', 'ᵂ'),
];
/// Each character Unicode lowers, and its subscript form.
const SUBSCRIPT: &[(char, char)] = &[
('0', '₀'),
('1', '₁'),
('2', '₂'),
('3', '₃'),
('4', '₄'),
('5', '₅'),
('6', '₆'),
('7', '₇'),
('8', '₈'),
('9', '₉'),
('+', '₊'),
('-', '₋'),
('=', '₌'),
('(', '₍'),
(')', '₎'),
('a', 'ₐ'),
('e', 'ₑ'),
('h', 'ₕ'),
('i', 'ᵢ'),
('j', 'ⱼ'),
('k', 'ₖ'),
('l', 'ₗ'),
('m', 'ₘ'),
('n', 'ₙ'),
('o', 'ₒ'),
('p', 'ₚ'),
('r', 'ᵣ'),
('s', 'ₛ'),
('t', 'ₜ'),
('u', 'ᵤ'),
('v', 'ᵥ'),
('x', 'ₓ'),
];
impl Script {
fn pairs(self) -> &'static [(char, char)] {
match self {
Script::Super => SUPERSCRIPT,
Script::Sub => SUBSCRIPT,
}
}
/// `c` raised or lowered, or `None` when Unicode has no such form for it. The minus sign
/// (U+2212) takes the hyphen-minus's form.
pub fn form(self, c: char) -> Option<char> {
let c = if c == '−' { '-' } else { c };
self.pairs().iter().find(|(plain, _)| *plain == c).map(|&(_, form)| form)
}
/// The character `c` is the raised or lowered form of, or `None` when it is no such form.
pub fn plain(self, c: char) -> Option<char> {
self.pairs().iter().find(|(_, form)| *form == c).map(|&(plain, _)| plain)
}
}
/// `range` (UTF-8 bytes of `text`) clamped to it and widened to whole characters, or all of `text`
/// when there is none.
fn span(text: &str, range: Option<&Range<usize>>) -> Range<usize> {
let Some(range) = range else { return 0..text.len() };
let mut start = range.start.min(text.len());
while !text.is_char_boundary(start) {
start = start.saturating_sub(1);
}
let mut end = range.end.min(text.len());
while !text.is_char_boundary(end) {
end = end.saturating_add(1);
}
start..end
}
/// Whether the characters of `text` in `range` (UTF-8 bytes; all of it when `None`) are already in
/// `script`: at least one of them is, and none of the others could be. Characters Unicode has no
/// form for (a space, a `q`) don't count, so toggling text that has some still toggles back.
pub fn is_in(text: &str, range: Option<&Range<usize>>, script: Script) -> bool {
let target = text.get(span(text, range)).unwrap_or_default();
target.chars().any(|c| script.plain(c).is_some()) && target.chars().all(|c| script.plain(c).is_some() || script.form(c).is_none())
}
/// Superscript or Subscript on the characters of `text` in `range` (UTF-8 bytes; all of it when
/// `None`): back to plain characters when they are already in `script` ([`is_in`]), and otherwise
/// each character that has a form takes it. Returns the new text and the range the converted
/// characters now take, or `None` when nothing changes.
pub fn toggle(text: &str, range: Option<&Range<usize>>, script: Script) -> Option<(String, Range<usize>)> {
let span = span(text, range);
let target = text.get(span.clone()).filter(|t| !t.is_empty())?;
let converted: String = if is_in(text, Some(&span), script) {
target.chars().map(|c| script.plain(c).unwrap_or(c)).collect()
} else {
target.chars().map(|c| script.form(c).unwrap_or(c)).collect()
};
if converted == target {
return None;
}
let mut out = String::with_capacity(text.len().saturating_add(converted.len()));
out.push_str(text.get(..span.start).unwrap_or_default());
out.push_str(&converted);
out.push_str(text.get(span.end..).unwrap_or_default());
let end = span.start.saturating_add(converted.len());
Some((out, span.start..end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_form_toggles_back_to_its_character() {
for script in [Script::Super, Script::Sub] {
for &(plain, form) in script.pairs() {
let s = plain.to_string();
let (raised, range) = toggle(&s, None, script).unwrap();
assert_eq!(raised, form.to_string());
assert_eq!(range, 0..raised.len());
assert_eq!(toggle(&raised, None, script).unwrap().0, s, "{script:?} {plain}");
}
}
// The minus sign takes the hyphen's form.
assert_eq!(toggle("−", None, Script::Super).unwrap().0, "⁻");
assert_eq!(toggle("−", None, Script::Sub).unwrap().0, "₋");
}
#[test]
fn text_with_characters_unicode_cannot_raise_toggles_back() {
// q has no superscript form and Q no subscript one; the space neither.
for (script, text, toggled) in [(Script::Super, "x2 q", "ˣ² q"), (Script::Sub, "CO2 Q", "CO₂ Q")] {
let (once, _) = toggle(text, None, script).unwrap();
assert_eq!(once, toggled);
assert!(is_in(&once, None, script));
assert_eq!(toggle(&once, None, script).unwrap().0, text, "{script:?}");
}
// Nothing to raise: nothing changes.
assert_eq!(toggle("q Q", None, Script::Super), None);
assert!(!is_in("q Q", None, Script::Super));
}
#[test]
fn a_range_converts_only_its_characters_and_follows_them() {
let (text, range) = toggle("E = mc2", Some(&(6..7)), Script::Super).unwrap();
assert_eq!((text.as_str(), range.clone()), ("E = mc²", 6..8));
assert_eq!(toggle(&text, Some(&range), Script::Super).unwrap(), ("E = mc2".to_string(), 6..7));
// A mixed range takes the form throughout.
assert_eq!(toggle("H₂2", None, Script::Sub).unwrap().0, "H₂₂");
assert!(!is_in("H₂2", None, Script::Sub));
}
#[test]
fn hostile_ranges_are_clamped_to_whole_characters() {
// Byte 1 is inside ²: widened to the whole character on each side.
assert_eq!(toggle("²x", Some(&(1..1)), Script::Super).unwrap(), ("2x".to_string(), 0..1));
assert_eq!(toggle("²x", Some(&(2..2)), Script::Super), None);
assert_eq!(toggle("x2", Some(&(1..usize::MAX)), Script::Super).unwrap(), ("x²".to_string(), 1..3));
assert_eq!(toggle("x2", Some(&(5..9)), Script::Super), None);
#[allow(clippy::reversed_empty_ranges)]
let backwards = 2..1;
assert_eq!(toggle("x2", Some(&backwards), Script::Super), None);
assert_eq!(toggle("", None, Script::Sub), None);
assert!(!is_in("", None, Script::Sub));
}
}
+6 -35
View File
@@ -281,11 +281,8 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
Some(paragraphs),
);
let mut changed = out.changed;
if out.toggle_superscript && crate::edit_text_ui::toggle_superscript(&mut ed) {
changed = true;
}
if out.toggle_subscript && crate::edit_text_ui::toggle_subscript(&mut ed) {
changed = true;
if let Some(script) = out.script {
changed |= crate::edit_text_ui::toggle_script(&mut ed.text, &mut ed.selection, script);
}
match out.font {
Some(FontChoice::KeepSource) => {
@@ -322,36 +319,10 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
let mut style = draft.style.clone();
let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true };
let out = added_text_format(ui, t, &mut style, Some(paragraphs));
if out.toggle_superscript || out.toggle_subscript {
let (s_pos, e_pos) =
if let Some(r) = &draft.selection { (r.start.min(draft.text.len()), r.end.min(draft.text.len())) } else { (0, draft.text.len()) };
let (start, end) = crate::edit_text_ui::snap_char_boundaries(&draft.text, s_pos, e_pos);
if start < end {
let target = &draft.text[start..end];
let converted: String = if out.toggle_superscript {
let all_sup = target.chars().all(|c| crate::edit_text_ui::char_from_superscript(c) != c);
if all_sup {
target.chars().map(crate::edit_text_ui::char_from_superscript).collect()
} else {
target.chars().map(crate::edit_text_ui::char_to_superscript).collect()
}
} else {
let all_sub = target.chars().all(|c| crate::edit_text_ui::char_from_subscript(c) != c);
if all_sub {
target.chars().map(crate::edit_text_ui::char_from_subscript).collect()
} else {
target.chars().map(crate::edit_text_ui::char_to_subscript).collect()
}
};
if converted != target {
let mut new_draft = draft.clone();
let conv_len = converted.len();
new_draft.text.replace_range(start..end, &converted);
if new_draft.selection.is_some() {
new_draft.selection = Some(start..start + conv_len);
}
app.views[i].content.draft = Some(new_draft);
}
if let Some(script) = out.script {
let mut toggled = draft.clone();
if crate::edit_text_ui::toggle_script(&mut toggled.text, &mut toggled.selection, script) {
app.views[i].content.draft = Some(toggled);
}
}
if style != draft.style {
+9 -25
View File
@@ -9,6 +9,7 @@
//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat.
use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
use pdfcraft_engine::text_script::Script;
use pdfcraft_engine::{AddedText, FontFamily, TextAlign};
use crate::edit_text_ui::Extras;
@@ -87,8 +88,8 @@ impl Paragraphs<'_> {
pub(crate) struct Outcome {
pub changed: bool,
pub font: Option<FontChoice>,
pub toggle_superscript: bool,
pub toggle_subscript: bool,
/// Superscript or Subscript was clicked, for the selected paragraphs' text.
pub script: Option<Script>,
}
const SIZES: [f64; 16] = [4.0, 6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0];
@@ -300,30 +301,13 @@ pub(crate) fn panel(
}
None => planned(ui, "underline", tl!("Underline")),
}
let (is_super, is_sub) = paragraphs
.as_ref()
.map(|p| {
let (s_pos, e_pos) =
if let Some(r) = &p.selection { (r.start.min(p.text.len()), r.end.min(p.text.len())) } else { (0, p.text.len()) };
let (start, end) = crate::edit_text_ui::snap_char_boundaries(p.text, s_pos, e_pos);
if start >= end {
return (false, false);
if let Some(p) = &paragraphs {
for (script, icon, tip) in [(Script::Super, "superscript", tl!("Superscript")), (Script::Sub, "subscript", tl!("Subscript"))] {
let on = pdfcraft_engine::text_script::is_in(p.text, p.selection.as_ref(), script);
if icons::button(ui, icon, 30.0, on, tip).clicked() {
out.script = Some(script);
out.changed = true;
}
let target = &p.text[start..end];
let all_sup = !target.is_empty() && target.chars().all(|c| crate::edit_text_ui::char_from_superscript(c) != c);
let all_sub = !target.is_empty() && target.chars().all(|c| crate::edit_text_ui::char_from_subscript(c) != c);
(all_sup, all_sub)
})
.unwrap_or((false, false));
if paragraphs.is_some() {
if icons::button(ui, "superscript", 30.0, is_super, tl!("Superscript")).clicked() {
out.toggle_superscript = true;
out.changed = true;
}
if icons::button(ui, "subscript", 30.0, is_sub, tl!("Subscript")).clicked() {
out.toggle_subscript = true;
out.changed = true;
}
} else {
planned(ui, "superscript", tl!("Superscript"));
+21 -295
View File
@@ -46,265 +46,16 @@ enum TextTarget {
/// hit-testing still covers every image, so nothing becomes unselectable.
pub(crate) const MAX_OUTLINES: usize = 250;
pub fn char_to_superscript(c: char) -> char {
match c {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
'+' => '⁺',
'-' | '−' => '⁻',
'=' => '⁼',
'(' => '⁽',
')' => '⁾',
'a' => 'ᵃ',
'b' => 'ᵇ',
'c' => 'ᶜ',
'd' => 'ᵈ',
'e' => 'ᵉ',
'f' => 'ᶠ',
'g' => 'ᵍ',
'h' => 'ʰ',
'i' => 'ⁱ',
'j' => 'ʲ',
'k' => 'ᵏ',
'l' => 'ˡ',
'm' => 'ᵐ',
'n' => 'ⁿ',
'o' => 'ᵒ',
'p' => 'ᵖ',
'r' => 'ʳ',
's' => 'ˢ',
't' => 'ᵗ',
'u' => 'ᵘ',
'v' => 'ᵛ',
'w' => 'ʷ',
'x' => 'ˣ',
'y' => 'ʸ',
'z' => 'ᶻ',
'A' => 'ᴬ',
'B' => 'ᴮ',
'D' => 'ᴰ',
'E' => 'ᴱ',
'G' => 'ᴳ',
'H' => 'ᴴ',
'I' => 'ᴵ',
'J' => 'ᴶ',
'K' => 'ᴷ',
'L' => 'ᴸ',
'M' => 'ᴹ',
'N' => 'ᴺ',
'O' => 'ᴼ',
'P' => 'ᴾ',
'R' => 'ᴿ',
'T' => 'ᵀ',
'U' => 'ᵁ',
'V' => 'ⱽ',
'W' => 'ᵂ',
other => other,
}
}
pub fn char_from_superscript(c: char) -> char {
match c {
'⁰' => '0',
'¹' => '1',
'²' => '2',
'³' => '3',
'⁴' => '4',
'⁵' => '5',
'⁶' => '6',
'⁷' => '7',
'⁸' => '8',
'⁹' => '9',
'⁺' => '+',
'⁻' => '-',
'⁼' => '=',
'⁽' => '(',
'⁾' => ')',
'ᵃ' => 'a',
'ᵇ' => 'b',
'ᶜ' => 'c',
'ᵈ' => 'd',
'ᵉ' => 'e',
'ᶠ' => 'f',
'ᵍ' => 'g',
'ʰ' => 'h',
'ⁱ' => 'i',
'ʲ' => 'j',
'ᵏ' => 'k',
'ˡ' => 'l',
'ᵐ' => 'm',
'ⁿ' => 'n',
'ᵒ' => 'o',
'ᵖ' => 'p',
'ʳ' => 'r',
'ˢ' => 's',
'ᵗ' => 't',
'ᵘ' => 'u',
'ᵛ' => 'v',
'ʷ' => 'w',
'ˣ' => 'x',
'ʸ' => 'y',
'ᶻ' => 'z',
'ᴬ' => 'A',
'ᴮ' => 'B',
'ᴰ' => 'D',
'ᴱ' => 'E',
'ᴳ' => 'G',
'ᴴ' => 'H',
'ᴵ' => 'I',
'ᴶ' => 'J',
'ᴷ' => 'K',
'ᴸ' => 'L',
'ᴹ' => 'M',
'ᴺ' => 'N',
'ᴼ' => 'O',
'ᴾ' => 'P',
'ᴿ' => 'R',
'ᵀ' => 'T',
'ᵁ' => 'U',
'ⱽ' => 'V',
'ᵂ' => 'W',
other => other,
}
}
pub fn char_to_subscript(c: char) -> char {
match c {
'0' => '₀',
'1' => '₁',
'2' => '₂',
'3' => '₃',
'4' => '₄',
'5' => '₅',
'6' => '₆',
'7' => '₇',
'8' => '₈',
'9' => '₉',
'+' => '₊',
'-' | '−' => '₋',
'=' => '₌',
'(' => '₍',
')' => '₎',
'a' => 'ₐ',
'e' => 'ₑ',
'h' => 'ₕ',
'i' => 'ᵢ',
'j' => 'ⱼ',
'k' => 'ₖ',
'l' => 'ₗ',
'm' => 'ₘ',
'n' => 'ₙ',
'o' => 'ₒ',
'p' => 'ₚ',
'r' => 'ᵣ',
's' => 'ₛ',
't' => 'ₜ',
'u' => 'ᵤ',
'v' => 'ᵥ',
'x' => 'ₓ',
other => other,
}
}
pub fn char_from_subscript(c: char) -> char {
match c {
'₀' => '0',
'₁' => '1',
'₂' => '2',
'₃' => '3',
'₄' => '4',
'₅' => '5',
'₆' => '6',
'₇' => '7',
'₈' => '8',
'₉' => '9',
'₊' => '+',
'₋' => '-',
'₌' => '=',
'₍' => '(',
'₎' => ')',
'ₐ' => 'a',
'ₑ' => 'e',
'ₕ' => 'h',
'ᵢ' => 'i',
'ⱼ' => 'j',
'ₖ' => 'k',
'ₗ' => 'l',
'ₘ' => 'm',
'ₙ' => 'n',
'ₒ' => 'o',
'ₚ' => 'p',
'ᵣ' => 'r',
'ₛ' => 's',
'ₜ' => 't',
'ᵤ' => 'u',
'ᵥ' => 'v',
'ₓ' => 'x',
other => other,
}
}
pub(crate) fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) {
let mut s_idx = start.min(s.len());
while s_idx > 0 && !s.is_char_boundary(s_idx) {
s_idx -= 1;
}
let mut e_idx = end.min(s.len());
while e_idx < s.len() && !s.is_char_boundary(e_idx) {
e_idx += 1;
}
(s_idx, e_idx)
}
pub fn toggle_superscript(ed: &mut LineEditor) -> bool {
let (s_pos, e_pos) = if let Some(r) = &ed.selection { (r.start.min(ed.text.len()), r.end.min(ed.text.len())) } else { (0, ed.text.len()) };
let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos);
if start >= end {
return false;
}
let target = &ed.text[start..end];
let all_sup = target.chars().all(|c| char_from_superscript(c) != c);
let converted: String =
if all_sup { target.chars().map(char_from_superscript).collect() } else { target.chars().map(char_to_superscript).collect() };
if converted != target {
let conv_len = converted.len();
ed.text.replace_range(start..end, &converted);
if ed.selection.is_some() {
ed.selection = Some(start..start + conv_len);
}
true
} else {
false
}
}
pub fn toggle_subscript(ed: &mut LineEditor) -> bool {
let (s_pos, e_pos) = if let Some(r) = &ed.selection { (r.start.min(ed.text.len()), r.end.min(ed.text.len())) } else { (0, ed.text.len()) };
let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos);
if start >= end {
return false;
}
let target = &ed.text[start..end];
let all_sub = target.chars().all(|c| char_from_subscript(c) != c);
let converted: String = if all_sub { target.chars().map(char_from_subscript).collect() } else { target.chars().map(char_to_subscript).collect() };
if converted != target {
let conv_len = converted.len();
ed.text.replace_range(start..end, &converted);
if ed.selection.is_some() {
ed.selection = Some(start..start + conv_len);
}
true
} else {
false
/// Format text ▸ Superscript or Subscript on `text`: on the `selection` (bytes of `text`), or all
/// of it without one ([`pdfcraft_engine::text_script::toggle`]). A selection follows the
/// converted characters. Whether the text changed.
pub(crate) fn toggle_script(text: &mut String, selection: &mut Option<std::ops::Range<usize>>, script: pdfcraft_engine::text_script::Script) -> bool {
let Some((toggled, range)) = pdfcraft_engine::text_script::toggle(text, selection.as_ref(), script) else { return false };
*text = toggled;
if selection.is_some() {
*selection = Some(range);
}
true
}
/// A paragraph being edited.
@@ -1614,44 +1365,19 @@ C", &picked
#[test]
fn superscript_and_subscript_conversion() {
let mut ed = LineEditor {
page: 0,
block: 0,
text: "E = mc2".to_string(),
original: "E = mc2".to_string(),
rect: egui::Rect::ZERO,
source_rect: [0.0; 4],
max_width: 100.0,
overflow: false,
selection: Some(6..7), // selecting '2'
font_resource: None,
fonts: std::collections::BTreeMap::new(),
source: None,
multiline: false,
size: 12.0,
family_chosen: false,
focus: true,
had_focus: false,
look: pdfcraft_engine::AddedText::default(),
look0: pdfcraft_engine::AddedText::default(),
extras: Extras::default(),
extras0: Extras::default(),
typing_from: None,
};
use pdfcraft_engine::text_script::Script;
let (mut text, mut selection) = ("E = mc2".to_string(), Some(6..7));
assert!(toggle_script(&mut text, &mut selection, Script::Super));
assert_eq!((text.as_str(), selection.clone()), ("E = mc²", Some(6..8)), "the selection follows the converted character");
assert!(toggle_script(&mut text, &mut selection, Script::Super));
assert_eq!((text.as_str(), selection), ("E = mc2", Some(6..7)));
assert!(toggle_superscript(&mut ed));
assert_eq!(ed.text, "E = mc²");
// Toggling superscript again returns to normal '2'
assert!(toggle_superscript(&mut ed));
assert_eq!(ed.text, "E = mc2");
// Test subscript on H2O
ed.text = "H2O".to_string();
ed.selection = Some(1..2); // selecting '2'
assert!(toggle_subscript(&mut ed));
assert_eq!(ed.text, "H₂O");
assert!(toggle_subscript(&mut ed));
assert_eq!(ed.text, "H2O");
// No selection: the whole text. H and O have no subscript form, and it still toggles back.
let (mut text, mut none) = ("H2O".to_string(), None);
assert!(toggle_script(&mut text, &mut none, Script::Sub));
assert_eq!((text.as_str(), none.clone()), ("H₂O", None));
assert!(toggle_script(&mut text, &mut none, Script::Sub));
assert_eq!(text, "H2O");
}
#[test]