mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M5: adjust Fill and Sign text size (#1285)
Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
12 files changed
+108
-19
No files matched your search
@@ -140,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged.
|
||||
- **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged.
|
||||
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
|
||||
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
|
||||
|
||||
@@ -477,6 +477,14 @@ impl Automation {
|
||||
if sensitivity.is_some() && !scan {
|
||||
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
|
||||
}
|
||||
let requested_size = a.opt_num("font_size")?;
|
||||
if requested_size.is_some() && !matches!(kind, "text" | "date") {
|
||||
return Err(ToolError::InvalidArgs("font_size is only for type text or date".into()));
|
||||
}
|
||||
let size = requested_size.unwrap_or(10.0);
|
||||
if !(4.0..=300.0).contains(&size) {
|
||||
return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into()));
|
||||
}
|
||||
if let Some(path) = a.opt_str("path")? {
|
||||
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
|
||||
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
|
||||
@@ -500,7 +508,6 @@ impl Automation {
|
||||
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
|
||||
return self.apply(a, edit);
|
||||
}
|
||||
let size = 10.0;
|
||||
let text_at = |t: &str| {
|
||||
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
|
||||
let h = size * 1.2 + 6.0;
|
||||
|
||||
@@ -1066,7 +1066,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"fill_sign_add",
|
||||
"Fill & Sign: type text or place a mark",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
|
||||
)
|
||||
.cmd("sign.fill.text")
|
||||
.with(schema(
|
||||
@@ -1076,6 +1076,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date", "signature", "initials"] },
|
||||
"at": point(),
|
||||
"text": { "type": "string", "minLength": 1 },
|
||||
"font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." },
|
||||
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
|
||||
"scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." },
|
||||
"sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." },
|
||||
|
||||
@@ -2248,6 +2248,36 @@ fn fill_and_sign_through_tools() {
|
||||
assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_sign_text_font_size_is_saved_and_validated() {
|
||||
let dir = workdir("fill-sign-text-size");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "Big", "font_size": 24 }));
|
||||
let comments = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
let rect = comments["comments"][0]["rect"].as_array().unwrap();
|
||||
let height = rect[3].as_f64().unwrap() - rect[1].as_f64().unwrap();
|
||||
assert!(height > 30.0, "24 pt text has a taller annotation: {comments}");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "sized.pdf" }));
|
||||
let saved = std::fs::read(dir.join("sized.pdf")).unwrap();
|
||||
let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap();
|
||||
let page = pdfcraft_model::pages(&pdf).remove(0);
|
||||
let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap());
|
||||
let annotation = pdf.resolve(annotations.as_array().unwrap().first().unwrap());
|
||||
let da = annotation.as_dict().unwrap().get(b"DA").unwrap().as_string().unwrap();
|
||||
assert!(da.bytes.windows(b"/Helv 24 Tf".len()).any(|w| w == b"/Helv 24 Tf"), "saved annotation uses the requested font size");
|
||||
for size in [0, 3, 301] {
|
||||
assert!(matches!(
|
||||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 80], "text": "No", "font_size": size })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 80], "text": "Ada", "font_size": 24 })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
|
||||
let dir = workdir("image-signatures");
|
||||
|
||||
@@ -2609,6 +2609,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
i,
|
||||
info,
|
||||
ft,
|
||||
app.fill_text_size,
|
||||
view,
|
||||
signature.as_ref(),
|
||||
initials.as_ref(),
|
||||
@@ -3944,6 +3945,15 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
|
||||
ui.label(tl!("Font size:"));
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut app.fill_text_size)
|
||||
.range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
|
||||
.speed(0.25)
|
||||
.suffix(" pt"),
|
||||
);
|
||||
}
|
||||
if let QuickTool::Comment(tool) = app.quick_tool {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover());
|
||||
ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
|
||||
|
||||
@@ -508,7 +508,7 @@ impl PdfCraftApp {
|
||||
return true;
|
||||
}
|
||||
let author = self.comment_prefs.author.clone();
|
||||
let edit = crate::fill_sign::typed(tb.page, tb.at, &text, &author);
|
||||
let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, &author);
|
||||
if self.apply_edit(edit) {
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
view.fill_text = None;
|
||||
|
||||
@@ -387,11 +387,18 @@ pub struct TypeBox {
|
||||
pub page: usize,
|
||||
pub at: [f64; 2],
|
||||
pub text: String,
|
||||
pub size: f64,
|
||||
pub focus: bool,
|
||||
}
|
||||
|
||||
/// The size Fill & Sign uses for typed text (Acrobat's default is 10 pt).
|
||||
/// The default size Fill & Sign uses for typed text.
|
||||
pub const TEXT_SIZE: f64 = 10.0;
|
||||
pub const MIN_TEXT_SIZE: f64 = 4.0;
|
||||
pub const MAX_TEXT_SIZE: f64 = 300.0;
|
||||
|
||||
pub fn valid_text_size(size: f64) -> Option<f64> {
|
||||
(size.is_finite() && (MIN_TEXT_SIZE..=MAX_TEXT_SIZE).contains(&size)).then_some(size)
|
||||
}
|
||||
|
||||
fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
|
||||
let (vx, vy) = xf.screen_to_view(p);
|
||||
@@ -405,9 +412,9 @@ fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit {
|
||||
}
|
||||
|
||||
/// A typewriter annotation sized to its text.
|
||||
pub fn typed(page: usize, at: [f64; 2], text: &str, author: &str) -> Edit {
|
||||
let rect = crate::comments::text_box_rect(at, text, TEXT_SIZE);
|
||||
new(page, Shape::Typewriter { rect, font_size: TEXT_SIZE }, text.to_string(), author)
|
||||
pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, author: &str) -> Edit {
|
||||
let rect = crate::comments::text_box_rect(at, text, size);
|
||||
new(page, Shape::Typewriter { rect, font_size: size }, text.to_string(), author)
|
||||
}
|
||||
|
||||
/// Place a saved signature (strokes normalised to a 0–1 box, y up) with its left edge at `at`,
|
||||
@@ -595,6 +602,7 @@ pub(crate) fn page_input(
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
tool: FillTool,
|
||||
text_size: f64,
|
||||
view: &mut DocView,
|
||||
signature: Option<&SavedSig>,
|
||||
initials: Option<&SavedSig>,
|
||||
@@ -629,11 +637,12 @@ pub(crate) fn page_input(
|
||||
FillTool::Text => {
|
||||
// Clicking away from an open box commits it: the page sees this click before the box
|
||||
// sees its lost focus, so replacing the box first would drop what was typed.
|
||||
let open = view.fill_text.replace(TypeBox { page, at: [at[0], at[1] + TEXT_SIZE * 0.6], text: String::new(), focus: true });
|
||||
open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| FillAction::Edit(Box::new(typed(tb.page, tb.at, tb.text.trim(), author))))
|
||||
let open =
|
||||
view.fill_text.replace(TypeBox { page, at: [at[0], at[1] + text_size * 0.6], text: String::new(), size: text_size, focus: true });
|
||||
open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| FillAction::Edit(Box::new(typed(tb.page, tb.at, tb.text.trim(), tb.size, author))))
|
||||
}
|
||||
FillTool::Date => Some(match date_text {
|
||||
Ok(text) => FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], text, author))),
|
||||
Ok(text) => FillAction::Edit(Box::new(typed(page, [at[0], at[1] + text_size * 0.6], text, text_size, author))),
|
||||
Err(why) => FillAction::Refused(why.clone()),
|
||||
}),
|
||||
FillTool::Signature => match signature {
|
||||
@@ -680,13 +689,13 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
let Some(t) = view.fill_text.as_mut() else { return };
|
||||
// The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).
|
||||
ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);
|
||||
let width = ((t.text.chars().count().max(8) as f32) * TEXT_SIZE as f32 * 0.6 * zoom).clamp(60.0, 600.0);
|
||||
let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0);
|
||||
// The area's max rect is last frame's size, which caps the edit's width; widen it so the
|
||||
// box grows as text is typed.
|
||||
ui.set_min_width(width);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut t.text)
|
||||
.font(egui::FontId::proportional((TEXT_SIZE as f32 * zoom).max(8.0)))
|
||||
.font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0)))
|
||||
.desired_width(width)
|
||||
.background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230))
|
||||
.text_color(Color32::BLACK)
|
||||
@@ -710,7 +719,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
if commit {
|
||||
let tb = view.fill_text.take()?;
|
||||
if !tb.text.trim().is_empty() {
|
||||
return Some(typed(tb.page, tb.at, tb.text.trim(), author));
|
||||
return Some(typed(tb.page, tb.at, tb.text.trim(), tb.size, author));
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -495,6 +495,8 @@ pub struct PdfCraftApp {
|
||||
ui_scale_applied: Option<f32>,
|
||||
/// Preferences: bake Fill & Sign marks into the page when saving. Off, so a normal save stays editable.
|
||||
pub flatten_fill_sign_on_save: bool,
|
||||
/// Point size for new Fill & Sign text and dates, kept across launches.
|
||||
pub fill_text_size: f64,
|
||||
pub dialog: Option<Dialog>,
|
||||
/// How to ask for the latest release (the desktop app sets it; see `updates`).
|
||||
pub update_source: Option<updates::UpdateSource>,
|
||||
@@ -819,6 +821,7 @@ impl PdfCraftApp {
|
||||
system_text_scale: 1.0,
|
||||
ui_scale_applied: None,
|
||||
flatten_fill_sign_on_save: false,
|
||||
fill_text_size: fill_sign::TEXT_SIZE,
|
||||
dialog: None,
|
||||
update_source: None,
|
||||
updates: updates::Updates::default(),
|
||||
@@ -1756,6 +1759,7 @@ impl PdfCraftApp {
|
||||
// Null follows the system's text scaling.
|
||||
"ui_scale": self.ui_scale,
|
||||
"flatten_fill_sign": self.flatten_fill_sign_on_save,
|
||||
"fill_text_size": self.fill_text_size,
|
||||
"date_format": self.session.date_format(),
|
||||
// Null follows the interface language.
|
||||
"date_language": self.session.date_language(),
|
||||
@@ -1859,6 +1863,9 @@ impl PdfCraftApp {
|
||||
if let Some(on) = v["flatten_fill_sign"].as_bool() {
|
||||
self.flatten_fill_sign_on_save = on;
|
||||
}
|
||||
if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) {
|
||||
self.fill_text_size = size;
|
||||
}
|
||||
// Settings are untrusted: an unreadable value (a colour that isn't three bytes, say)
|
||||
// fails to deserialize and keeps Off.
|
||||
if let Ok(colors) = serde_json::from_value::<pdfcraft_render::ReadingColors>(v["reading_colors"].clone()) {
|
||||
|
||||
@@ -1389,7 +1389,7 @@ fn rail_reveals_its_remembered_inactive_inspector_then_closes_the_visible_panel(
|
||||
fn control_quit_retains_inactive_fill_sign_typing_after_cancel() {
|
||||
let (mut h, c) = harness();
|
||||
h.state_mut().views[0].fill_text =
|
||||
Some(pdfcraft_ui_egui::fill_sign::TypeBox { page: 0, at: [40.0, 200.0], text: "Unsaved signature note".into(), focus: false });
|
||||
Some(pdfcraft_ui_egui::fill_sign::TypeBox { page: 0, at: [40.0, 200.0], text: "Unsaved signature note".into(), size: 10.0, focus: false });
|
||||
h.state_mut().active = None;
|
||||
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
|
||||
assert_eq!(ok(&mut h, &c, "ui.quit", json!({})), json!({"quitting":false,"needs_confirmation":true}));
|
||||
|
||||
@@ -144,6 +144,30 @@ fn clicking_elsewhere_with_add_text_keeps_the_typed_text() {
|
||||
assert!(h.state().views[0].fill_text.as_ref().is_some_and(|t| t.text.is_empty()), "a new, empty box is open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_text_size_controls_new_text_and_survives_settings_restore() {
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("sign.fill.text");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Font size:");
|
||||
h.state_mut().fill_text_size = 24.0;
|
||||
click(&mut h, 50.0, 250.0);
|
||||
assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0));
|
||||
h.event(egui::Event::Text("Larger text".into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let annotation =
|
||||
h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Larger text")).unwrap();
|
||||
assert!(annotation.rect[3] - annotation.rect[1] > 24.0, "24 pt text has a larger annotation: {annotation:?}");
|
||||
let settings = h.state().persist();
|
||||
let mut restored = PdfCraftApp::new();
|
||||
restored.restore(&settings);
|
||||
assert_eq!(restored.fill_text_size, 24.0);
|
||||
restored.restore(r#"{"fill_text_size":1000000}"#);
|
||||
assert_eq!(restored.fill_text_size, 24.0, "invalid settings do not change the size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_type_box_grows_as_text_is_typed() {
|
||||
let mut h = harness();
|
||||
@@ -947,7 +971,7 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() {
|
||||
app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap();
|
||||
let id = app.views[0].id;
|
||||
app.session.apply(id, mark).unwrap();
|
||||
app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), focus: false });
|
||||
app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, focus: false });
|
||||
let kept = dir.join("kept.pdf");
|
||||
app.save_override = Some(kept.to_string_lossy().into_owned());
|
||||
assert!(app.save_active(SaveTarget::InPlace));
|
||||
@@ -958,7 +982,7 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() {
|
||||
assert!(texts.iter().any(|t| t == "Typed"), "an open type box is saved: {texts:?}");
|
||||
assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().info.annotations.len(), 2, "the check mark stays too");
|
||||
|
||||
app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), focus: false });
|
||||
app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), size: 10.0, focus: false });
|
||||
app.flatten_fill_sign_on_save = true;
|
||||
let flat = dir.join("flat.pdf");
|
||||
app.save_override = Some(flat.to_string_lossy().into_owned());
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ total matches the parity estimate.
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
|
||||
@@ -3620,8 +3620,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["sign.fill.text"]
|
||||
tools = ["fill_sign_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/fill_sign.rs::text_marks_and_date"]
|
||||
notes = ""
|
||||
evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/automation.rs::fill_sign_text_font_size_is_saved_and_validated"]
|
||||
notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches. fill_sign_add accepts font_size for text and dates."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.text-box"
|
||||
|
||||
Reference in new issue
Block a user