mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
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:
co-authored by
Claude Opus 5.5
parent
2ef192b7cd
commit
f01823f6fa
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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) = ¶graphs {
|
||||
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"));
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user