module fortplot_raster_labels !! Raster axis labels (title, xlabel, ylabel) rendering functionality !! Extracted from fortplot_raster_axes.f90 for single responsibility principle use fortplot_constants, only: TICK_MARK_LENGTH, & YLABEL_EXTRA_GAP, TITLE_VERTICAL_OFFSET, & REFERENCE_DPI, FALLBACK_LABEL_HEIGHT_PX, & MIN_LABEL_MARGIN_PX, CANVAS_EDGE_PADDING_PX, & AXIS_LABEL_PAD_PT, TITLE_PAD_PT use fortplot_text_rendering, only: calculate_text_descent, & calculate_text_width_with_size, & calculate_text_height_with_size, & render_text_with_size, TITLE_FONT_SIZE_PT, & DEFAULT_FONT_SIZE use fortplot_text_helpers, only: prepare_text_for_raster use fortplot_margins, only: plot_area_t use fortplot_raster_core, only: raster_image_t, scale_px, pt2px use fortplot_bitmap, only: rotate_bitmap_90_ccw, & rotate_bitmap_90_cw, composite_bitmap_to_raster, & render_text_to_bitmap_with_size, & get_text_bitmap_metrics use fortplot_raster_ticks, only: & Y_TICK_LABEL_RIGHT_PAD, & Y_TICK_LABEL_LEFT_PAD, X_TICK_LABEL_TOP_PAD, & X_TICK_LABEL_PAD use, intrinsic :: iso_fortran_env, only: wp => real64 implicit none private public :: raster_draw_axis_labels public :: raster_render_ylabel public :: raster_render_ylabel_right public :: raster_draw_top_xlabel public :: render_title_centered public :: render_title_centered_with_size public :: compute_title_position public :: compute_ylabel_x_pos public :: y_tick_label_right_edge_at_axis contains pure function axis_label_font_px(dpi) result(px) !! Axis-label font size in pixels, scaled from points by DPI so labels !! grow proportionally with resolution (matches the tick convention). !! At REFERENCE_DPI this equals DEFAULT_FONT_SIZE, the size the legacy !! no-size text routines rendered, keeping default output unchanged. real(wp), intent(in) :: dpi real(wp) :: px px = real(DEFAULT_FONT_SIZE, wp) * dpi / REFERENCE_DPI end function axis_label_font_px subroutine raster_draw_axis_labels(raster, width, height, plot_area, title, & xlabel, ylabel) !! Draw all axis labels (title, xlabel, ylabel) type(raster_image_t), intent(inout) :: raster integer, intent(in) :: width, height type(plot_area_t), intent(in) :: plot_area character(len=*), intent(in) :: title, xlabel, ylabel character(len=600) :: escaped_text integer :: label_x, label_y integer :: label_width, label_height integer :: label_top_y, baseline_offset, descent_offset real(wp) :: label_font_px real(wp) :: ascent_px, descent_px logical :: metrics_ok ! Title at top if (len_trim(title) > 0) then call render_title_centered(raster, width, height, & plot_area, title, raster%config_title_font_size) end if ! X label at bottom if (len_trim(xlabel) > 0) then call prepare_text_for_raster(xlabel, escaped_text) label_font_px = axis_label_font_px(raster%dpi) label_width = calculate_text_width_with_size(trim(escaped_text), & label_font_px) label_height = calculate_text_height_with_size(label_font_px) call get_text_bitmap_metrics(label_font_px, ascent_px, descent_px, & label_height, metrics_ok) if (metrics_ok) then baseline_offset = nint(max(0.0_wp, ascent_px)) descent_offset = nint(max(0.0_wp, -descent_px)) else baseline_offset = label_height descent_offset = 0 end if label_x = plot_area%left + plot_area%width/2 - label_width/2 ! Position xlabel below the outer (lower) edge of the x-tick labels by ! an explicit labelpad (matplotlib axes.labelpad). The tick-label ! bottom edge is the tick-label top plus its measured height. label_top_y = plot_area%bottom + plot_area%height + & scale_px(X_TICK_LABEL_PAD, raster%dpi) + & max(raster%last_x_tick_max_height_bottom, FALLBACK_LABEL_HEIGHT_PX) + & nint(pt2px(AXIS_LABEL_PAD_PT, raster%dpi)) label_y = label_top_y + baseline_offset label_y = min(label_y, height - descent_offset - CANVAS_EDGE_PADDING_PX) call render_text_with_size(raster%image_data, width, height, & label_x, label_y, & trim(escaped_text), 0_1, 0_1, 0_1, & label_font_px) end if ! Y label at left if (len_trim(ylabel) > 0) then call raster_render_ylabel(raster, width, height, plot_area, ylabel) end if end subroutine raster_draw_axis_labels subroutine raster_render_ylabel(raster, width, height, plot_area, ylabel) !! Render rotated ylabel to the left of y-axis type(raster_image_t), intent(inout) :: raster integer, intent(in) :: width, height type(plot_area_t), intent(in) :: plot_area character(len=*), intent(in) :: ylabel character(len=600) :: escaped_text integer(1), allocatable :: text_bitmap(:, :, :), rotated_bitmap(:, :, :) integer :: text_width, text_height, text_descent integer :: rotated_width, rotated_height integer :: target_x, target_y integer :: y_tick_label_edge real(wp) :: label_font_px, ascent_px, descent_px logical :: metrics_ok if (len_trim(ylabel) == 0) return call prepare_text_for_raster(ylabel, escaped_text) label_font_px = axis_label_font_px(raster%dpi) text_width = calculate_text_width_with_size(trim(escaped_text), & label_font_px) text_height = calculate_text_height_with_size(label_font_px) call get_text_bitmap_metrics(label_font_px, ascent_px, descent_px, & text_height, metrics_ok) if (.not. metrics_ok) then text_height = calculate_text_height_with_size(label_font_px) descent_px = real(calculate_text_descent(trim(escaped_text)), wp) end if text_descent = int(abs(descent_px)) ! Allocate text bitmap allocate (text_bitmap(text_width, text_height, 3)) text_bitmap = -1_1 ! Initialize to white ! Render text to bitmap (upright). ! Position baseline to leave room for descenders. call render_text_to_bitmap_with_size(text_bitmap, text_width, text_height, 0, & text_height - text_descent, & trim(escaped_text), 0_1, 0_1, 0_1, & label_font_px) ! Rotate 90 degrees counter-clockwise rotated_width = text_height rotated_height = text_width allocate (rotated_bitmap(rotated_width, rotated_height, 3)) call rotate_bitmap_90_ccw(text_bitmap, rotated_bitmap, text_width, text_height) ! Compute the rightmost edge of y-tick labels y_tick_label_edge = y_tick_label_right_edge_at_axis(plot_area, & raster%last_y_tick_max_width, & raster%dpi) ! Compute ylabel position with dynamic gap target_x = compute_ylabel_x_pos(y_tick_label_edge, rotated_width, raster%dpi) ! Center vertically in plot area target_y = plot_area%bottom + plot_area%height/2 - rotated_height/2 ! Composite to raster call composite_bitmap_to_raster(raster%image_data, width, height, & rotated_bitmap, & rotated_width, rotated_height, & target_x, target_y) end subroutine raster_render_ylabel integer function y_tick_label_left_edge_at_axis(plot_area, max_width_measured, dpi) !! Compute the leftmost edge of right-side y-tick labels relative to the axis use, intrinsic :: iso_fortran_env, only: wp => real64 type(plot_area_t), intent(in) :: plot_area integer, intent(in) :: max_width_measured real(wp), intent(in), optional :: dpi real(wp) :: dpi_val dpi_val = REFERENCE_DPI if (present(dpi)) dpi_val = dpi y_tick_label_left_edge_at_axis = plot_area%left + plot_area%width + & scale_px(TICK_MARK_LENGTH, dpi_val) + & scale_px(Y_TICK_LABEL_LEFT_PAD, dpi_val) end function y_tick_label_left_edge_at_axis integer function compute_ylabel_right_x_pos(y_tick_label_edge, rotated_width, & plot_area, canvas_width, dpi) !! Compute x-position for right-side ylabel avoiding overlap with tick labels use, intrinsic :: iso_fortran_env, only: wp => real64 integer, intent(in) :: y_tick_label_edge integer, intent(in) :: rotated_width type(plot_area_t), intent(in) :: plot_area integer, intent(in) :: canvas_width real(wp), intent(in), optional :: dpi real(wp) :: dpi_val dpi_val = REFERENCE_DPI if (present(dpi)) dpi_val = dpi compute_ylabel_right_x_pos = y_tick_label_edge + & scale_px(YLABEL_EXTRA_GAP, dpi_val) if (compute_ylabel_right_x_pos + rotated_width > canvas_width - MIN_LABEL_MARGIN_PX) then compute_ylabel_right_x_pos = max(plot_area%left + plot_area%width + CANVAS_EDGE_PADDING_PX, & canvas_width - rotated_width - MIN_LABEL_MARGIN_PX) end if end function compute_ylabel_right_x_pos subroutine raster_render_ylabel_right(raster, width, height, plot_area, ylabel) !! Render rotated ylabel along the right side of the axis type(raster_image_t), intent(inout) :: raster integer, intent(in) :: width, height type(plot_area_t), intent(in) :: plot_area character(len=*), intent(in) :: ylabel character(len=600) :: escaped_text integer(1), allocatable :: text_bitmap(:, :, :), rotated_bitmap(:, :, :) integer :: text_width, text_height, text_descent integer :: rotated_width, rotated_height integer :: target_x, target_y integer :: y_tick_label_edge real(wp) :: label_font_px, ascent_px, descent_px logical :: metrics_ok if (len_trim(ylabel) == 0) return call prepare_text_for_raster(ylabel, escaped_text) label_font_px = axis_label_font_px(raster%dpi) text_width = calculate_text_width_with_size(trim(escaped_text), & label_font_px) text_height = calculate_text_height_with_size(label_font_px) call get_text_bitmap_metrics(label_font_px, ascent_px, descent_px, & text_height, metrics_ok) if (.not. metrics_ok) then text_height = calculate_text_height_with_size(label_font_px) descent_px = real(calculate_text_descent(trim(escaped_text)), wp) end if text_descent = int(abs(descent_px)) allocate (text_bitmap(text_width, text_height, 3)) text_bitmap = -1_1 call render_text_to_bitmap_with_size(text_bitmap, text_width, text_height, 0, & text_height - text_descent, & trim(escaped_text), 0_1, 0_1, 0_1, & label_font_px) rotated_width = text_height rotated_height = text_width allocate (rotated_bitmap(rotated_width, rotated_height, 3)) call rotate_bitmap_90_cw(text_bitmap, rotated_bitmap, text_width, text_height) y_tick_label_edge = y_tick_label_left_edge_at_axis(plot_area, & raster%last_y_tick_max_width_right, & raster%dpi) + & raster%last_y_tick_max_width_right + & nint(pt2px(AXIS_LABEL_PAD_PT, raster%dpi)) target_x = compute_ylabel_right_x_pos(y_tick_label_edge, rotated_width, & plot_area, width, raster%dpi) target_y = plot_area%bottom + plot_area%height/2 - rotated_height/2 call composite_bitmap_to_raster(raster%image_data, width, height, & rotated_bitmap, & rotated_width, rotated_height, & target_x, target_y) end subroutine raster_render_ylabel_right integer function y_tick_label_right_edge_at_axis(plot_area, max_width_measured, & dpi) !! Compute the rightmost edge of y-tick labels relative to the y-axis use, intrinsic :: iso_fortran_env, only: wp => real64 type(plot_area_t), intent(in) :: plot_area integer, intent(in) :: max_width_measured real(wp), intent(in), optional :: dpi real(wp) :: dpi_val dpi_val = REFERENCE_DPI if (present(dpi)) dpi_val = dpi y_tick_label_right_edge_at_axis = plot_area%left - & scale_px(TICK_MARK_LENGTH, dpi_val) - & scale_px(Y_TICK_LABEL_RIGHT_PAD, dpi_val) - & max(0, max_width_measured) end function y_tick_label_right_edge_at_axis integer function compute_ylabel_x_pos(y_tick_label_edge, rotated_width, dpi) !! Compute x-position for ylabel to avoid overlapping with y-tick labels. !! The gap between the ylabel's right edge and the outer edge of the !! y-tick labels is matplotlib's axes.labelpad (AXIS_LABEL_PAD_PT), !! scaled to device pixels by DPI, so the label sits snug against the !! tick labels rather than floating far to the left. use, intrinsic :: iso_fortran_env, only: wp => real64 integer, intent(in) :: y_tick_label_edge integer, intent(in) :: rotated_width real(wp), intent(in), optional :: dpi integer :: min_left_margin integer :: ideal_x integer :: safe_x integer :: label_pad_px real(wp) :: dpi_val dpi_val = REFERENCE_DPI if (present(dpi)) dpi_val = dpi min_left_margin = max(MIN_LABEL_MARGIN_PX, rotated_width/4) label_pad_px = nint(pt2px(AXIS_LABEL_PAD_PT, dpi_val)) ideal_x = y_tick_label_edge - label_pad_px - rotated_width ! If tick labels are already off-canvas, favor keeping the label visible. if (y_tick_label_edge <= 0) then compute_ylabel_x_pos = max(min_left_margin, ideal_x) return end if if (ideal_x >= min_left_margin) then compute_ylabel_x_pos = ideal_x return end if ! If there is not enough room to keep both a minimum margin and the ! extra gap, drop the extra gap first; if still no room, allow the ! ylabel to extend off-canvas to avoid overlapping tick labels. safe_x = y_tick_label_edge - rotated_width - 1 if (safe_x >= min_left_margin) then compute_ylabel_x_pos = min_left_margin else compute_ylabel_x_pos = safe_x end if end function compute_ylabel_x_pos integer function compute_top_xlabel_y_pos(raster, plot_area, label_height, dpi) !! Compute y-position for an x-label rendered above the axis use, intrinsic :: iso_fortran_env, only: wp => real64 type(raster_image_t), intent(in) :: raster type(plot_area_t), intent(in) :: plot_area integer, intent(in) :: label_height real(wp), intent(in), optional :: dpi real(wp) :: dpi_val dpi_val = REFERENCE_DPI if (present(dpi)) dpi_val = dpi compute_top_xlabel_y_pos = max(1, plot_area%bottom - & scale_px(X_TICK_LABEL_TOP_PAD, dpi_val) - & raster%last_x_tick_max_height_top - label_height - CANVAS_EDGE_PADDING_PX) end function compute_top_xlabel_y_pos subroutine raster_draw_top_xlabel(raster, width, height, plot_area, xlabel) !! Render an xlabel centered above the plot area type(raster_image_t), intent(inout) :: raster integer, intent(in) :: width, height type(plot_area_t), intent(in) :: plot_area character(len=*), intent(in) :: xlabel character(len=600) :: escaped_text integer :: label_width, label_height integer :: label_x, label_y real(wp) :: label_font_px if (len_trim(xlabel) == 0) return call prepare_text_for_raster(xlabel, escaped_text) label_font_px = axis_label_font_px(raster%dpi) label_width = calculate_text_width_with_size(trim(escaped_text), label_font_px) label_height = calculate_text_height_with_size(label_font_px) if (label_height <= 0) label_height = FALLBACK_LABEL_HEIGHT_PX label_x = plot_area%left + plot_area%width/2 - label_width/2 label_y = compute_top_xlabel_y_pos(raster, plot_area, label_height, raster%dpi) call render_text_with_size(raster%image_data, width, height, label_x, label_y, & trim(escaped_text), 0_1, 0_1, 0_1, label_font_px) end subroutine raster_draw_top_xlabel subroutine render_title_centered(raster, width, height, plot_area, & title_text, custom_font_size) !! Render title centered above the plot area type(raster_image_t), intent(inout) :: raster integer, intent(in) :: width, height type(plot_area_t), intent(in) :: plot_area character(len=*), intent(in) :: title_text real(wp), intent(in), optional :: custom_font_size character(len=600) :: escaped_text integer :: title_px, title_py real(wp) :: title_px_real, title_py_real, fsize if (len_trim(title_text) == 0) return fsize = TITLE_FONT_SIZE_PT * raster%dpi / 72.0_wp if (present(custom_font_size)) then ! Configured title size is in points; scale to pixels by DPI so an ! explicit value renders consistently across resolutions. if (custom_font_size > 0.0_wp) & fsize = custom_font_size * raster%dpi / REFERENCE_DPI end if call compute_title_position_sized(plot_area, title_text, & escaped_text, title_px_real, title_py_real, & fsize, raster%dpi, raster%last_x_tick_max_height_top) title_px = int(title_px_real) title_py = int(title_py_real) call render_text_with_size(raster%image_data, width, height, & title_px, title_py, & trim(escaped_text), 0_1, 0_1, 0_1, & fsize) end subroutine render_title_centered subroutine compute_title_position(plot_area, title_text, escaped_text, & title_px, title_py, dpi, & top_tick_height) !! Compute the position for centered title above plot area type(plot_area_t), intent(in) :: plot_area character(len=*), intent(in) :: title_text character(len=*), intent(out) :: escaped_text real(wp), intent(out) :: title_px, title_py real(wp), intent(in), optional :: dpi integer, intent(in), optional :: top_tick_height integer :: tick_h real(wp) :: dpi_val tick_h = -1 if (present(top_tick_height)) tick_h = top_tick_height dpi_val = REFERENCE_DPI if (present(dpi)) dpi_val = dpi call compute_title_position_sized(plot_area, title_text, & escaped_text, title_px, title_py, & TITLE_FONT_SIZE_PT * dpi_val / 72.0_wp, & dpi_val, tick_h) end subroutine compute_title_position subroutine compute_title_position_sized(plot_area, title_text, & escaped_text, title_px, & title_py, fsize, dpi, & top_tick_height) !! Compute centered title position with explicit font size. !! When top_tick_height > 0 (twiny active), the title is placed !! above the top-axis xlabel to avoid overlap with tick labels !! and the top x-axis label. type(plot_area_t), intent(in) :: plot_area character(len=*), intent(in) :: title_text character(len=*), intent(out) :: escaped_text real(wp), intent(out) :: title_px, title_py real(wp), intent(in) :: fsize real(wp), intent(in), optional :: dpi integer, intent(in) :: top_tick_height integer :: title_width, title_height real(wp) :: dpi_val integer :: top_xlabel_top, title_gap, xlabel_height dpi_val = REFERENCE_DPI if (present(dpi)) dpi_val = dpi call prepare_text_for_raster(title_text, escaped_text) title_width = calculate_text_width_with_size( & trim(escaped_text), fsize) title_height = max(1, int(fsize)) title_px = real(plot_area%left + plot_area%width / 2 & - title_width / 2, wp) if (top_tick_height > 0) then ! twiny active: stack ticks, then the top xlabel, then the title. ! Mirror raster_draw_top_xlabel's placement so the title clears the ! actual top axis label rather than the tick labels alone. xlabel_height = calculate_text_height_with_size( & axis_label_font_px(dpi_val)) if (xlabel_height <= 0) xlabel_height = FALLBACK_LABEL_HEIGHT_PX top_xlabel_top = plot_area%bottom - & scale_px(X_TICK_LABEL_TOP_PAD, dpi_val) - & top_tick_height - xlabel_height - & CANVAS_EDGE_PADDING_PX title_gap = TITLE_VERTICAL_OFFSET title_py = real(max(1, top_xlabel_top - title_gap - title_height), wp) else ! Place the title baseline a fixed pad above the top spine so it ! sits snug over the axes box (matplotlib axes.titlepad = 6 pt), ! rather than floating near the figure's top edge. title_py = real(plot_area%bottom, wp) - pt2px(TITLE_PAD_PT, dpi_val) title_py = max(1.0_wp, title_py) end if end subroutine compute_title_position_sized subroutine render_title_centered_with_size(raster, width, height, center_x, & title_y, title_text, font_scale) !! Render title centered at specified position with custom font scale !! Used for suptitle rendering above subplots type(raster_image_t), intent(inout) :: raster integer, intent(in) :: width, height integer, intent(in) :: center_x, title_y character(len=*), intent(in) :: title_text real(wp), intent(in) :: font_scale character(len=600) :: escaped_text integer :: title_width, title_px real(wp) :: scaled_font_size if (len_trim(title_text) == 0) return call prepare_text_for_raster(title_text, escaped_text) scaled_font_size = TITLE_FONT_SIZE_PT * raster%dpi / 72.0_wp * font_scale title_width = calculate_text_width_with_size(trim(escaped_text), & scaled_font_size) title_px = center_x - title_width / 2 call render_text_with_size(raster%image_data, width, height, title_px, & title_y, trim(escaped_text), 0_1, 0_1, 0_1, & scaled_font_size) end subroutine render_title_centered_with_size end module fortplot_raster_labels