Files
pdfcraft/crates/ui-egui/tests/content.rs
T
6dec57bf80 M7: lay out Format text as Acrobat's panel (#2249)
* M7: lay out Format text as Acrobat's panel

Format text was a row of separate combos, a Load font button, an
Installed fonts combo, a swatch grid and labelled drag values. It now
follows Acrobat's arrangement: the font across the panel (the page's
fonts, the standard families, Load font… and Installed fonts in one
list, showing the paragraph's own font by name); size and a colour
swatch, then lists and an alignment flyout; bold, italic, underline,
superscript and subscript; line and paragraph spacing; horizontal scale
and character spacing. One panel serves an edited paragraph and added
text. What PdfCraft can't apply yet (lists, superscript and subscript,
paragraph spacing, spacing for added text) is shown disabled and says
it is planned.

Icons stay Lucide's: superscript, subscript, pilcrow and
move-horizontal are added from lucide-static 1.49.0 with attribution.
Eight new strings are translated in every catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: parity notes for the Format text panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(vi): Superscript and Subscript for Format text

The Vietnamese catalogue came from main without the Format text panel's
Superscript and Subscript labels; vietnamese_covers_ui_literals lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: Format text takes any horizontal scale and character spacing again

Review of #2249: the panel's preset lists narrowed what could be set to
50-200 % and -1..5 pt, where the old fields took 10-400 % and -5..50 pt.
Each now has a number field over the old range again, with the list of
common values beside it.

Test: the_format_panel_takes_any_scale_and_character_spacing types values
between the listed ones and beyond them, saves and reopens, and compares
the paragraph with the engine's own rewrite. It fails without the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M7: Format text's rows line their controls up and fit the panel

Raised in #3037: the size list sat lower than the colour swatch and the
alignment button beside it. egui lays a list out from the default control
height and grows it downward, so a row's shorter controls ended up off its
middle. Each row is now one control height and centres what it holds, at
a list's own height.

The spacing rows' columns are measured from what they hold, so the second
list no longer runs past the panel; the number fields' lists open from a
small arrow, which leaves the field the room its value needs.

Test: format_text_rows_line_up_and_fit_the_panel checks the size list is
centred with the swatch and alignment button and as tall as the font list,
that each spacing row shares one line, and that nothing passes the panel.
It fails without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Format text: choosing the family a font name reads as still changes the font

CodeRabbit on #2249: a Calibri paragraph is shown as Helvetica, so picking Helvetica left
look.family equal to look0.family and no family change was sent. The pick is now tracked
(LineEditor::choose_family) and counts as a change in style(), font_shown(), has_changes()
and the editor preview; Keep source fonts and an applied edit clear it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Format text: the reusable text style follows a selection only when its edit was applied

A refused UpdateContent used to leave the new style in app.text_style, so the next
added text inherited formatting the selected text never received.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jk4iYSh3mEd145niec77LF

---------

Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:26:28 -04:00

645 lines
27 KiB
Rust

