fortplot_pdf_axes.f90 Source File


Source Code

module fortplot_pdf_axes
    !! PDF axes, grid, and tick drawing operations
    !! Handles plot frame, axes, tick marks, and grid lines

    use iso_fortran_env, only: wp => real64
    use fortplot_pdf_core, only: pdf_context_core, PDF_MARGIN, &
                                 PDF_TICK_SIZE, PDF_LABEL_SIZE, &
                                 PDF_TICK_LABEL_SIZE, PDF_TITLE_SIZE
    use fortplot_constants, only: XLABEL_VERTICAL_OFFSET
    use fortplot_pdf_drawing, only: pdf_stream_writer
    use fortplot_pdf_text, only: draw_pdf_text, draw_pdf_text_bold, &
                                 draw_mixed_font_text, draw_rotated_mixed_font_text, &
                                 draw_pdf_mathtext, estimate_pdf_text_width
    use fortplot_text_helpers, only: prepare_mathtext_if_needed
    use fortplot_text_layout, only: has_mathtext, preprocess_math_text
    use fortplot_latex_parser, only: process_latex_in_text
    use fortplot_mathtext, only: mathtext_element_t, parse_mathtext
    use fortplot_pdf_mathtext_render, only: render_mathtext_element_pdf
    use fortplot_unicode, only: utf8_char_length, utf8_to_codepoint
    use fortplot_axes, only: compute_scale_ticks, format_tick_label, MAX_TICKS
    use fortplot_tick_calculation, only: determine_decimals_from_ticks, &
                                         format_tick_value_consistent
    use fortplot_scales, only: apply_scale_transform
    implicit none
    private

    ! Public procedures
    public :: draw_pdf_axes_and_labels
    public :: draw_pdf_frame_with_area
    public :: draw_pdf_tick_marks_with_area
    public :: draw_pdf_tick_labels_with_area
    public :: draw_pdf_title_and_labels
    public :: setup_axes_data_ranges
    public :: generate_tick_data
    public :: render_mixed_text
    public :: draw_pdf_minor_tick_marks

    real(wp), parameter :: PDF_MINOR_TICK_SIZE = 3.0_wp

