diff --git a/crates/automation/README.md b/crates/automation/README.md index 176946c175..7a9de516c0 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -199,6 +199,19 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md) and [image editing](../../docs/editing-images.md). +### Superscript and subscript normalization + +`text_script` changes characters to their Unicode superscript or subscript forms; it does not +change the font size or baseline. For characters that have such forms, uniformly plain text +toggles to the requested script, and uniformly scripted text toggles back to plain text. +Characters without a matching form stay unchanged and do not determine the selection's state. + +A selection mixing plain and target-script characters normalizes to the requested script. +For example, subscript changes `H₂2` to `H₂₂`, then to `H22` on the next call; superscript +changes `Q²2` to `Q²²`, then to `Q22`. A second toggle does not reconstruct the original +mixture. Each call is one undoable text edit: `edit_undo` restores the exact prior text, +including its original mixture. Save/reopen preserves the resulting normalized characters. + ## Opening local GoToR links `link_open {doc, page, index, password?}` follows a local PDF link using `link_list` diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e0318fa155..98e0b933ca 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1439,7 +1439,7 @@ pub fn tools() -> Vec { "scale": { "type": "number", "description": "Horizontal scale in percent." } }), &["doc", "page"], )), - t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay as they are and don't stop a second call toggling back. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has a reversible form; ASCII hyphen-minus stays unchanged.") + t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 17d2600a93..aa778273da 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10417,3 +10417,41 @@ fn image_flip_tools_follow_displayed_axes_on_every_page_rotation() { } assert!(failures.is_empty(), "{}", failures.join("\n")); } + +#[test] +fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() { + let dir = workdir("mixed-script-policy"); + let mut a = auto(&dir); + for (script, mixed, uniform, plain) in [("superscript", "Q²2", "Q²²", "Q22"), ("subscript", "H₂2", "H₂₂", "H22")] { + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":mixed,"size":14,"width":160})); + let paragraph = + |a: &mut Automation| ok(a, "text_paragraphs", json!({"doc":doc,"page":1}))["paragraphs"][0]["editor_text"].as_str().unwrap().to_owned(); + assert_eq!(paragraph(&mut a), mixed); + let before = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone(); + let args = json!({"doc":doc,"page":1,"paragraph":1,"script":script}); + let once = ok(&mut a, "text_script", args.clone()); + assert_eq!(once["paragraph"]["text"], uniform); + assert_eq!(paragraph(&mut a), uniform); + ok(&mut a, "text_script", args); + assert_eq!(paragraph(&mut a), plain); + assert_ne!(plain, mixed, "normalization does not promise to recreate the original mixture"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), uniform, "one Undo restores the preceding scripted text"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), mixed, "Undo restores the exact initial Unicode mixture"); + assert_eq!( + a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.as_ref(), + before.as_ref(), + "Undo also restores the exact prior document bytes" + ); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), uniform); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), plain); + let path = format!("mixed-{script}.pdf"); + ok(&mut a, "doc_save", json!({"doc":doc,"path":path})); + let reopened = ok(&mut a, "doc_open", json!({"path":path}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, reopened)[0], plain); + } +} diff --git a/crates/engine/src/text_script.rs b/crates/engine/src/text_script.rs index 268fca3d21..04866eda60 100644 --- a/crates/engine/src/text_script.rs +++ b/crates/engine/src/text_script.rs @@ -4,6 +4,11 @@ //! 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. +//! +//! A selection mixing plain and target-script characters is normalized: `H₂2` becomes `H₂₂`, +//! then `H22` on the next subscript toggle; superscript follows the same policy. Two toggles +//! restore uniformly plain or uniformly scripted input, but do not reconstruct an original +//! mixture. Restoring that exact prior text requires the caller's edit history (Undo). use std::ops::Range; @@ -150,7 +155,7 @@ fn span(text: &str, range: Option<&Range>) -> Range { /// 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. +/// form for (a space, a `q`) don't count toward that state. 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()) @@ -160,6 +165,10 @@ pub fn is_in(text: &str, range: Option<&Range>, script: Script) -> bool { /// `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. +/// +/// Mixed plain/scripted input normalizes to the target script and then to plain text; for +/// example, `H₂2` → `H₂₂` → `H22`. This stateless conversion does not remember the original +/// mixture. Use edit history to restore it, rather than treating a second toggle as Undo. 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())?; @@ -231,6 +240,22 @@ mod tests { assert!(!is_in("H₂2", None, Script::Sub)); } + #[test] + fn mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping() { + for (script, mixed, uniform, plain) in [(Script::Super, "Q²2", "Q²²", "Q22"), (Script::Sub, "H₂2", "H₂₂", "H22")] { + assert!(!is_in(mixed, None, script)); + let (once, range) = toggle(mixed, None, script).unwrap(); + assert_eq!(once, uniform); + assert_eq!(range, 0..uniform.len()); + assert!(is_in(&once, None, script)); + let (twice, range) = toggle(&once, None, script).unwrap(); + assert_eq!(twice, plain); + assert_eq!(range, 0..plain.len()); + assert_ne!(twice, mixed, "a second toggle normalizes the original mixture"); + assert_eq!(toggle(&twice, None, script).unwrap().0, uniform); + } + } + #[test] fn hostile_ranges_are_clamped_to_whole_characters() { // Byte 1 is inside ²: widened to the whole character on each side. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 024ae558a5..4a1f78514f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1974,8 +1974,10 @@ evidence = [ "crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion", "crates/ui-egui/tests/it/editing.rs::the_format_panel_toggles_superscript_and_subscript_on_selection", "crates/automation/tests/it/automation.rs::superscript_and_subscript_through_tools", + "crates/engine/src/text_script.rs::mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping", + "crates/automation/tests/it/automation.rs::mixed_script_tools_normalize_but_undo_restores_the_exact_mixture", ] -notes = "Toggled in the Format text panel for the selection, and headlessly with text_script on a paragraph or a byte range of it; each toggle of a paragraph is one undo step. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters. Characters Unicode has no form for (q, most capitals in subscript) stay as they are and don't stop a toggle back; the mathematical minus sign U+2212 has a reversible form, while ASCII hyphen-minus stays unchanged." +notes = "Toggled in the Format text panel for the selection, and headlessly with text_script on a paragraph or a byte range of it; each toggle of a paragraph is one undo step. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters. Characters Unicode has no form for (q, most capitals in subscript) stay unchanged and do not determine script state. Two toggles restore uniformly plain or uniformly scripted inputs. Mixed input normalizes (H₂2 → H₂₂ → H22), so a second toggle does not recover the original mixture; Undo restores the exact prior text. The mathematical minus sign U+2212 has its own script mapping, while ASCII hyphen-minus stays unchanged." [[feature]] id = "edit.paragraph-alignment"