//! Edit a PDF ▸ Add content in the real shell (egui_kittest): type text onto a page, move it,
//! restyle it from the panel, add an image, delete with the keyboard.
use egui::{Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
fn harness() -> Harness<'static, PdfCraftApp> {
harness_with_step_dt(0.25)
}
fn harness_with_step_dt(step_dt: f32) -> Harness<'static, PdfCraftApp> {
harness_bytes_with_step_dt(include_bytes!("data/form.pdf").to_vec(), step_dt)
}
fn harness_bytes(bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
harness_bytes_with_step_dt(bytes, 0.25)
}
fn harness_bytes_with_step_dt(bytes: Vec<u8>, step_dt: f32) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(step_dt).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, bytes.clone()).unwrap();
app.set_option("zoom", "150").unwrap();
app
});
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
h
}
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 300.0;
r.min + egui::vec2(x * k, y * k)
}
fn click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
}
fn added(h: &Harness<'static, PdfCraftApp>) -> Vec<pdfcraft_engine::Added> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().added().to_vec()
}
/// A click whose press and release arrive in the same frame, as a quick real mouse click does.
fn quick_click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(3);
}
fn texts(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
added(h)
.into_iter()
.filter_map(|a| match a.content {
pdfcraft_engine::AddedContent::Text(t) => Some(t.text),
_ => None,
})
.collect()
}
#[test]
fn text_tool_drafts_grow_for_latin_and_arabic() {
for text in ["Wide words ".repeat(8), "سلام عليكم ".repeat(8)] {
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let point = at(&h, 40.0, 300.0);
click(&mut h, point);
let before = h.state().views[0].content.draft.as_ref().unwrap().rect;
h.event(egui::Event::Text(text.clone()));
h.run_steps(4);
let draft = h.state().views[0].content.draft.as_ref().unwrap();
assert!(draft.rect[2] - draft.rect[0] > before[2] - before[0] + 10.0, "the editor grows before wrapping: {}", text);
assert!(draft.rect[2] <= 300.0 && draft.rect[1] < before[1], "the frame stays on the page and grows vertically");
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
let language = if text.starts_with("Wide") { "latin" } else { "arabic" };
h.render().unwrap().save(format!("{dir}/growing-{language}-text.png")).unwrap();
}
h.get_by_label("Done adding text").click();
h.run_steps(3);
if text.starts_with("سلام") && pdfcraft_fonts::document_arabic_font().is_none() {
assert!(h.state().views[0].content.draft.is_some(), "without a document font the draft remains available");
assert!(added(&h).is_empty());
continue;
}
let pdfcraft_engine::AddedContent::Text(saved) = &added(&h)[0].content else { panic!() };
assert_eq!(saved.text, text.trim_end());
assert!(saved.rect[2] - saved.rect[0] > before[2] - before[0] + 10.0, "Save uses the grown width");
}
}
#[test]
fn text_tool_single_click_retypes_added_text_without_another_item() {
for command in ["edit.text", "edit.edit_text"] {
for (original, replacement) in [("Original", "Replacement"), ("سلام", "سلام عليكم ورحمة الله")] {
if original == "سلام" && pdfcraft_fonts::document_arabic_font().is_none() {
continue;
}
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: original.into(), ..Default::default() },
}));
assert!(h.state_mut().execute(command));
h.run_steps(3);
let point = at(&h, 45.0, 195.0);
click(&mut h, point);
let draft = h.state().views[0].content.draft.as_ref().expect("one click edits the existing item");
assert_eq!(draft.index, Some(0));
assert_eq!(draft.text, original);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text(replacement.into()));
h.run_steps(3);
h.get_by_label("Done adding text").click();
h.run_steps(3);
assert_eq!(texts(&h), [replacement]);
let state = h.state();
let bytes = state.session.save_bytes(state.views[0].id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let id = reopened.open("again.pdf", None, bytes, None).unwrap();
assert_eq!(reopened.get(id).unwrap().added().len(), 1);
let pdfcraft_engine::AddedContent::Text(text) = &reopened.get(id).unwrap().added()[0].content else { panic!() };
assert_eq!(text.text, replacement);
}
}
}
#[test]
fn text_tool_deleting_a_draft_hides_the_old_added_glyphs_and_cancel_restores_them() {
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 220.0], text: "Original glyphs".into(), size: 18.0, ..Default::default() },
}));
let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone();
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(3);
let point = at(&h, 45.0, 185.0);
click(&mut h, point);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.key_press(egui::Key::Backspace);
for _ in 0..100 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(h.state().views[0].content.draft.as_ref().unwrap().text.is_empty());
let shot = h.render().unwrap();
for x in 105..140 {
for y in 185..197 {
let pixel = at(&h, x as f32, y as f32);
let [r, g, b, a] = shot.get_pixel(pixel.x as u32, pixel.y as u32).0;
assert!(r > 200 && g > 200 && b > 200 && a == 255, "old glyph ink remains at {x},{y}: {r},{g},{b},{a}");
}
}
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
shot.save(format!("{dir}/cleared-added-text.png")).unwrap();
}
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes, "previewing does not edit the document");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(texts(&h), ["Original glyphs"]);
assert!(h.state().views[0].content.draft.is_none());
}
#[test]
fn clicking_elsewhere_keeps_the_text_and_starts_a_new_box() {
// #74: a click elsewhere on the page threw away the text being typed and opened a new box.
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 40.0, 100.0);
click(&mut h, p);
h.event(egui::Event::Text("First".into()));
h.run_steps(2);
// A slow click (press and release in different frames)…
let p = at(&h, 40.0, 200.0);
click(&mut h, p);
assert_eq!(texts(&h), ["First"]);
assert!(h.state().views[0].content.draft.is_some(), "a new box opened");
assert_eq!(h.state().quick_tool, QuickTool::AddText, "still adding text");
h.event(egui::Event::Text("Second".into()));
h.run_steps(2);
// …and a quick one (both in one frame, as a real mouse click usually arrives).
let p = at(&h, 40.0, 300.0);
quick_click(&mut h, p);
assert_eq!(texts(&h), ["First", "Second"]);
// Discard throws away only the box being typed.
h.event(egui::Event::Text("Third".into()));
h.run_steps(2);
h.get_by_label("Discard this text (Esc)").click();
h.run_steps(3);
assert_eq!(texts(&h), ["First", "Second"]);
assert!(h.state().views[0].content.draft.is_none());
}
#[test]
fn typing_moving_styling_and_deleting_added_content() {
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
h.get_by_label("Edit a PDF");
let p = at(&h, 40.0, 300.0);
click(&mut h, p);
assert!(h.state().views[0].content.draft.is_some(), "the editor opened");
h.event(egui::Event::Text("Reviewed".into()));
h.run_steps(2);
// Done keeps the text and goes back to Select, with the new text selected.
h.get_by_label("Done adding text").click();
h.run_steps(3);
let a = added(&h);
assert_eq!(a.len(), 1);
let pdfcraft_engine::AddedContent::Text(t) = &a[0].content else { panic!() };
assert_eq!(t.text, "Reviewed");
assert_eq!(h.state().quick_tool, QuickTool::Select);
h.run_steps(2);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the new text is selected");
// Bold from the Format panel: one undoable change.
h.get_by_label("Bold").click();
h.run_steps(3);
let pdfcraft_engine::AddedContent::Text(t) = &added(&h)[0].content else { panic!() };
assert!(t.bold);
{
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Edit content"));
}
// Delete.
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert!(added(&h).is_empty());
// An image lands in the middle of the page, selected.
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 30, 20);
enc.set_color(png::ColorType::Rgb);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[90u8; 1800]).unwrap();
}
h.state_mut().add_image("logo.png".into(), png);
h.run_steps(3);
let a = added(&h);
assert_eq!(a.len(), 1);
assert_eq!(a[0].content.rect(), [135.0, 190.0, 165.0, 210.0]);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)));
// Edit image: rotate clockwise turns the box around its centre.
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
let a = added(&h);
let pdfcraft_engine::AddedContent::Image(img) = &a[0].content else { panic!() };
assert_eq!((img.rotation, img.rect), (3, [140.0, 185.0, 160.0, 215.0]));
h.get_by_label("Flip horizontal").click();
h.run_steps(3);
let pdfcraft_engine::AddedContent::Image(img) = &added(&h)[0].content else { panic!() };
assert!(img.flip_h);
}
/// #125: text the standard fonts can't draw stays in the editor with a warning instead of being
/// written to the page as `?` (or lost).
#[test]
fn text_the_standard_fonts_cant_draw_stays_in_the_editor() {
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 40.0, 300.0);
click(&mut h, p);
h.event(egui::Event::Text("Hello 世界".into()));
h.run_steps(2);
h.get_by_label_contains("can't draw “世” (U+4E16)");
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
h.render().unwrap().save(format!("{dir}/undrawable-text.png")).unwrap();
}
h.get_by_label("Done adding text").click();
h.run_steps(3);
assert!(texts(&h).is_empty(), "nothing was written to the page");
let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone());
assert_eq!(draft.as_deref(), Some("Hello 世界"), "the typed text is kept");
// A click elsewhere on the page doesn't finish it, or replace it with a new box.
let elsewhere = at(&h, 40.0, 150.0);
click(&mut h, elsewhere);
let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone());
assert_eq!(draft.as_deref(), Some("Hello 世界"), "clicking elsewhere keeps the typed text");
assert!(texts(&h).is_empty());
// Once the text can be drawn, Done adds it (Done took the focus, so edit the draft directly).
if let Some(d) = h.state_mut().views[0].content.draft.as_mut() {
d.text = "Hello world".into();
}
h.run_steps(2);
h.get_by_label("Done adding text").click();
h.run_steps(3);
assert_eq!(texts(&h), ["Hello world"]);
}
/// Text kept because it can't be drawn goes with its page when the page is deleted, so nothing
/// is left blocking Save out of sight.
#[test]
fn a_kept_draft_goes_with_its_deleted_page() {
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 }));
h.run_steps(2);
assert!(h.state_mut().execute("edit.text"));
h.state_mut().views[0].go_to_page(1);
h.run_steps(4);
let p = h.state().views[0].page_screen_rect(1).expect("page 2 on screen").min + egui::vec2(40.0, 40.0);
click(&mut h, p);
h.event(egui::Event::Text("日本語".into()));
h.run_steps(2);
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| (d.page, d.text.as_str())), Some((1, "日本語")));
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![1] }));
h.run_steps(2);
assert!(h.state().views[0].content.draft.is_none());
}
#[test]
fn typing_thai_text_keeps_marks_and_commits_to_page() {
if pdfcraft_fonts::document_thai_font().is_none() {
return;
}
let fonts = pdfcraft_ui_egui::theme::font_definitions();
let face = pdfcraft_fonts::document_thai_font().unwrap();
assert!(fonts.families[&egui::FontFamily::Proportional].contains(&face.name()));
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 40.0, 100.0);
click(&mut h, p);
h.event(egui::Event::Text("น้ำ กิ่ ผู้ใช้ Hello".into()));
h.run_steps(3);
let p = at(&h, 40.0, 200.0);
click(&mut h, p);
assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]);
}
#[test]
fn finished_text_over_form_field_keeps_selection_and_drag_ownership() {
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 60.0, 202.0);
click(&mut h, p);
h.event(egui::Event::Text("Reviewed".into()));
h.run_steps(2);
h.get_by_label("Done adding text").click();
h.run_steps(3);
assert_eq!(texts(&h), ["Reviewed"]);
let rect = added(&h)[0].content.rect();
let mid_x = ((rect[0] + rect[2]) / 2.0) as f32;
let mid_y = ((rect[1] + rect[3]) / 2.0) as f32;
assert!((20.0..280.0).contains(&mid_x) && (180.0..200.0).contains(&mid_y), "fixture text must overlap city: {rect:?}");
let center = at(&h, mid_x, 400.0 - mid_y);
click(&mut h, center);
assert!(h.state().views[0].forms.focus.is_none(), "selecting finished text must not focus the field underneath");
assert_eq!(h.state().views[0].content.selected, Some((0, 0)));
h.run_steps(60);
let start = at(&h, mid_x, 400.0 - mid_y);
let end = at(&h, mid_x, 400.0 - mid_y - 80.0);
h.drag_at(start);
h.run_steps(1);
for step in 1..=40 {
h.hover_at(start + (end - start) * (step as f32 / 40.0));
h.run_steps(1);
}
h.drop_at(end);
h.run_steps(4);
let moved = added(&h)[0].content.rect();
for (actual, expected) in moved.into_iter().zip([rect[0], rect[1] + 80.0, rect[2], rect[3] + 80.0]) {
assert!((actual - expected).abs() < 1.0, "text should move upward: {rect:?} -> {moved:?}");
}
assert!(h.state().views[0].forms.focus.is_none());
let bare = at(&h, 270.0, 210.0);
click(&mut h, bare);
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
}
fn text_over_city() -> Harness<'static, PdfCraftApp> {
let mut h = harness_with_step_dt(1.0 / 60.0);
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 60.0, 202.0);
click(&mut h, p);
h.event(egui::Event::Text("Reviewed".into()));
h.run_steps(2);
h.get_by_label("Done adding text").click();
h.run_steps(3);
h
}
#[test]
fn finished_text_over_field_keeps_move_and_resize_cursors() {
let mut h = text_over_city();
let rect = added(&h)[0].content.rect();
let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32);
h.hover_at(center);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
// The upper-right handle overlaps the city field; it retains its resize gesture.
let corner = at(&h, rect[2] as f32, (400.0 - rect[3]) as f32);
h.hover_at(corner);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw);
let end = corner + egui::vec2(30.0, -20.0);
h.drag_at(corner);
h.run_steps(1);
for step in 1..=20 {
h.hover_at(corner + (end - corner) * (step as f32 / 20.0));
h.run_steps(1);
}
h.drop_at(end);
h.run_steps(4);
let resized = added(&h)[0].content.rect();
assert!(resized[2] > rect[2] + 1.0 && resized[3] > rect[3] + 1.0, "resize must retain ownership: {rect:?} -> {resized:?}");
assert!(h.state().views[0].forms.focus.is_none());
}
#[test]
fn finished_text_over_field_still_opens_its_editor() {
let mut h = text_over_city();
let rect = added(&h)[0].content.rect();
let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32);
h.run_steps(60);
h.hover_at(center);
h.run_steps(1);
for _ in 0..2 {
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: center, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(1);
}
h.run_steps(2);
assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "double click edits the added text");
assert!(h.state().views[0].forms.focus.is_none());
}
#[test]
fn closing_edit_panel_leaves_form_filling_available() {
let mut h = text_over_city();
h.state_mut().set_option("left", "closed").unwrap();
h.run_steps(3);
let p = at(&h, 100.0, 210.0);
click(&mut h, p);
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
}
/// The screen rectangle of an added item's display-space box (y up, from the page's bottom).
fn item_on_screen(h: &Harness<'static, PdfCraftApp>, r: [f64; 4]) -> egui::Rect {
let s = h.state();
let ph = s.session.get(s.views[0].id).unwrap().info.pages[0].height;
let page = s.views[0].page_screen_rect(0).expect("on screen");
let k = page.width() / 300.0;
let p = |x: f64, y: f64| page.min + egui::vec2(x as f32 * k, (ph - y as f32) * k);
egui::Rect::from_two_pos(p(r[0], r[1]), p(r[2], r[3]))
}
/// Dark pixels in `r`: glyph ink (the selection outline is blue, the page white).
fn ink(pixels: &image::RgbaImage, r: egui::Rect) -> usize {
let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32);
(y0..y1.min(pixels.height()))
.flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y)))
.filter(|(x, y)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90))
.count()
}
/// Only the left part of the glyphs: kittest paints its cursor down and right of the pointer.
fn probe(r: egui::Rect) -> egui::Rect {
egui::Rect::from_min_size(r.min, egui::vec2(r.width() * 0.6, r.height()))
}
/// Ink in the probes of the text's first and dragged places.
fn ink_at(h: &mut Harness<'static, PdfCraftApp>, from: egui::Rect, to: egui::Rect) -> (usize, usize) {
let pixels = h.render().unwrap();
(ink(&pixels, probe(from)), ink(&pixels, probe(to)))
}
/// Types text, finishes it, and drags it up without releasing, until the drag preview shows.
/// Returns the text's box, its screen rectangles before and after, and the pointer.
fn drag_added_text(h: &mut Harness<'static, PdfCraftApp>) -> ([f64; 4], egui::Rect, egui::Rect, Pos2) {
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(h, 40.0, 300.0);
click(h, p);
h.event(egui::Event::Text("MMMMMMMM".into()));
h.run_steps(2);
h.get_by_label("Done adding text").click();
h.run_steps(3);
let rect = added(h)[0].content.rect();
let from = item_on_screen(h, rect);
let mut shown = 0;
for _ in 0..100 {
h.run_steps(2);
shown = ink(&h.render().unwrap(), probe(from));
if shown > 20 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(shown > 20, "the added text is on the page ({shown} dark pixels)");
let grab = pos2(from.right() - 12.0, from.center().y);
let shift = egui::vec2(0.0, -150.0);
h.hover_at(grab);
h.run_steps(1);
h.drag_at(grab);
h.run_steps(1);
h.hover_at(grab + shift);
let to = from.translate(shift);
for _ in 0..100 {
h.run_steps(2);
let (old, new) = ink_at(h, from, to);
if old == 0 && new > 20 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
(rect, from, to, grab + shift)
}
/// #1488: while a finished text box is dragged, its text follows the box instead of staying put
/// until the button is released.
#[test]
fn dragged_text_follows_the_pointer_before_release() {
let mut h = harness();
let (rect, from, to, end) = drag_added_text(&mut h);
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
h.render().unwrap().save(format!("{dir}/added-text-drag.png")).unwrap();
}
let (old, new) = ink_at(&mut h, from, to);
assert!(new > 20, "the text is drawn where the box was dragged ({new} dark pixels)");
assert_eq!(old, 0, "and no longer where it was");
assert_eq!(added(&h)[0].content.rect(), rect, "nothing is applied before release");
h.drop_at(end);
h.run_steps(3);
assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it");
}
/// After release the text stays in its new place while the page is re-rendered with the move,
/// instead of jumping back to where it was for those frames.
#[test]
fn released_text_does_not_jump_back_before_the_page_is_redrawn() {
// A page slow enough to render that the frames before its new raster can be looked at: many
// pale squares, too light to count as ink.
let mut content = String::from("0.9 g\n");
for i in 0..60_000 {
content.push_str(&format!("{} {} 1 1 re f\n", i % 290, (i / 290) % 390));
}
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
content.len()
);
let mut h = harness_bytes(pdf.into_bytes());
let (rect, from, to, end) = drag_added_text(&mut h);
h.drop_at(end);
// Frame by frame until the page has been re-rendered. The one frame that swaps in the new
// raster reads back blank here (kittest renders it after its old texture is freed).
let mut shown = Vec::new();
while h.state().render_pending() || shown.is_empty() {
h.run_steps(1);
assert!(shown.len() < 500, "the page is re-rendered");
let (old, new) = ink_at(&mut h, from, to);
assert_eq!(old, 0, "frame {} after release: the text is back where it was", shown.len() + 1);
shown.push(new);
}
assert!(shown.len() > 2, "frames before the new raster: {shown:?}");
assert!(shown.iter().filter(|n| **n <= 20).count() <= 1, "the text stays where it was released: {shown:?}");
assert_ne!(added(&h)[0].content.rect(), rect, "the move is applied");
h.run_steps(2);
let (old, new) = ink_at(&mut h, from, to);
assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)");
}
/// Format text is laid out as Acrobat's: alignment from its flyout, style buttons by name, and
/// the controls PdfCraft can't apply yet present but disabled.
/// Format text lines its rows up (#3037): the size list is centred beside the colour swatch and
/// the alignment button and is as tall as the font list, each spacing row's controls share one
/// line, and nothing reaches past the font list, which spans the panel.
#[test]
fn format_text_rows_line_up_and_fit_the_panel() {
use egui::accesskit::Role;
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.get_by_label("FORMAT TEXT");
let combo = |h: &Harness<'static, PdfCraftApp>, value: &str| {
h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some(value)).map(|n| n.rect()).expect(value)
};
let (font, size) = (combo(&h, "Helvetica"), combo(&h, "12"));
let middle = |r: egui::Rect| r.center().y;
for label in ["Colour", "Alignment"] {
let other = h.get_by_label(label).rect();
assert!((middle(size) - middle(other)).abs() < 0.5, "the size list ({size:?}) is centred with {label} ({other:?})");
}
assert_eq!(size.height(), font.height(), "the font and size lists are the same height");
// Line and paragraph spacing; scale and character spacing with their lists.
let line = [combo(&h, "Auto"), combo(&h, "0")];
assert!((middle(line[0]) - middle(line[1])).abs() < 0.5, "{line:?}");
// Each: its number field and the arrow that lists common values.
let scale_row: Vec<egui::Rect> =
["Horizontal scale", "Character spacing"].iter().flat_map(|l| h.query_all_by_label(l).map(|n| n.rect())).collect();
assert_eq!(scale_row.len(), 4, "two fields and their two arrows: {scale_row:?}");
for r in &scale_row {
assert!((middle(*r) - middle(scale_row[0])).abs() < 0.5, "{scale_row:?}");
}
for r in h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).chain(scale_row) {
assert!(r.right() <= font.right() + 0.5, "{r:?} reaches past the panel ({font:?})");
}
}
#[test]
fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() {
use egui_kittest::kittest::NodeT;
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.get_by_label("FORMAT TEXT");
for planned in ["Bulleted list", "Numbered list", "Superscript", "Subscript"] {
assert!(h.get_by_label(planned).accesskit_node().is_disabled(), "{planned} is shown but disabled");
}
h.get_by_label("Alignment").click();
h.run_steps(2);
h.get_by_label("Centre").click();
h.run_steps(2);
assert_eq!(h.state().text_style.align, pdfcraft_engine::TextAlign::Center);
h.get_by_label("Bold").click();
h.run_steps(2);
assert!(h.state().text_style.bold);
}