fortplot_subplot_layout.f90 Source File


Source Code

module fortplot_subplot_layout
    use, intrinsic :: iso_fortran_env, only: wp => real64
    use fortplot_plot_data, only: subplot_data_t
    use fortplot_figure_data_ranges, only: calculate_figure_data_ranges
    use fortplot_subplot_colorbar, only: panel_colorbar_tick_labels
    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_constants, only: TICK_MARK_LENGTH, TITLE_PAD_PT, &
                                  AXIS_LABEL_PAD_PT, XLABEL_VERTICAL_OFFSET, &
                                  X_TICK_LABEL_PAD, &
                                  Y_TICK_LABEL_RIGHT_PAD
    use fortplot_text_layout, only: calculate_text_width, calculate_text_height, &
                                    calculate_text_height_with_size_internal, &
                                    TITLE_FONT_SIZE
    use fortplot_latex_parser, only: process_latex_in_text
    use fortplot_text_helpers, only: prepare_mathtext_if_needed
    use fortplot_unicode, only: escape_unicode_for_raster
    use fortplot_raster, only: raster_context
    use fortplot_raster_core, only: pt2px, raster_image_t
    use fortplot_raster_labels, only: xlabel_bottom_padding
    use fortplot_pdf, only: pdf_context
    use fortplot_pdf_text, only: estimate_pdf_text_width
    use fortplot_pdf_core, only: PDF_TICK_LABEL_SIZE, PDF_LABEL_SIZE, PDF_TITLE_SIZE
    implicit none

    private
    public :: compute_tight_subplot_margins

    ! Minimum fraction of the figure height the panel rows should fill, so short
    ! figures (e.g. a 1xN strip) do not waste vertical space the way a purely
    ! decoration-driven layout does. Matches matplotlib's default axes band
    ! (figure.subplot.top - figure.subplot.bottom).
    real(wp), parameter :: MIN_AXES_FILL_FRAC = 0.77_wp
    ! Gap between a colorbar's bar and its tick labels (tick mark + pad)
    integer, parameter :: CBAR_TICK_GAP_PX = 8

