fortplot_raster_labels.f90 Source File


Source Code

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