mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
385313e0bf
commit
d7c22d7ef0
15 files changed
+241
-32
No files matched your search
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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\""
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-align-justify"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 5h18" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M3 19h18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -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:?}"))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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:?}"))),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"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<ToolDef> {
|
||||
"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<ToolDef> {
|
||||
.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<ToolDef> {
|
||||
"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.")
|
||||
|
||||
@@ -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<u8>, 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<Content> {
|
||||
align: match d.int(b"Align") {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
Some(3) => Align::Justify,
|
||||
_ => Align::Left,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
+65
-5
@@ -566,6 +566,13 @@ pub struct BlockStyle {
|
||||
/// Fill colour (RGB 0–1).
|
||||
pub color: Option<[f64; 3]>,
|
||||
pub align: Option<crate::added::Align>,
|
||||
/// Draw a line under each line of text.
|
||||
pub underline: Option<bool>,
|
||||
/// Line spacing as a multiple of the font size (1.2 is ordinary).
|
||||
pub line_spacing: Option<f64>,
|
||||
/// Character spacing (points) and horizontal scaling (percent).
|
||||
pub char_spacing: Option<f64>,
|
||||
pub scale: Option<f64>,
|
||||
}
|
||||
|
||||
/// 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::<f64>()
|
||||
})
|
||||
.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<Op> = 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<usize> = members.iter().flat_map(|l| l.ops.iter().copied()).collect();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user