contains

    subroutine compute_tight_subplot_margins(backend, subplots_array, nr, nc, &
                                             xscale, yscale, symlog_threshold, left_f, &
                                             right_f, bottom_f, top_f, &
                                             ok, suptitle_height_frac)
        !! Compute per-cell subplot margins in fractional figure coordinates.
        !! Margins, inter-column gaps, and inter-row gaps are driven by each
        !! panel's decoration sizes (tick labels, axis labels, titles) so every
        !! panel's y-axis label sits to the left of its plot box and the top
        !! row's x-axis label clears the title of the row below. Short figures
        !! additionally reclaim excess margin so the panels fill at least
        !! MIN_AXES_FILL_FRAC of the figure height, matching matplotlib's
        !! default vertical fill.
        class(*), intent(in) :: backend
        type(subplot_data_t), intent(in) :: subplots_array(:, :)
        integer, intent(in) :: nr, nc
        character(len=*), intent(in) :: xscale, yscale
        real(wp), intent(in) :: symlog_threshold
        real(wp), allocatable, intent(out) :: left_f(:, :), right_f(:, :)
        real(wp), allocatable, intent(out) :: bottom_f(:, :), top_f(:, :)
        logical, intent(out) :: ok
        real(wp), intent(in), optional :: suptitle_height_frac

        real(wp) :: sup_frac

        real(wp), allocatable :: dec_left(:, :), dec_right(:, :)
        real(wp), allocatable :: dec_bottom(:, :), dec_top(:, :)

        integer :: i, j
        real(wp) :: fig_w, fig_h

        ok = .false.
        sup_frac = 0.0_wp
        if (present(suptitle_height_frac)) sup_frac = max(0.0_wp, suptitle_height_frac)

        if (nr <= 0 .or. nc <= 0) return
        if (size(subplots_array, 1) /= nr) return
        if (size(subplots_array, 2) /= nc) return

        allocate (left_f(nr, nc), right_f(nr, nc))
        allocate (bottom_f(nr, nc), top_f(nr, nc))
        allocate (dec_left(nr, nc), dec_right(nr, nc))
        allocate (dec_bottom(nr, nc), dec_top(nr, nc))

        select type (bk => backend)
        class is (raster_context)
            fig_w = real(max(1, bk%width), wp)
            fig_h = real(max(1, bk%height), wp)
            do i = 1, nr
                do j = 1, nc
                    call estimate_subplot_decorations_raster(subplots_array(i, j), &
                                                             subplots_array(i, j)%xscale, &
                                                             subplots_array(i, j)%yscale, &
                                                             subplots_array(i, j)% &
                                                             symlog_threshold, &
                                                             bk%raster, &
                                                             dec_left(i, j), &
                                                             dec_right(i, j), &
                                                             dec_bottom(i, j), &
                                                             dec_top(i, j))
                end do
            end do
        class is (pdf_context)
            fig_w = real(max(1, bk%width), wp)
            fig_h = real(max(1, bk%height), wp)
            do i = 1, nr
                do j = 1, nc
                    call estimate_subplot_decorations_pdf(subplots_array(i, j), &
                                                          subplots_array(i, j)%xscale, &
                                                          subplots_array(i, j)%yscale, &
                                                          subplots_array(i, j)% &
                                                          symlog_threshold, &
                                                          dec_left(i, j), &
                                                          dec_right(i, j), &
                                                          dec_bottom(i, j), &
                                                          dec_top(i, j))
                end do
            end do
        class default
            return
        end select

        call solve_tight_grid(fig_w, fig_h, dec_left, dec_right, dec_bottom, &
                              dec_top, left_f, right_f, bottom_f, top_f, ok, sup_frac)
    end subroutine compute_tight_subplot_margins

    subroutine solve_tight_grid(fig_w, fig_h, dec_left, dec_right, dec_bottom, &
                                dec_top, left_f, right_f, bottom_f, top_f, ok, &
                                suptitle_height_frac)
        real(wp), intent(in) :: fig_w, fig_h
        real(wp), contiguous, intent(in) :: dec_left(:, :), dec_right(:, :)
        real(wp), contiguous, intent(in) :: dec_bottom(:, :), dec_top(:, :)
        real(wp), intent(out) :: left_f(:, :), right_f(:, :)
        real(wp), intent(out) :: bottom_f(:, :), top_f(:, :)
        logical, intent(out) :: ok
        real(wp), intent(in) :: suptitle_height_frac

        integer :: nr, nc, i, j
        real(wp), allocatable :: gap_x(:), gap_y(:)
        real(wp) :: margin_l, margin_r, margin_b, margin_t
        real(wp) :: axes_w, axes_h
        real(wp) :: avail_w, avail_h
        real(wp) :: pad
        real(wp) :: x0, x1, y0, y1

        nr = size(dec_left, 1)
        nc = size(dec_left, 2)
        ok = .false.

        pad = 6.0_wp

        allocate (gap_x(max(0, nc - 1)))
        allocate (gap_y(max(0, nr - 1)))

        call compute_margins_and_gaps(dec_left, dec_right, dec_bottom, dec_top, &
                                      pad, fig_h, suptitle_height_frac, &
                                      margin_l, margin_r, margin_b, margin_t, &
                                      gap_x, gap_y)

        call reclaim_vertical_margin(fig_h, pad, suptitle_height_frac, &
                                     sum(gap_y), nr, margin_t, margin_b)

        avail_w = fig_w - margin_l - margin_r
        if (nc > 1) avail_w = avail_w - sum(gap_x)
        avail_h = fig_h - margin_t - margin_b
        if (nr > 1) avail_h = avail_h - sum(gap_y)

        if (avail_w <= real(nc, wp) .or. avail_h <= real(nr, wp)) return

        axes_w = avail_w/real(nc, wp)
        axes_h = avail_h/real(nr, wp)

        do i = 1, nr
            y1 = fig_h - margin_t
            if (i > 1) then
                y1 = y1 - real(i - 1, wp)*axes_h - sum(gap_y(1:i - 1))
            end if
            y0 = y1 - axes_h
            do j = 1, nc
                x0 = margin_l
                if (j > 1) then
                    x0 = x0 + real(j - 1, wp)*axes_w + sum(gap_x(1:j - 1))
                end if
                x1 = x0 + axes_w

                left_f(i, j) = x0/fig_w
                right_f(i, j) = x1/fig_w
                bottom_f(i, j) = y0/fig_h
                top_f(i, j) = y1/fig_h
            end do
        end do

        ok = .true.
    end subroutine solve_tight_grid

    subroutine compute_margins_and_gaps(dec_left, dec_right, dec_bottom, dec_top, &
                                        pad, fig_h, suptitle_height_frac, &
                                        margin_l, margin_r, margin_b, margin_t, &
                                        gap_x, gap_y)
        !! Outer margins and inter-panel gaps from per-cell decoration sizes.
        !! Margins reserve room for the outermost decorations plus a uniform pad
        !! (and a suptitle band on top); each gap reserves room for the adjacent
        !! decorations facing it, so y-labels stay left of every panel and the
        !! top row's x-label clears the title of the row below.
        real(wp), contiguous, intent(in) :: dec_left(:, :), dec_right(:, :)
        real(wp), contiguous, intent(in) :: dec_bottom(:, :), dec_top(:, :)
        real(wp), intent(in) :: pad, fig_h, suptitle_height_frac
        real(wp), intent(out) :: margin_l, margin_r, margin_b, margin_t
        real(wp), intent(out) :: gap_x(:), gap_y(:)

        integer :: nr, nc, i, j

        nr = size(dec_left, 1)
        nc = size(dec_left, 2)

        margin_l = pad
        margin_r = pad
        margin_b = pad
        margin_t = pad
        do i = 1, nr
            margin_l = max(margin_l, dec_left(i, 1) + pad)
            margin_r = max(margin_r, dec_right(i, nc) + pad)
        end do
        do j = 1, nc
            margin_b = max(margin_b, dec_bottom(nr, j) + pad)
            margin_t = max(margin_t, dec_top(1, j) + pad)
        end do
        if (suptitle_height_frac > 0.0_wp) then
            margin_t = margin_t + suptitle_height_frac*fig_h + 2.0_wp*pad
        end if

        do j = 1, nc - 1
            gap_x(j) = pad
            do i = 1, nr
                gap_x(j) = max(gap_x(j), dec_right(i, j) + dec_left(i, j + 1) + pad)
            end do
        end do
        do i = 1, nr - 1
            gap_y(i) = pad
            do j = 1, nc
                gap_y(i) = max(gap_y(i), dec_bottom(i, j) + dec_top(i + 1, j) + pad)
            end do
        end do
    end subroutine compute_margins_and_gaps

    subroutine reclaim_vertical_margin(fig_h, pad, suptitle_height_frac, &
                                       gap_y_sum, nr, margin_t, margin_b)
        !! Shrink excess outer vertical margin so short figures fill the height
        !! like matplotlib, without clipping decorations off the figure. Only the
        !! outer-margin padding and the suptitle clearance padding are reclaimed;
        !! the decoration sizes and inter-row gaps stay as overlap guards.
        real(wp), intent(in) :: fig_h, pad, suptitle_height_frac, gap_y_sum
        integer, intent(in) :: nr
        real(wp), intent(inout) :: margin_t, margin_b

        real(wp) :: target_h, panels_h, slack, factor

        target_h = MIN_AXES_FILL_FRAC*fig_h
        if (nr > 1) target_h = target_h - gap_y_sum
        panels_h = fig_h - margin_t - margin_b
        if (panels_h >= target_h) return

        slack = 2.0_wp*pad
        if (suptitle_height_frac > 0.0_wp) slack = slack + 2.0_wp*pad
        if (slack <= 0.0_wp) return

        factor = min(1.0_wp, (target_h - panels_h)/slack)
        margin_b = margin_b - factor*pad
        margin_t = margin_t - factor*pad
        if (suptitle_height_frac > 0.0_wp) margin_t = margin_t - factor*2.0_wp*pad
    end subroutine reclaim_vertical_margin

    subroutine estimate_subplot_decorations_raster(subplot, xscale, yscale, &
                                                   symlog_threshold, raster, dec_left, &
                                                   dec_right, dec_bottom, dec_top)
        type(subplot_data_t), intent(in) :: subplot
        character(len=*), intent(in) :: xscale, yscale
        real(wp), intent(in) :: symlog_threshold
        type(raster_image_t), intent(in) :: raster
        real(wp), intent(out) :: dec_left, dec_right, dec_bottom, dec_top

        integer :: n_x, n_y, i
        character(len=50) :: x_labels(MAX_TICKS), y_labels(MAX_TICKS)
        integer :: max_y_w, max_x_h
        real(wp) :: last_x_half_w, top_y_half_h
        integer :: ylabel_h, title_h
        character(len=:), allocatable :: title, xlabel, ylabel
        real(wp) :: vxmin, vxmax, vymin, vymax

        call panel_view_limits(subplot, xscale, yscale, symlog_threshold, &
                               vxmin, vxmax, vymin, vymax)
        max_y_w = 0
        max_x_h = 0
        last_x_half_w = 0.0_wp
        top_y_half_h = 0.0_wp

        call panel_tick_labels(subplot%custom_xtick_positions, &
            subplot%custom_xtick_labels, xscale, vxmin, vxmax, &
            symlog_threshold, x_labels, n_x)
        call panel_tick_labels(subplot%custom_ytick_positions, &
            subplot%custom_ytick_labels, yscale, vymin, vymax, &
            symlog_threshold, y_labels, n_y)
        if (n_x > 0) then
            do i = 1, n_x
                max_x_h = max(max_x_h, measure_raster_height(trim(x_labels(i))))
            end do
            last_x_half_w = 0.5_wp*real(measure_raster_width(trim(x_labels(n_x))), wp)
        end if
        if (n_y > 0) then
            do i = 1, n_y
                max_y_w = max(max_y_w, measure_raster_width(trim(y_labels(i))))
            end do
            top_y_half_h = 0.5_wp*real(measure_raster_height(trim(y_labels(n_y))), wp)
        end if

        title = ''
        xlabel = ''
        ylabel = ''
        if (allocated(subplot%title)) title = subplot%title
        if (allocated(subplot%xlabel)) xlabel = subplot%xlabel
        if (allocated(subplot%ylabel)) ylabel = subplot%ylabel

        ylabel_h = 0
        if (len_trim(ylabel) > 0) ylabel_h = measure_raster_height(ylabel)

        title_h = 0
        if (len_trim(title) > 0) then
            title_h = &
                calculate_text_height_with_size_internal(real(TITLE_FONT_SIZE, wp))
        end if

        dec_left = real(Y_TICK_LABEL_RIGHT_PAD + max_y_w, wp)
        dec_left = max(dec_left, real(TICK_MARK_LENGTH, wp))
        if (len_trim(ylabel) > 0) then
            ! Reserve the y-label band to match raster_render_ylabel: tick mark,
            ! tick-label right pad, tick-label width, the matplotlib axes.labelpad
            ! gap, and the rotated y-label thickness.
            dec_left = real(TICK_MARK_LENGTH + Y_TICK_LABEL_RIGHT_PAD + max_y_w + &
                            ylabel_h, wp) + pt2px(AXIS_LABEL_PAD_PT, raster%dpi)
        end if
        dec_right = last_x_half_w

        ! Tick labels sit X_TICK_LABEL_PAD below the spine; the x-axis label is
        ! placed an additional pt2px(AXIS_LABEL_PAD_PT) below the tick-label
        ! bottom edge (matplotlib axes.labelpad), matching the raster renderer.
        dec_bottom = real(X_TICK_LABEL_PAD + max_x_h, wp)
        if (len_trim(xlabel) > 0) then
            dec_bottom = max(dec_bottom, xlabel_bottom_padding(raster, max_x_h))
        end if
        call add_colorbar_space_raster(subplot, raster, dec_right, dec_bottom)

        dec_top = 0.0_wp
        if (len_trim(title) > 0) then
            ! Title baseline sits pt2px(TITLE_PAD_PT) above the top spine
            ! (matplotlib axes.titlepad); the glyphs rise title_h above it.
            dec_top = pt2px(TITLE_PAD_PT, raster%dpi) + real(title_h, wp)
        end if
        dec_top = max(dec_top, top_y_half_h)
    end subroutine estimate_subplot_decorations_raster

    subroutine estimate_subplot_decorations_pdf(subplot, xscale, yscale, &
                                                symlog_threshold, dec_left, dec_right, &
                                                dec_bottom, dec_top)
        type(subplot_data_t), intent(in) :: subplot
        character(len=*), intent(in) :: xscale, yscale
        real(wp), intent(in) :: symlog_threshold
        real(wp), intent(out) :: dec_left, dec_right, dec_bottom, dec_top

        integer :: n_x, n_y, i
        character(len=50) :: x_labels(MAX_TICKS), y_labels(MAX_TICKS)
        real(wp) :: max_y_w, max_x_h, last_x_half_w
        real(wp) :: xlabel_h, title_h
        character(len=:), allocatable :: title, xlabel, ylabel

        real(wp), parameter :: X_TICK_GAP = 15.0_wp
        real(wp), parameter :: Y_TICK_GAP = 1.0_wp
        real(wp), parameter :: TITLE_GAP = 6.0_wp
        real(wp), parameter :: YLABEL_PAD = 1.0_wp
        real(wp), parameter :: LABEL_THICKNESS = 1.2_wp*PDF_LABEL_SIZE
        real(wp) :: vxmin, vxmax, vymin, vymax

        call panel_view_limits(subplot, xscale, yscale, symlog_threshold, &
                               vxmin, vxmax, vymin, vymax)
        max_y_w = 0.0_wp
        max_x_h = 0.0_wp
        last_x_half_w = 0.0_wp

        call panel_tick_labels(subplot%custom_xtick_positions, &
            subplot%custom_xtick_labels, xscale, vxmin, vxmax, &
            symlog_threshold, x_labels, n_x)
        call panel_tick_labels(subplot%custom_ytick_positions, &
            subplot%custom_ytick_labels, yscale, vymin, vymax, &
            symlog_threshold, y_labels, n_y)
        if (n_x > 0) then
            do i = 1, n_x
                max_x_h = max(max_x_h, PDF_TICK_LABEL_SIZE*1.2_wp)
            end do
            last_x_half_w = 0.5_wp*estimate_pdf_text_width(trim(x_labels(n_x)), &
                                                          PDF_TICK_LABEL_SIZE)
        end if
        if (n_y > 0) then
            do i = 1, n_y
                max_y_w = max(max_y_w, estimate_pdf_text_width(trim(y_labels(i)), &
                                                               PDF_TICK_LABEL_SIZE))
            end do
        end if

        title = ''
        xlabel = ''
        ylabel = ''
        if (allocated(subplot%title)) title = subplot%title
        if (allocated(subplot%xlabel)) xlabel = subplot%xlabel
        if (allocated(subplot%ylabel)) ylabel = subplot%ylabel

        xlabel_h = 0.0_wp
        if (len_trim(xlabel) > 0) xlabel_h = PDF_LABEL_SIZE*1.2_wp

        title_h = 0.0_wp
        if (len_trim(title) > 0) title_h = PDF_TITLE_SIZE*1.2_wp

        dec_left = Y_TICK_GAP + max_y_w
        if (len_trim(ylabel) > 0) then
            dec_left = dec_left + YLABEL_PAD + LABEL_THICKNESS
        end if
        dec_right = last_x_half_w

        dec_bottom = X_TICK_GAP + max_x_h
        if (len_trim(xlabel) > 0) then
            dec_bottom = max(dec_bottom, real(XLABEL_VERTICAL_OFFSET, wp) + xlabel_h)
        end if
        call add_colorbar_space_pdf(subplot, dec_right, dec_bottom)

        dec_top = 0.0_wp
        if (len_trim(title) > 0) then
            dec_top = TITLE_GAP + title_h
        end if
        ! The top y tick label is centred on the upper spine.
        if (n_y > 0) dec_top = max(dec_top, 0.6_wp*PDF_TICK_LABEL_SIZE)
    end subroutine estimate_subplot_decorations_pdf

    integer function measure_raster_width(text) result(w)
        character(len=*), intent(in) :: text
        character(len=500) :: processed
        integer :: plen, mlen
        character(len=600) :: math_ready
        character(len=600) :: escaped

        if (len_trim(text) == 0) then
            w = 0
            return
        end if

        call process_latex_in_text(trim(text), processed, plen)
        call prepare_mathtext_if_needed(processed(1:plen), math_ready, mlen)
        call escape_unicode_for_raster(math_ready(1:mlen), escaped)
        w = calculate_text_width(trim(escaped))
    end function measure_raster_width

    integer function measure_raster_height(text) result(h)
        character(len=*), intent(in) :: text
        character(len=500) :: processed
        integer :: plen, mlen
        character(len=600) :: math_ready
        character(len=600) :: escaped

        if (len_trim(text) == 0) then
            h = 0
            return
        end if

        call process_latex_in_text(trim(text), processed, plen)
        call prepare_mathtext_if_needed(processed(1:plen), math_ready, mlen)
        call escape_unicode_for_raster(math_ready(1:mlen), escaped)
        h = calculate_text_height(trim(escaped))
        if (h <= 0) h = 12
    end function measure_raster_height

    subroutine panel_tick_labels(positions, custom_labels, scale, lo, hi, &
            threshold, labels, n)
        real(wp), intent(in), optional :: positions(:)
        character(len=*), intent(in), optional :: custom_labels(:)
        character(len=*), intent(in) :: scale
        real(wp), intent(in) :: lo, hi, threshold
        character(len=*), intent(out) :: labels(:)
        integer, intent(out) :: n
        real(wp) :: ticks(MAX_TICKS)
        integer :: i, decimals

        call compute_scale_ticks(scale, lo, hi, threshold, ticks, n)
        if (present(positions)) then
            n = min(size(labels), size(positions))
            ticks(:n) = positions(:n)
        end if
        decimals = 0
        if (trim(scale) == 'linear' .and. n >= 2) &
            decimals = determine_decimals_from_ticks(ticks, n)
        do i = 1, n
            if (trim(scale) == 'linear') then
                labels(i) = format_tick_value_consistent(ticks(i), decimals)
            else
                labels(i) = format_tick_label(ticks(i), scale, data_min=lo, data_max=hi)
            end if
            if (present(custom_labels)) then
                if (i <= size(custom_labels)) labels(i) = custom_labels(i)
            end if
        end do
    end subroutine panel_tick_labels

    subroutine panel_view_limits(subplot, xscale, yscale, symlog_threshold, &
                                 vxmin, vxmax, vymin, vymax)
        !! View limits the panel renderer will use: explicit xlim/ylim when set,
        !! otherwise the autoscaled extent of every artist in the panel (meshes,
        !! fills and markers included), so tick labels are estimated for the
        !! axes that are actually drawn.
        type(subplot_data_t), intent(in) :: subplot
        character(len=*), intent(in) :: xscale, yscale
        real(wp), intent(in) :: symlog_threshold
        real(wp), intent(out) :: vxmin, vxmax, vymin, vymax
        real(wp) :: txmin, txmax, tymin, tymax

        vxmin = 0.0_wp; vxmax = 1.0_wp; vymin = 0.0_wp; vymax = 1.0_wp
        if (subplot%xlim_set) then
            vxmin = subplot%x_min; vxmax = subplot%x_max
        end if
        if (subplot%ylim_set) then
            vymin = subplot%y_min; vymax = subplot%y_max
        end if
        if (.not. allocated(subplot%plots)) return
        call calculate_figure_data_ranges(subplot%plots, subplot%plot_count, &
                                          subplot%xlim_set, subplot%ylim_set, &
                                          vxmin, vxmax, vymin, vymax, &
                                          txmin, txmax, tymin, tymax, &
                                          xscale, yscale, symlog_threshold)
    end subroutine panel_view_limits

    subroutine add_colorbar_space_raster(subplot, raster, dec_right, dec_bottom)
        !! Room for a panel colorbar's tick labels and label (pixels). The bar
        !! sits inside the panel box; its labels extend beyond it.
        type(subplot_data_t), intent(in) :: subplot
        type(raster_image_t), intent(in) :: raster
        real(wp), intent(inout) :: dec_right, dec_bottom
        character(len=50) :: labels(40)
        character(len=:), allocatable :: label
        character(len=10) :: loc
        logical :: active
        integer :: n, i, tick_w, tick_h
        real(wp) :: fs, extent

        call panel_colorbar_tick_labels(subplot, active, loc, labels, n, label, fs)
        if (.not. active) return
        tick_w = 0
        tick_h = 0
        do i = 1, n
            tick_w = max(tick_w, measure_raster_width(trim(labels(i))))
            tick_h = max(tick_h, measure_raster_height(trim(labels(i))))
        end do
        if (trim(loc) == 'right') then
            extent = real(CBAR_TICK_GAP_PX + tick_w, wp)
            ! The rotated label is centred 4 font heights right of the bar.
            if (len_trim(label) > 0) extent = max(extent, &
                4.6_wp*pt2px(fs, raster%dpi) + 2.0_wp)
            dec_right = max(dec_right, extent)
        else if (trim(loc) == 'bottom') then
            extent = real(CBAR_TICK_GAP_PX + tick_h, wp)
            if (len_trim(label) > 0) extent = extent + &
                real(measure_raster_height(label), wp)
            dec_bottom = dec_bottom + extent
        end if
    end subroutine add_colorbar_space_raster

    subroutine add_colorbar_space_pdf(subplot, dec_right, dec_bottom)
        !! PDF counterpart of add_colorbar_space_raster (points).
        type(subplot_data_t), intent(in) :: subplot
        real(wp), intent(inout) :: dec_right, dec_bottom
        character(len=50) :: labels(40)
        character(len=:), allocatable :: label
        character(len=10) :: loc
        logical :: active
        integer :: n, i
        real(wp) :: fs, tick_w, extent

        call panel_colorbar_tick_labels(subplot, active, loc, labels, n, label, fs)
        if (.not. active) return
        tick_w = 0.0_wp
        do i = 1, n
            tick_w = max(tick_w, estimate_pdf_text_width(trim(labels(i)), &
                                                         PDF_TICK_LABEL_SIZE))
        end do
        if (trim(loc) == 'right') then
            extent = real(CBAR_TICK_GAP_PX, wp) + tick_w
            if (len_trim(label) > 0) extent = max(extent, 4.6_wp*fs + 2.0_wp)
            dec_right = max(dec_right, extent)
        else if (trim(loc) == 'bottom') then
            extent = real(CBAR_TICK_GAP_PX, wp) + 1.2_wp*PDF_TICK_LABEL_SIZE
            if (len_trim(label) > 0) extent = extent + 1.2_wp*fs
            dec_bottom = dec_bottom + extent
        end if
    end subroutine add_colorbar_space_pdf

end module fortplot_subplot_layout