Browse / github.com/storytold/pdfcraft / patch
M7: Add text remembers its format across restarts (#4864)
Add text: remember the chosen format (font, size, colour) across restarts ### Description The Format panel for **Add text** sets the font, size, colour and style for new text. The choice is reused for later text boxes, but only until the app closes: it isn't saved with the other preferences, so after a restart new text is back to the default (12 pt, standard font) and has to be formatted again. Users who always add text in the same style have to pick it again in every session. Other tools already keep their defaults: Fill & Sign saves its text size (`fill_text_size`), and the comment tools save their styles (`comment_styles`, #340). ### Steps 1. Choose **Edit a
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- AdrianTM (imported from pull request #4909, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +110 −7 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
baea4209418d93672383c4c0
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/edit/src/added.rs crates/edit/src/added/store.rs crates/edit/src/added/types.rs crates/engine/src/lib.rs crates/ui-egui/src/app/prefs.rs crates/ui-egui/tests/it/fill_sign.rs parity/acrobat-features.toml
@@ -745,3 +745,5 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.+- **2026-10-26 (M7, #4864):** Add text remembers its Format panel (font, bold/italic, size, colour, alignment, character spacing) across restarts, saved as `text_style` with the other settings; each field is validated on its own, so a damaged value keeps the current one. Tested through `persist`/`restore`.+@@ -31,7 +31,7 @@ use layout::Piece; pub(crate) use layout::WRAP_CALLS; pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width}; pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content};-pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan};+pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, FONT_SIZE_RANGE, Family, MarkerScan}; #[derive(Clone, Debug, PartialEq)] pub enum Content {@@ -1,6 +1,6 @@ use super::draw::{draw, params}; use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};-use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};+use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, FONT_SIZE_RANGE, Family, MarkerScan, TAG}; use super::*; fn parse(doc: &Document, d: &Dict) -> Option<Content> {@@ -123,7 +123,7 @@ fn validate(c: &Content) -> Result<(), EditError> { if t.text.trim().is_empty() { return Err(EditError::Invalid("type some text first".into())); }- if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) {+ if !(t.size.is_finite() && FONT_SIZE_RANGE.contains(&t.size)) { return Err(EditError::Invalid("the font size must be between 1 and 500 points".into())); } if !(t.char_spacing.is_finite() && (-CHAR_SPACING_LIMIT..=CHAR_SPACING_LIMIT).contains(&t.char_spacing)) {@@ -6,6 +6,9 @@ pub(super) const TAG: &str = "Added"; /// The most character spacing an added text takes either way, in points: as far as the font size goes. pub const CHAR_SPACING_LIMIT: f64 = 500.0; +/// The font sizes an added text takes, in points.+pub const FONT_SIZE_RANGE: std::ops::RangeInclusive<f64> = 1.0..=500.0;+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Family { #[default]@@ -78,8 +78,8 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific pub use pdfcraft_cos::Algorithm; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; pub use pdfcraft_edit::added::{- CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,- text_width as added_text_width,+ CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, FONT_SIZE_RANGE as ADDED_FONT_SIZE_RANGE, paste_place as added_paste_place,+ paste_rect as added_paste_rect, text_rect as added_text_rect, text_width as added_text_width, }; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,@@ -19,6 +19,54 @@ pub(crate) const MAX_AUTHOR_CHARS: usize = 200; /// A restored colour: an `[r, g, b]` array of finite numbers, each clamped to 0–1 (restored /// settings are untrusted). Anything else (a string, a wrong length, a null or a non-finite /// number) is refused whole, so the caller keeps its default.+/// The format Add text gives new text, without the text or its box (#4864).+fn text_style_json(s: &pdfcraft_engine::AddedText) -> serde_json::Value {+ use pdfcraft_engine::{FontFamily, TextAlign};+ serde_json::json!({+ "family": match s.family { FontFamily::Helvetica => "helvetica", FontFamily::Times => "times", FontFamily::Courier => "courier" },+ "bold": s.bold,+ "italic": s.italic,+ "size": s.size,+ "color": s.color,+ "align": match s.align { TextAlign::Left => "left", TextAlign::Center => "center", TextAlign::Right => "right", TextAlign::Justify => "justify" },+ "char_spacing": s.char_spacing,+ })+}++/// Read [`text_style_json`] back. Settings are untrusted: each field is taken on its own, and one+/// that is missing, unknown or out of range keeps the current value.+fn restore_text_style(style: &mut pdfcraft_engine::AddedText, v: &serde_json::Value) {+ use pdfcraft_engine::{FontFamily, TextAlign};+ match v["family"].as_str() {+ Some("helvetica") => style.family = FontFamily::Helvetica,+ Some("times") => style.family = FontFamily::Times,+ Some("courier") => style.family = FontFamily::Courier,+ _ => {}+ }+ match v["align"].as_str() {+ Some("left") => style.align = TextAlign::Left,+ Some("center") => style.align = TextAlign::Center,+ Some("right") => style.align = TextAlign::Right,+ Some("justify") => style.align = TextAlign::Justify,+ _ => {}+ }+ if let Some(on) = v["bold"].as_bool() {+ style.bold = on;+ }+ if let Some(on) = v["italic"].as_bool() {+ style.italic = on;+ }+ if let Some(size) = v["size"].as_f64().filter(|s| pdfcraft_engine::ADDED_FONT_SIZE_RANGE.contains(s)) {+ style.size = size;+ }+ if let Some(color) = rgb_triple(&v["color"]) {+ style.color = color;+ }+ if let Some(gap) = v["char_spacing"].as_f64().filter(|g| g.abs() <= pdfcraft_engine::ADDED_CHAR_SPACING_LIMIT) {+ style.char_spacing = gap;+ }+}+ fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> { let a = v.as_array()?; if a.len() != 3 {@@ -102,6 +150,7 @@ impl PdfCraftApp { "ui_scale": self.ui_scale, "flatten_fill_sign": self.flatten_fill_sign_on_save, "fill_text_size": self.fill_text_size,+ "text_style": text_style_json(&self.text_style), "date_format": self.session.date_format(), // Null follows the interface language. "date_language": self.session.date_language(),@@ -248,6 +297,7 @@ impl PdfCraftApp { if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) { self.fill_text_size = size; }+ restore_text_style(&mut self.text_style, &v["text_style"]); if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs;@@ -1765,3 +1765,51 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { h.run_steps(3); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); }++/// #4864: the format Add text gives new text survives a restart; a damaged value keeps the+/// current one, field by field.+#[test]+fn add_text_format_survives_settings_restore() {+ use pdfcraft_engine::{AddedText, FontFamily, TextAlign};+ let mut app = PdfCraftApp::new();+ app.text_style = AddedText {+ family: FontFamily::Courier,+ bold: true,+ italic: true,+ size: 24.0,+ color: [1.0, 0.0, 0.5],+ align: TextAlign::Justify,+ char_spacing: 2.0,+ ..AddedText::default()+ };+ let settings = app.persist();++ let mut restored = PdfCraftApp::new();+ assert_ne!(restored.text_style, app.text_style);+ restored.restore(&settings);+ assert_eq!(restored.text_style, app.text_style);++ // Every size the edit accepts (1 to 500 pt) comes back, including those outside Fill & Sign's.+ for size in [1.0, 3.5, 400.0, 500.0] {+ let mut app = PdfCraftApp::new();+ app.text_style.size = size;+ let mut back = PdfCraftApp::new();+ back.restore(&app.persist());+ assert_eq!(back.text_style.size, size);+ }+ for size in [0.5, 500.5] {+ let mut app = PdfCraftApp::new();+ app.text_style.size = size;+ let mut back = PdfCraftApp::new();+ back.restore(&app.persist());+ assert_eq!(back.text_style.size, 12.0, "{size} pt is refused");+ }++ // Each field is judged alone: the bad ones keep what was there, the good one applies.+ restored.restore(r#"{"text_style":{"family":"comic","bold":"yes","size":1e9,"color":[2,0],"align":7,"char_spacing":1e9,"italic":false}}"#);+ let want = AddedText { italic: false, ..app.text_style.clone() };+ assert_eq!(restored.text_style, want);+ restored.restore(r#"{"text_style":"junk"}"#);+ restored.restore(r#"{"text_style":{"size":null,"color":[0,0,0]}}"#);+ assert_eq!(restored.text_style, AddedText { color: [0.0; 3], ..want });+}@@ -2022,8 +2022,8 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"]-evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"]-notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"+evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/fill_sign.rs::add_text_format_survives_settings_restore"]+notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box The Format panel's font, style, size, colour, alignment and character spacing for new text are saved with the settings and restored on start-up, each field checked alone (#4864); the text size on Fill & Sign is separate." [[feature]] id = "edit.find-replace"