Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
54e2895
fix(ui): the added-text editor keeps the box's alignment
RiceDee Oct 22, 2026
dcf33ed
fix(ui): Ctrl+Z in an open paragraph undoes the document, not stale text
RiceDee Oct 23, 2026
5c1c346
M7: align the selected lines of a paragraph or text box on their own
RiceDee Oct 23, 2026
1f29575
M7: align a one-row paragraph across the column it sits in
RiceDee Oct 23, 2026
5c61312
fix(ui): Redo after Undo in a page text box puts the typing back
RiceDee Oct 23, 2026
a44b850
fix(ui): a narrow added-text box keeps its text at its own edge
RiceDee Oct 23, 2026
eba7e47
M7: text_edit with aligns alone keeps the paragraph's rows
RiceDee Oct 23, 2026
7f0b451
fix(ui): a narrow added-text box keeps the edge its lines share
RiceDee Oct 23, 2026
d162efa
test(ui): the Redo test checks Undo restores the exact paragraph text
RiceDee Oct 23, 2026
489a76a
fix(ui): an unchanged added-text box closes without an edit
RiceDee Oct 23, 2026
56b5d4f
test(ui): the narrow-box tests check the text reaches the box's right…
RiceDee Oct 23, 2026
7e96230
M7: parity notes say a justified row in a mixed-style region isn't st…
RiceDee Oct 23, 2026
ea6420c
M7: a lone row aligns across the column under it, not the page's busiest
RiceDee Oct 23, 2026
98a5dde
fix(ui): formatting keeps per-row alignments, the selection, and Redo
RiceDee Oct 23, 2026
caf7bdd
fix(ui): typing in one tab's text box never reaches another tab's
RiceDee Oct 23, 2026
dce32fb
fix(ui): Redo from the menu with typing in a page box stays in the box
RiceDee Oct 24, 2026
a8ea7d0
Merge main into feat/align-selected-lines
RiceDee Oct 24, 2026
f5e4b8e
fix(ui): menu Redo is enabled for the open text box's own Redo
RiceDee Oct 24, 2026
25da4b1
Merge main into feat/align-selected-lines
RiceDee Oct 24, 2026
6901f66
Merge main into feat/align-selected-lines
RiceDee Oct 25, 2026
b7f7b27
fix(ui): menu Undo takes back typing in the open text box first
RiceDee Oct 24, 2026
2f50e1f
fix(ui): typing Undo brings back a deleted row's alignment
RiceDee Oct 25, 2026
c9abf15
fix(edit): only a row aligned on its own is rewritten
RiceDee Oct 25, 2026
bfc6f8b
fix(ui): a text box keeps its typing history through a trip to anothe…
RiceDee Oct 25, 2026
e6e57a9
fix(ui): formatting restarts a text box's history; the menu claims th…
RiceDee Oct 25, 2026
a33a8bb
Merge main into feat/align-selected-lines
RiceDee Oct 26, 2026
a85a6c7
fix(ui): restyling an open added-text box restarts its typing history
RiceDee Oct 26, 2026
6733d9e
Merge main into feat/align-selected-lines
RiceDee Oct 27, 2026
7679f6b
fix(edit): keep column frames local and clear stale history selections
RiceDee Oct 27, 2026
43f848d
Merge main into feat/align-selected-lines
RiceDee Oct 28, 2026
b9940d7
fix(edit): a lone row aligns in its own column, not a closer page-wid…
RiceDee Oct 29, 2026
648dd42
fix(edit): a column is measured by its whole rows; an isolated row ke…
RiceDee Oct 29, 2026
5a86f35
fix(edit, ui): alignments follow the right rows; Format by keyboard k…
RiceDee Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions crates/automation/src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
t.color = parse_color(c)?;
}
if let Some(al) = a.opt_str("align")? {
t.align = match al {
"left" => TextAlign::Left,
"center" | "centre" => TextAlign::Center,
"right" => TextAlign::Right,
"justify" => TextAlign::Justify,
other => return Err(bad(format!("unknown align {other:?}"))),
};
t.align = parse_align(al)?;
// The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one.
t.aligns.clear();
}
if let Some(aligns) = opt_aligns(a)? {
let count = pdfcraft_engine::paragraph_align::count(&t.text);
t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align);
}
if let Some(c) = a.opt_num("char_spacing")? {
if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) {
Expand All @@ -70,6 +70,37 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
Ok(())
}

pub(crate) fn parse_align(al: &str) -> Result<TextAlign> {
Ok(match al {
"left" => TextAlign::Left,
"center" | "centre" => TextAlign::Center,
"right" => TextAlign::Right,
"justify" => TextAlign::Justify,
other => return Err(bad(format!("unknown align {other:?}"))),
})
}

/// Most entries `aligns` takes: one per paragraph, and no text has this many.
const MAX_ALIGNS: usize = 4096;

