mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
refactor(ui-egui): split edit_text_ui.rs into edit_text_ui/
New modules: background, layout, image.
This commit is contained in:
+14
-1099
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
use super::*;
|
||||
|
||||
/// A cached, read-only page background. The source glyphs are removed before rasterization;
|
||||
/// colour, pictures and unselected text beneath the editor remain visible.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TextBackground {
|
||||
key: Option<(u64, usize, TextTarget)>,
|
||||
pub(super) renderer: Option<pdfcraft_render::RenderPool>,
|
||||
pub(super) texture: Option<egui::TextureHandle>,
|
||||
pub(super) tag: Option<u64>,
|
||||
received: Option<u64>,
|
||||
draft_key: Option<(String, pdfcraft_engine::BlockStyle)>,
|
||||
draft_rect: Option<[f64; 4]>,
|
||||
/// This frame's place for the textless page under the open paragraph's editor. It stays
|
||||
/// empty, so the page shows its own text, until the paragraph is changed ([`Self::reveal`]).
|
||||
slot: Option<(egui::Painter, egui::layers::ShapeIdx, egui::Shape)>,
|
||||
/// How the open paragraph looked in its editor last frame.
|
||||
look: Option<EditorLook>,
|
||||
/// An applied paragraph's last look. The page raster still has the old text until the page
|
||||
/// is rendered again, so the textless page and this look stay up until then.
|
||||
settling: Option<EditorLook>,
|
||||
}
|
||||
|
||||
/// A paragraph editor's text as last drawn: its laid-out rows, where they were on the page (in
|
||||
/// the page's 0..1 coordinates), and the page's width and turn on screen then.
|
||||
struct EditorLook {
|
||||
page: usize,
|
||||
block: usize,
|
||||
galley: Arc<egui::Galley>,
|
||||
at: (f32, f32),
|
||||
color: Color32,
|
||||
page_width: f32,
|
||||
rot: u16,
|
||||
}
|
||||
|
||||
impl TextBackground {
|
||||
pub fn pending(&self) -> bool {
|
||||
self.renderer.is_some() && self.received != self.tag
|
||||
}
|
||||
|
||||
/// The open paragraph's editor drew `galley` at `pos` this frame.
|
||||
pub fn remember(&mut self, xf: &PageXform, page: usize, block: usize, galley: Arc<egui::Galley>, pos: Pos2, color: Color32) {
|
||||
self.look = Some(EditorLook { page, block, galley, at: xf.screen_to_norm(pos), color, page_width: xf.rect.width(), rot: xf.rot });
|
||||
}
|
||||
|
||||
/// An edit was applied. When it is the open paragraph's, its last look stays up over the
|
||||
/// textless page until the page is rendered again; any other edit ends both.
|
||||
pub fn committed(&mut self, edit: &Edit) {
|
||||
let look = self.look.take().filter(|look| {
|
||||
matches!(edit, Edit::EditTextBlock { page, block, .. } if (look.page, look.block) == (*page, *block))
|
||||
&& self.key.is_some_and(|(_, page, target)| (page, target) == (look.page, TextTarget::Block(look.block)))
|
||||
&& self.texture.is_some()
|
||||
});
|
||||
match look {
|
||||
Some(look) => {
|
||||
self.renderer = None;
|
||||
self.settling = Some(look);
|
||||
}
|
||||
None => *self = Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_received(&mut self, page: usize) {
|
||||
if self.settling.as_ref().is_some_and(|look| look.page == page) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
doc: &pdfcraft_engine::Document,
|
||||
editor: Option<&LineEditor>,
|
||||
draft: Option<&crate::content_ui::TextDraft>,
|
||||
scale: f32,
|
||||
) {
|
||||
let key = editor
|
||||
.map(|ed| (doc.edit_generation(), ed.page, TextTarget::Block(ed.block)))
|
||||
.or_else(|| draft.and_then(|d| d.index.map(|index| (doc.edit_generation(), d.page, TextTarget::Added(index)))));
|
||||
self.slot = None;
|
||||
if key.is_none() && self.settling.is_some() {
|
||||
return;
|
||||
}
|
||||
if self.key != key {
|
||||
*self = Self { key, ..Default::default() };
|
||||
if let Some((_, page, target)) = key {
|
||||
self.renderer = match target {
|
||||
TextTarget::Block(block) => doc.text_edit_preview(page, block),
|
||||
TextTarget::Added(index) => doc.added_text_preview(page, index),
|
||||
}
|
||||
.ok()
|
||||
.map(|p| p.renderer());
|
||||
}
|
||||
}
|
||||
if let Some(ed) = editor {
|
||||
let draft_key = (ed.text.clone(), ed.style());
|
||||
if self.draft_key.as_ref() != Some(&draft_key) {
|
||||
self.draft_rect = doc.text_edit_layout(ed.page, ed.block, &ed.text, &draft_key.1).ok().and_then(|layout| layout.draft_rect);
|
||||
self.draft_key = Some(draft_key);
|
||||
}
|
||||
}
|
||||
let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return };
|
||||
let Some(p) = doc.info.pages.get(page) else { return };
|
||||
// One raster stands in for the page's tiles while a paragraph is open, so everything
|
||||
// around the editor is drawn from it: at the tiling threshold it stays sharp on a Retina
|
||||
// display up to about 2.5x on a Letter or A4 page, where the backdrop side (2048) went
|
||||
// soft past 1.2x.
|
||||
let max_side = ctx.input(|i| i.max_texture_side) as f32;
|
||||
let scale = scale.min(crate::canvas::TILE_THRESHOLD.min(max_side) / p.width.max(p.height).max(1.0));
|
||||
let tag = (scale * 1000.0) as u64;
|
||||
if self.tag != Some(tag) {
|
||||
renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]);
|
||||
self.tag = Some(tag);
|
||||
}
|
||||
if let Some(out) = renderer.try_recv() {
|
||||
if out.error.is_some() {
|
||||
self.renderer = None;
|
||||
self.texture = None;
|
||||
return;
|
||||
}
|
||||
if out.request.tag == tag {
|
||||
self.texture = Some(ctx.load_texture(
|
||||
"text-editor-background",
|
||||
crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba),
|
||||
egui::TextureOptions::LINEAR,
|
||||
));
|
||||
self.received = Some(tag);
|
||||
}
|
||||
}
|
||||
if self.received != Some(tag) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// The draft's measured extent, when it was measured for paragraph `block` on `page`.
|
||||
pub fn draft_rect_for(&self, page: usize, block: usize) -> Option<[f64; 4]> {
|
||||
self.draft_rect.filter(|_| self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block))))
|
||||
}
|
||||
|
||||
pub fn ready(&self, page: usize, block: usize) -> bool {
|
||||
self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block))) && self.texture.is_some()
|
||||
}
|
||||
|
||||
pub fn ready_added(&self, page: usize, index: usize) -> bool {
|
||||
self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Added(index))) && self.texture.is_some()
|
||||
}
|
||||
|
||||
pub fn paint(
|
||||
&mut self,
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
editor: Option<&LineEditor>,
|
||||
draft: Option<&crate::content_ui::TextDraft>,
|
||||
) {
|
||||
let for_editor = editor.is_some_and(|ed| ed.page == page && self.ready(page, ed.block));
|
||||
// A zoom or a turn of the view since the paragraph was applied would misplace its rows:
|
||||
// the page raster (with the old text) shows until the new one arrives instead.
|
||||
let settled =
|
||||
self.settling.as_ref().filter(|look| look.page == page && look.rot == xf.rot && (look.page_width - xf.rect.width()).abs() < 0.5);
|
||||
let ready =
|
||||
for_editor || draft.is_some_and(|d| d.page == page && d.index.is_some_and(|index| self.ready_added(page, index))) || settled.is_some();
|
||||
if !ready {
|
||||
return;
|
||||
}
|
||||
let Some(tex) = &self.texture else { return };
|
||||
// Font metrics can underestimate the actual glyph ink. Paint the complete textless
|
||||
// page so overhanging glyphs cannot remain outside the estimated source rectangle.
|
||||
let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
|
||||
if for_editor {
|
||||
// Its place under everything drawn on the page later; the editor fills it once the
|
||||
// paragraph changes (an unchanged paragraph looks as the page draws it).
|
||||
let mut mesh = egui::Mesh::with_texture(tex.id());
|
||||
for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.norm_to_screen(u, v), uv: Pos2::new(u, v), color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
let idx = painter.add(egui::Shape::Noop);
|
||||
self.slot = Some((painter, idx, egui::Shape::mesh(mesh)));
|
||||
} else {
|
||||
xf.paint_image(&painter, tex.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
if let Some(look) = settled {
|
||||
painter.galley(xf.norm_to_screen(look.at.0, look.at.1), Arc::clone(&look.galley), look.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The open paragraph has changed: show the textless page under its editor this frame.
|
||||
pub fn reveal(&mut self) {
|
||||
if let Some((painter, idx, shape)) = self.slot.take() {
|
||||
painter.set(idx, shape);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
use super::*;
|
||||
|
||||
/// A selected page image, and what the pointer is doing to it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ImageSelection {
|
||||
pub page: usize,
|
||||
pub index: usize,
|
||||
/// Dragging: the start point and, for a corner, the opposite corner (screen).
|
||||
drag: Option<(Pos2, Option<Pos2>)>,
|
||||
/// Crop mode (Acrobat's crop handles): the box kept so far.
|
||||
pub crop: Option<CropMode>,
|
||||
/// The drag is turning the picture by its rotate handle (the `drag` start is then unused).
|
||||
turning: bool,
|
||||
/// A move or resize applied on release: the frame it had and the one it got, in the page's
|
||||
/// 0..1 coordinates, while the page raster still has the picture where it was.
|
||||
settled: Option<(Rect, Rect)>,
|
||||
}
|
||||
|
||||
impl ImageSelection {
|
||||
/// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing.
|
||||
pub(crate) fn cancel_pointer_gesture(&mut self) {
|
||||
self.drag = None;
|
||||
self.turning = false;
|
||||
if let Some(crop) = &mut self.crop
|
||||
&& let Some((_, _, before)) = crop.drag.take()
|
||||
{
|
||||
crop.keep = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cropping a page image: the handles trim instead of resizing, the part that will go is
|
||||
/// dimmed, Enter or Done applies and Esc or Cancel leaves it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CropMode {
|
||||
/// The box to keep (user space).
|
||||
pub keep: [f64; 4],
|
||||
/// A drag in progress: the grip (0–3 corners, 4–7 edges, 8 the inside), where it started
|
||||
/// and the box then.
|
||||
drag: Option<(usize, Pos2, [f64; 4])>,
|
||||
}
|
||||
|
||||
impl CropMode {
|
||||
pub fn new(keep: [f64; 4]) -> Self {
|
||||
Self { keep, drag: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen boxes of the crop's eight handles: corners, then the top, right, bottom and left
|
||||
/// edge midpoints.
|
||||
fn crop_handles(k: Rect) -> [Pos2; 8] {
|
||||
[k.left_top(), k.right_top(), k.left_bottom(), k.right_bottom(), k.center_top(), k.right_center(), k.center_bottom(), k.left_center()]
|
||||
}
|
||||
|
||||
/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true`
|
||||
/// (the pointer and keys are the crop's while it lasts).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn crop_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
view: &mut DocView,
|
||||
i: usize,
|
||||
b: Rect,
|
||||
allow_interaction: bool,
|
||||
) -> bool {
|
||||
let painter = ui.painter();
|
||||
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
|
||||
let hover_pos = ptr.pos;
|
||||
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
|
||||
// A drag in progress moves a grip; the box stays inside the picture and at least 6 px.
|
||||
if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = ptr.origin
|
||||
{
|
||||
let k = keep_screen(mode.keep);
|
||||
let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8));
|
||||
if let Some(g) = grip {
|
||||
mode.drag = Some((g, o, mode.keep));
|
||||
}
|
||||
}
|
||||
if let Some((grip, start, at_start)) = mode.drag
|
||||
&& let Some(p) = hover_pos
|
||||
{
|
||||
let d = p - start;
|
||||
let k0 = keep_screen(at_start);
|
||||
let (mut min, mut max) = (k0.min, k0.max);
|
||||
match grip {
|
||||
0 => {
|
||||
min.x += d.x;
|
||||
min.y += d.y;
|
||||
}
|
||||
1 => {
|
||||
max.x += d.x;
|
||||
min.y += d.y;
|
||||
}
|
||||
2 => {
|
||||
min.x += d.x;
|
||||
max.y += d.y;
|
||||
}
|
||||
3 => {
|
||||
max.x += d.x;
|
||||
max.y += d.y;
|
||||
}
|
||||
4 => min.y += d.y,
|
||||
5 => max.x += d.x,
|
||||
6 => max.y += d.y,
|
||||
7 => min.x += d.x,
|
||||
_ => {
|
||||
let shift = egui::vec2(d.x.clamp(b.min.x - k0.min.x, b.max.x - k0.max.x), d.y.clamp(b.min.y - k0.min.y, b.max.y - k0.max.y));
|
||||
min += shift;
|
||||
max += shift;
|
||||
}
|
||||
}
|
||||
let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height()));
|
||||
min.x = min.x.clamp(b.min.x, b.max.x - min_w);
|
||||
min.y = min.y.clamp(b.min.y, b.max.y - min_h);
|
||||
max.x = max.x.clamp(min.x + min_w, b.max.x);
|
||||
max.y = max.y.clamp(min.y + min_h, b.max.y);
|
||||
mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max));
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
mode.drag = None;
|
||||
}
|
||||
}
|
||||
let k = keep_screen(mode.keep);
|
||||
// What goes is dimmed; what stays keeps its frame and handles.
|
||||
let dim = Color32::from_black_alpha(96);
|
||||
for r in [
|
||||
Rect::from_min_max(b.min, Pos2::new(b.max.x, k.min.y)),
|
||||
Rect::from_min_max(Pos2::new(b.min.x, k.max.y), b.max),
|
||||
Rect::from_min_max(Pos2::new(b.min.x, k.min.y), Pos2::new(k.min.x, k.max.y)),
|
||||
Rect::from_min_max(Pos2::new(k.max.x, k.min.y), Pos2::new(b.max.x, k.max.y)),
|
||||
] {
|
||||
if r.is_positive() {
|
||||
painter.rect_filled(r.intersect(xf.rect), CornerRadius::ZERO, dim);
|
||||
}
|
||||
}
|
||||
painter.rect_stroke(k, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
for h in crop_handles(k) {
|
||||
painter.rect(
|
||||
Rect::from_center_size(h, egui::vec2(8.0, 8.0)),
|
||||
CornerRadius::ZERO,
|
||||
Color32::WHITE,
|
||||
Stroke::new(1.0, ACCENT),
|
||||
egui::StrokeKind::Middle,
|
||||
);
|
||||
}
|
||||
// Done and Cancel, painted under the box (above it near the bottom of the view).
|
||||
let font = egui::FontId::proportional(12.5);
|
||||
let pill = |label: &str, right: f32, y: f32| -> Rect {
|
||||
let w = painter.layout_no_wrap(label.to_string(), font.clone(), Color32::WHITE).size().x + 20.0;
|
||||
Rect::from_min_size(Pos2::new(right - w, y), egui::vec2(w, 22.0))
|
||||
};
|
||||
let y = if k.max.y + 30.0 < xf.rect.max.y { k.max.y + 6.0 } else { k.min.y - 28.0 };
|
||||
let done = pill(tl!("Done"), k.max.x, y);
|
||||
let cancel = pill(tl!("Cancel"), done.min.x - 6.0, y);
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
painter.rect_filled(done, CornerRadius::same(11), ACCENT);
|
||||
painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE);
|
||||
painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text);
|
||||
if let Some(p) = hover_pos {
|
||||
let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) {
|
||||
match g {
|
||||
0 | 3 => egui::CursorIcon::ResizeNwSe,
|
||||
1 | 2 => egui::CursorIcon::ResizeNeSw,
|
||||
4 | 6 => egui::CursorIcon::ResizeVertical,
|
||||
_ => egui::CursorIcon::ResizeHorizontal,
|
||||
}
|
||||
} else if done.contains(p) || cancel.contains(p) {
|
||||
egui::CursorIcon::PointingHand
|
||||
} else if k.contains(p) {
|
||||
egui::CursorIcon::Move
|
||||
} else {
|
||||
egui::CursorIcon::Default
|
||||
};
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
}
|
||||
let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p));
|
||||
let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input();
|
||||
if clicked(done) || keys(egui::Key::Enter) {
|
||||
let keep = mode.keep;
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.crop = None;
|
||||
}
|
||||
if keep_screen(keep) != b {
|
||||
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) });
|
||||
}
|
||||
} else if (clicked(cancel) || keys(egui::Key::Escape))
|
||||
&& let Some(s) = view.image_selection.as_mut()
|
||||
{
|
||||
s.crop = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl ImageSelection {
|
||||
pub(crate) fn dragging(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where
|
||||
/// the screen may still show the picture).
|
||||
fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) {
|
||||
let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
|
||||
for r in blank {
|
||||
clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE);
|
||||
}
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
for (a, b) in [
|
||||
(from.left_top(), to.left_top()),
|
||||
(from.right_top(), to.right_top()),
|
||||
(from.right_bottom(), to.right_bottom()),
|
||||
(from.left_bottom(), to.left_bottom()),
|
||||
] {
|
||||
let (u, v) = xf.screen_to_norm(a);
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
clipped.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same
|
||||
/// applies after the pointer leaves the image/page while dragging.
|
||||
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool {
|
||||
let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false };
|
||||
if selection.drag.is_some() {
|
||||
return true;
|
||||
}
|
||||
let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false };
|
||||
let Some(rect) = image.visible_rect else { return false };
|
||||
let b = xf.user_rect(info, page, rect.map(|v| v as f32));
|
||||
let p = if ptr.primary_down { ptr.origin } else { ptr.pos };
|
||||
let knob = rotate_knob(b, ui.clip_rect()).0;
|
||||
p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0))
|
||||
}
|
||||
|
||||
/// What a right-click on a selected image asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum ImageAction {
|
||||
Replace(usize, usize),
|
||||
Save(usize, usize),
|
||||
Copy(usize, usize),
|
||||
CopyFile(usize, usize),
|
||||
/// Copy the picture, then remove it from the page.
|
||||
Cut(usize, usize),
|
||||
/// Mark the picture for redaction (applied later from Redact a PDF).
|
||||
Redact(usize, usize),
|
||||
/// Create a link over the picture.
|
||||
Link(usize, usize),
|
||||
/// Open Recognize Text for the picture's page.
|
||||
Recognize(usize),
|
||||
}
|
||||
|
||||
/// Where the rotate knob sits, and the edge point its stem starts from: above the middle of the
|
||||
/// top edge, or below the bottom edge when the area on screen (`view`) has no room above.
|
||||
pub(super) fn rotate_knob(b: Rect, view: Rect) -> (Pos2, Pos2) {
|
||||
let above = b.center_top() - egui::vec2(0.0, 22.0);
|
||||
if above.y - 6.0 >= view.top() { (above, b.center_top()) } else { (b.center_bottom() + egui::vec2(0.0, 22.0), b.center_bottom()) }
|
||||
}
|
||||
|
||||
/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap`
|
||||
/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing.
|
||||
pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 {
|
||||
let v = pointer - centre;
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || v.length() < 4.0 {
|
||||
return 0.0;
|
||||
}
|
||||
// Screen y points down: up is (0, -1); clockwise is atan2(x, -y).
|
||||
let d = v.x.atan2(-v.y).to_degrees();
|
||||
if snap { (d / 15.0).round() * 15.0 } else { d }
|
||||
}
|
||||
|
||||
/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn image_input(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
xf: &PageXform,
|
||||
ptr: &PagePointer,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
images: &[pdfcraft_engine::PageImage],
|
||||
view: &mut DocView,
|
||||
action: &mut Option<ImageAction>,
|
||||
allow_interaction: bool,
|
||||
) -> bool {
|
||||
let boxes: Vec<Rect> = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect();
|
||||
let painter = ui.painter();
|
||||
let hover_pos = ptr.pos;
|
||||
let image_contains = |index: usize, point: Pos2| {
|
||||
let (x, y) = xf.screen_to_view(point);
|
||||
let Some(p) = info.pages.get(page) else { return false };
|
||||
let user = p.view_to_user(x, y);
|
||||
images.get(index).is_some_and(|im| im.contains(user.map(f64::from)))
|
||||
};
|
||||
let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len());
|
||||
let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some());
|
||||
// Few images: faint outlines for all of them, as before. Many: only the selected and
|
||||
// hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images.
|
||||
// Fully clipped placements (empty visible frames) get no outline.
|
||||
let faint_all = boxes.len() <= MAX_OUTLINES;
|
||||
// The picture being dragged has its frame at the pointer; its outline where it was would
|
||||
// trail behind until the release.
|
||||
let moving = selected.filter(|_| dragging);
|
||||
if faint_all {
|
||||
for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) {
|
||||
painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p)));
|
||||
if !faint_all {
|
||||
for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) {
|
||||
if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) {
|
||||
painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in selected.into_iter().chain(hovered_box).collect::<Vec<_>>() {
|
||||
let Some(b) = boxes.get(i).copied().filter(|b| b.is_positive()) else { continue };
|
||||
if selected == Some(i) {
|
||||
// The selected frame and handles are drawn below (or the drag preview instead).
|
||||
if !dragging {
|
||||
painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
|
||||
}
|
||||
} else {
|
||||
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
// The selected image: frame, corner handles, dragging.
|
||||
if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) {
|
||||
let b = boxes[i];
|
||||
if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) {
|
||||
return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction);
|
||||
}
|
||||
let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()];
|
||||
let handles = |rect: Rect| {
|
||||
for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] {
|
||||
painter.rect(
|
||||
Rect::from_center_size(c, egui::vec2(8.0, 8.0)),
|
||||
CornerRadius::ZERO,
|
||||
Color32::WHITE,
|
||||
Stroke::new(1.0, ACCENT),
|
||||
egui::StrokeKind::Middle,
|
||||
);
|
||||
}
|
||||
};
|
||||
// Until the page is rendered again its raster has an applied move or resize's picture
|
||||
// where it was: it stays blanked there and drawn where it went.
|
||||
// The whole-page raster and its tiles are replaced separately: the old frame stays blanked
|
||||
// while anything still shows the picture there, but its pixels come from the old frame
|
||||
// only while the whole-page raster itself is the old one. Once that is fresh it has the
|
||||
// picture where it went, and that is where they come from.
|
||||
let (out_of_date, raster_old, raster) = (view.page_out_of_date(page), view.page_raster_out_of_date(page), view.page_raster(page));
|
||||
let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y));
|
||||
// Where the screen may still show the picture from before an applied move, while the page
|
||||
// isn't drawn again yet. Another drag keeps it blanked too.
|
||||
let left_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from);
|
||||
if let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) {
|
||||
match (s.settled, raster) {
|
||||
(Some((from, to)), Some(tex)) if out_of_date => {
|
||||
let source = if raster_old { from } else { to };
|
||||
stretched_picture(painter, xf, tex, &[screen(from)], screen(source), screen(to));
|
||||
}
|
||||
_ => s.settled = None,
|
||||
}
|
||||
}
|
||||
if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) {
|
||||
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
handles(b);
|
||||
}
|
||||
if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) {
|
||||
ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
|
||||
}
|
||||
// The rotate handle: a knob above the top edge, joined to it by a short stem.
|
||||
let (knob, stem) = rotate_knob(b, ui.clip_rect());
|
||||
if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) {
|
||||
painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT));
|
||||
painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT));
|
||||
if allow_interaction && hover_pos.is_some_and(|p| p.distance(knob) < 8.0) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = ptr.origin
|
||||
&& o.distance(knob) < 8.0
|
||||
{
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = Some((o, None));
|
||||
s.turning = true;
|
||||
}
|
||||
} else if allow_interaction
|
||||
&& resp.drag_started()
|
||||
&& let Some(o) = ptr.origin
|
||||
{
|
||||
let corner = corners.iter().position(|c| c.distance(o) < 8.0);
|
||||
if corner.is_some() || image_contains(i, o) {
|
||||
let opposite = corner.map(|k| corners[3 - k]);
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = Some((o, opposite));
|
||||
}
|
||||
}
|
||||
}
|
||||
if view.image_selection.as_ref().is_some_and(|s| s.turning) {
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift));
|
||||
// The turned outline as a preview; the engine redraws the pixels on release.
|
||||
let centre = b.center();
|
||||
let (sin, cos) = degrees.to_radians().sin_cos();
|
||||
let turn = |c: Pos2| {
|
||||
let v = c - centre;
|
||||
centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos)
|
||||
};
|
||||
let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec();
|
||||
painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT)));
|
||||
let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT);
|
||||
let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0));
|
||||
painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
|
||||
painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = None;
|
||||
s.turning = false;
|
||||
}
|
||||
if degrees.abs() >= 0.05 {
|
||||
view.pending_edit =
|
||||
Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag)
|
||||
&& let Some(p) = hover_pos
|
||||
{
|
||||
let preview = match opposite {
|
||||
// Resize from the opposite corner, keeping the aspect ratio.
|
||||
Some(fixed) => {
|
||||
let (w0, h0) = (b.width().max(1.0), b.height().max(1.0));
|
||||
// Use the axis with the larger relative pointer change. Taking the maximum
|
||||
// absolute size freezes shrinking when the other axis hasn't moved. Measure
|
||||
// from the press point so grabbing a handle off-centre cannot cause a jump.
|
||||
let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0;
|
||||
let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0;
|
||||
let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
|
||||
let k = (1.0 + delta).max(0.05);
|
||||
let (w, h) = (w0 * k, h0 * k);
|
||||
let x = if start.x < fixed.x { fixed.x - w } else { fixed.x };
|
||||
let y = if start.y < fixed.y { fixed.y - h } else { fixed.y };
|
||||
Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h))
|
||||
}
|
||||
None => b.translate(p - start),
|
||||
};
|
||||
// Move/resize the full placement by the visible frame's change. The engine
|
||||
// transforms an isolated crop together with its image, preserving its contents.
|
||||
let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32)));
|
||||
// A zero-width or zero-height visible frame (or a degenerate ratio) keeps the
|
||||
// placement's scale instead of producing inf/NaN.
|
||||
let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 };
|
||||
let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height()));
|
||||
let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor);
|
||||
let visible = preview.intersect(xf.rect);
|
||||
if visible.is_positive() {
|
||||
// The pixels follow the frame: the page raster's picture of the image, stretched
|
||||
// into the preview over a blank where it was. Re-rendering every frame of a drag
|
||||
// would lag; the edit renders it properly on release.
|
||||
if preview != b
|
||||
&& let Some(tex) = view.page_raster(page)
|
||||
{
|
||||
// Its pixels: where the whole-page raster has it.
|
||||
let source = left_at.filter(|_| raster_old).map_or(b, screen);
|
||||
let blank: Vec<Rect> = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect();
|
||||
stretched_picture(painter, xf, tex, &blank, source, preview);
|
||||
}
|
||||
painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
|
||||
painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
handles(preview);
|
||||
}
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = None;
|
||||
let norm = |r: Rect| {
|
||||
let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max));
|
||||
Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1))
|
||||
};
|
||||
s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview)));
|
||||
}
|
||||
if preview != b {
|
||||
view.pending_edit =
|
||||
Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) });
|
||||
}
|
||||
}
|
||||
ui.ctx().set_cursor_icon(match opposite {
|
||||
Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw,
|
||||
Some(_) => egui::CursorIcon::ResizeNwSe,
|
||||
None => egui::CursorIcon::Move,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if allow_interaction
|
||||
&& ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace))
|
||||
&& !ui.ctx().egui_wants_keyboard_input()
|
||||
{
|
||||
view.image_selection = None;
|
||||
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if !allow_interaction {
|
||||
return false;
|
||||
}
|
||||
// An open menu stays drawn wherever the pointer is: most of its items lie outside the picture
|
||||
// (a logo is smaller than its menu), and a menu that is not drawn for a frame cannot be clicked.
|
||||
// Only a menu opened by a right-click on the picture: one opened over text or empty page while
|
||||
// a picture stays selected is the page's own menu.
|
||||
let menu_key = resp.id.with("image-menu");
|
||||
if resp.secondary_clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(menu_key, false));
|
||||
}
|
||||
let mut menu_drawn = false;
|
||||
if let Some(sel) = selected
|
||||
&& resp.context_menu_opened()
|
||||
&& ui.ctx().data(|d| d.get_temp::<bool>(menu_key)).unwrap_or(false)
|
||||
{
|
||||
resp.context_menu(|ui| image_menu(ui, page, sel, images, view, action));
|
||||
menu_drawn = true;
|
||||
}
|
||||
let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false };
|
||||
let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else { return false };
|
||||
if selected != Some(hit) {
|
||||
painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
|
||||
}
|
||||
let Some(b) = boxes.get(hit).copied() else { return false };
|
||||
let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0);
|
||||
ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) {
|
||||
(true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe,
|
||||
(true, Some(_)) => egui::CursorIcon::ResizeNeSw,
|
||||
(true, None) => egui::CursorIcon::Move,
|
||||
_ => egui::CursorIcon::PointingHand,
|
||||
});
|
||||
if resp.clicked() || resp.secondary_clicked() {
|
||||
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, turning: false, settled: None });
|
||||
}
|
||||
if resp.secondary_clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(menu_key, true));
|
||||
}
|
||||
if selected == Some(hit) && !menu_drawn {
|
||||
resp.context_menu(|ui| image_menu(ui, page, hit, images, view, action));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The right-click menu of the picture `hit` (its rotate, flip, replace, copy, cut, redact, link,
|
||||
/// recognize and delete items).
|
||||
fn image_menu(
|
||||
ui: &mut egui::Ui,
|
||||
page: usize,
|
||||
hit: usize,
|
||||
images: &[pdfcraft_engine::PageImage],
|
||||
view: &mut DocView,
|
||||
action: &mut Option<ImageAction>,
|
||||
) {
|
||||
use pdfcraft_engine::ImageEdit as E;
|
||||
let items: [(&str, Option<E>); 4] = [
|
||||
(tl!("Rotate Clockwise"), Some(E::Rotate(1))),
|
||||
(tl!("Rotate Counterclockwise"), Some(E::Rotate(3))),
|
||||
(tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })),
|
||||
(tl!("Flip Vertical"), Some(E::Flip { horizontal: false })),
|
||||
];
|
||||
for (label, change) in items {
|
||||
if ui.button(label).clicked() {
|
||||
view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c });
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
// Crop: the handles trim instead of resizing until Done.
|
||||
if ui.button(tl!("Crop Image")).clicked() {
|
||||
if let (Some(s), Some(im)) = (view.image_selection.as_mut(), images.get(hit)) {
|
||||
s.crop = Some(CropMode::new(im.visible_rect.unwrap_or(im.rect)));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
let cropped = images.get(hit).is_some_and(|im| im.visible_rect.is_some_and(|v| v.iter().zip(im.rect.iter()).any(|(a, b)| (a - b).abs() > 0.01)));
|
||||
if ui.add_enabled(cropped, egui::Button::new(tl!("Remove Crop"))).clicked() {
|
||||
if let Some(im) = images.get(hit) {
|
||||
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Crop(im.rect) });
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
let raster = images.get(hit).is_some_and(|image| !image.is_form);
|
||||
if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() {
|
||||
*action = Some(ImageAction::Replace(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() {
|
||||
*action = Some(ImageAction::Save(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
// Cutting and copying also find the picture inside grouped Form artwork.
|
||||
if ui.button(tl!("Cut Image")).clicked() {
|
||||
*action = Some(ImageAction::Cut(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Copy Image")).clicked() {
|
||||
*action = Some(ImageAction::Copy(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Copy Image in Original Format")).clicked() {
|
||||
*action = Some(ImageAction::CopyFile(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(tl!("Redact Image")).clicked() {
|
||||
*action = Some(ImageAction::Redact(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Create Link…")).clicked() {
|
||||
*action = Some(ImageAction::Link(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Recognize Text…")).clicked() {
|
||||
*action = Some(ImageAction::Recognize(page));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(tl!("Delete")).clicked() {
|
||||
view.image_selection = None;
|
||||
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete });
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use super::*;
|
||||
|
||||
/// The look shown for a paragraph: the family and weight guessed from its PDF font name.
|
||||
fn source_look(base_font: &str, size: f64, color: [f64; 3], detected_bold: bool, detected_italic: bool) -> pdfcraft_engine::AddedText {
|
||||
use pdfcraft_engine::FontFamily as F;
|
||||
let name = base_font.to_ascii_lowercase();
|
||||
let family = if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
|
||||
F::Courier
|
||||
} else if !name.contains("sans") && ["times", "serif", "roman", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) {
|
||||
F::Times
|
||||
} else {
|
||||
F::Helvetica
|
||||
};
|
||||
pdfcraft_engine::AddedText {
|
||||
family,
|
||||
bold: detected_bold || ["bold", "black", "heavy", "semibold", "demi"].iter().any(|s| name.contains(s)),
|
||||
italic: detected_italic || ["italic", "oblique", "slanted"].iter().any(|s| name.contains(s)),
|
||||
size: (size * 10.0).round() / 10.0,
|
||||
color,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn look_of(b: &pdfcraft_engine::TextBlock) -> pdfcraft_engine::AddedText {
|
||||
source_look(&b.base_font, b.size, b.color, b.bold, b.italic)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_font(look: &pdfcraft_engine::AddedText, size: f32) -> FontId {
|
||||
let family = match (look.family, look.bold) {
|
||||
(pdfcraft_engine::FontFamily::Courier, _) => FontFamily::Monospace,
|
||||
(_, true) => FontFamily::Name("semibold".into()),
|
||||
(_, false) => FontFamily::Proportional,
|
||||
};
|
||||
FontId::new(size, family)
|
||||
}
|
||||
|
||||
pub(super) fn color32(color: [f64; 3]) -> Color32 {
|
||||
Color32::from_rgb(
|
||||
(color[0].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color[1].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color[2].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
/// The dark box behind text too pale to read on white.
|
||||
pub(super) const DARK_EDITOR_FILL: Color32 = Color32::from_gray(0x26);
|
||||
|
||||
/// WCAG relative luminance of an sRGB colour.
|
||||
pub(super) fn luminance(c: Color32) -> f32 {
|
||||
let channel = |v: u8| {
|
||||
let v = f32::from(v) / 255.0;
|
||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b())
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio between two colours (1 to 21).
|
||||
pub(super) fn contrast(a: Color32, b: Color32) -> f32 {
|
||||
let (la, lb) = (luminance(a), luminance(b));
|
||||
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
|
||||
}
|
||||
|
||||
/// The inline editor's background for text drawn in `text`: white, or a dark box when the text
|
||||
/// reads better on that (white or pale text, which would vanish on white).
|
||||
pub(crate) fn editor_fill(text: Color32) -> Color32 {
|
||||
if contrast(text, Color32::WHITE) >= contrast(text, DARK_EDITOR_FILL) { Color32::WHITE } else { DARK_EDITOR_FILL }
|
||||
}
|
||||
|
||||
/// One TextEdit lays out all source runs; its caret and selection span the whole region.
|
||||
pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> BTreeMap<String, String> {
|
||||
let programs = doc.text_editor_fonts(page);
|
||||
let mut definitions = ctx.fonts(|f| f.definitions().clone());
|
||||
// Only the current editor's document fonts live in the atlas. Never accumulate the
|
||||
// programs of previously opened files or add them to the interface's default families.
|
||||
definitions.font_data.retain(|name, _| !name.starts_with("pdf-source-"));
|
||||
definitions.families.retain(|family, _| !matches!(family, FontFamily::Name(name) if name.starts_with("pdf-source-")));
|
||||
let mut families = BTreeMap::new();
|
||||
for (index, program) in programs.iter().enumerate() {
|
||||
let name = format!("pdf-source-{index}");
|
||||
definitions.font_data.insert(name.clone(), Arc::new(egui::FontData::from_owned(program.bytes.clone())));
|
||||
families.insert(program.resource.clone(), name);
|
||||
}
|
||||
for program in &programs {
|
||||
let Some(name) = families.get(&program.resource) else { continue };
|
||||
// Word-created PDFs often split one face into several subsets. Use all of the
|
||||
// document's matching subsets before falling back for newly inserted characters.
|
||||
let mut stack = vec![name.clone()];
|
||||
stack.extend(
|
||||
programs
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face)
|
||||
&& p.resource != program.resource
|
||||
})
|
||||
.filter_map(|p| families.get(&p.resource).cloned()),
|
||||
);
|
||||
if let Some(fallback) = definitions.families.get(&FontFamily::Proportional) {
|
||||
stack.extend(fallback.iter().cloned());
|
||||
}
|
||||
definitions.families.insert(FontFamily::Name(name.clone().into()), stack);
|
||||
}
|
||||
if !families.is_empty() {
|
||||
ctx.set_fonts(definitions);
|
||||
}
|
||||
families
|
||||
}
|
||||
|
||||
pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> egui::text::LayoutJob {
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
// An unchanged PDF row already has an authoritative line break. Do not rewrap it
|
||||
// because font rounding or a fallback face measures a fraction of a pixel wider.
|
||||
job.wrap.max_width = if text == ed.original && ed.look == ed.look0 && ed.extras == ed.extras0 { f32::INFINITY } else { width };
|
||||
if let Some((block, lines)) = &ed.source {
|
||||
let lefts: Vec<_> = block.rows.iter().filter_map(|r| r.first().and_then(|i| lines.get(*i)).map(|l| l.rect[0])).collect();
|
||||
let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let centered = block.rows.len() > 1
|
||||
&& left_span > block.size * 0.5
|
||||
&& block.rows.iter().all(|row| {
|
||||
let rect = row
|
||||
.iter()
|
||||
.filter_map(|i| lines.get(*i))
|
||||
.map(|l| l.rect)
|
||||
.reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]);
|
||||
rect.is_some_and(|r| ((r[0] + r[2] - block.rect[0] - block.rect[2]) / 2.0).abs() < block.size)
|
||||
});
|
||||
if centered {
|
||||
job.halign = egui::Align::Center;
|
||||
}
|
||||
for (range, index) in block.edited_spans(text) {
|
||||
let Some(line) = lines.get(index) else { continue };
|
||||
let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic);
|
||||
look.size = line.size;
|
||||
if ed.look.family != ed.look0.family || ed.family_chosen {
|
||||
look.family = ed.look.family;
|
||||
}
|
||||
if ed.look.bold != ed.look0.bold {
|
||||
look.bold = ed.look.bold;
|
||||
}
|
||||
if ed.look.italic != ed.look0.italic {
|
||||
look.italic = ed.look.italic;
|
||||
}
|
||||
if ed.look.color != ed.look0.color {
|
||||
look.color = ed.look.color;
|
||||
}
|
||||
if ed.look.size != ed.look0.size {
|
||||
look.size = ed.look.size;
|
||||
}
|
||||
let mut font = editor_font(&look, (look.size as f32 * scale).clamp(1.0, 300.0));
|
||||
let original_face =
|
||||
ed.look.family == ed.look0.family && !ed.family_chosen && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic;
|
||||
let embedded = original_face && ed.fonts.contains_key(&line.font);
|
||||
if embedded && let Some(name) = ed.fonts.get(&line.font) {
|
||||
font.family = FontFamily::Name(name.clone().into());
|
||||
}
|
||||
let native_width = ui.fonts_mut(|f| f.layout_no_wrap(line.text.clone(), font.clone(), Color32::BLACK).size().x);
|
||||
let target_width = ((line.rect[2] - line.rect[0]) * look.size / line.size.max(0.01)) as f32 * scale;
|
||||
let spacing = if original_face { (target_width - native_width) / line.text.chars().count().saturating_sub(1).max(1) as f32 } else { 0.0 };
|
||||
job.append(
|
||||
text.get(range).unwrap_or_default(),
|
||||
0.0,
|
||||
egui::TextFormat {
|
||||
font_id: font,
|
||||
extra_letter_spacing: spacing + ed.extras.char_spacing as f32 * scale,
|
||||
color: color32(look.color),
|
||||
italics: look.italic && !embedded,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
job.append(
|
||||
text,
|
||||
0.0,
|
||||
egui::TextFormat {
|
||||
font_id: editor_font(&ed.look, ed.size),
|
||||
color: color32(ed.look.color),
|
||||
italics: ed.look.italic,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
/// `galley` drawn in no colour: its rows still place the caret and the selection.
|
||||
pub(super) fn unseen(mut galley: Arc<egui::Galley>) -> Arc<egui::Galley> {
|
||||
for placed in &mut Arc::make_mut(&mut galley).rows {
|
||||
for vertex in &mut Arc::make_mut(&mut placed.row).visuals.mesh.vertices {
|
||||
vertex.color = Color32::TRANSPARENT;
|
||||
}
|
||||
}
|
||||
galley
|
||||
}
|
||||
|
||||
/// Align the editable galley's baselines to the source rows instead of egui's interface
|
||||
/// leading. Glyphs, meshes, caret and selection receive the same transform.
|
||||
pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> Arc<egui::Galley> {
|
||||
let mut job = editor_job(ui, ed, text, scale, width);
|
||||
let centered = job.halign == egui::Align::Center;
|
||||
job.halign = egui::Align::LEFT;
|
||||
let mut galley = ui.fonts_mut(|fonts| fonts.layout_job(job));
|
||||
let Some((block, lines)) = &ed.source else { return galley };
|
||||
let sources: Vec<_> = block
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
let first = row.first().and_then(|i| lines.get(*i))?;
|
||||
let rect = row
|
||||
.iter()
|
||||
.filter_map(|i| lines.get(*i))
|
||||
.map(|l| l.rect)
|
||||
.reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?;
|
||||
Some((first.baseline()[1], rect))
|
||||
})
|
||||
.collect();
|
||||
let Some((first_baseline, first_rect)) = sources.first().copied() else { return galley };
|
||||
let leading = if ed.extras.line_spacing > 0.0 {
|
||||
ed.extras.line_spacing * ed.look.size
|
||||
} else {
|
||||
let fallback = if block.lines.len() == 1 && !text.contains('\n') {
|
||||
block.lines.first().and_then(|i| lines.get(*i)).map_or(block.size * 1.2, |line| line.wrap_leading())
|
||||
} else {
|
||||
block.size * 1.5
|
||||
};
|
||||
sources.windows(2).map(|r| (r[0].0 - r[1].0).abs()).rfind(|v| *v > 0.0).unwrap_or(fallback)
|
||||
};
|
||||
let g = Arc::make_mut(&mut galley);
|
||||
let mut bounds = Rect::NOTHING;
|
||||
let mut meshes = Rect::NOTHING;
|
||||
for (index, placed) in g.rows.iter_mut().enumerate() {
|
||||
let (last_baseline, last_rect) = sources.last().copied().unwrap_or((first_baseline, first_rect));
|
||||
let (baseline, source_rect) = sources
|
||||
.get(index)
|
||||
.copied()
|
||||
.unwrap_or((last_baseline - index.saturating_add(1).saturating_sub(sources.len()) as f64 * leading, last_rect));
|
||||
let changed_size = ed.look.size != ed.look0.size;
|
||||
let ratio = if changed_size { ed.look.size / ed.look0.size.max(0.01) } else { 1.0 };
|
||||
let ascent = (source_rect[3] - sources.get(index).map_or(last_baseline, |s| s.0)) as f32 * scale * ratio as f32;
|
||||
let descent = (sources.get(index).map_or(last_baseline, |s| s.0) - source_rect[1]) as f32 * scale * ratio as f32;
|
||||
let base_y = (block.rect[3] - baseline) as f32 * scale;
|
||||
let row = Arc::make_mut(&mut placed.row);
|
||||
placed.pos.y = base_y - ascent;
|
||||
placed.pos.x = if centered { (width - row.size.x) / 2.0 } else { (source_rect[0] - block.rect[0]) as f32 * scale };
|
||||
for glyph in &mut row.glyphs {
|
||||
let dy = ascent - glyph.pos.y;
|
||||
glyph.pos.y = ascent;
|
||||
glyph.font_ascent = ascent;
|
||||
glyph.font_height = ascent + descent;
|
||||
glyph.line_height = ascent + descent;
|
||||
if !glyph.uv_rect.is_nothing() {
|
||||
for vertex in row.visuals.mesh.vertices.iter_mut().skip(glyph.first_vertex as usize).take(4) {
|
||||
vertex.pos.y += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
row.size.y = (ascent + descent).max(1.0);
|
||||
row.visuals.mesh_bounds = row.visuals.mesh.vertices.iter().fold(Rect::NOTHING, |r, v| r.union(Rect::from_min_max(v.pos, v.pos)));
|
||||
bounds = bounds.union(Rect::from_min_size(placed.pos, row.size));
|
||||
meshes = meshes.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2()));
|
||||
}
|
||||
g.rect = Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(bounds.right().max(0.0), bounds.bottom().max(0.0)));
|
||||
g.mesh_bounds = meshes;
|
||||
galley
|
||||
}
|
||||
|
||||
/// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones.
|
||||
pub(crate) fn caret_color_on(fill: Color32) -> Color32 {
|
||||
let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b());
|
||||
if luma > 127_500 { Color32::BLACK } else { Color32::WHITE }
|
||||
}
|
||||
Reference in New Issue
Block a user