diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index da1c3a6d20..a3c34048a3 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -668,6 +668,7 @@ impl Automation { } out } + "text_script" => self.text_script(&a)?, "doc_audit_space" => { let rows: Vec = self .doc(&a)? @@ -859,4 +860,52 @@ impl Automation { }; Ok(vec![Content::Json(out)]) } + + /// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range + /// of its editor text, written as `text_edit` writes the paragraph. + fn text_script(&mut self, a: &Args) -> Result { + use pdfcraft_engine::text_script::{Script, toggle}; + let name = a.str("script")?; + let script = match name { + "superscript" => Script::Super, + "subscript" => Script::Sub, + other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))), + }; + let page = self.page(a)?; + let k = a.int("paragraph")?; + let text = { + let doc = self.doc(a)?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))? + }; + let range = match a.opt_ints("range")? { + None => None, + Some(r) => { + let bounds = match r.as_slice() { + [start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()), + _ => None, + }; + let range = bounds.map(|(start, end)| start..end).filter(|r| { + r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end) + }); + let Some(range) = range else { + return Err(ToolError::InvalidArgs(format!( + "range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries", + text.len() + ))); + }; + Some(range) + } + }; + let (toggled, _) = toggle(&text, range.as_ref(), script) + .ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?; + let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled }); + let mut out = match self.call("text_edit", &args)?.into_iter().next() { + Some(Content::Json(out)) => out, + _ => json!({}), + }; + out["script"] = json!(name); + Ok(out) + } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f144beedc6..ba6a7e9d88 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1439,6 +1439,14 @@ 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.") + .cmd("edit.edit_text") + .with(schema( + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, + "script": { "type": "string", "enum": ["superscript", "subscript"] }, + "range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }), + &["doc", "page", "paragraph", "script"], + )), t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.") .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 520e0b22d1..60d8d58edd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3286,6 +3286,38 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +/// Format text ▸ Superscript and Subscript without the GUI: a range, then back again over a space +/// (which has no superscript form), each one undo step; refusals change nothing. +#[test] +fn superscript_and_subscript_through_tools() { + let dir = workdir("text-script"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let paragraph = |a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 2 }))["paragraphs"][0]["text"].as_str().unwrap().to_string(); + let script = |script: &str, range: Value| json!({ "doc": doc, "page": 2, "paragraph": 1, "script": script, "range": range }); + // "Page 2": the 2 is byte 5. + let r = ok(&mut a, "text_script", script("superscript", json!([5, 6]))); + assert_eq!((r["script"].as_str(), r["paragraph"]["text"].as_str()), (Some("superscript"), Some("Page ²")), "{r}"); + assert_eq!(page_text(&mut a, doc)[1], "Page ²"); + // " ²": the space has no superscript form, and the range still toggles back. + ok(&mut a, "text_script", script("superscript", json!([4, 7]))); + assert_eq!(paragraph(&mut a), "Page 2"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text"); + assert_eq!(paragraph(&mut a), "Page ²"); + // Refused, changing nothing: a range that splits ², one past the end, an empty one, a range + // with nothing that has a subscript form, an unknown script. + for (name, range) in [ + ("superscript", json!([5, 6])), + ("superscript", json!([0, 99])), + ("superscript", json!([3, 3])), + ("subscript", json!([4, 5])), + ("italic", json!([0, 1])), + ] { + assert!(matches!(a.call("text_script", &script(name, range.clone())), Err(ToolError::InvalidArgs(_))), "{name} {range}"); + } + assert_eq!(paragraph(&mut a), "Page ²"); +} + #[test] fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() { if pdfcraft_fonts::document_japanese_font().is_none() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b36cba3677..677baf3efb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1967,12 +1967,15 @@ tier = "P2" milestone = "M7" status = "partial" commands = ["edit.edit_text"] -tools = [] +tools = ["text_script"] evidence = [ + "crates/engine/src/text_script.rs::every_form_toggles_back_to_its_character", + "crates/engine/src/text_script.rs::text_with_characters_unicode_cannot_raise_toggles_back", "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", ] -notes = "Toggled in the Format text panel for the selection. 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." +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 minus sign comes back as a hyphen-minus." [[feature]] id = "edit.paragraph-alignment"