mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge PR #4266: M7: added text takes character spacing from Format text
crates/engine/src/lib.rs: union of the added:: re-exports (paste_place/paste_rect from #4230, CHAR_SPACING_LIMIT). crates/ui-egui/src/content_ui.rs: editor keeps #4178's anchored width and horizontal_align and #4266's spacing layouter; spaced_job takes the alignment so the custom layouter keeps it. crates/ui-egui/tests/it/content.rs: kept both tests.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -4396,6 +4396,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
@@ -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 {
|
||||
@@ -651,6 +666,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.
|
||||
@@ -676,7 +694,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 {
|
||||
@@ -691,7 +709,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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -843,9 +861,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,
|
||||
@@ -916,6 +937,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"));
|
||||
@@ -959,6 +983,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" => {
|
||||
@@ -1059,6 +1085,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()));
|
||||
}
|
||||
|
||||
@@ -6518,6 +6518,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]
|
||||
|
||||
@@ -56,7 +56,8 @@ 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::{
|
||||
paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, text_width as added_text_width,
|
||||
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, 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,
|
||||
|
||||
@@ -6505,6 +6505,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();
|
||||
@@ -6544,6 +6545,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] },
|
||||
|
||||
@@ -577,19 +577,27 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
|
||||
pdfcraft_engine::TextAlign::Right => egui::Align::Max,
|
||||
pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min,
|
||||
};
|
||||
// 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 job = spaced_job(text.as_str(), &font, Color32::from_rgb(cr, cg, cb), letter_spacing, wrap_width, halign);
|
||||
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(width)
|
||||
.desired_rows(1)
|
||||
.horizontal_align(halign)
|
||||
.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(width)
|
||||
.desired_rows(1)
|
||||
.horizontal_align(halign)
|
||||
.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;
|
||||
@@ -945,6 +953,17 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::
|
||||
out
|
||||
}
|
||||
|
||||
/// The editor's layout of added text with character spacing. The page puts the spacing after every
|
||||
/// character, the last of a line included (`latin_width`), while epaint puts it only between
|
||||
/// characters: the wrap width gives up one spacing so both break lines at the same place.
|
||||
fn spaced_job(text: &str, font: &egui::FontId, color: Color32, letter_spacing: f32, wrap_width: f32, halign: egui::Align) -> egui::text::LayoutJob {
|
||||
let format = egui::TextFormat { font_id: font.clone(), color, extra_letter_spacing: letter_spacing, ..Default::default() };
|
||||
let mut job = egui::text::LayoutJob::single_section(text.to_owned(), format);
|
||||
job.wrap.max_width = (wrap_width - letter_spacing).max(0.0);
|
||||
job.halign = halign;
|
||||
job
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
@@ -993,4 +1012,44 @@ mod tests {
|
||||
let i = flipped("Flip vertical");
|
||||
assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom");
|
||||
}
|
||||
|
||||
/// #4245: the page counts the spacing after a line's last character too, so a line that fits
|
||||
/// only without it wraps in the saved text and must wrap in the editor.
|
||||
#[test]
|
||||
fn editor_wraps_where_the_saved_text_does_with_trailing_spacing() {
|
||||
let rows = |wrap: f32| {
|
||||
let mut n = 0;
|
||||
let mut h = egui_kittest::Harness::new_ui_state(
|
||||
move |ui, n: &mut usize| {
|
||||
let font = egui::FontId::proportional(14.0);
|
||||
let job = spaced_job("AAAA BBBB", &font, Color32::BLACK, 10.0, wrap, egui::Align::Min);
|
||||
*n = ui.fonts_mut(|f| f.layout_job(job)).rows.len();
|
||||
},
|
||||
n,
|
||||
);
|
||||
h.run();
|
||||
n = *h.state();
|
||||
n
|
||||
};
|
||||
// The text's width without the trailing spacing, as epaint measures it.
|
||||
let one_line = rows(f32::INFINITY);
|
||||
assert_eq!(one_line, 1);
|
||||
let width = {
|
||||
let mut w = 0.0;
|
||||
let mut h = egui_kittest::Harness::new_ui_state(
|
||||
|ui, w: &mut f32| {
|
||||
let font = egui::FontId::proportional(14.0);
|
||||
let job = spaced_job("AAAA BBBB", &font, Color32::BLACK, 10.0, f32::INFINITY, egui::Align::Min);
|
||||
*w = ui.fonts_mut(|f| f.layout_job(job)).rect.width();
|
||||
},
|
||||
w,
|
||||
);
|
||||
h.run();
|
||||
w = *h.state();
|
||||
w
|
||||
};
|
||||
// Page width = width + 10 (spacing after the last character) > width + 5: the page wraps.
|
||||
assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too");
|
||||
assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -763,6 +763,27 @@ fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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")
|
||||
|
||||
@@ -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"
|
||||
@@ -2616,7 +2616,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"
|
||||
|
||||
Reference in New Issue
Block a user