contains

    subroutine setup_axes_data_ranges(ctx, x_min_orig, x_max_orig, y_min_orig, &
                                      y_max_orig, &
                                      x_min_adj, x_max_adj, y_min_adj, y_max_adj, &
                                      xscale, yscale)
        !! Set up data ranges for axes with optional log scaling
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: x_min_orig, x_max_orig, y_min_orig, y_max_orig
        real(wp), intent(out) :: x_min_adj, x_max_adj, y_min_adj, y_max_adj
        character(len=*), intent(in), optional :: xscale, yscale

        real(wp) :: x_range, y_range
        associate (dummy_ctx => ctx%width); end associate

        ! Initialize adjusted values
        x_min_adj = x_min_orig
        x_max_adj = x_max_orig
        y_min_adj = y_min_orig
        y_max_adj = y_max_orig

        ! Apply log scaling if requested
        if (present(xscale)) then
            if (xscale == 'log' .and. x_min_adj > 0.0_wp) then
                x_min_adj = log10(x_min_adj)
                x_max_adj = log10(x_max_adj)
            end if
        end if

        if (present(yscale)) then
            if (yscale == 'log' .and. y_min_adj > 0.0_wp) then
                y_min_adj = log10(y_min_adj)
                y_max_adj = log10(y_max_adj)
            end if
        end if

        ! Ensure valid ranges
        x_range = x_max_adj - x_min_adj
        y_range = y_max_adj - y_min_adj

        if (abs(x_range) < 1.0e-10_wp) then
            x_min_adj = x_min_adj - 0.5_wp
            x_max_adj = x_max_adj + 0.5_wp
        end if

        if (abs(y_range) < 1.0e-10_wp) then
            y_min_adj = y_min_adj - 0.5_wp
            y_max_adj = y_max_adj + 0.5_wp
        end if
    end subroutine setup_axes_data_ranges

    subroutine generate_tick_data(ctx, data_x_min, data_x_max, data_y_min, data_y_max, &
                                  x_positions, y_positions, x_labels, y_labels, &
                                  num_x_ticks, num_y_ticks, xscale, yscale, &
                                  x_date_format, y_date_format, &
                                  plot_area_left, plot_area_bottom, plot_area_width, &
                                  plot_area_height, &
                                  symlog_threshold)
        !! Generate tick positions and labels for axes
        !! Refactored to be under 100 lines (QADS compliance)
        type(pdf_context_core), intent(in) :: ctx
        real(wp), intent(in) :: data_x_min, data_x_max, data_y_min, data_y_max
        real(wp), allocatable, intent(out) :: x_positions(:), y_positions(:)
        character(len=50), allocatable, intent(out) :: x_labels(:), y_labels(:)
        integer, intent(out) :: num_x_ticks, num_y_ticks
        character(len=*), intent(in), optional :: xscale, yscale
        character(len=*), intent(in), optional :: x_date_format, y_date_format
        real(wp), intent(in) :: plot_area_left, plot_area_bottom, plot_area_width, &
                                plot_area_height
        real(wp), intent(in), optional :: symlog_threshold

        associate (dummy_ctx => ctx%width); end associate
        ! Calculate number of ticks and allocate arrays
        call initialize_tick_arrays(plot_area_width, plot_area_height, num_x_ticks, &
                                    num_y_ticks, &
                                    x_positions, y_positions, x_labels, y_labels)

        ! Generate X axis ticks
        call generate_x_axis_ticks(data_x_min, data_x_max, num_x_ticks, &
                                   plot_area_left, &
                                   plot_area_width, x_positions, x_labels, xscale, &
                                   date_format=x_date_format, &
                                   symlog_threshold=symlog_threshold)

        ! Generate Y axis ticks
        call generate_y_axis_ticks(data_y_min, data_y_max, num_y_ticks, &
                                   plot_area_bottom, &
                                   plot_area_height, y_positions, y_labels, yscale, &
                                   date_format=y_date_format, &
                                   symlog_threshold=symlog_threshold)

    end subroutine generate_tick_data

    subroutine initialize_tick_arrays(plot_width, plot_height, num_x_ticks, &
                                      num_y_ticks, &
                                      x_positions, y_positions, x_labels, y_labels)
        !! Initialize tick count and allocate arrays
        real(wp), intent(in) :: plot_width, plot_height
        integer, intent(out) :: num_x_ticks, num_y_ticks
        real(wp), allocatable, intent(out) :: x_positions(:), y_positions(:)
        character(len=50), allocatable, intent(out) :: x_labels(:), y_labels(:)

        integer, parameter :: TARGET_TICKS = 8

        ! Determine number of ticks using plot area dimensions
        num_x_ticks = min(TARGET_TICKS, max(2, int(plot_width/50.0_wp)))
        num_y_ticks = min(TARGET_TICKS, max(2, int(plot_height/40.0_wp)))

        ! Allocate arrays
        allocate (x_positions(num_x_ticks))
        allocate (y_positions(num_y_ticks))
        allocate (x_labels(num_x_ticks))
        allocate (y_labels(num_y_ticks))
    end subroutine initialize_tick_arrays

    subroutine generate_x_axis_ticks(data_min, data_max, num_ticks, plot_left, &
                                     plot_width, &
                                     positions, labels, scale_type, date_format, &
                                     symlog_threshold)
        !! Generate X axis tick positions and labels
        real(wp), intent(in) :: data_min, data_max, plot_left, plot_width
        integer, intent(inout) :: num_ticks
        real(wp), intent(out) :: positions(:)
        character(len=50), intent(out) :: labels(:)
        character(len=*), intent(in), optional :: scale_type
        character(len=*), intent(in), optional :: date_format
        real(wp), intent(in), optional :: symlog_threshold

        call generate_axis_ticks_internal(data_min, data_max, num_ticks, plot_left, &
                                          plot_width, &
                                          positions, labels, scale_type, date_format, &
                                          symlog_threshold, 'x')
    end subroutine generate_x_axis_ticks

    subroutine generate_y_axis_ticks(data_min, data_max, num_ticks, plot_bottom, &
                                     plot_height, &
                                     positions, labels, scale_type, date_format, &
                                     symlog_threshold)
        !! Generate Y axis tick positions and labels
        real(wp), intent(in) :: data_min, data_max, plot_bottom, plot_height
        integer, intent(inout) :: num_ticks
        real(wp), intent(out) :: positions(:)
        character(len=50), intent(out) :: labels(:)
        character(len=*), intent(in), optional :: scale_type
        character(len=*), intent(in), optional :: date_format
        real(wp), intent(in), optional :: symlog_threshold

        call generate_axis_ticks_internal(data_min, data_max, num_ticks, plot_bottom, &
                                          plot_height, &
                                          positions, labels, scale_type, date_format, &
                                          symlog_threshold, 'y')
    end subroutine generate_y_axis_ticks

    subroutine generate_axis_ticks_internal(data_min, data_max, num_ticks, plot_start, &
                                            plot_size, &
                                            positions, labels, scale_type, &
                                            date_format, &
                                            symlog_threshold, axis)
        !! Internal helper to generate axis tick positions and labels
        real(wp), intent(in) :: data_min, data_max, plot_start, plot_size
        integer, intent(inout) :: num_ticks
        real(wp), intent(out) :: positions(:)
        character(len=50), intent(out) :: labels(:)
        character(len=*), intent(in), optional :: scale_type
        character(len=*), intent(in), optional :: date_format
        real(wp), intent(in), optional :: symlog_threshold
        character, intent(in) :: axis  ! 'x' or 'y'

        real(wp) :: tvals(MAX_TICKS)
        integer :: nt
        character(len=16) :: scale
        real(wp) :: thr
        integer :: used_ticks

        scale = 'linear'
        if (present(scale_type)) scale = scale_type
        thr = 1.0_wp
        if (present(symlog_threshold)) thr = symlog_threshold

        call compute_scale_ticks(scale, data_min, data_max, thr, tvals, nt)
        if (nt <= 0) then
            num_ticks = min(num_ticks, size(positions))
            if (num_ticks <= 0) then
                num_ticks = 0
                return
            end if
            call handle_zero_range_ticks(data_min, num_ticks, plot_start + &
                                         plot_size*0.5_wp, &
                                         positions, labels, scale, date_format)
            return
        end if

        used_ticks = min(nt, min(num_ticks, size(positions)))
        if (used_ticks <= 0) then
            num_ticks = 0
            return
        end if

        call fill_tick_positions_and_labels(tvals, nt, data_min, data_max, plot_start, &
                                            plot_size, &
                                            used_ticks, positions, labels, scale, thr, &
                                            date_format)
        num_ticks = used_ticks
    end subroutine generate_axis_ticks_internal

    subroutine fill_tick_positions_and_labels(tvals, nt, data_min, data_max, &
                                              plot_start, plot_size, &
                                              num_ticks, positions, labels, &
                                              scale, threshold, date_format)
        !! Fill tick positions and labels arrays
        real(wp), intent(in) :: tvals(:), data_min, data_max, plot_start, &
                                plot_size, threshold
        integer, intent(in) :: nt, num_ticks
        real(wp), intent(out) :: positions(:)
        character(len=50), intent(out) :: labels(:)
        character(len=*), intent(in) :: scale
        character(len=*), intent(in), optional :: date_format

        real(wp) :: min_t, max_t, tv_t
        integer :: i, limit, decimals

        min_t = apply_scale_transform(data_min, scale, threshold)
        max_t = apply_scale_transform(data_max, scale, threshold)

        decimals = 0
        if (trim(scale) == 'linear' .and. nt >= 2) then
            decimals = determine_decimals_from_ticks(tvals, nt)
        end if

        limit = min(num_ticks, size(positions))
        do i = 1, limit
            if (i > nt) exit
            tv_t = apply_scale_transform(tvals(i), scale, threshold)
            if (max_t > min_t) then
                positions(i) = plot_start + (tv_t - min_t)/(max_t - min_t)*plot_size
            else
                positions(i) = plot_start + 0.5_wp*plot_size
            end if
            if (trim(scale) == 'linear') then
                labels(i) = adjustl(format_tick_value_consistent(tvals(i), decimals))
            else
                labels(i) = adjustl(format_tick_label(tvals(i), scale, &
                                                      date_format=date_format, &
                                                      data_min=data_min, &
                                                      data_max=data_max))
            end if
        end do
        do i = nt + 1, limit
            labels(i) = ''
        end do
    end subroutine fill_tick_positions_and_labels

    subroutine handle_zero_range_ticks(data_value, num_ticks, center_position, &
                                       positions, labels, scale_type, date_format)
        !! Handle ticks for zero or near-zero range data
        real(wp), intent(in) :: data_value, center_position
        integer, intent(in) :: num_ticks
        real(wp), intent(out) :: positions(:)
        character(len=50), intent(out) :: labels(:)
        character(len=*), intent(in), optional :: scale_type
        character(len=*), intent(in), optional :: date_format

        integer :: i

        do i = 1, num_ticks
            positions(i) = center_position
            if (present(scale_type)) then
                labels(i) = adjustl(format_tick_label(data_value, scale_type, &
                                                      date_format=date_format, &
                                                      data_min=data_value, &
                                                      data_max=data_value))
            else
                labels(i) = adjustl(format_tick_label(data_value, 'linear'))
            end if
        end do
    end subroutine handle_zero_range_ticks

    subroutine draw_pdf_axes_and_labels(ctx, xscale, yscale, symlog_threshold, &
                                        data_x_min, data_x_max, data_y_min, &
                                        data_y_max, title, xlabel, ylabel, &
                                        x_date_format, y_date_format, &
                                        plot_area_left, plot_area_bottom, &
                                        plot_area_width, plot_area_height)
        !! Draw complete axes system with labels using actual plot area coordinates
        type(pdf_context_core), intent(inout) :: ctx
        character(len=*), intent(in), optional :: xscale, yscale
        real(wp), intent(in), optional :: symlog_threshold
        real(wp), intent(in) :: data_x_min, data_x_max, data_y_min, data_y_max
        character(len=*), intent(in), optional :: title, xlabel, ylabel
        character(len=*), intent(in), optional :: x_date_format, y_date_format
        real(wp), intent(in) :: plot_area_left, plot_area_bottom, plot_area_width, &
                                plot_area_height

        real(wp), allocatable :: x_positions(:), y_positions(:)
        character(len=50), allocatable :: x_labels(:), y_labels(:)
        integer :: num_x_ticks, num_y_ticks
        real(wp) :: x_min_adj, x_max_adj, y_min_adj, y_max_adj

        ! Setup data ranges and generate ticks
        call prepare_axes_data(ctx, data_x_min, data_x_max, data_y_min, data_y_max, &
                               x_min_adj, x_max_adj, y_min_adj, y_max_adj, &
                               x_positions, y_positions, x_labels, y_labels, &
                               num_x_ticks, num_y_ticks, xscale, yscale, &
                               x_date_format, y_date_format, &
                               plot_area_left, plot_area_bottom, plot_area_width, &
                               plot_area_height, &
                               symlog_threshold)

        ! Draw axes elements
        call draw_axes_elements(ctx, x_positions, y_positions, x_labels, y_labels, &
                                num_x_ticks, num_y_ticks, title, xlabel, ylabel, &
                                plot_area_left, plot_area_bottom, plot_area_width, &
                                plot_area_height)
    end subroutine draw_pdf_axes_and_labels

    subroutine prepare_axes_data(ctx, data_x_min, data_x_max, data_y_min, data_y_max, &
                                 x_min_adj, x_max_adj, y_min_adj, y_max_adj, &
                                 x_positions, y_positions, x_labels, y_labels, &
                                 num_x_ticks, num_y_ticks, xscale, yscale, &
                                 x_date_format, y_date_format, &
                                 plot_area_left, plot_area_bottom, plot_area_width, &
                                 plot_area_height, &
                                 symlog_threshold)
        !! Prepare axes data ranges and generate tick positions
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: data_x_min, data_x_max, data_y_min, data_y_max
        real(wp), intent(out) :: x_min_adj, x_max_adj, y_min_adj, y_max_adj
        real(wp), allocatable, intent(out) :: x_positions(:), y_positions(:)
        character(len=50), allocatable, intent(out) :: x_labels(:), y_labels(:)
        integer, intent(out) :: num_x_ticks, num_y_ticks
        character(len=*), intent(in), optional :: xscale, yscale
        character(len=*), intent(in), optional :: x_date_format, y_date_format
        real(wp), intent(in) :: plot_area_left, plot_area_bottom, plot_area_width, &
                                plot_area_height
        real(wp), intent(in), optional :: symlog_threshold

        call setup_axes_data_ranges(ctx, data_x_min, data_x_max, data_y_min, &
                                    data_y_max, &
                                    x_min_adj, x_max_adj, y_min_adj, y_max_adj, &
                                    xscale, yscale)

        call generate_tick_data(ctx, data_x_min, data_x_max, data_y_min, data_y_max, &
                                x_positions, y_positions, x_labels, y_labels, &
                                num_x_ticks, num_y_ticks, xscale, yscale, &
                                x_date_format, y_date_format, &
                                plot_area_left, plot_area_bottom, plot_area_width, &
                                plot_area_height, &
                                symlog_threshold)
    end subroutine prepare_axes_data

    subroutine draw_axes_elements(ctx, x_positions, y_positions, x_labels, y_labels, &
                                  num_x_ticks, num_y_ticks, title, xlabel, ylabel, &
                                  plot_area_left, plot_area_bottom, plot_area_width, &
                                  plot_area_height)
        !! Draw axes frame, ticks, and labels
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: x_positions(:), y_positions(:)
        character(len=50), intent(in) :: x_labels(:), y_labels(:)
        integer, intent(in) :: num_x_ticks, num_y_ticks
        character(len=*), intent(in), optional :: title, xlabel, ylabel
        real(wp), intent(in) :: plot_area_left, plot_area_bottom, plot_area_width, &
                                plot_area_height
        real(wp) :: max_y_tick_label_width

        ! Ensure axes are drawn in black independent of prior plot color state
        call ctx%set_color(0.0_wp, 0.0_wp, 0.0_wp)
        call ctx%set_line_width(1.0_wp)

        call draw_pdf_frame_with_area(ctx, plot_area_left, plot_area_bottom, &
                                      plot_area_width, plot_area_height)

        call draw_pdf_tick_marks_with_area(ctx, x_positions, y_positions, num_x_ticks, &
                                           num_y_ticks, &
                                           plot_area_left, plot_area_bottom)

        max_y_tick_label_width = 0.0_wp
        call draw_pdf_tick_labels_with_area(ctx, x_positions, y_positions, x_labels, &
                                            y_labels, &
                                            num_x_ticks, num_y_ticks, plot_area_left, &
                                            plot_area_bottom, &
                                            plot_area_height, &
                                            max_y_tick_label_width)

        if (present(title) .or. present(xlabel) .or. present(ylabel)) then
            call draw_pdf_title_and_labels(ctx, title, xlabel, ylabel, &
                                           plot_area_left, plot_area_bottom, &
                                           plot_area_width, plot_area_height, &
                                           max_y_tick_label_width)
        end if
    end subroutine draw_axes_elements

    subroutine draw_pdf_frame_with_area(ctx, plot_left, plot_bottom, plot_width, &
                                        plot_height)
        !! Draw the plot frame using actual plot area coordinates (FIXED version)
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: plot_left, plot_bottom, plot_width, plot_height
        character(len=2048) :: frame_cmd
        real(wp) :: x1, y1

        ! PDF coordinates: Y=0 at bottom (same as our data coordinates)
        x1 = plot_left
        y1 = plot_bottom  ! No conversion needed - PDF Y=0 is at bottom

        ! Force solid stroke for the frame regardless of prior dash settings
        ctx%stream_data = ctx%stream_data//'[] 0 d'//new_line('a')

        ! Draw rectangle frame
        write (frame_cmd, '(F0.3, 1X, F0.3, " ", F0.3, 1X, F0.3, " re S")') &
            x1, y1, plot_width, plot_height
        ctx%stream_data = ctx%stream_data//trim(adjustl(frame_cmd))//new_line('a')
    end subroutine draw_pdf_frame_with_area

    subroutine draw_pdf_tick_marks_with_area(ctx, x_positions, y_positions, &
                                             num_x, num_y, plot_left, plot_bottom)
        !! Draw tick marks using actual plot area coordinates (FIXED version)
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: x_positions(:), y_positions(:)
        integer, intent(in) :: num_x, num_y
        real(wp), intent(in) :: plot_left, plot_bottom

        integer :: i
        character(len=2048) :: tick_cmd
        real(wp) :: tick_length, bottom_y

        ! Ensure tick marks are stroked in black and solid regardless of prior
        ! drawing state.
        call ctx%set_color(0.0_wp, 0.0_wp, 0.0_wp)
        call ctx%set_line_width(1.0_wp)
        ctx%stream_data = ctx%stream_data//'[] 0 d'//new_line('a')

        tick_length = PDF_TICK_SIZE
        bottom_y = plot_bottom  ! PDF Y=0 is at bottom, no conversion needed

        ! Draw X-axis ticks (bottom of plot area) — draw inward into frame
        do i = 1, num_x
            write (tick_cmd, '(F0.3, 1X, F0.3, " m ", F0.3, 1X, F0.3, " l S")') &
                x_positions(i), bottom_y, &
                x_positions(i), bottom_y + tick_length
            ctx%stream_data = ctx%stream_data//trim(adjustl(tick_cmd))//new_line('a')
        end do

        ! Draw Y-axis ticks (left side of plot area) — draw inward into frame
        do i = 1, num_y
            write (tick_cmd, '(F0.3, 1X, F0.3, " m ", F0.3, 1X, F0.3, " l S")') &
                plot_left, y_positions(i), &
                plot_left + tick_length, y_positions(i)
            ctx%stream_data = ctx%stream_data//trim(adjustl(tick_cmd))//new_line('a')
        end do
    end subroutine draw_pdf_tick_marks_with_area

    subroutine draw_pdf_tick_labels_with_area(ctx, x_positions, y_positions, x_labels, &
                                              y_labels, num_x, num_y, plot_left, &
                                              plot_bottom, plot_height, &
                                              max_y_tick_label_width)
        !! Draw tick labels using actual plot area coordinates (FIXED version)
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: x_positions(:), y_positions(:)
        character(len=*), intent(in) :: x_labels(:), y_labels(:)
        integer, intent(in) :: num_x, num_y
        real(wp), intent(in) :: plot_left, plot_bottom, plot_height
        real(wp), intent(out), optional :: max_y_tick_label_width

        integer :: i, prev_idx
        real(wp) :: label_x, label_y, bottom_y
        real(wp) :: y_label_w
        real(wp) :: max_y_w
        real(wp) :: min_spacing
        real(wp) :: label_budget
        integer :: max_labels
        integer :: idx
        integer :: k
        ! Legacy fallback width (unused for mathtext)
        real(wp), parameter :: X_TICK_GAP = 15.0_wp
        ! Distance below plot for X tick labels
        real(wp), parameter :: Y_TICK_GAP_LOCAL = 1.0_wp
        real(wp), parameter :: Y_TICK_BASELINE_NUDGE = 0.35_wp
        bottom_y = plot_bottom  ! PDF Y=0 is at bottom, no conversion needed

        ! Draw X-axis labels (center-aligned under each tick)
        do i = 1, num_x
            ! Estimate label width in points for proper centering (handles mathtext)
            label_x = x_positions(i) - &
                      0.5_wp*estimate_pdf_text_width(trim(x_labels(i)), &
                                                     PDF_TICK_LABEL_SIZE)
            label_y = bottom_y - X_TICK_GAP
            call render_mixed_text(ctx, label_x, label_y, trim(x_labels(i)))
        end do

        max_y_w = 0.0_wp
        min_spacing = max(9.0_wp, 1.15_wp*PDF_TICK_LABEL_SIZE)
        label_budget = max(0.0_wp, plot_height - PDF_TICK_LABEL_SIZE)
        max_labels = max(2, int(label_budget/min_spacing) + 1)

        if (num_y <= max_labels) then
            do i = 1, num_y
                label_y = y_positions(i) - Y_TICK_BASELINE_NUDGE*PDF_TICK_LABEL_SIZE
                y_label_w = estimate_pdf_text_width(trim(y_labels(i)), &
                                                    PDF_TICK_LABEL_SIZE)
                max_y_w = max(max_y_w, y_label_w)
                label_x = plot_left - Y_TICK_GAP_LOCAL - y_label_w
                call render_mixed_text(ctx, label_x, label_y, trim(y_labels(i)))
            end do
        else
            prev_idx = 0
            do k = 1, max_labels
                idx = 1 + int(nint(real(k - 1, wp)*real(num_y - 1, wp)/ &
                                   real(max_labels - 1, wp)))
                idx = max(idx, prev_idx + 1)
                idx = min(idx, num_y - (max_labels - k))
                prev_idx = idx
                label_y = y_positions(idx) - Y_TICK_BASELINE_NUDGE*PDF_TICK_LABEL_SIZE
                y_label_w = estimate_pdf_text_width(trim(y_labels(idx)), &
                                                    PDF_TICK_LABEL_SIZE)
                max_y_w = max(max_y_w, y_label_w)
                label_x = plot_left - Y_TICK_GAP_LOCAL - y_label_w
                call render_mixed_text(ctx, label_x, label_y, trim(y_labels(idx)))
            end do
        end if

        if (present(max_y_tick_label_width)) then
            max_y_tick_label_width = max_y_w
        end if
    end subroutine draw_pdf_tick_labels_with_area

    subroutine draw_pdf_title_and_labels(ctx, title, xlabel, ylabel, &
                                         plot_area_left, plot_area_bottom, &
                                         plot_area_width, plot_area_height, &
                                         y_tick_label_max_width)
        !! Draw plot title and axis labels
        type(pdf_context_core), intent(inout) :: ctx
        character(len=*), intent(in), optional :: title, xlabel, ylabel
        real(wp), intent(in) :: plot_area_left, plot_area_bottom, plot_area_width, &
                                plot_area_height
        real(wp), intent(in), optional :: y_tick_label_max_width

        real(wp) :: title_x, title_y
        real(wp) :: xlabel_x, xlabel_y
        real(wp) :: ylabel_x, ylabel_y
        real(wp) :: width_pt
        real(wp) :: y_tick_w
        character(len=512) :: processed_title, processed_xlabel, processed_ylabel
        integer :: processed_len
        real(wp), parameter :: TITLE_GAP = 6.0_wp
        real(wp), parameter :: Y_TICK_GAP_LOCAL = 1.0_wp
        real(wp), parameter :: YLABEL_PAD = 1.0_wp
        real(wp), parameter :: LABEL_THICKNESS = 1.2_wp*PDF_LABEL_SIZE

        ! Draw title (centered at top)
        if (present(title)) then
            if (len_trim(title) > 0) then
                ! Process LaTeX commands for accurate width calculation
                call process_latex_in_text(trim(title), processed_title, processed_len)

                width_pt = estimate_pdf_text_width(processed_title(1:processed_len), &
                                                   PDF_TITLE_SIZE)
                title_x = plot_area_left + 0.5_wp*plot_area_width - 0.5_wp*width_pt
                title_y = plot_area_bottom + plot_area_height + TITLE_GAP
                ! Process LaTeX commands to Unicode and render with mixed fonts
                ! Use mathtext rendering for title to handle superscripts properly
                call draw_pdf_mathtext(ctx, title_x, title_y, trim(title), &
                                       PDF_TITLE_SIZE)
            end if
        end if

        ! Draw X-axis label (centered at bottom).
        if (present(xlabel)) then
            if (len_trim(xlabel) > 0) then
                ! Process LaTeX commands for accurate width calculation
                call process_latex_in_text(trim(xlabel), processed_xlabel, &
                                           processed_len)

                width_pt = estimate_pdf_text_width(processed_xlabel(1:processed_len), &
                                                   PDF_LABEL_SIZE)
                xlabel_x = plot_area_left + 0.5_wp*plot_area_width - 0.5_wp*width_pt
                xlabel_y = plot_area_bottom - real(XLABEL_VERTICAL_OFFSET, wp)
                call render_mixed_text(ctx, xlabel_x, xlabel_y, trim(xlabel))
            end if
        end if

        ! Draw Y-axis label (rotated on left) - anchor point is the right edge of the
        ! rotated glyphs (text extends to the left in device X).
        if (present(ylabel)) then
            if (len_trim(ylabel) > 0) then
                ! Process LaTeX commands for accurate width calculation
                call process_latex_in_text(trim(ylabel), processed_ylabel, &
                                           processed_len)

                y_tick_w = 0.0_wp
                if (present(y_tick_label_max_width)) y_tick_w = y_tick_label_max_width

                ! Place y-label block left of the y-tick label block by a fixed padding.
                ! Account for rotated text matrix: glyphs extend left of (ylabel_x).
                ylabel_x = plot_area_left - Y_TICK_GAP_LOCAL - y_tick_w - YLABEL_PAD

                ! Vertically center rotated label: its extent along Y equals the
                ! unrotated text width in points.
                width_pt = estimate_pdf_text_width(processed_ylabel(1:processed_len), &
                                                   PDF_LABEL_SIZE)
                ylabel_y = plot_area_bottom + 0.5_wp*plot_area_height - 0.5_wp*width_pt
                call render_rotated_mixed_text(ctx, ylabel_x, ylabel_y, trim(ylabel))
            end if
        end if
    end subroutine draw_pdf_title_and_labels

    subroutine render_mixed_text(ctx, x, y, text, font_size)
        !! Helper: process LaTeX and render mixed-font text (with mathtext support)
        !! PDF needs Unicode superscripts converted to mathtext for proper rendering
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: x, y
        character(len=*), intent(in) :: text
        real(wp), intent(in), optional :: font_size
        character(len=512) :: processed
        integer :: plen
        character(len=600) :: math_ready
        integer :: mlen

        ! For PDF, we need to handle Unicode superscripts properly
        ! The mathtext system will render them as superscripts
        call process_latex_in_text(text, processed, plen)
        ! Mirror raster backend behavior: pass trimmed text through as-is
        ! Mathtext engages only when callers supply explicit $...$ delimiters
        call prepare_mathtext_if_needed(processed(1:plen), math_ready, mlen)

        ! Route through mathtext renderer only when math segments are present
        if (has_mathtext(math_ready(1:mlen))) then
            if (present(font_size)) then
                call draw_pdf_mathtext(ctx, x, y, math_ready(1:mlen), font_size)
            else
                call draw_pdf_mathtext(ctx, x, y, math_ready(1:mlen))
            end if
        else
            if (present(font_size)) then
                call draw_mixed_font_text(ctx, x, y, processed(1:plen), font_size)
            else
                call draw_mixed_font_text(ctx, x, y, processed(1:plen))
            end if
        end if
    end subroutine render_mixed_text

    subroutine render_rotated_mixed_text(ctx, x, y, text)
        !! Helper: process LaTeX and render rotated mixed-font ylabel
        !! Now supports mathtext rendering for ylabel with $...$ delimiters
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: x, y
        character(len=*), intent(in) :: text
        character(len=512) :: processed
        integer :: plen
        character(len=600) :: math_ready
        integer :: mlen

        ! Process LaTeX commands
        call process_latex_in_text(text, processed, plen)

        ! Check if mathtext is present ($...$ delimiters)
        call prepare_mathtext_if_needed(processed(1:plen), math_ready, mlen)

        if (has_mathtext(math_ready(1:mlen))) then
            ! For mathtext, we need to use a rotated mathtext renderer
            ! Since draw_pdf_mathtext doesn't support rotation, we'll use
            ! the text matrix approach with mathtext rendering
            call draw_rotated_pdf_mathtext(ctx, x, y, math_ready(1:mlen))
        else
            call draw_rotated_mixed_font_text(ctx, x, y, processed(1:plen))
        end if
    end subroutine render_rotated_mixed_text

    subroutine draw_rotated_pdf_mathtext(ctx, x, y, text)
        !! Draw rotated mathtext for ylabel
        !! Uses rotation matrix with manual text positioning for subscripts/superscripts
        use fortplot_pdf_text_segments, only: process_text_segments
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: x, y
        character(len=*), intent(in) :: text
        character(len=1024) :: matrix_cmd, td_cmd
        character(len=2048) :: preprocessed_text
        integer :: processed_len
        character(len=4096) :: math_ready
        integer :: mlen
        type(mathtext_element_t), allocatable :: elements(:)
        integer :: i
        real(wp) :: elem_font_size, elem_y_offset
        real(wp) :: char_width
        integer :: j, codepoint, char_len, text_len
        logical :: in_symbol_font

        ! Process text for mathtext
        call process_latex_in_text(text, preprocessed_text, processed_len)
        call preprocess_math_text(preprocessed_text(1:processed_len), math_ready, mlen)

        ! Parse mathtext elements
        elements = parse_mathtext(math_ready(1:mlen))

        ! Begin text object with rotation matrix (90 degrees counterclockwise)
        ctx%stream_data = ctx%stream_data//'BT'//new_line('a')

        ! Set rotation matrix: [0 1 -1 0 x y] for 90-degree rotation
        write (matrix_cmd, '("0 1 -1 0 ", F0.3, 1X, F0.3, " Tm")') x, y
        ctx%stream_data = ctx%stream_data//trim(adjustl(matrix_cmd))//new_line('a')

        ! Render each mathtext element with proper font size and vertical offset
        in_symbol_font = .false.
        do i = 1, size(elements)
            if (len_trim(elements(i)%text) > 0) then
                ! Calculate element font size and vertical offset
                elem_font_size = PDF_LABEL_SIZE*elements(i)%font_size_ratio
                elem_y_offset = elements(i)%vertical_offset*PDF_LABEL_SIZE

                ! Move to position for this element using Td (relative positioning)
                ! The rotation matrix transforms these:
                ! x->forward along text, y->perpendicular.
                if (i > 1) then
                    ! Move horizontally by previous element width, vertically by
                    ! offset difference.
                    write (td_cmd, '(F0.3, 1X, F0.3, " Td")') char_width, &
                        elem_y_offset - (elements(i - 1)%vertical_offset*PDF_LABEL_SIZE)
                    ctx%stream_data = &
                        ctx%stream_data//trim(adjustl(td_cmd))//new_line('a')
                else if (abs(elem_y_offset) > 0.01_wp) then
                    ! First element with non-zero offset
                    write (td_cmd, '("0 ", F0.3, " Td")') elem_y_offset
                    ctx%stream_data = &
                        ctx%stream_data//trim(adjustl(td_cmd))//new_line('a')
                end if

                ! Set font size for this element
                write (matrix_cmd, '("/F", I0, 1X, F0.1, " Tf")') &
                    ctx%fonts%get_helvetica_obj(), elem_font_size
                ctx%stream_data = &
                    ctx%stream_data//trim(adjustl(matrix_cmd))//new_line('a')

                ! Render text segments
                call process_text_segments(ctx, elements(i)%text, in_symbol_font, &
                                           elem_font_size)

                ! Calculate width for next element positioning
                char_width = 0.0_wp
                j = 1
                text_len = len_trim(elements(i)%text)
                do while (text_len < len(elements(i)%text))
                    if (elements(i)%text(text_len + 1:text_len + 1) == ' ') then
                        text_len = text_len + 1
                    else
                        exit
                    end if
                end do

                do while (j <= text_len)
                    char_len = utf8_char_length(elements(i)%text(j:j))
                    if (char_len == 0) then
                        codepoint = iachar(elements(i)%text(j:j))
                        char_len = 1
                    else
                        codepoint = utf8_to_codepoint(elements(i)%text, j)
                    end if

                    if (codepoint >= 48 .and. codepoint <= 57) then
                        char_width = char_width + elem_font_size*0.55_wp
                    else if (codepoint >= 65 .and. codepoint <= 90) then
                        char_width = char_width + elem_font_size*0.65_wp
                    else if (codepoint >= 97 .and. codepoint <= 122) then
                        char_width = char_width + elem_font_size*0.5_wp
                    else if (codepoint == 32) then
                        char_width = char_width + elem_font_size*0.3_wp
                    else
                        char_width = char_width + elem_font_size*0.5_wp
                    end if

                    j = j + char_len
                end do
            end if
        end do

        ctx%stream_data = ctx%stream_data//'ET'//new_line('a')
    end subroutine draw_rotated_pdf_mathtext

    subroutine draw_pdf_minor_tick_marks(ctx, x_minor_positions, y_minor_positions, &
                                         num_x_minor, num_y_minor, &
                                         plot_left, plot_bottom)
        !! Draw minor tick marks (shorter than major ticks)
        type(pdf_context_core), intent(inout) :: ctx
        real(wp), intent(in) :: x_minor_positions(:), y_minor_positions(:)
        integer, intent(in) :: num_x_minor, num_y_minor
        real(wp), intent(in) :: plot_left, plot_bottom

        integer :: i
        character(len=2048) :: tick_cmd
        real(wp) :: bottom_y

        call ctx%set_color(0.0_wp, 0.0_wp, 0.0_wp)
        call ctx%set_line_width(0.5_wp)
        ctx%stream_data = ctx%stream_data//'[] 0 d'//new_line('a')

        bottom_y = plot_bottom

        do i = 1, num_x_minor
            write (tick_cmd, '(F0.3, 1X, F0.3, " m ", F0.3, 1X, F0.3, " l S")') &
                x_minor_positions(i), bottom_y, &
                x_minor_positions(i), bottom_y + PDF_MINOR_TICK_SIZE
            ctx%stream_data = ctx%stream_data//trim(adjustl(tick_cmd))//new_line('a')
        end do

        do i = 1, num_y_minor
            write (tick_cmd, '(F0.3, 1X, F0.3, " m ", F0.3, 1X, F0.3, " l S")') &
                plot_left, y_minor_positions(i), &
                plot_left + PDF_MINOR_TICK_SIZE, y_minor_positions(i)
            ctx%stream_data = ctx%stream_data//trim(adjustl(tick_cmd))//new_line('a')
        end do
    end subroutine draw_pdf_minor_tick_marks

end module fortplot_pdf_axes