From f01823f6fad6bf5dedcfd5926265aa85d60fd80c Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:18:59 +0100 Subject: [PATCH] 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 --- crates/engine/src/lib.rs | 1 + crates/engine/src/text_script.rs | 240 +++++++++++++++ crates/ui-egui/src/panels/edit.rs | 41 +-- crates/ui-egui/src/panels/format_text_ui.rs | 34 +-- crates/ui-egui/src/tools/edit_text_ui.rs | 316 ++------------------ 5 files changed, 277 insertions(+), 355 deletions(-) create mode 100644 crates/engine/src/text_script.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5043a558e7..ce8d3c9cec 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/text_script.rs b/crates/engine/src/text_script.rs new file mode 100644 index 0000000000..f1f72a1681 --- /dev/null +++ b/crates/engine/src/text_script.rs @@ -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 { + 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 { + 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>) -> Range { + 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>, 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>, script: Script) -> Option<(String, Range)> { + 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)); + } +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 71a53edddc..100b0cc11b 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index c89a21bd79..ea3bfaa3ce 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -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, - pub toggle_superscript: bool, - pub toggle_subscript: bool, + /// Superscript or Subscript was clicked, for the selected paragraphs' text. + pub script: Option