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"