feat: add anchors, links; smooth pan/zoom animations
This commit is contained in:
parent
c374ba0fc8
commit
24cbc894de
2 changed files with 368 additions and 27 deletions
272
src/app.rs
272
src/app.rs
|
|
@ -40,6 +40,10 @@ pub struct TemplateApp {
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
reset_view_requested: bool,
|
reset_view_requested: bool,
|
||||||
|
|
||||||
|
/// Smooth camera animation state.
|
||||||
|
#[serde(skip)]
|
||||||
|
view_animation: Option<ViewAnimation>,
|
||||||
|
|
||||||
/// Last pointer position for manual drag tracking (smoother than `Sense::drag`).
|
/// Last pointer position for manual drag tracking (smoother than `Sense::drag`).
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
last_pointer_pos: Option<egui::Pos2>,
|
last_pointer_pos: Option<egui::Pos2>,
|
||||||
|
|
@ -69,12 +73,27 @@ impl Default for TemplateApp {
|
||||||
text_cache: None,
|
text_cache: None,
|
||||||
did_fit_start: false,
|
did_fit_start: false,
|
||||||
last_cursor_pos: None,
|
last_cursor_pos: None,
|
||||||
reset_view_requested: false
|
reset_view_requested: false,
|
||||||
|
view_animation: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ViewAnimation {
|
||||||
|
start_center_x: f32,
|
||||||
|
start_center_y: f32,
|
||||||
|
start_zoom: f32,
|
||||||
|
target_center_x: f32,
|
||||||
|
target_center_y: f32,
|
||||||
|
target_zoom: f32,
|
||||||
|
elapsed: f32,
|
||||||
|
duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
impl TemplateApp {
|
impl TemplateApp {
|
||||||
|
const NAV_DURATION: f32 = 1.5;
|
||||||
|
|
||||||
/// Called once before the first frame.
|
/// Called once before the first frame.
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
log::info!("Initializing application...");
|
log::info!("Initializing application...");
|
||||||
|
|
@ -120,6 +139,83 @@ impl TemplateApp {
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cancel_animation(&mut self) {
|
||||||
|
self.view_animation = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_view_animation(
|
||||||
|
&mut self,
|
||||||
|
target_pan_x: f32,
|
||||||
|
target_pan_y: f32,
|
||||||
|
target_zoom: f32,
|
||||||
|
duration: f32,
|
||||||
|
canvas_rect: &egui::Rect,
|
||||||
|
) {
|
||||||
|
if duration <= 0.0 {
|
||||||
|
self.pan_x = target_pan_x;
|
||||||
|
self.pan_y = target_pan_y;
|
||||||
|
self.zoom = target_zoom;
|
||||||
|
self.view_animation = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start_center_x, start_center_y) =
|
||||||
|
view_center(self.pan_x, self.pan_y, self.zoom, canvas_rect);
|
||||||
|
let (target_center_x, target_center_y) =
|
||||||
|
view_center(target_pan_x, target_pan_y, target_zoom, canvas_rect);
|
||||||
|
|
||||||
|
self.view_animation = Some(ViewAnimation {
|
||||||
|
start_center_x,
|
||||||
|
start_center_y,
|
||||||
|
start_zoom: self.zoom,
|
||||||
|
target_center_x,
|
||||||
|
target_center_y,
|
||||||
|
target_zoom,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_animation(&mut self, dt: f32, canvas_rect: &egui::Rect) {
|
||||||
|
if let Some(anim) = &mut self.view_animation {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||||
|
let eased = ease_in_out_cubic(t);
|
||||||
|
|
||||||
|
let zoom = lerp(
|
||||||
|
anim.start_zoom,
|
||||||
|
anim.target_zoom,
|
||||||
|
zoom_ease(t, anim.start_zoom, anim.target_zoom),
|
||||||
|
);
|
||||||
|
let center_x = lerp(anim.start_center_x, anim.target_center_x, eased);
|
||||||
|
let center_y = lerp(anim.start_center_y, anim.target_center_y, eased);
|
||||||
|
|
||||||
|
if canvas_rect.width() > 0.0 && canvas_rect.height() > 0.0 {
|
||||||
|
let visible_w = canvas_rect.width() / zoom;
|
||||||
|
let visible_h = canvas_rect.height() / zoom;
|
||||||
|
self.pan_x = -center_x + visible_w * 0.5;
|
||||||
|
self.pan_y = -center_y + visible_h * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.zoom = zoom;
|
||||||
|
|
||||||
|
if t >= 1.0 {
|
||||||
|
self.view_animation = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_view_to_rect(
|
||||||
|
&mut self,
|
||||||
|
rect: (f32, f32, f32, f32),
|
||||||
|
canvas_rect: &egui::Rect,
|
||||||
|
duration: f32,
|
||||||
|
) {
|
||||||
|
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, canvas_rect) {
|
||||||
|
self.begin_view_animation(pan_x, pan_y, zoom, duration, canvas_rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle zoom towards a specific point.
|
/// Handle zoom towards a specific point.
|
||||||
fn zoom_towards(&mut self, new_zoom: f32, pos: egui::Pos2, canvas_rect: &egui::Rect) {
|
fn zoom_towards(&mut self, new_zoom: f32, pos: egui::Pos2, canvas_rect: &egui::Rect) {
|
||||||
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
|
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
|
||||||
|
|
@ -130,6 +226,64 @@ impl TemplateApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ease_in_out_cubic(t: f32) -> f32 {
|
||||||
|
if t < 0.5 {
|
||||||
|
4.0 * t * t * t
|
||||||
|
} else {
|
||||||
|
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ease_out_cubic(t: f32) -> f32 {
|
||||||
|
1.0 - (1.0 - t).powi(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zoom_ease(t: f32, start: f32, target: f32) -> f32 {
|
||||||
|
if target > start {
|
||||||
|
// Zooming in: bias progress late but soften landing
|
||||||
|
let biased = t.powf(1.6);
|
||||||
|
ease_in_out_cubic(biased)
|
||||||
|
} else {
|
||||||
|
// Zooming out: shed zoom early
|
||||||
|
ease_out_cubic(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lerp(a: f32, b: f32, t: f32) -> f32 {
|
||||||
|
a + (b - a) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_view_for_rect(
|
||||||
|
rect: (f32, f32, f32, f32),
|
||||||
|
canvas_rect: &egui::Rect,
|
||||||
|
) -> Option<(f32, f32, f32)> {
|
||||||
|
if canvas_rect.width() <= 0.0 || canvas_rect.height() <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sx, sy, sw, sh) = rect;
|
||||||
|
if sw <= 0.0 || sh <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale_x = canvas_rect.width() / sw;
|
||||||
|
let scale_y = canvas_rect.height() / sh;
|
||||||
|
let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0);
|
||||||
|
let visible_w = canvas_rect.width() / fit_zoom;
|
||||||
|
let visible_h = canvas_rect.height() / fit_zoom;
|
||||||
|
|
||||||
|
let pan_x = -sx + (visible_w - sw) * 0.5;
|
||||||
|
let pan_y = -sy + (visible_h - sh) * 0.5;
|
||||||
|
|
||||||
|
Some((pan_x, pan_y, fit_zoom))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_center(pan_x: f32, pan_y: f32, zoom: f32, canvas_rect: &egui::Rect) -> (f32, f32) {
|
||||||
|
let visible_w = canvas_rect.width() / zoom;
|
||||||
|
let visible_h = canvas_rect.height() / zoom;
|
||||||
|
(-pan_x + visible_w * 0.5, -pan_y + visible_h * 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
impl eframe::App for TemplateApp {
|
impl eframe::App for TemplateApp {
|
||||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||||
eframe::set_value(storage, eframe::APP_KEY, self);
|
eframe::set_value(storage, eframe::APP_KEY, self);
|
||||||
|
|
@ -150,6 +304,7 @@ impl eframe::App for TemplateApp {
|
||||||
primary_down,
|
primary_down,
|
||||||
primary_pressed,
|
primary_pressed,
|
||||||
primary_released,
|
primary_released,
|
||||||
|
space_pressed,
|
||||||
) = ctx.input(|i| {
|
) = ctx.input(|i| {
|
||||||
(
|
(
|
||||||
i.key_pressed(egui::Key::Escape),
|
i.key_pressed(egui::Key::Escape),
|
||||||
|
|
@ -163,9 +318,14 @@ impl eframe::App for TemplateApp {
|
||||||
i.pointer.primary_down(),
|
i.pointer.primary_down(),
|
||||||
i.pointer.primary_pressed(),
|
i.pointer.primary_pressed(),
|
||||||
i.pointer.primary_released(),
|
i.pointer.primary_released(),
|
||||||
|
i.key_pressed(egui::Key::Space),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if primary_pressed || scroll_delta != 0.0 || (zoom_delta - 1.0).abs() > f32::EPSILON {
|
||||||
|
self.cancel_animation();
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(pos) = pointer_latest.or(pointer_pos) {
|
if let Some(pos) = pointer_latest.or(pointer_pos) {
|
||||||
self.last_cursor_pos = Some(pos);
|
self.last_cursor_pos = Some(pos);
|
||||||
}
|
}
|
||||||
|
|
@ -237,43 +397,50 @@ impl eframe::App for TemplateApp {
|
||||||
let canvas_rect = response.rect;
|
let canvas_rect = response.rect;
|
||||||
painter.rect_filled(canvas_rect, 0.0, background_color);
|
painter.rect_filled(canvas_rect, 0.0, background_color);
|
||||||
|
|
||||||
|
// Advance any pending camera animation (needs canvas size)
|
||||||
|
let anim_dt = if frame_time > 0.0 {
|
||||||
|
frame_time
|
||||||
|
} else {
|
||||||
|
1.0 / 60.0
|
||||||
|
};
|
||||||
|
self.update_animation(anim_dt, &canvas_rect);
|
||||||
|
if self.view_animation.is_some() {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
// Fullscreen toggle via double-click or 'F'
|
// Fullscreen toggle via double-click or 'F'
|
||||||
if response.double_clicked() || f_pressed {
|
if response.double_clicked() || f_pressed {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen));
|
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.did_fit_start {
|
if !self.did_fit_start {
|
||||||
self.reset_view_requested = true;
|
|
||||||
self.did_fit_start = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut maybe_fit_view = |rect: &egui::Rect| {
|
|
||||||
if let Some(ref content) = self.svg_content {
|
if let Some(ref content) = self.svg_content {
|
||||||
if let Some((sx, sy, sw, sh)) = content.start_rect.or(content.viewbox) {
|
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
||||||
if sw > 0.0 && sh > 0.0 {
|
if let Some((pan_x, pan_y, zoom)) =
|
||||||
let scale_x = rect.width() / sw;
|
compute_view_for_rect(rect, &canvas_rect)
|
||||||
let scale_y = rect.height() / sh;
|
{
|
||||||
let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0);
|
self.pan_x = pan_x;
|
||||||
let visible_w = rect.width() / fit_zoom;
|
self.pan_y = pan_y;
|
||||||
let visible_h = rect.height() / fit_zoom;
|
self.zoom = zoom;
|
||||||
|
|
||||||
self.zoom = fit_zoom;
|
|
||||||
self.pan_x = -sx + (visible_w - sw) * 0.5;
|
|
||||||
self.pan_y = -sy + (visible_h - sh) * 0.5;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
self.did_fit_start = true;
|
||||||
};
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
if space_pressed {
|
||||||
|
self.reset_view_requested = true;
|
||||||
|
}
|
||||||
|
|
||||||
if self.reset_view_requested {
|
if self.reset_view_requested {
|
||||||
if maybe_fit_view(&canvas_rect) {
|
if let Some(ref content) = self.svg_content {
|
||||||
self.reset_view_requested = false;
|
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
||||||
} else {
|
self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION);
|
||||||
// Nothing to fit, still clear the flag to avoid loops
|
}
|
||||||
self.reset_view_requested = false;
|
|
||||||
}
|
}
|
||||||
|
// Always clear to avoid loops even if no rect present
|
||||||
|
self.reset_view_requested = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag handling
|
// Drag handling
|
||||||
|
|
@ -397,9 +564,57 @@ impl eframe::App for TemplateApp {
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let text_cache = self.text_cache.as_mut().expect("just initialized");
|
let mut pending_navigation: Option<(f32, f32, f32, f32)> = None;
|
||||||
|
|
||||||
if let Some(ref content) = self.svg_content {
|
if let Some(ref content) = self.svg_content {
|
||||||
|
let pointer_svg_hover = pointer_latest.or(pointer_pos).and_then(|p| {
|
||||||
|
if canvas_rect.contains(p) {
|
||||||
|
Some(screen_to_svg(p))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let click_svg_pos = if primary_released && !self.is_dragging {
|
||||||
|
pointer_latest.or(pointer_pos).and_then(|p| {
|
||||||
|
if canvas_rect.contains(p) {
|
||||||
|
Some(screen_to_svg(p))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((svg_x, svg_y)) = click_svg_pos {
|
||||||
|
if let Some(link) = content.anchor_links.iter().find(|link| {
|
||||||
|
svg_x >= link.x
|
||||||
|
&& svg_x <= link.x + link.width
|
||||||
|
&& svg_y >= link.y
|
||||||
|
&& svg_y <= link.y + link.height
|
||||||
|
}) {
|
||||||
|
if let Some(anchor) =
|
||||||
|
content.anchors.iter().find(|a| a.id == link.target_id)
|
||||||
|
{
|
||||||
|
pending_navigation = Some(anchor.bounds());
|
||||||
|
} else {
|
||||||
|
log::warn!("Anchor link target not found: {}", link.target_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((svg_x, svg_y)) = pointer_svg_hover {
|
||||||
|
if content.anchor_links.iter().any(|link| {
|
||||||
|
svg_x >= link.x
|
||||||
|
&& svg_x <= link.x + link.width
|
||||||
|
&& svg_y >= link.y
|
||||||
|
&& svg_y <= link.y + link.height
|
||||||
|
}) {
|
||||||
|
ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.render_internal_areas {
|
if self.render_internal_areas {
|
||||||
let mut hovered_descs: Vec<String> = Vec::new();
|
let mut hovered_descs: Vec<String> = Vec::new();
|
||||||
let pointer_svg = pointer_pos.map(screen_to_svg);
|
let pointer_svg = pointer_pos.map(screen_to_svg);
|
||||||
|
|
@ -481,6 +696,7 @@ impl eframe::App for TemplateApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text elements
|
// Text elements
|
||||||
|
let text_cache = self.text_cache.as_mut().expect("just initialized");
|
||||||
for text_elem in &content.texts {
|
for text_elem in &content.texts {
|
||||||
let (x, y, w, h) = text_elem.bounds();
|
let (x, y, w, h) = text_elem.bounds();
|
||||||
let rect =
|
let rect =
|
||||||
|
|
@ -528,6 +744,10 @@ impl eframe::App for TemplateApp {
|
||||||
rendered_count += 1;
|
rendered_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(rect) = pending_navigation {
|
||||||
|
self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug window
|
// Debug window
|
||||||
|
|
|
||||||
123
src/svg.rs
123
src/svg.rs
|
|
@ -1,7 +1,7 @@
|
||||||
//! SVG parsing module for extracting special elements from SVG files.
|
//! SVG parsing module for extracting special elements from SVG files.
|
||||||
|
|
||||||
use quick_xml::Reader;
|
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
|
use quick_xml::Reader;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
/// Trait for elements that can be rendered with a bounding box.
|
/// Trait for elements that can be rendered with a bounding box.
|
||||||
|
|
@ -77,6 +77,16 @@ pub struct TextElement {
|
||||||
pub font_size: f32, // Parsed from style attribute
|
pub font_size: f32, // Parsed from style attribute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A hyperlink pointing to an anchor by id.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnchorLink {
|
||||||
|
pub target_id: String,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
impl Renderable for TextElement {
|
impl Renderable for TextElement {
|
||||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||||
if self.lines.is_empty() {
|
if self.lines.is_empty() {
|
||||||
|
|
@ -111,6 +121,7 @@ pub struct SvgContent {
|
||||||
pub video_scrolls: Vec<VideoScroll>,
|
pub video_scrolls: Vec<VideoScroll>,
|
||||||
pub audio_areas: Vec<AudioArea>,
|
pub audio_areas: Vec<AudioArea>,
|
||||||
pub anchors: Vec<Anchor>,
|
pub anchors: Vec<Anchor>,
|
||||||
|
pub anchor_links: Vec<AnchorLink>,
|
||||||
pub texts: Vec<TextElement>,
|
pub texts: Vec<TextElement>,
|
||||||
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
|
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
|
||||||
pub background_color: Option<[u8; 3]>,
|
pub background_color: Option<[u8; 3]>,
|
||||||
|
|
@ -139,6 +150,56 @@ enum PendingElement {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct LinkAccumulator {
|
||||||
|
target_id: String,
|
||||||
|
min_x: f32,
|
||||||
|
max_x: f32,
|
||||||
|
min_y: f32,
|
||||||
|
max_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinkAccumulator {
|
||||||
|
fn new(target_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
target_id,
|
||||||
|
min_x: f32::INFINITY,
|
||||||
|
max_x: f32::NEG_INFINITY,
|
||||||
|
min_y: f32::INFINITY,
|
||||||
|
max_y: f32::NEG_INFINITY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_bounds(&mut self, x: f32, y: f32, width: f32, height: f32) {
|
||||||
|
if width <= 0.0 || height <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.min_x = self.min_x.min(x);
|
||||||
|
self.min_y = self.min_y.min(y);
|
||||||
|
self.max_x = self.max_x.max(x + width);
|
||||||
|
self.max_y = self.max_y.max(y + height);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(&self) -> Option<(f32, f32, f32, f32)> {
|
||||||
|
if self.min_x.is_finite()
|
||||||
|
&& self.min_y.is_finite()
|
||||||
|
&& self.max_x.is_finite()
|
||||||
|
&& self.max_y.is_finite()
|
||||||
|
&& self.max_x > self.min_x
|
||||||
|
&& self.max_y > self.min_y
|
||||||
|
{
|
||||||
|
Some((
|
||||||
|
self.min_x,
|
||||||
|
self.min_y,
|
||||||
|
self.max_x - self.min_x,
|
||||||
|
self.max_y - self.min_y,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SvgContent {
|
impl SvgContent {
|
||||||
/// Parse an SVG file and extract special elements.
|
/// Parse an SVG file and extract special elements.
|
||||||
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
|
@ -168,12 +229,28 @@ impl SvgContent {
|
||||||
let mut tspan_y = 0.0f32;
|
let mut tspan_y = 0.0f32;
|
||||||
let mut tspan_content = String::new();
|
let mut tspan_content = String::new();
|
||||||
|
|
||||||
|
// Track hyperlink bounds targeting anchors
|
||||||
|
let mut current_link: Option<LinkAccumulator> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match reader.read_event_into(&mut buf) {
|
match reader.read_event_into(&mut buf) {
|
||||||
Ok(Event::Start(ref e)) => {
|
Ok(Event::Start(ref e)) => {
|
||||||
let name_bytes = e.name();
|
let name_bytes = e.name();
|
||||||
let name = String::from_utf8_lossy(name_bytes.as_ref());
|
let name = String::from_utf8_lossy(name_bytes.as_ref());
|
||||||
match name.as_ref() {
|
match name.as_ref() {
|
||||||
|
"a" => {
|
||||||
|
for attr in e.attributes().flatten() {
|
||||||
|
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||||
|
let value = String::from_utf8_lossy(&attr.value);
|
||||||
|
if key.as_ref().ends_with("href") {
|
||||||
|
let cleaned = value.trim_start_matches('#');
|
||||||
|
if cleaned.starts_with("anchor") {
|
||||||
|
current_link =
|
||||||
|
Some(LinkAccumulator::new(cleaned.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"svg" => {
|
"svg" => {
|
||||||
// Extract viewBox
|
// Extract viewBox
|
||||||
for attr in e.attributes().flatten() {
|
for attr in e.attributes().flatten() {
|
||||||
|
|
@ -220,6 +297,10 @@ impl SvgContent {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some(ref mut link) = current_link {
|
||||||
|
link.update_bounds(x, y, width, height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"circle" => {
|
"circle" => {
|
||||||
let mut cx = 0.0f32;
|
let mut cx = 0.0f32;
|
||||||
|
|
@ -240,6 +321,10 @@ impl SvgContent {
|
||||||
}
|
}
|
||||||
let _: bool = has_id_with_desc_potential; // May check desc child later
|
let _: bool = has_id_with_desc_potential; // May check desc child later
|
||||||
pending = Some(PendingElement::Circle { cx, cy, r });
|
pending = Some(PendingElement::Circle { cx, cy, r });
|
||||||
|
|
||||||
|
if let Some(ref mut link) = current_link {
|
||||||
|
link.update_bounds(cx - r, cy - r, r * 2.0, r * 2.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"ellipse" => {
|
"ellipse" => {
|
||||||
let mut cx = 0.0f32;
|
let mut cx = 0.0f32;
|
||||||
|
|
@ -259,6 +344,12 @@ impl SvgContent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pending = Some(PendingElement::Ellipse { cx, cy, rx, ry });
|
pending = Some(PendingElement::Ellipse { cx, cy, rx, ry });
|
||||||
|
|
||||||
|
if let Some(ref mut link) = current_link {
|
||||||
|
let width = rx * 2.0;
|
||||||
|
let height = ry * 2.0;
|
||||||
|
link.update_bounds(cx - rx, cy - ry, width, height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"rect" => {
|
"rect" => {
|
||||||
let mut id = String::new();
|
let mut id = String::new();
|
||||||
|
|
@ -291,6 +382,10 @@ impl SvgContent {
|
||||||
} else if id == "start" {
|
} else if id == "start" {
|
||||||
svg_content.start_rect = Some((x, y, width, height));
|
svg_content.start_rect = Some((x, y, width, height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref mut link) = current_link {
|
||||||
|
link.update_bounds(x, y, width, height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"text" => {
|
"text" => {
|
||||||
in_text = true;
|
in_text = true;
|
||||||
|
|
@ -370,6 +465,10 @@ impl SvgContent {
|
||||||
} else if id == "start" {
|
} else if id == "start" {
|
||||||
svg_content.start_rect = Some((x, y, width, height));
|
svg_content.start_rect = Some((x, y, width, height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref mut link) = current_link {
|
||||||
|
link.update_bounds(x, y, width, height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Event::Text(ref e)) => {
|
Ok(Event::Text(ref e)) => {
|
||||||
|
|
@ -423,6 +522,19 @@ impl SvgContent {
|
||||||
"image" | "circle" | "ellipse" => {
|
"image" | "circle" | "ellipse" => {
|
||||||
pending = None;
|
pending = None;
|
||||||
}
|
}
|
||||||
|
"a" => {
|
||||||
|
if let Some(link) = current_link.take() {
|
||||||
|
if let Some((x, y, width, height)) = link.finalize() {
|
||||||
|
svg_content.anchor_links.push(AnchorLink {
|
||||||
|
target_id: link.target_id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"tspan" => {
|
"tspan" => {
|
||||||
if in_tspan {
|
if in_tspan {
|
||||||
let content = tspan_content.trim().to_owned();
|
let content = tspan_content.trim().to_owned();
|
||||||
|
|
@ -444,6 +556,15 @@ impl SvgContent {
|
||||||
lines: text_lines.clone(),
|
lines: text_lines.clone(),
|
||||||
font_size: text_font_size,
|
font_size: text_font_size,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some(ref mut link) = current_link {
|
||||||
|
let text_elem = TextElement {
|
||||||
|
lines: text_lines.clone(),
|
||||||
|
font_size: text_font_size,
|
||||||
|
};
|
||||||
|
let (x, y, w, h) = text_elem.bounds();
|
||||||
|
link.update_bounds(x, y, w, h);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
in_text = false;
|
in_text = false;
|
||||||
text_lines.clear();
|
text_lines.clear();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue