fortplot_ascii_rendering.f90 Source File


Source Code

module fortplot_ascii_rendering
    !! ASCII terminal plotting backend - Core Rendering Logic
    !!
    !! This module contains the core rendering functionality for ASCII plotting,
    !! including canvas output, file writing, and terminal display.
    !!
    !! Author: fortplot contributors
    
    use fortplot_logging, only: log_info, log_error
    use fortplot_ascii_utils, only: text_element_t, render_text_elements_to_canvas
    use fortplot_ascii_utils, only: print_centered_title, write_centered_title
    use fortplot_ascii_axis_policy, only: put_cell, LAYER_DATA
    use fortplot_ascii_drawing, only: text_charset_t, unicode_glyphs, &
                                      normalize_text_charset, charset_is_unicode, &
                                      text_frame_line, translate_text_cell
    use fortplot_unicode, only: escape_unicode_for_ascii
    use fortplot_braille, only: braille_char
    use fortplot_text_color, only: resolve_text_color_mode_file, &
                                   resolve_text_color_mode_terminal, &
                                   sgr_foreground, sgr_reset, unpack_rgb, COLOR_NONE
    use, intrinsic :: iso_fortran_env, only: wp => real64
    implicit none

    private
    public :: output_to_terminal, output_to_file
    public :: ascii_finalize, ascii_get_output
    public :: braille_finalize, braille_output_to_file
    
