mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 09:21:17 +00:00
An explicit size commit normalizes mixed-size selections even when the value matches the displayed size. Caret-only script commands format the whole draft. Mathematical minus uses reversible Unicode forms and ASCII hyphen-minus remains unchanged. Correct the applied-selection comment and document the character policy in parity and the tool. Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
248 lines
8.2 KiB
Rust
248 lines
8.2 KiB
Rust
//! 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 distinct reversible form for it.
|
||
/// Mathematical minus U+2212 has a form; ASCII hyphen-minus stays unchanged to avoid a lossy collision.
|
||
pub fn form(self, c: char) -> Option<char> {
|
||
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}");
|
||
}
|
||
}
|
||
assert_eq!(toggle("-", None, Script::Super), None);
|
||
assert_eq!(toggle("-", None, Script::Sub), None);
|
||
}
|
||
|
||
#[test]
|
||
fn review_script_round_trips_preserve_both_minus_characters() {
|
||
for script in [Script::Super, Script::Sub] {
|
||
let text = "−2 -3";
|
||
let once = toggle(text, None, script).unwrap().0;
|
||
assert_eq!(toggle(&once, None, script).unwrap().0, text);
|
||
}
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
}
|