From d7c22d7ef0c035ce278a774311765a23f71cfcc8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:16:03 -0400 Subject: [PATCH] M7: Justify, underline, line spacing, character spacing and horizontal scale for existing paragraphs (and justify for added text); Lucide align-justify icon Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 3 +- ATTRIBUTION.toml | 11 +++++ assets/icons/align-justify.svg | 17 ++++++++ crates/automation/src/content.rs | 1 + crates/automation/src/lib.rs | 10 ++++- crates/automation/src/tools.rs | 12 +++-- crates/edit/src/added.rs | 13 +++++- crates/edit/src/tests.rs | 32 ++++++++++++++ crates/edit/src/text.rs | 70 +++++++++++++++++++++++++++--- crates/ui-egui/src/content_ui.rs | 1 + crates/ui-egui/src/edit_text_ui.rs | 52 ++++++++++++++++++++++ crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/panels.rs | 7 ++- crates/ui-egui/tests/editing.rs | 7 +++ parity/acrobat-features.toml | 36 +++++++-------- 15 files changed, 241 insertions(+), 32 deletions(-) create mode 100644 assets/icons/align-justify.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a05f8422a..c415c3fda 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,13 +4,14 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (238) +## In this repository (239) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 92a0df3f9..2bdc101ef 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -41,6 +41,17 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "bc70fba645343be0760d1a87f9e0975a10ead2b8d95883314f4cea007376e79f" +[[asset]] +path = "assets/icons/align-justify.svg" +title = "Lucide icon \"align-justify\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "df46328a73b53ae85f6837d170411b2616204c6cedc59bd8603112b5b38b3b3e" + [[asset]] path = "assets/icons/align-right.svg" title = "Lucide icon \"align-right\"" diff --git a/assets/icons/align-justify.svg b/assets/icons/align-justify.svg new file mode 100644 index 000000000..6407db3ae --- /dev/null +++ b/assets/icons/align-justify.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 52e872a86..79f29a2f1 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -57,6 +57,7 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> { "left" => TextAlign::Left, "center" | "centre" => TextAlign::Center, "right" => TextAlign::Right, + "justify" => TextAlign::Justify, other => return Err(bad(format!("unknown align {other:?}"))), }; } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d337df460..d892b94d8 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -390,7 +390,14 @@ impl Automation { } let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone(); let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); - let mut style = printcraft_engine::BlockStyle { size: a.opt_num("size")?, ..Default::default() }; + let mut style = printcraft_engine::BlockStyle { + size: a.opt_num("size")?, + underline: a.opt_bool("underline")?, + line_spacing: a.opt_num("line_spacing")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + ..Default::default() + }; if let Some(f) = a.opt_str("font")? { let family = match f { "helvetica" => printcraft_engine::FontFamily::Helvetica, @@ -408,6 +415,7 @@ impl Automation { "left" => printcraft_engine::TextAlign::Left, "center" => printcraft_engine::TextAlign::Center, "right" => printcraft_engine::TextAlign::Right, + "justify" => printcraft_engine::TextAlign::Justify, other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), }); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6411adea5..827e872ed 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -410,7 +410,7 @@ pub fn tools() -> Vec { "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "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"] }, + "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, }), &["doc", "page", "text"], )), @@ -431,7 +431,7 @@ pub fn tools() -> Vec { "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "text": { "type": "string" }, "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"] }, + "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, "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." }, @@ -842,7 +842,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb) and align (left, center, right); text may then be omitted. Its own font is reused when it can show every character; otherwise the line is set in Helvetica (the result shows the font used). Text that no available font can show is refused. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent); text may then be omitted. Its own font is reused when it can show every character; otherwise the line is set in Helvetica (the result shows the font used). Text that no available font can show is refused. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, @@ -853,7 +853,11 @@ pub fn tools() -> Vec { "italic": { "type": "boolean" }, "size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#rrggbb" }, - "align": { "type": "string", "enum": ["left", "center", "right"] } }), + "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "underline": { "type": "boolean" }, + "line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." }, + "char_spacing": { "type": "number", "description": "Points." }, + "scale": { "type": "number", "description": "Horizontal scale in percent." } }), &["doc", "page"], )), 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.") diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 11673092d..111c49401 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -79,6 +79,8 @@ pub enum Align { Left, Center, Right, + /// Lines (but the last) stretched to the box's width. + Justify, } #[derive(Clone, Debug, PartialEq)] @@ -246,13 +248,18 @@ fn draw(doc: &Document, c: &Content, view: [f64; 6]) -> Result<(Vec, Dict), for (i, line) in lines(t).iter().enumerate() { let w = t.family.width(line, t.size, t.bold); let x = match t.align { - Align::Left => r[0], + Align::Left | Align::Justify => r[0], Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, Align::Right => r[2] - w, }; // Baseline: 0.8 em below the line top. let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + // Justified: word spacing makes every line but the last fill the box. + let all = lines(t); + let spaces = line.matches(' ').count(); + let tw = + if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; + out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes()); out.extend(literal(&win_ansi(line))); out.extend_from_slice(b" Tj\n"); } @@ -307,6 +314,7 @@ fn params(c: &Content) -> Dict { Align::Left => 0, Align::Center => 1, Align::Right => 2, + Align::Justify => 3, }), ); d.set(b"Rect".to_vec(), arr(&text_rect(t))); @@ -350,6 +358,7 @@ fn parse(doc: &Document, d: &Dict) -> Option { align: match d.int(b"Align") { Some(1) => Align::Center, Some(2) => Align::Right, + Some(3) => Align::Justify, _ => Align::Left, }, })) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 6eeccf85c..73ebf2caf 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -424,6 +424,7 @@ fn paragraphs_take_new_formatting() { size: Some(12.0), color: Some([1.0, 0.0, 0.0]), align: Some(added::Align::Center), + ..Default::default() }; text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap(); let doc = reopen(&doc); @@ -447,3 +448,34 @@ fn paragraphs_take_new_formatting() { assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]); } } + +#[test] +fn justify_underline_and_spacing() { + let src = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj T* (end) Tj ET"; + // Justified: every line but the last reaches the right edge. + let mut doc = text_page(src); + let right = text::text_blocks(&doc, 0).unwrap()[0].rect[2]; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + for l in &lines[..lines.len() - 1] { + assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]); + } + // Double line spacing, underlined. + let mut doc = text_page(src); + let style = text::BlockStyle { line_spacing: Some(2.0), underline: Some(true), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!((lines[0].origin_baseline() - lines[1].origin_baseline() - 20.0).abs() < 0.01); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains(" l\nS\n") || content.contains(" l S"), "{content}"); + // Character spacing widens, horizontal scale narrows. + let mut doc = text_page("BT /F1 10 Tf 100 700 Td (Wide text) Tj ET"); + let w0 = text::text_lines(&doc, 0).unwrap()[0].rect; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { char_spacing: Some(2.0), ..Default::default() }).unwrap(); + let w1 = text::text_lines(&doc, 0).unwrap()[0].rect; + assert!((w1[2] - w1[0]) - (w0[2] - w0[0]) > 15.0, "{w0:?} → {w1:?}"); + let mut doc = text_page("BT /F1 10 Tf 100 700 Td (Wide text) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { scale: Some(50.0), ..Default::default() }).unwrap(); + let w2 = text::text_lines(&doc, 0).unwrap()[0].rect; + assert!(((w2[2] - w2[0]) - (w0[2] - w0[0]) / 2.0).abs() < 1.0, "{w0:?} → {w2:?}"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index f66f9c431..645fb2a72 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -566,6 +566,13 @@ pub struct BlockStyle { /// Fill colour (RGB 0–1). pub color: Option<[f64; 3]>, pub align: Option, + /// Draw a line under each line of text. + pub underline: Option, + /// Line spacing as a multiple of the font size (1.2 is ordinary). + pub line_spacing: Option, + /// Character spacing (points) and horizontal scaling (percent). + pub char_spacing: Option, + pub scale: Option, } /// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width. @@ -587,6 +594,15 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option let k = o.k.max(1e-6); // The size in text space: points ÷ the text-to-user scale. let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); + // Spacing and scaling (text state), with the chosen values. + let mut ts_state = o.state.clone(); + if let Some(c) = style.char_spacing { + ts_state.char_spacing = c / k; + } + if let Some(sc) = style.scale { + ts_state.scale = (sc / 100.0).clamp(0.1, 10.0); + } + let o_state = ts_state.clone(); let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); let reuse = style.family.is_none() && metrics.as_ref().is_some_and(|m| m.encode(&text).is_some()); // The standard font used when the paragraph's own can't be (chosen, or substituted). @@ -611,13 +627,13 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option .map(|bytes| { m.codes(&bytes) .iter() - .map(|(c, l)| m.width(*c) * size + o.state.char_spacing + if m.is_space(*c, *l) { o.state.word_spacing } else { 0.0 }) + .map(|(c, l)| m.width(*c) * size + o_state.char_spacing + if m.is_space(*c, *l) { o_state.word_spacing } else { 0.0 }) .sum::() }) .unwrap_or(0.0), - _ => std_width(s, size), + _ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing, }; - t * o.state.scale * k + t * o_state.scale * k }; let wrapped = wrap(&text, width + 0.5, advance); let mut substituted = None; @@ -645,10 +661,15 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option (name, Box::new(|s: &str| Some(printcraft_fonts::win_ansi(s)))) }; // Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. - let lead = if members.len() > 1 { (members[0].origin.baseline - members[1].origin.baseline) / k * size / old_size } else { size * 1.2 }; + let lead = match style.line_spacing { + Some(m) => size * m.clamp(0.5, 5.0), + None if members.len() > 1 => (members[0].origin.baseline - members[1].origin.baseline) / k * size / old_size, + None => size * 1.2, + }; let n = printcraft_content::num; let mut block_ops = vec![Op::new("BT", vec![])]; - let mut state = o.state.clone(); + let mut state = o_state.clone(); + state.word_spacing = 0.0; state.font = Some((show_font, size)); if let Some([r, g, bl]) = style.color { state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; @@ -664,17 +685,56 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option _ => 0.0, } }; + // Justify: word spacing (single-byte code 32 only) so each line but the last fills the width. + let single_byte = !reuse || metrics.as_ref().is_some_and(|m| !m.composite); + let justify = style.align == Some(crate::added::Align::Justify) && single_byte; let mut x = 0.0; + let mut tw_set = 0.0; + let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space for (i, line) in wrapped.iter().enumerate() { let dx = offset(line); if i > 0 || dx != 0.0 { block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); } x = dx; + let spaces = line.matches(' ').count(); + let tw = + if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 }; + if tw != tw_set { + block_ops.push(Op::new("Tw", vec![n(tw)])); + tw_set = tw; + } let bytes = encode(line).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))?; block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k; + underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); } block_ops.push(Op::new("ET", vec![])); + if style.underline == Some(true) { + // In text space (the paragraph's text matrix), in its fill colour. + let colour: Vec = state + .fill + .iter() + .map(|f| match f.op.as_slice() { + b"g" => Op::new("G", f.operands.clone()), + b"rg" => Op::new("RG", f.operands.clone()), + b"k" => Op::new("K", f.operands.clone()), + b"cs" => Op::new("CS", f.operands.clone()), + b"sc" => Op::new("SC", f.operands.clone()), + _ => Op::new("SCN", f.operands.clone()), + }) + .collect(); + block_ops.push(Op::new("q", vec![])); + block_ops.push(Op::new("cm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.extend(colour); + block_ops.push(Op::new("w", vec![n(size * 0.06)])); + for (x0, x1, y) in underlines { + block_ops.push(Op::new("m", vec![n(x0), n(y)])); + block_ops.push(Op::new("l", vec![n(x1), n(y)])); + } + block_ops.push(Op::new("S", vec![])); + block_ops.push(Op::new("Q", vec![])); + } // What the text after the paragraph expects: the state at its BT. block_ops.extend(o.bt_state.ops().into_iter().filter(|op| !op.is("Tf") || o.bt_state.font.is_some())); let drop: std::collections::HashSet = members.iter().flat_map(|l| l.ops.iter().copied()).collect(); diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 3b3963a1e..4ea3305f1 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -349,6 +349,7 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> (TextAlign::Left, "align-left", "Align left"), (TextAlign::Center, "align-center", "Centre"), (TextAlign::Right, "align-right", "Align right"), + (TextAlign::Justify, "align-justify", "Justify"), ] { if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() { s.align = a; diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 776f7cfb2..a0f2d92e3 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -24,6 +24,50 @@ pub struct LineEditor { /// The Format text panel's values, and what the paragraph had (to send only changes). pub look: printcraft_engine::AddedText, look0: printcraft_engine::AddedText, + /// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and + /// horizontal scale (%), and what they were. + pub extras: Extras, + extras0: Extras, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Extras { + pub underline: bool, + pub line_spacing: f64, + pub char_spacing: f64, + pub scale: f64, +} + +impl Default for Extras { + fn default() -> Self { + Extras { underline: false, line_spacing: 0.0, char_spacing: 0.0, scale: 100.0 } + } +} + +/// Underline, line spacing, character spacing and horizontal scale for the paragraph being +/// edited (under Format text). Returns `true` when something changed. +pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { + let before = *e; + ui.horizontal(|ui| { + if crate::icons::button(ui, "underline", 26.0, e.underline, "Underline").clicked() { + e.underline = !e.underline; + } + let label = |v: f64| if v == 0.0 { "Line spacing".to_string() } else { format!("{v:.2}×") }; + egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| { + for v in [1.0, 1.15, 1.5, 2.0] { + ui.selectable_value(&mut e.line_spacing, v, label(v)); + } + }); + }); + ui.horizontal(|ui| { + let l = ui.label("Character spacing"); + ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id); + }); + ui.horizontal(|ui| { + let l = ui.label("Horizontal scale"); + ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id); + }); + *e != before } impl LineEditor { @@ -35,12 +79,18 @@ impl LineEditor { size: (l.size != o.size).then_some(l.size), color: (l.color != o.color).then_some(l.color), align: (l.align != o.align).then_some(l.align), + underline: (self.extras.underline != self.extras0.underline).then_some(self.extras.underline), + line_spacing: (self.extras.line_spacing != self.extras0.line_spacing && self.extras.line_spacing > 0.0) + .then_some(self.extras.line_spacing), + char_spacing: (self.extras.char_spacing != self.extras0.char_spacing).then_some(self.extras.char_spacing), + scale: (self.extras.scale != self.extras0.scale).then_some(self.extras.scale), } } /// After applying formatting: the paragraph now has it. pub fn applied(&mut self) { self.look0 = self.look.clone(); + self.extras0 = self.extras; self.original = self.text.clone(); self.focus = true; } @@ -243,6 +293,8 @@ pub(crate) fn page_input( focus: true, look: look_of(l), look0: look_of(l), + extras: Extras::default(), + extras0: Extras::default(), }); } true diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 39694d89d..5f9e0993f 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -4,6 +4,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("accessibility", include_bytes!("../../../assets/icons/accessibility.svg")), ("align-center", include_bytes!("../../../assets/icons/align-center.svg")), + ("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")), ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), ("align-right", include_bytes!("../../../assets/icons/align-right.svg")), ("app-window", include_bytes!("../../../assets/icons/app-window.svg")), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 402a504ce..5097e91c7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -277,9 +277,14 @@ fn format_section(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) { if let Some((i, _)) = app.active_ids() && let Some(ed) = app.views[i].line_editor.clone() { + let mut ed = ed; + let mut changed = false; if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) { - let mut ed = ed; ed.look = look; + changed = true; + } + changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); + if changed { let edit = printcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; if app.apply_edit(edit) { ed.applied(); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index e23f7d050..885b3b817 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -689,4 +689,11 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert_eq!(doc.can_undo(), Some("Edit text")); assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold"); assert!(s.views[0].line_editor.is_some(), "still editing"); + // Underline: another step, the text keeps its bold. + h.get_by_label("Underline").click(); + h.run_steps(4); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold"); + assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline)); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a8b406153..48beb026a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1839,11 +1839,11 @@ title = "Bold, italic, underline" area = "C" tier = "P1" milestone = "M7" -status = "partial" -commands = ["edit.edit_text", "edit.text"] +status = "shipped" +commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "bold and italic through the standard font families; underline is not done yet" +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] +notes = "bold and italic through the standard families; underline drawn under each line in the text colour" [[feature]] id = "edit.text-super-subscript" @@ -1863,11 +1863,11 @@ title = "Paragraph alignment (left, centre, right, justify)" area = "C" tier = "P0" milestone = "M7" -status = "partial" +status = "shipped" commands = ["edit.edit_text", "edit.text"] -tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "left, centre, right for existing paragraphs (per wrapped line) and added text; justify is not done yet" +tools = ["text_edit", "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/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] +notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text" [[feature]] id = "edit.line-paragraph-spacing" @@ -1875,11 +1875,11 @@ title = "Line and paragraph spacing" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +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/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] +notes = "line spacing (1, 1.15, 1.5, 2 × size or any multiple); space between paragraphs is not adjustable yet" [[feature]] id = "edit.char-spacing-scale" @@ -1887,11 +1887,11 @@ title = "Character spacing and horizontal scaling" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +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/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] +notes = "character spacing in points and horizontal scaling in percent for a paragraph (Tc, Tz), rewrapped" [[feature]] id = "edit.lists"