M7: added text takes character spacing from Format text (fixes #4245)

This commit is contained in:
Marco Arnulfo
2026-10-23 17:31:02 +02:00
parent 57e531ccca
commit cd6f031b82
11 changed files with 173 additions and 42 deletions
+10 -3
View File
@@ -2,7 +2,7 @@
//! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the
//! displayed page (y down), like every other tool.
use pdfcraft_engine::{AddedContent, AddedText, Edit, FontFamily, TextAlign};
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign};
use serde_json::{Value, json};
use crate::comments::parse_color;
@@ -61,6 +61,12 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
other => return Err(bad(format!("unknown align {other:?}"))),
};
}
if let Some(c) = a.opt_num("char_spacing")? {
if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) {
return Err(bad(format!("char_spacing must be between -{ADDED_CHAR_SPACING_LIMIT} and {ADDED_CHAR_SPACING_LIMIT} points")));
}
t.char_spacing = c;
}
Ok(())
}
@@ -85,6 +91,7 @@ impl Automation {
v["size"] = json!(t.size);
v["bold"] = json!(t.bold);
v["italic"] = json!(t.italic);
v["char_spacing"] = json!(t.char_spacing);
}
AddedContent::Image(_) => v["type"] = json!("image"),
}
@@ -164,8 +171,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color and align apply to text"));
if ["font", "size", "bold", "italic", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, align and char_spacing apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
+4 -2
View File
@@ -582,7 +582,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"page_add_text",
"Add text to a page",
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
)
.cmd("edit.text")
.with(schema(
@@ -594,6 +594,7 @@ pub fn tools() -> Vec<ToolDef> {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
}),
&["doc", "page", "text"],
)),
@@ -607,7 +608,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }),
&["doc", "page", "path"],
)),
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
.with(schema(
json!({
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
@@ -615,6 +616,7 @@ pub fn tools() -> Vec<ToolDef> {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 },
"image": { "type": "string", "description": "Replace the picture with this file." },
+18
View File
@@ -4289,6 +4289,24 @@ fn content_index_out_of_range_is_an_error_not_a_panic() {
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1);
}
/// Character spacing on added text (#4245): set when adding or restyling, listed back, and refused
/// out of range or on images.
#[test]
fn added_text_takes_character_spacing() {
let dir = workdir("content-char-spacing");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Spaced", "at": [20, 20], "char_spacing": 2 }));
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], 2.0);
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "char_spacing": -0.5 }));
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], -0.5);
for bad in [json!(1000), json!(-1000), json!("wide")] {
let args = json!({ "doc": doc, "page": 1, "index": 1, "char_spacing": bad });
assert!(matches!(a.call("content_update", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
}
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], -0.5);
}
fn added_rotation_image(dir: &Path) {
let mut png = Vec::new();
let mut enc = png::Encoder::new(&mut png, 2, 1);
+41 -10
View File
@@ -20,6 +20,9 @@ use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, p
const TAG: &str = "Added";
/// The most character spacing an added text takes either way, in points: as far as the font size goes.
pub const CHAR_SPACING_LIMIT: f64 = 500.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Family {
#[default]
@@ -116,6 +119,8 @@ pub struct AddedText {
pub size: f64,
pub color: [f64; 3],
pub align: Align,
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
pub char_spacing: f64,
}
impl Default for AddedText {
@@ -129,6 +134,7 @@ impl Default for AddedText {
size: 12.0,
color: [0.0; 3],
align: Align::Left,
char_spacing: 0.0,
}
}
}
@@ -192,7 +198,9 @@ impl Content {
fn scaled(self, factor: f64) -> Content {
match self {
_ if factor == 1.0 => self,
Content::Text(t) => Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, ..t }),
Content::Text(t) => {
Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, char_spacing: t.char_spacing * factor, ..t })
}
Content::Image(i) => Content::Image(AddedImage { rect: i.rect.map(|v| v * factor), ..i }),
}
}
@@ -230,6 +238,12 @@ fn norm(r: [f64; 4]) -> [f64; 4] {
[r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]
}
/// The advance of `s` in the item's standard font: its glyphs' widths and the character spacing
/// after each of them, as `Tc` moves the pen (ISO 32000-2 §9.3.2).
fn latin_width(t: &AddedText, s: &str) -> f64 {
t.family.width(s, t.size, t.bold, t.italic) + t.char_spacing * s.chars().count() as f64
}
/// The lines of a text item after wrapping to its box width.
pub fn lines(t: &AddedText) -> Vec<String> {
wrapped(t).into_iter().map(|(line, _)| line).collect()
@@ -264,7 +278,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
// of an em apart.
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) };
let space = measure(" ");
let (mut line, mut line_w) = (String::new(), 0.0);
for word in para.split(' ') {
@@ -292,10 +306,10 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, Edit
let mut atoms = Vec::new();
for piece in pieces {
match piece {
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))),
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size + t.char_spacing))),
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
let text = c.to_string();
let w = t.family.width(&text, t.size, t.bold, t.italic);
let w = latin_width(t, &text);
(text, w)
})),
}
@@ -459,8 +473,9 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
let width = pieces
.iter()
.map(|p| match p {
Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic),
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
Piece::Latin(s) => latin_width(t, s),
// Each cluster is shown on its own, so the spacing follows each once.
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::<f64>(),
})
.sum();
Ok((pieces, width))
@@ -469,7 +484,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
/// then reports why).
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic))
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s))
}
fn arabic_error(e: GlyphError, text: &str) -> EditError {
@@ -596,6 +611,9 @@ fn draw_laid_shaped(
}
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
// Shaped glyphs are painted in visual order. Keep the original line's typing order
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
@@ -621,7 +639,7 @@ fn draw_laid_shaped(
out.extend(literal(&win_ansi(s)));
out.extend_from_slice(b" Tj\n");
current = Some(latin.as_str());
pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64;
pen += latin_width(t, s) + tw * s.matches(' ').count() as f64;
}
Piece::Shaped(clusters) => {
for c in clusters {
@@ -636,7 +654,7 @@ fn draw_laid_shaped(
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
out.extend(literal(&[code]));
out.extend_from_slice(b" Tj\n");
pen += c.advance * t.size + if c.text == " " { tw } else { 0.0 };
pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 };
}
}
}
@@ -788,9 +806,12 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
let r = text_rect(t);
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
let all = lines(t);
for (i, line) in all.iter().enumerate() {
let w = t.family.width(line, t.size, t.bold, t.italic);
let w = latin_width(t, line);
let x = match t.align {
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
@@ -861,6 +882,9 @@ fn params(c: &Content) -> Dict {
}),
);
d.set(b"Rect".to_vec(), arr(&text_rect(t)));
if t.char_spacing != 0.0 {
d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing));
}
}
Content::Image(i) => {
d.set(b"Kind".to_vec(), Object::name("Image"));
@@ -904,6 +928,8 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
Some(3) => Align::Justify,
_ => Align::Left,
},
// Items written before character spacing have none.
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
}))
}
b"Image" => {
@@ -1004,6 +1030,11 @@ fn validate(c: &Content) -> Result<(), EditError> {
if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) {
return Err(EditError::Invalid("the font size must be between 1 and 500 points".into()));
}
if !(t.char_spacing.is_finite() && (-CHAR_SPACING_LIMIT..=CHAR_SPACING_LIMIT).contains(&t.char_spacing)) {
return Err(EditError::Invalid(format!(
"the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points"
)));
}
if (r[2] - r[0]).abs() < 1.0 {
return Err(EditError::Invalid("the text box is too narrow".into()));
}
+29
View File
@@ -6434,6 +6434,35 @@ fn added_text_is_wrapped_once_per_write() {
assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines");
}
/// Character spacing on added text (#4245) is the PDF's `Tc`, kept in `/PCAdded` so the item
/// reopens with it; an item written without it reads 0.
#[test]
fn added_text_character_spacing_is_drawn_and_reopens() {
let mut doc = fixture();
let plain = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "Approved".into(), size: 10.0, ..AddedText::default() };
add_content(&mut doc, 0, &Content::Text(plain.clone())).unwrap();
add_content(&mut doc, 0, &Content::Text(AddedText { char_spacing: 2.5, ..plain })).unwrap();
let doc = reopen(&doc);
let spacings: Vec<f64> = list_added(&doc).iter().map(|a| if let Content::Text(t) = &a.content { t.char_spacing } else { f64::NAN }).collect();
assert_eq!(spacings, [0.0, 2.5]);
let page = streams(&doc, 0);
let tc: Vec<bool> = page.iter().filter(|s| s.contains("(Approved) Tj")).map(|s| s.contains("2.5 Tc")).collect();
assert_eq!(tc, [false, true], "{page:?}");
}
/// Spacing widens every measure the layout takes: the widest line and wrapping.
#[test]
fn added_text_character_spacing_widens_its_layout() {
let plain = AddedText { rect: [0.0, 0.0, 1000.0, 100.0], text: "Approved".into(), size: 10.0, ..AddedText::default() };
let spaced = AddedText { char_spacing: 3.0, ..plain.clone() };
// After each of the 8 letters, like `Tc`.
assert!((added::text_width(&spaced) - added::text_width(&plain) - 24.0).abs() < 1e-9);
let w = added::text_width(&plain);
let narrow = |t: &AddedText| AddedText { text: "Approved Approved".into(), rect: [0.0, 0.0, 2.0 * w + 5.0, 100.0], ..t.clone() };
assert_eq!(added::lines(&narrow(&plain)).len(), 1);
assert_eq!(added::lines(&narrow(&spaced)).len(), 2, "the spaced words no longer fit one line");
}
/// A field tree whose every node lists its child twice is pruned in work linear in its nodes, not
/// in its paths, and a node listing itself is visited once.
#[test]
+1 -1
View File
@@ -55,7 +55,7 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific
pub use pdfcraft_cos::Algorithm;
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width};
pub use pdfcraft_edit::added::{CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, text_rect as added_text_rect, text_width as added_text_width};
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
+2
View File
@@ -6369,6 +6369,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() {
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
char_spacing: 0.0,
};
session.apply(id, Edit::AddText { page: 1, text }).unwrap();
let after = session.get(id).unwrap();
@@ -6408,6 +6409,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
char_spacing: 0.0,
},
},
Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] },
+25 -10
View File
@@ -560,18 +560,33 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
// Character spacing shows as the page will draw it, scaled like the font.
let letter_spacing = (t.style.char_spacing * f64::from(zoom)) as f32;
let mut spaced = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| {
let format = egui::TextFormat {
font_id: font.clone(),
color: Color32::from_rgb(cr, cg, cb),
extra_letter_spacing: letter_spacing,
..Default::default()
};
let mut job = egui::text::LayoutJob::single_section(text.as_str().to_owned(), format);
job.wrap.max_width = wrap_width;
ui.fonts_mut(|f| f.layout_job(job))
};
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let resp = ui.add(
egui::TextEdit::multiline(&mut t.text)
.font(font)
.desired_width(r.width().max(60.0))
.desired_rows(1)
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
.text_color(Color32::from_rgb(cr, cg, cb))
.hint_text(tl!("Type text"))
.id_salt("added-text-edit"),
);
let mut edit = egui::TextEdit::multiline(&mut t.text)
.font(font.clone())
.desired_width(r.width().max(60.0))
.desired_rows(1)
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
.text_color(Color32::from_rgb(cr, cg, cb))
.hint_text(tl!("Type text"))
.id_salt("added-text-edit");
if letter_spacing != 0.0 {
edit = edit.layouter(&mut spaced);
}
let resp = ui.add(edit);
if t.focus {
resp.request_focus();
t.focus = false;
+17 -11
View File
@@ -2,8 +2,9 @@
//! alignment; bold, italic, underline, superscript and subscript; line and paragraph spacing;
//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the
//! fonts already on its page and the spacing controls) and added text (the standard families,
//! size, colour, style and alignment). What PdfCraft can't apply yet (lists, superscript and
//! subscript, paragraph spacing, or spacing for added text) is shown disabled and says so.
//! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists,
//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown
//! disabled and says so.
//!
//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat.
@@ -197,9 +198,10 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras:
// Line and paragraph spacing; horizontal scale and character spacing: two columns, each an
// icon and its control, `half` wide.
let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0);
ui.add_enabled_ui(extras.is_some(), |ui| {
let mut fallback = Extras::default();
let e = extras.as_deref_mut().unwrap_or(&mut fallback);
let editing = extras.is_some();
let mut fallback = Extras::default();
let e = extras.as_deref_mut().unwrap_or(&mut fallback);
ui.add_enabled_ui(editing, |ui| {
row(ui, |ui| {
labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing"));
let own = tl!("Auto").to_string();
@@ -222,16 +224,20 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras:
.on_disabled_hover_text(tip);
});
});
// Any value the engine takes can be typed; the lists hold the common ones.
row(ui, |ui| {
});
// Any value the engine takes can be typed; the lists hold the common ones. Added text has
// character spacing of its own (#4245), but no scale yet.
row(ui, |ui| {
ui.add_enabled_ui(editing, |ui| {
let label = labelled_icon(ui, t, "move-horizontal", tl!("Horizontal scale"));
let scale = Number { value: &mut e.scale, range: SCALE_RANGE, speed: 1.0, suffix: " %" };
number_with_presets(ui, scale, &SCALES, half, (label.id, tl!("Horizontal scale")));
ui.add_space(8.0);
let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing"));
let spacing = Number { value: &mut e.char_spacing, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" };
number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing")));
});
ui.add_space(8.0);
let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing"));
let value = if editing { &mut e.char_spacing } else { &mut look.char_spacing };
let spacing = Number { value, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" };
number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing")));
});
out.changed = (look.clone(), extras.as_deref().copied()) != before;
+21
View File
@@ -714,6 +714,27 @@ fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() {
assert!(h.state().text_style.bold);
}
/// Added text takes character spacing from Format text (#4245), typed like a paragraph's; its
/// line spacing and horizontal scale stay disabled until added text has them.
#[test]
fn format_text_sets_character_spacing_for_added_text() {
use egui::accesskit::Role;
use egui_kittest::kittest::NodeT;
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
let disabled = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_role_and_label(Role::SpinButton, label).accesskit_node().is_disabled();
assert!(disabled(&h, "Horizontal scale"), "added text has no scale yet");
assert!(!disabled(&h, "Character spacing"), "added text takes character spacing");
h.get_by_role_and_label(Role::SpinButton, "Character spacing").click();
h.run_steps(2);
h.event(egui::Event::Text("2.5".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(h.state().text_style.char_spacing, 2.5);
}
/// Where a mode bar tab is, by its label.
fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect {
h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab")
+5 -5
View File
@@ -1984,10 +1984,10 @@ area = "C"
tier = "P1"
milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/it/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"]
notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list"
commands = ["edit.edit_text", "edit.text"]
tools = ["text_edit", "page_add_text", "content_update"]
evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/it/editing.rs::the_format_panel_takes_any_scale_and_character_spacing", "crates/edit/src/tests.rs::added_text_character_spacing_is_drawn_and_reopens", "crates/edit/src/tests.rs::added_text_character_spacing_widens_its_layout", "crates/automation/tests/it/automation.rs::added_text_takes_character_spacing"]
notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list. Added text takes character spacing too (Tc, kept in /PCAdded and rewrapped); horizontal scaling for added text is not there yet"
[[feature]]
id = "edit.lists"
@@ -2605,7 +2605,7 @@ status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/it/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/it/content.rs::format_text_rows_line_up_and_fit_the_panel"]
notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text."
notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline, line spacing and horizontal scale on added text."
[[feature]]
id = "edit.adobe-express-edits"