fix(text): apply explicit sizes and preserve script characters

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>
This commit is contained in:
FredApps
2026-11-01 14:51:27 +01:00
co-authored by Codex GPT-6.1
parent a310d5a41a
commit faf85f5af9
6 changed files with 64 additions and 14 deletions
+1 -1
View File
@@ -1439,7 +1439,7 @@ 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.")
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.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 },
+15 -8
View File
@@ -27,7 +27,7 @@ const SUPERSCRIPT: &[(char, char)] = &[
('8', '⁸'),
('9', '⁹'),
('+', '⁺'),
('-', '⁻'),
('−', '⁻'),
('=', '⁼'),
('(', '⁽'),
(')', '⁾'),
@@ -90,7 +90,7 @@ const SUBSCRIPT: &[(char, char)] = &[
('8', '₈'),
('9', '₉'),
('+', '₊'),
('-', '₋'),
('−', '₋'),
('=', '₌'),
('(', '₍'),
(')', '₎'),
@@ -121,10 +121,9 @@ impl Script {
}
}
/// `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.
/// `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> {
let c = if c == '−' { '-' } else { c };
self.pairs().iter().find(|(plain, _)| *plain == c).map(|&(_, form)| form)
}
@@ -195,9 +194,17 @@ mod tests {
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, "₋");
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]
+3
View File
@@ -280,6 +280,9 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
Fonts { sources: Some(&sources), shown, mixed_size },
Some(paragraphs),
);
if out.size_committed {
ed.choose_size(ed.look.size);
}
let mut changed = out.changed;
if let Some(script) = out.script {
changed |= crate::edit_text_ui::toggle_script(&mut ed.text, &mut ed.selection, script);
@@ -87,6 +87,7 @@ impl Paragraphs<'_> {
#[derive(Default)]
pub(crate) struct Outcome {
pub changed: bool,
pub size_committed: bool,
pub font: Option<FontChoice>,
/// Superscript or Subscript was clicked, for the selected paragraphs' text.
pub script: Option<Script>,
@@ -366,6 +367,7 @@ pub(crate) fn panel(
number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing")));
});
out.size_committed = size_committed;
out.changed = out.changed || size_committed || (look.clone(), extras.as_deref().copied()) != before;
ui.add_space(6.0);
ui.separator();
+42 -4
View File
@@ -50,6 +50,9 @@ pub(crate) const MAX_OUTLINES: usize = 250;
/// 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 {
if selection.as_ref().is_some_and(|r| r.is_empty()) {
*selection = None;
}
let Some((toggled, range)) = pdfcraft_engine::text_script::toggle(text, selection.as_ref(), script) else { return false };
*text = toggled;
if selection.is_some() {
@@ -92,6 +95,7 @@ pub struct LineEditor {
/// (Calibri reads as Helvetica) can be the one picked, so comparing `look` with `look0` alone
/// would miss the choice.
family_chosen: bool,
size_chosen: bool,
/// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and
/// horizontal scale (%), and what they were.
pub extras: Extras,
@@ -265,7 +269,7 @@ impl LineEditor {
/// Whether the paragraph with `text` in it differs from what was opened.
fn changes_with(&self, text: &str) -> bool {
text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen
text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen || self.size_chosen
}
/// Extra width permitted by the editor. Source containers now remain fixed.
@@ -284,7 +288,7 @@ impl LineEditor {
let mut style = pdfcraft_engine::BlockStyle {
resource: self.font_resource.clone(),
family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)),
size: (l.size != o.size).then_some(l.size),
size: (self.size_chosen || l.size != o.size).then_some(l.size),
color: (l.color != o.color).then_some(l.color),
// The whole paragraph's alignment when it was picked, or when the rows' own were
// dropped for it (the rows are where those put them, so leaving it out keeps them there).
@@ -325,9 +329,16 @@ impl LineEditor {
style
}
/// After applying formatting: the paragraph now has it. The selection stays, as the box still
/// shows it, so a second alignment or format picked for it applies to the same rows.
/// An explicit size commit also normalizes a mixed selection to the displayed value.
pub(crate) fn choose_size(&mut self, size: f64) {
self.look.size = size;
self.size_chosen = true;
}
/// After applying formatting: the paragraph now has it. Its old byte selection is cleared;
/// the rewritten paragraph may have different text or geometry.
pub fn applied(&mut self) {
self.size_chosen = false;
self.font_resource = None;
self.family_chosen = false;
self.look0 = self.look.clone();
@@ -640,6 +651,7 @@ fn open_editor(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], bo
look: look_of(l),
look0: look_of(l),
family_chosen: false,
size_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
typing_from: None,
@@ -1189,6 +1201,7 @@ C", &picked
look: look_of(&block),
look0: look_of(&block),
family_chosen: false,
size_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
typing_from: None,
@@ -1196,6 +1209,30 @@ C", &picked
(app, ed)
}
#[test]
fn review_committing_the_shown_size_applies_it_to_a_mixed_selection() {
let (_app, mut ed) = preview_app();
ed.selection = Some(0..2);
let mut fmt = ed.current_formatting(&[]);
fmt.size = None;
ed.sync_selection_look(&fmt);
let shown = ed.look.size;
ed.choose_size(shown);
assert!(ed.has_changes());
assert_eq!(ed.style().ranges[0].size, Some(shown));
ed.applied();
assert!(ed.style().ranges.is_empty());
}
#[test]
fn review_a_caret_only_script_formats_the_whole_draft() {
let mut text = "x2".to_string();
let mut caret = Some(1..1);
assert!(toggle_script(&mut text, &mut caret, pdfcraft_engine::text_script::Script::Super));
assert_eq!(text, "ˣ²");
assert_eq!(caret, None);
}
#[test]
fn picking_the_family_the_font_name_reads_as_still_changes_the_font() {
// A Calibri paragraph reads as Helvetica; picking Helvetica from the list used to leave
@@ -1389,6 +1426,7 @@ C", &picked
multiline: false,
size: 12.0,
family_chosen: false,
size_chosen: false,
focus: true,
had_focus: false,
look: pdfcraft_engine::AddedText::default(),
+1 -1
View File
@@ -1975,7 +1975,7 @@ evidence = [
"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, 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."
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."
[[feature]]
id = "edit.paragraph-alignment"