contains

    subroutine ascii_finalize(canvas, text_elements, num_text_elements, &
                             arrow_elements, num_arrow_elements, &
                             plot_width, plot_height, title_text, xlabel_text, ylabel_text, &
                             legend_lines, num_legend_lines, filename, text_charset, &
                             color_mode, canvas_color)
        character(len=1), intent(inout) :: canvas(:,:)
        type(text_element_t), intent(inout) :: text_elements(:)
        integer, intent(in) :: num_text_elements
        type(text_element_t), intent(inout) :: arrow_elements(:)
        integer, intent(in) :: num_arrow_elements
        integer, intent(in) :: plot_width, plot_height
        character(len=:), allocatable, intent(in) :: title_text, xlabel_text, ylabel_text
        character(len=*), intent(in) :: legend_lines(:)
        integer, intent(in) :: num_legend_lines
        character(len=*), intent(in) :: filename
        character(len=*), intent(in), optional :: text_charset
        !! Raw color mode ('never'..'auto'); resolved per destination here so
        !! file saves stay reproducible and terminal output honors 'auto'.
        character(len=*), intent(in), optional :: color_mode
        integer, intent(inout), optional :: canvas_color(:,:)

        integer :: unit, ios
        character(len=512) :: error_msg
        character(len=:), allocatable :: mode, raw_color, term_color, file_color

        mode = 'ascii'
        if (present(text_charset)) mode = normalize_text_charset(text_charset)

        raw_color = 'never'
        if (present(color_mode)) raw_color = trim(color_mode)
        term_color = resolve_text_color_mode_terminal(raw_color)
        file_color = resolve_text_color_mode_file(raw_color)

        if (len_trim(filename) == 0 .or. trim(filename) == "terminal") then
            call output_to_terminal(canvas, text_elements, num_text_elements, &
                                  arrow_elements, num_arrow_elements, &
                                  plot_width, plot_height, title_text, xlabel_text, ylabel_text, &
                                  legend_lines, num_legend_lines, mode, &
                                  term_color, canvas_color)
        else
            open(newunit=unit, file=filename, status='replace', iostat=ios, iomsg=error_msg)

            if (ios /= 0) then
                call log_error("Cannot save ASCII file '" // trim(filename) // "': " // trim(error_msg))
                ! Fall back to terminal output
                call log_info("Falling back to terminal output due to file error")
                call output_to_terminal(canvas, text_elements, num_text_elements, &
                                      arrow_elements, num_arrow_elements, &
                                      plot_width, plot_height, title_text, xlabel_text, ylabel_text, &
                                      legend_lines, num_legend_lines, mode, &
                                      term_color, canvas_color)
                return
            end if

            call output_to_file(canvas, text_elements, num_text_elements, &
                              arrow_elements, num_arrow_elements, &
                              plot_width, plot_height, title_text, xlabel_text, ylabel_text, &
                              legend_lines, num_legend_lines, unit, mode, &
                              file_color, canvas_color)
            close(unit)
            call log_info("Unicode plot saved to '" // trim(filename) // "'")
        end if
    end subroutine ascii_finalize
    
    subroutine output_to_terminal(canvas, text_elements, num_text_elements, &
                                 arrow_elements, num_arrow_elements, &
                                 plot_width, plot_height, title_text, xlabel_text, ylabel_text, &
                                 legend_lines, num_legend_lines, text_charset, &
                                 color_mode, canvas_color)
        character(len=1), intent(inout) :: canvas(:,:)
        type(text_element_t), intent(in) :: text_elements(:)
        integer, intent(in) :: num_text_elements
        type(text_element_t), intent(in) :: arrow_elements(:)
        integer, intent(in) :: num_arrow_elements
        integer, intent(in) :: plot_width, plot_height
        character(len=:), allocatable, intent(in) :: title_text, xlabel_text, ylabel_text
        character(len=*), intent(in) :: legend_lines(:)
        integer, intent(in) :: num_legend_lines
        character(len=*), intent(in), optional :: text_charset
        !! Resolved color mode ('never' disables all escapes) and the per-cell
        !! packed-RGB buffer used to wrap colored data glyphs in SGR spans.
        character(len=*), intent(in), optional :: color_mode
        integer, intent(inout), optional :: canvas_color(:,:)
        integer :: i, j, legend_idx
        logical :: unicode_mode, use_color
        character(len=:), allocatable :: resolved_color
        type(text_charset_t) :: glyphs

        unicode_mode = .false.
        if (present(text_charset)) unicode_mode = charset_is_unicode(text_charset)
        call resolve_output_color(color_mode, canvas_color, resolved_color, use_color)

        ! Render text elements to canvas before output
        call render_text_elements_to_canvas(canvas, text_elements, &
                                            num_text_elements, &
                                            plot_width, plot_height, canvas_color)
        call render_overlay_elements_to_canvas(canvas, arrow_elements, &
                                               num_arrow_elements, &
                                               plot_width, plot_height)

        if (allocated(title_text)) then
            print '(A)', ''  ! Empty line before title
            call print_centered_title(title_text, plot_width, raw_text=unicode_mode)
        end if

        if (unicode_mode) then
            glyphs = unicode_glyphs()
            print '(A)', text_frame_line(plot_width, glyphs, .true.)
            if (use_color) then
                do i = 1, plot_height
                    print '(A)', build_unicode_color_row(canvas(i, :), &
                        canvas_color(i, :), plot_width, glyphs, resolved_color)
                end do
            else
                do i = 1, plot_height
                    print '(A)', build_unicode_row(canvas(i, :), plot_width, glyphs)
                end do
            end if
            print '(A)', text_frame_line(plot_width, glyphs, .false.)
        else
            print '(A)', '+' // repeat('-', plot_width) // '+'
            if (use_color) then
                do i = 1, plot_height
                    print '(A)', build_ascii_color_row(canvas(i, :), &
                        canvas_color(i, :), plot_width, resolved_color)
                end do
            else
                block
                    character(len=:), allocatable :: row_buffer
                    allocate(character(len=plot_width + 2) :: row_buffer)
                    do i = 1, plot_height
                        row_buffer(1:1) = '|'
                        do j = 1, plot_width
                            row_buffer(j + 1:j + 1) = canvas(i, j)
                        end do
                        row_buffer(plot_width + 2:plot_width + 2) = '|'
                        print '(A)', row_buffer
                    end do
                end block
            end if
            print '(A)', '+' // repeat('-', plot_width) // '+'
        end if

        ! Print xlabel below the plot if present
        if (allocated(xlabel_text)) then
            call print_centered_title(xlabel_text, plot_width, raw_text=unicode_mode)
        end if

        ! Print ylabel with Unicode-to-ASCII conversion
        if (allocated(ylabel_text)) then
            if (unicode_mode) then
                print '(A)', trim(ylabel_text)
            else
                block
                    character(len=500) :: ascii_ylabel
                    call escape_unicode_for_ascii(ylabel_text, ascii_ylabel)
                    print '(A)', trim(ascii_ylabel)
                end block
            end if
        end if

        if (num_legend_lines > 0) then
            print '(A)', ''
            do legend_idx = 1, min(num_legend_lines, size(legend_lines))
                print '(A)', trim(legend_lines(legend_idx))
            end do
        end if
    end subroutine output_to_terminal

    subroutine output_to_file(canvas, text_elements, num_text_elements, &
                            arrow_elements, num_arrow_elements, &
                            plot_width, plot_height, title_text, xlabel_text, ylabel_text, &
                            legend_lines, num_legend_lines, unit, text_charset, &
                            color_mode, canvas_color)
        character(len=1), intent(inout) :: canvas(:,:)
        type(text_element_t), intent(in) :: text_elements(:)
        integer, intent(in) :: num_text_elements
        type(text_element_t), intent(in) :: arrow_elements(:)
        integer, intent(in) :: num_arrow_elements
        integer, intent(in) :: plot_width, plot_height
        character(len=:), allocatable, intent(in) :: title_text, xlabel_text, ylabel_text
        character(len=*), intent(in) :: legend_lines(:)
        integer, intent(in) :: num_legend_lines
        integer, intent(in) :: unit
        character(len=*), intent(in), optional :: text_charset
        !! Resolved color mode ('never' disables all escapes) and the per-cell
        !! packed-RGB buffer used to wrap colored data glyphs in SGR spans.
        character(len=*), intent(in), optional :: color_mode
        integer, intent(inout), optional :: canvas_color(:,:)
        integer :: i, j, legend_idx
        character(len=:), allocatable :: row_buffer, resolved_color
        logical :: unicode_mode, use_color
        type(text_charset_t) :: glyphs

        unicode_mode = .false.
        if (present(text_charset)) unicode_mode = charset_is_unicode(text_charset)
        call resolve_output_color(color_mode, canvas_color, resolved_color, use_color)

        ! Render text elements to canvas before output
        call render_text_elements_to_canvas(canvas, text_elements, &
                                            num_text_elements, &
                                            plot_width, plot_height, canvas_color)
        call render_overlay_elements_to_canvas(canvas, arrow_elements, &
                                               num_arrow_elements, &
                                               plot_width, plot_height)

        if (allocated(title_text)) then
            write(unit, '(A)') ''  ! Empty line before title
            call write_centered_title(unit, title_text, plot_width, raw_text=unicode_mode)
        end if

        if (unicode_mode) then
            glyphs = unicode_glyphs()
            write(unit, '(A)') text_frame_line(plot_width, glyphs, .true.)
            if (use_color) then
                do i = 1, plot_height
                    write(unit, '(A)') build_unicode_color_row(canvas(i, :), &
                        canvas_color(i, :), plot_width, glyphs, resolved_color)
                end do
            else
                do i = 1, plot_height
                    write(unit, '(A)') build_unicode_row(canvas(i, :), plot_width, glyphs)
                end do
            end if
            write(unit, '(A)') text_frame_line(plot_width, glyphs, .false.)
        else
            write(unit, '(A)') '+' // repeat('-', plot_width) // '+'
            if (use_color) then
                do i = 1, plot_height
                    write(unit, '(A)') build_ascii_color_row(canvas(i, :), &
                        canvas_color(i, :), plot_width, resolved_color)
                end do
            else
                ! Buffer each row in a single string before writing. Per-character
                ! `advance='no'` writes are pathologically slow when streaming many
                ! frames (e.g. ASCII animation save), so build the row first.
                allocate(character(len=plot_width + 2) :: row_buffer)
                do i = 1, plot_height
                    row_buffer(1:1) = '|'
                    do j = 1, plot_width
                        row_buffer(j + 1:j + 1) = canvas(i, j)
                    end do
                    row_buffer(plot_width + 2:plot_width + 2) = '|'
                    write(unit, '(A)') row_buffer
                end do
            end if
            write(unit, '(A)') '+' // repeat('-', plot_width) // '+'
        end if

        ! Write xlabel below the plot if present
        if (allocated(xlabel_text)) then
            call write_centered_title(unit, xlabel_text, plot_width, raw_text=unicode_mode)
        end if

        ! Write ylabel with Unicode-to-ASCII conversion
        if (allocated(ylabel_text)) then
            if (unicode_mode) then
                write(unit, '(A)') trim(ylabel_text)
            else
                block
                    character(len=500) :: ascii_ylabel
                    call escape_unicode_for_ascii(ylabel_text, ascii_ylabel)
                    write(unit, '(A)') trim(ascii_ylabel)
                end block
            end if
        end if

        if (num_legend_lines > 0) then
            write(unit, '(A)') ''
            do legend_idx = 1, min(num_legend_lines, size(legend_lines))
                write(unit, '(A)') trim(legend_lines(legend_idx))
            end do
        end if
    end subroutine output_to_file

    function ascii_get_output(canvas, width, height) result(output)
        !! Get the complete ASCII canvas as a string
        character(len=1), intent(in) :: canvas(:,:)
        integer, intent(in) :: width, height
        character(len=:), allocatable :: output
        character(len=1000) :: line_buffer
        integer :: i, j, total_len, line_len
        
        ! Calculate total length needed
        total_len = height * (width + 1)  ! +1 for newline per row
        allocate(character(len=total_len) :: output)
        
        output = ""
        do i = 1, height
            line_buffer = ""
            do j = 1, width
                line_buffer(j:j) = canvas(i, j)
            end do
            line_len = len_trim(line_buffer(:width))
            if (line_len == 0) line_len = 1  ! Ensure at least one character
            output = output // line_buffer(1:line_len) // new_line('a')
        end do
    end function ascii_get_output

    subroutine resolve_output_color(color_mode, canvas_color, resolved_color, use_color)
        !! Decide whether the ASCII output path should emit ANSI color. Color is
        !! used only when a concrete (non-'never') mode is supplied together with
        !! a per-cell color buffer.
        character(len=*), intent(in), optional :: color_mode
        integer, intent(in), optional :: canvas_color(:,:)
        character(len=:), allocatable, intent(out) :: resolved_color
        logical, intent(out) :: use_color

        resolved_color = 'never'
        if (present(color_mode)) resolved_color = trim(color_mode)
        use_color = present(canvas_color) .and. resolved_color /= 'never'
    end subroutine resolve_output_color

    function build_ascii_color_row(canvas_row, color_row, plot_width, &
                                   color_mode) result(row)
        !! Assemble one bordered ASCII row, wrapping each colored data glyph in an
        !! SGR foreground span. A span is opened when the packed color changes and
        !! always reset before a new color, before a plain cell, and before the
        !! trailing border so every line ends reset (issue #2062).
        character(len=1), intent(in) :: canvas_row(:)
        integer, intent(in) :: color_row(:)
        integer, intent(in) :: plot_width
        character(len=*), intent(in) :: color_mode
        character(len=:), allocatable :: row
        integer :: j, active
        real(wp) :: r, g, b
        logical :: colored

        row = '|'
        active = COLOR_NONE
        do j = 1, plot_width
            colored = color_row(j) /= COLOR_NONE
            if (colored) colored = canvas_row(j) /= ' '
            if (colored) then
                if (color_row(j) /= active) then
                    if (active /= COLOR_NONE) row = row//sgr_reset()
                    call unpack_rgb(color_row(j), r, g, b)
                    row = row//sgr_foreground(color_mode, r, g, b)
                    active = color_row(j)
                end if
                row = row//canvas_row(j)
            else
                if (active /= COLOR_NONE) then
                    row = row//sgr_reset()
                    active = COLOR_NONE
                end if
                row = row//canvas_row(j)
            end if
        end do
        if (active /= COLOR_NONE) row = row//sgr_reset()
        row = row//'|'
    end function build_ascii_color_row

    function build_unicode_row(canvas_row, plot_width, glyphs) result(row)
        !! Assemble one bordered output row for the Unicode charset, translating
        !! each interior canvas byte through the glyph table. Multibyte glyphs
        !! force variable-length concatenation, so this path is kept separate
        !! from the fixed-width ASCII fast path.
        character(len=1), intent(in) :: canvas_row(:)
        integer, intent(in) :: plot_width
        type(text_charset_t), intent(in) :: glyphs
        character(len=:), allocatable :: row
        integer :: j

        row = glyphs%vline
        do j = 1, plot_width
            row = row//translate_text_cell(canvas_row(j), glyphs)
        end do
        row = row//glyphs%vline
    end function build_unicode_row

    function build_unicode_color_row(canvas_row, color_row, plot_width, &
                                     glyphs, color_mode) result(row)
        character(len=1), intent(in) :: canvas_row(:)
        integer, intent(in) :: color_row(:)
        integer, intent(in) :: plot_width
        type(text_charset_t), intent(in) :: glyphs
        character(len=*), intent(in) :: color_mode
        character(len=:), allocatable :: row
        integer :: j, active
        real(wp) :: r, g, b
        logical :: colored

        row = glyphs%vline
        active = COLOR_NONE
        do j = 1, plot_width
            colored = color_row(j) /= COLOR_NONE
            if (colored) colored = canvas_row(j) /= ' '
            if (colored) then
                if (color_row(j) /= active) then
                    if (active /= COLOR_NONE) row = row//sgr_reset()
                    call unpack_rgb(color_row(j), r, g, b)
                    row = row//sgr_foreground(color_mode, r, g, b)
                    active = color_row(j)
                end if
                row = row//translate_text_cell(canvas_row(j), glyphs)
            else
                if (active /= COLOR_NONE) then
                    row = row//sgr_reset()
                    active = COLOR_NONE
                end if
                row = row//translate_text_cell(canvas_row(j), glyphs)
            end if
        end do
        if (active /= COLOR_NONE) row = row//sgr_reset()
        row = row//glyphs%vline
    end function build_unicode_color_row

    subroutine render_overlay_elements_to_canvas(canvas, overlay_elements, num_overlay_elements, &
                                                 plot_width, plot_height)
        character(len=1), intent(inout) :: canvas(:,:)
        type(text_element_t), intent(in) :: overlay_elements(:)
        integer, intent(in) :: num_overlay_elements, plot_width, plot_height
        integer :: i, x, y
        character(len=500) :: ascii_text

        do i = 1, num_overlay_elements
            call escape_unicode_for_ascii(overlay_elements(i)%text, ascii_text)
            if (len_trim(ascii_text) == 0) cycle
            x = max(1, min(overlay_elements(i)%x, plot_width - 1))
            y = max(1, min(overlay_elements(i)%y, plot_height))
            ! Overlay arrows are plot data: keep them from clobbering axis,
            ! tick, or label glyphs that share the cell (issue #2069).
            call put_cell(canvas, y, x, ascii_text(1:1), LAYER_DATA)
        end do
    end subroutine render_overlay_elements_to_canvas

    subroutine braille_finalize(canvas, mask, text_elements, num_text_elements, &
                                arrow_elements, num_arrow_elements, &
                                plot_width, plot_height, &
                                title_text, xlabel_text, ylabel_text, &
                                legend_lines, num_legend_lines, filename)
        !! Braille counterpart of ascii_finalize: writes the composited braille
        !! canvas to a file, falling back to terminal output on error.
        character(len=1), intent(inout) :: canvas(:,:)
        integer, intent(in) :: mask(:,:)
        type(text_element_t), intent(inout) :: text_elements(:)
        integer, intent(in) :: num_text_elements
        type(text_element_t), intent(inout) :: arrow_elements(:)
        integer, intent(in) :: num_arrow_elements
        integer, intent(in) :: plot_width, plot_height
        character(len=:), allocatable, intent(in) :: title_text, xlabel_text, ylabel_text
        character(len=*), intent(in) :: legend_lines(:)
        integer, intent(in) :: num_legend_lines
        character(len=*), intent(in) :: filename

        integer :: unit, ios
        character(len=512) :: error_msg

        if (len_trim(filename) == 0 .or. trim(filename) == "terminal") then
            call braille_output_to_terminal(canvas, mask, text_elements, &
                num_text_elements, arrow_elements, num_arrow_elements, &
                plot_width, plot_height, title_text, xlabel_text, ylabel_text, &
                legend_lines, num_legend_lines)
            return
        end if

        open(newunit=unit, file=filename, status='replace', iostat=ios, iomsg=error_msg)
        if (ios /= 0) then
            call log_error("Cannot save braille file '" // trim(filename) // &
                           "': " // trim(error_msg))
            call log_info("Falling back to terminal output due to file error")
            call braille_output_to_terminal(canvas, mask, text_elements, &
                num_text_elements, arrow_elements, num_arrow_elements, &
                plot_width, plot_height, title_text, xlabel_text, ylabel_text, &
                legend_lines, num_legend_lines)
            return
        end if
        call braille_output_to_file(canvas, mask, text_elements, num_text_elements, &
            arrow_elements, num_arrow_elements, plot_width, plot_height, &
            title_text, xlabel_text, ylabel_text, legend_lines, num_legend_lines, unit)
        close(unit)
        call log_info("Braille plot saved to '" // trim(filename) // "'")
    end subroutine braille_finalize

    subroutine braille_output_to_file(canvas, mask, text_elements, num_text_elements, &
                                      arrow_elements, num_arrow_elements, &
                                      plot_width, plot_height, &
                                      title_text, xlabel_text, ylabel_text, &
                                      legend_lines, num_legend_lines, unit)
        !! Compose the ASCII text canvas and the braille subpixel mask into UTF-8
        !! rows: cells with a set braille mask emit the braille glyph, all other
        !! cells keep their ASCII character (frame, axes, ticks, labels).
        character(len=1), intent(inout) :: canvas(:,:)
        integer, intent(in) :: mask(:,:)
        type(text_element_t), intent(in) :: text_elements(:)
        integer, intent(in) :: num_text_elements
        type(text_element_t), intent(in) :: arrow_elements(:)
        integer, intent(in) :: num_arrow_elements
        integer, intent(in) :: plot_width, plot_height
        character(len=:), allocatable, intent(in) :: title_text, xlabel_text, ylabel_text
        character(len=*), intent(in) :: legend_lines(:)
        integer, intent(in) :: num_legend_lines
        integer, intent(in) :: unit
        integer :: i, legend_idx

        call render_text_elements_to_canvas(canvas, text_elements, &
                                            num_text_elements, plot_width, plot_height)
        call render_overlay_elements_to_canvas(canvas, arrow_elements, &
                                               num_arrow_elements, plot_width, plot_height)

        if (allocated(title_text)) then
            write(unit, '(A)') ''
            call write_centered_title(unit, title_text, plot_width)
        end if

        write(unit, '(A)') '+' // repeat('-', plot_width) // '+'
        do i = 1, plot_height
            write(unit, '(A)') '|' // braille_row_utf8(canvas, mask, i, plot_width) // '|'
        end do
        write(unit, '(A)') '+' // repeat('-', plot_width) // '+'

        if (allocated(xlabel_text)) then
            call write_centered_title(unit, xlabel_text, plot_width)
        end if
        if (allocated(ylabel_text)) then
            block
                character(len=500) :: ascii_ylabel
                call escape_unicode_for_ascii(ylabel_text, ascii_ylabel)
                write(unit, '(A)') trim(ascii_ylabel)
            end block
        end if

        if (num_legend_lines > 0) then
            write(unit, '(A)') ''
            do legend_idx = 1, min(num_legend_lines, size(legend_lines))
                write(unit, '(A)') trim(legend_lines(legend_idx))
            end do
        end if
    end subroutine braille_output_to_file

    subroutine braille_output_to_terminal(canvas, mask, text_elements, num_text_elements, &
                                          arrow_elements, num_arrow_elements, &
                                          plot_width, plot_height, &
                                          title_text, xlabel_text, ylabel_text, &
                                          legend_lines, num_legend_lines)
        character(len=1), intent(inout) :: canvas(:,:)
        integer, intent(in) :: mask(:,:)
        type(text_element_t), intent(in) :: text_elements(:)
        integer, intent(in) :: num_text_elements
        type(text_element_t), intent(in) :: arrow_elements(:)
        integer, intent(in) :: num_arrow_elements
        integer, intent(in) :: plot_width, plot_height
        character(len=:), allocatable, intent(in) :: title_text, xlabel_text, ylabel_text
        character(len=*), intent(in) :: legend_lines(:)
        integer, intent(in) :: num_legend_lines
        integer :: i, legend_idx

        call render_text_elements_to_canvas(canvas, text_elements, &
                                            num_text_elements, plot_width, plot_height)
        call render_overlay_elements_to_canvas(canvas, arrow_elements, &
                                               num_arrow_elements, plot_width, plot_height)

        if (allocated(title_text)) then
            print '(A)', ''
            call print_centered_title(title_text, plot_width)
        end if

        print '(A)', '+' // repeat('-', plot_width) // '+'
        do i = 1, plot_height
            print '(A)', '|' // braille_row_utf8(canvas, mask, i, plot_width) // '|'
        end do
        print '(A)', '+' // repeat('-', plot_width) // '+'

        if (allocated(xlabel_text)) then
            call print_centered_title(xlabel_text, plot_width)
        end if
        if (allocated(ylabel_text)) then
            block
                character(len=500) :: ascii_ylabel
                call escape_unicode_for_ascii(ylabel_text, ascii_ylabel)
                print '(A)', trim(ascii_ylabel)
            end block
        end if

        if (num_legend_lines > 0) then
            print '(A)', ''
            do legend_idx = 1, min(num_legend_lines, size(legend_lines))
                print '(A)', trim(legend_lines(legend_idx))
            end do
        end if
    end subroutine braille_output_to_terminal

    function braille_row_utf8(canvas, mask, row, plot_width) result(line)
        !! Build one UTF-8 output row: braille glyph where the mask is set,
        !! otherwise the ASCII canvas character for that cell.
        character(len=1), intent(in) :: canvas(:,:)
        integer, intent(in) :: mask(:,:)
        integer, intent(in) :: row, plot_width
        character(len=:), allocatable :: line
        integer :: j

        line = ''
        do j = 1, plot_width
            if (mask(row, j) /= 0) then
                line = line // braille_char(mask(row, j))
            else
                line = line // canvas(row, j)
            end if
        end do
    end function braille_row_utf8

end module fortplot_ascii_rendering