/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`.
pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
let Some(v) = a.get("aligns") else { return Ok(None) };
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?;
if items.len() > MAX_ALIGNS {
return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
}
items
.iter()
.map(|v| match v {
Value::Null => Ok(None),
Value::String(s) => parse_align(s).map(Some),
_ => Err(bad("each entry of aligns is left, center, right, justify or null")),
})
.collect::<Result<Vec<_>>>()
.map(Some)
}

impl Automation {
pub(crate) fn content_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
Expand Down Expand Up @@ -160,6 +191,8 @@ impl Automation {
match &mut content {
AddedContent::Text(t) => {
if let Some(s) = a.opt_str("text")? {
// Each paragraph keeps its own alignment through the retyping.
t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns);
t.text = s.to_string();
}
style(a, t)?;
Expand All @@ -171,8 +204,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no 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 ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
Expand Down
18 changes: 9 additions & 9 deletions crates/automation/src/dispatch.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary};
use super::paths::{read_input, write_atomic};
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools};
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, content, json, tools};

/// One piece of a tool's result.
#[derive(Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -510,7 +510,10 @@ impl Automation {
};
let index = self.paragraph_index(id, page, k, &blocks)?;
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
// `aligns` counts rows, so without new text the paragraph keeps its rows (the
// editor text); otherwise its text joined with spaces is rewrapped.
let own = if a.get("aligns").is_some() { block.editor_text } else { block.text };
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(own);
let mut style = pdfcraft_engine::BlockStyle {
ranges: parse_style_ranges(a.get("ranges"))?,
resource: a.opt_str("font_resource")?.map(str::to_owned),
Expand Down Expand Up @@ -540,13 +543,10 @@ impl Automation {
style.color = Some(comments::parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
style.align = Some(match al {
"left" => pdfcraft_engine::TextAlign::Left,
"center" => pdfcraft_engine::TextAlign::Center,
"right" => pdfcraft_engine::TextAlign::Right,
"justify" => pdfcraft_engine::TextAlign::Justify,
other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
});
style.align = Some(content::parse_align(al)?);
}
if let Some(aligns) = content::opt_aligns(&a)? {
style.aligns = aligns;
}
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
Expand Down
3 changes: 3 additions & 0 deletions crates/automation/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,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"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
}),
&["doc", "page", "text"],
Expand All @@ -625,6 +626,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"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"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.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
Expand Down Expand Up @@ -1387,6 +1389,7 @@ pub fn tools() -> Vec<ToolDef> {
"size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "description": "#rrggbb" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"underline": { "type": "boolean" },
"line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." },
"dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." },
Expand Down
43 changes: 43 additions & 0 deletions crates/automation/tests/it/automation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9162,6 +9162,49 @@ fn bookmark_list_reports_colour_and_style() {
assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved");
}

/// `aligns` gives each line of added text an alignment of its own; `align` alone is the whole box
/// again. Text edits take `aligns` for a paragraph's rows the same way.
#[test]
fn added_text_lines_take_their_own_alignments() {
let dir = workdir("paragraph-aligns");
let mut a = auto(&dir);
let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]}));
let rect = |a: &mut Automation, text: &str| -> Vec<f64> {
let lines = ok(a, "text_lines", json!({"doc":id,"page":1}));
let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap();
line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
};
assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0);
assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line"));
// The whole box centred: the right-aligned line goes with it.
ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"}));
let right = rect(&mut a, "Right line");
assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}");
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
}

/// `aligns` alone on an existing paragraph aligns its rows by index, as the app does: the rows
/// stay rows instead of being rewrapped into one, where only the first entry would count.
#[test]
fn text_edit_aligns_alone_aligns_the_paragraphs_rows() {
let dir = workdir("paragraph-row-aligns");
let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running.) Tj ET";
std::fs::write(dir.join("rows.pdf"), win_ansi_paragraph_pdf(body)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "rows.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "aligns": [null, "right"] }));
let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
let rect = |t: &str| -> Vec<f64> {
let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == t).unwrap_or_else(|| panic!("{t:?} is a row: {lines}"));
line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
};
let (first, second) = (rect("The quick brown fox jumps over the lazy dog"), rect("and keeps running."));
assert!((first[0] - 72.0).abs() < 0.5, "the first row stays at the left: {first:?}");
assert!((second[2] - first[2]).abs() < 1.0 && second[0] > 100.0, "the second row is right-aligned: {second:?} vs {first:?}");
}

/// An editable combo box (ISO 32000-2 Table 233, Edit) takes a value that isn't one of its
/// options, and its page appearance shows it, before and after a save; one without Edit takes
/// only its options.
Expand Down
18 changes: 18 additions & 0 deletions crates/edit/src/added.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,23 @@ impl Content {
}
}

fn align_code(a: Align) -> i64 {
match a {
Align::Left => 0,
Align::Center => 1,
Align::Right => 2,
Align::Justify => 3,
}
}

fn align_from(code: Option<i64>) -> Align {
match code {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
_ => Align::Left,
}
}

#[cfg(test)]
mod arabic_tests;
4 changes: 2 additions & 2 deletions crates/edit/src/added/arabic_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ fn cluster(id: u32, advance: f64) -> ShapedCluster {
fn line(clusters: Vec<ShapedCluster>, rtl: bool) -> LaidLine {
let text = clusters.iter().map(|c| c.text.as_str()).collect();
let width = clusters.iter().map(|c| c.advance).sum::<f64>() * item().size;
(text, rtl, vec![Piece::Shaped(clusters)], width)
(text, rtl, 0, vec![Piece::Shaped(clusters)], width)
}

/// A glyph outline: a box on the baseline, `advance` em wide.
Expand Down Expand Up @@ -147,7 +147,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
let mixed: LaidLine =
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
("Hi ب".into(), false, 0, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true);
let laid = vec![mixed, rtl];
let mut doc = Document::new_empty();
Expand Down
Loading
Loading