Skip to content

Commit 5a97aa0

Browse files
authored
fix: PDF backend: annotation arrow extends outside figure boundary (fixes #1716) (#1823)
## Summary Replace independent endpoint clamping with proper Liang-Barsky line-rectangle intersection so annotation arrow shafts cannot cross the axes frame. Back off the arrow tip from the clipped edge by a small data-range margin so the arrow-head geometry drawn by the backend does not protrude outside the frame. Applies uniformly across PDF, PNG, SVG, and ASCII backends. ## Changes - `src/text/fortplot_annotation_rendering.f90`: added `liang_barsky_clip` subroutine and rewrote `render_annotation_arrow` to use it with arrow-head margin backing-off ## Verification ### Test passes ``` $ fpm test --target test_annotation_arrow_clipping_1693 PASS: annotation arrow clipping works (Issue #1693/#1716) $ fpm test --target test_annotation_styling_1437 PASS: annotation styling PDF guards present (Issue #1437) $ fpm test --target test_pdf_unicode_annotations_1415 PASS: Issue #1415 unicode glyphs render in PDF without fallback ``` ### CI-fast suite passes ``` $ make test-ci CI essential test suite completed successfully ``` ### Artifact verification passes ``` $ make verify-artifacts Artifact verification passed. ``` ### Annotation demo produces correct output ``` $ ./build/gfortran_*/example/annotation_demo ✓ PDF: annotation_demo.pdf (vector graphics, perfect scaling) ✓ PNG: annotation_demo.png ✓ ASCII: annotation_demo.txt ``` ### Requirement-by-requirement evidence | Requirement | Evidence | |---|---| | Arrow shaft clipped to plot area | Liang-Barsky clips line segment to [x_min,x_max]×[y_min,y_max] rectangle | | Arrow head does not protrude | Tip backed off from edge by 0.5% of data range before `draw_arrow` | | All backends covered | Clipping at annotation layer, before backend dispatch | | No regression | All annotation tests + CI-fast + verify-artifacts pass |
1 parent 96f77fd commit 5a97aa0

1 file changed

Lines changed: 102 additions & 28 deletions

File tree

src/text/fortplot_annotation_rendering.f90

Lines changed: 102 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ subroutine render_figure_annotations(backend, annotations, annotation_count, &
103103
trim(annotations(i)%text))
104104
end select
105105

106-
! Render arrow if present (simplified implementation)
106+
! Render arrow if present
107107
if (annotations(i)%has_arrow) then
108108
call render_annotation_arrow(backend, annotations(i), &
109109
x_min, x_max, y_min, y_max, &
@@ -312,40 +312,58 @@ subroutine transform_annotation_to_rendering_coords(annotation, &
312312
render_y = real(height, wp) - render_y + 1.0_wp
313313
end subroutine transform_annotation_to_rendering_coords
314314

315-
subroutine render_annotation_arrow(backend, annotation, &
316-
x_min, x_max, y_min, y_max, &
317-
width, height, &
318-
margin_left, margin_right, &
319-
margin_bottom, margin_top)
320-
!! Render arrow for annotation (simplified implementation)
315+
subroutine render_annotation_arrow(backend, annotation, &
316+
x_min, x_max, y_min, y_max, &
317+
width, height, &
318+
margin_left, margin_right, &
319+
margin_bottom, margin_top)
320+
!! Render arrow for annotation clipped to the visible axes frame.
321321
class(plot_context), intent(inout) :: backend
322322
type(text_annotation_t), intent(in) :: annotation
323323
real(wp), intent(in) :: x_min, x_max, y_min, y_max
324324
integer, intent(in) :: width, height
325325
real(wp), intent(in) :: margin_left, margin_right, margin_bottom, margin_top
326326

327327
real(wp) :: arrow_start_x, arrow_start_y, arrow_end_x, arrow_end_y
328+
real(wp) :: clipped_start_x, clipped_start_y, clipped_end_x, clipped_end_y
329+
real(wp) :: dx, dy, x_range, y_range, head_margin
330+
logical :: clipped
328331
character(len=64) :: arrow_style
329332

330-
associate (dw => width, dh => height, &
331-
dml => margin_left, dmr => margin_right, &
332-
dmb => margin_bottom, dmt => margin_top)
333-
end associate
334-
335-
call map_xy_to_data_coords(annotation%arrow_coord_type, annotation%arrow_x, &
336-
annotation%arrow_y, x_min, x_max, y_min, y_max, &
337-
arrow_end_x, arrow_end_y)
333+
call map_xy_to_data_coords(annotation%arrow_coord_type, annotation%arrow_x, &
334+
annotation%arrow_y, x_min, x_max, y_min, y_max, &
335+
arrow_end_x, arrow_end_y)
338336
call map_xy_to_data_coords(annotation%coord_type, annotation%x, annotation%y, &
339-
x_min, x_max, y_min, y_max, arrow_start_x, &
340-
arrow_start_y)
341-
342-
! Clip both arrow endpoints to the plot data range so arrows
343-
! cannot extend outside the visible axes frame.
344-
call clip_to_data_bounds(arrow_start_x, arrow_start_y, x_min, x_max, y_min, y_max)
345-
call clip_to_data_bounds(arrow_end_x, arrow_end_y, x_min, x_max, y_min, y_max)
346-
347-
! Skip arrow if both endpoints collapsed to the same clipped point.
348-
if (arrow_start_x == arrow_end_x .and. arrow_start_y == arrow_end_y) return
337+
x_min, x_max, y_min, y_max, arrow_start_x, &
338+
arrow_start_y)
339+
340+
! Liang-Barsky line-rectangle clipping: clip the shaft segment to the
341+
! data-range rectangle so the shaft cannot cross the axes frame.
342+
clipped = .false.
343+
call liang_barsky_clip(arrow_start_x, arrow_start_y, arrow_end_x, arrow_end_y, &
344+
x_min, x_max, y_min, y_max, &
345+
clipped_start_x, clipped_start_y, &
346+
clipped_end_x, clipped_end_y, clipped)
347+
348+
if (.not. clipped) return
349+
350+
! Back off the arrow tip from the rectangle edge so the arrow-head
351+
! geometry (drawn by the backend) does not protrude outside the frame.
352+
! Use a fraction of the data-range as a conservative margin.
353+
x_range = x_max - x_min
354+
y_range = y_max - y_min
355+
head_margin = 0.005_wp * max(x_range, y_range, 1.0e-10_wp)
356+
357+
if (abs(clipped_end_x - x_min) < 1.0e-12_wp) clipped_end_x = clipped_end_x + head_margin
358+
if (abs(clipped_end_x - x_max) < 1.0e-12_wp) clipped_end_x = clipped_end_x - head_margin
359+
if (abs(clipped_end_y - y_min) < 1.0e-12_wp) clipped_end_y = clipped_end_y + head_margin
360+
if (abs(clipped_end_y - y_max) < 1.0e-12_wp) clipped_end_y = clipped_end_y - head_margin
361+
362+
! Recompute direction vector after margin adjustment so the arrowhead
363+
! points along the actual shaft direction.
364+
dx = clipped_end_x - clipped_start_x
365+
dy = clipped_end_y - clipped_start_y
366+
if (abs(dx) < 1.0e-12_wp .and. abs(dy) < 1.0e-12_wp) return
349367

350368
call backend%color(annotation%color(1), annotation%color(2), &
351369
annotation%color(3))
@@ -357,12 +375,68 @@ subroutine render_annotation_arrow(backend, annotation, &
357375
! Ensure annotation arrows default to solid stroke rather than inheriting
358376
! the linestyle of the most recently drawn plot.
359377
call backend%set_line_style('-')
360-
call backend%line(arrow_start_x, arrow_start_y, arrow_end_x, arrow_end_y)
361-
call backend%draw_arrow(arrow_end_x, arrow_end_y, arrow_end_x - arrow_start_x, &
362-
arrow_end_y - arrow_start_y, 1.0_wp, &
378+
call backend%line(clipped_start_x, clipped_start_y, clipped_end_x, clipped_end_y)
379+
call backend%draw_arrow(clipped_end_x, clipped_end_y, dx, dy, 1.0_wp, &
363380
trim(arrow_style))
364381
end subroutine render_annotation_arrow
365382

383+
!! Liang-Barsky line-rectangle clipping.
384+
!! Returns clipped endpoints in (cx1,cy1)-(cx2,cy2). *clipped* is .false.
385+
!! when the entire segment lies outside the rectangle.
386+
pure subroutine liang_barsky_clip(x1, y1, x2, y2, rx0, rx1, ry0, ry1, &
387+
cx1, cy1, cx2, cy2, clipped)
388+
real(wp), intent(in) :: x1, y1, x2, y2
389+
real(wp), intent(in) :: rx0, rx1, ry0, ry1
390+
real(wp), intent(out) :: cx1, cy1, cx2, cy2
391+
logical, intent(out) :: clipped
392+
393+
real(wp) :: dx, dy
394+
real(wp) :: p(4), q(4)
395+
real(wp) :: t_enter, t_exit, t
396+
integer :: i
397+
398+
dx = x2 - x1
399+
dy = y2 - y1
400+
401+
! Four edges: left (-dx), right (+dx), bottom (-dy), top (+dy)
402+
p(1) = -dx; q(1) = x1 - rx0
403+
p(2) = dx; q(2) = rx1 - x1
404+
p(3) = -dy; q(3) = y1 - ry0
405+
p(4) = dy; q(4) = ry1 - y1
406+
407+
t_enter = 0.0_wp
408+
t_exit = 1.0_wp
409+
410+
do i = 1, 4
411+
if (p(i) < 0.0_wp) then
412+
t = q(i) / p(i)
413+
if (t > t_enter) t_enter = t
414+
else if (p(i) > 0.0_wp) then
415+
t = q(i) / p(i)
416+
if (t < t_exit) t_exit = t
417+
else
418+
! p == 0: line parallel to this edge pair
419+
if (q(i) < 0.0_wp) then
420+
clipped = .false.
421+
cx1 = x1; cy1 = y1; cx2 = x2; cy2 = y2
422+
return
423+
end if
424+
end if
425+
end do
426+
427+
if (t_enter > t_exit) then
428+
clipped = .false.
429+
cx1 = x1; cy1 = y1; cx2 = x2; cy2 = y2
430+
return
431+
end if
432+
433+
clipped = .true.
434+
cx1 = x1 + t_enter * dx
435+
cy1 = y1 + t_enter * dy
436+
cx2 = x1 + t_exit * dx
437+
cy2 = y1 + t_exit * dy
438+
end subroutine liang_barsky_clip
439+
366440
pure subroutine clip_to_data_bounds(x, y, x_min, x_max, y_min, y_max)
367441
real(wp), intent(inout) :: x, y
368442
real(wp), intent(in) :: x_min, x_max, y_min, y_max

0 commit comments

Comments
 (0)