feat(automation): text_script tool for superscript and subscript

Format text's Superscript and Subscript had no headless path (AGENTS §3):
text_script toggles them on a paragraph, or a byte range of its editor text,
through the engine's text_script and writes the paragraph as text_edit does.
The parity entry now lists the tool and its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FredApps
2026-11-01 12:34:56 +01:00
co-authored by Claude Opus 5.5
parent f01823f6fa
commit 8abd35cf67
4 changed files with 94 additions and 2 deletions
+49
View File
@@ -668,6 +668,7 @@ impl Automation {
}
out
}
"text_script" => self.text_script(&a)?,
"doc_audit_space" => {
let rows: Vec<Value> = 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<Value> {
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)
}
}
+8
View File
@@ -1439,6 +1439,14 @@ pub fn tools() -> Vec<ToolDef> {
"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.")
+32
View File
@@ -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() {
+5 -2
View File
@@ -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"