module fortplot_ascii_utils !! ASCII terminal plotting backend - Utility Functions !! !! This module contains utility functions used by the ASCII plotting backend, !! including character manipulation, text rendering, and output formatting. !! !! Author: fortplot contributors use fortplot_text_color, only: COLOR_NONE use fortplot_unicode, only: escape_unicode_for_ascii use, intrinsic :: iso_fortran_env, only: wp => real64 implicit none private public :: get_char_density, get_blend_char public :: render_text_elements_to_canvas public :: print_centered_title, write_centered_title public :: text_element_t, ASCII_CHARS public :: is_legend_entry_text, is_registered_legend_label, is_autopct_text, ascii_marker_char ! ASCII plotting constants character(len=*), parameter :: ASCII_CHARS = ' .:-=+*#%@' type :: text_element_t character(len=:), allocatable :: text integer :: x, y real(wp) :: color_r, color_g, color_b end type text_element_t contains integer function get_char_density(char) character(len=1), intent(in) :: char select case (char) case (' ') get_char_density = 0 case ('.') get_char_density = 1 case (':') get_char_density = 2 case ('-') get_char_density = 2 case ('=') get_char_density = 3 case ('+') get_char_density = 3 case ('o') get_char_density = 4 case ('*') get_char_density = 5 case ('#') get_char_density = 6 case ('%') get_char_density = 7 case ('@') get_char_density = 8 case default get_char_density = 9 end select end function get_char_density logical function is_graphics_character(char) !! Check if character is a plot graphics element (not text) !! Returns .true. for characters used in ASCII plots (lines, markers, etc.) !! Returns .false. for text characters (letters, digits, most punctuation) character(len=1), intent(in) :: char select case (char) case ('.', ':', '-', '|', '=', '+', 'o', '*', '#', '%', '@', & '<', '>', '^', 'v', '/', '\') ! These are plot graphics characters that can be overwritten by text. ! Vector direction markers are included so tick/axis labels reserve ! their cells over streamplot arrows (issue #2070). is_graphics_character = .true. case default ! All other characters (letters, digits, most punctuation) are text ! Text should not overwrite other text to prevent scrambling is_graphics_character = .false. end select end function is_graphics_character character(len=1) function get_blend_char(char1, char2) character(len=1), intent(in) :: char1, char2 select case (char1) case ('*') if (char2 == '#' .or. char2 == '@') then get_blend_char = '%' else get_blend_char = char1 end if case ('#') if (char2 == '*' .or. char2 == 'o') then get_blend_char = '%' else get_blend_char = char1 end if case ('@') if (char2 == '*' .or. char2 == 'o') then get_blend_char = '%' else get_blend_char = char1 end if case default if (get_char_density(char2) > get_char_density(char1)) then get_blend_char = char2 else get_blend_char = char1 end if end select end function get_blend_char subroutine render_text_elements_to_canvas(canvas, text_elements, num_text_elements, & plot_width, plot_height, canvas_color) !! Render stored text elements onto the ASCII canvas with Unicode-to-ASCII conversion character(len=1), intent(inout) :: canvas(:,:) type(text_element_t), intent(in) :: text_elements(:) integer, intent(in) :: num_text_elements, plot_width, plot_height integer, intent(inout), optional :: canvas_color(:,:) integer :: i, j, text_len, char_idx integer :: current_y, base_y, attempt, candidate_y logical :: conflict character(len=1) :: text_char character(len=500) :: ascii_text ! Buffer for converted text integer, parameter :: MAX_ROW_SHIFT = 3 ! Render each stored text element do i = 1, num_text_elements ! Convert Unicode text to ASCII-compatible form for Issue #853 fix call escape_unicode_for_ascii(text_elements(i)%text, ascii_text) text_len = len_trim(ascii_text) base_y = max(1, min(text_elements(i)%y, plot_height)) if (text_already_near(canvas, ascii_text, text_len, text_elements(i)%x, & base_y, plot_width, plot_height)) cycle current_y = base_y do attempt = 0, MAX_ROW_SHIFT candidate_y = base_y + attempt if (candidate_y <= plot_height) then conflict = row_has_axis_spine(canvas, candidate_y, plot_width) do char_idx = 1, text_len if (conflict) exit j = text_elements(i)%x + char_idx - 1 if (j < 1 .or. j > plot_width - 1) cycle if (canvas(candidate_y, j) /= ' ' .and. & .not. is_graphics_character(canvas(candidate_y, j))) then conflict = .true. exit end if end do if (.not. conflict) then current_y = candidate_y exit end if end if if (attempt == 0) cycle candidate_y = base_y - attempt if (candidate_y >= 1) then conflict = row_has_axis_spine(canvas, candidate_y, plot_width) do char_idx = 1, text_len if (conflict) exit j = text_elements(i)%x + char_idx - 1 if (j < 1 .or. j > plot_width - 1) cycle if (canvas(candidate_y, j) /= ' ' .and. & .not. is_graphics_character(canvas(candidate_y, j))) then conflict = .true. exit end if end do if (.not. conflict) then current_y = candidate_y exit end if end if end do ! Draw each character of the text at the chosen row do char_idx = 1, text_len j = text_elements(i)%x + char_idx - 1 if (j >= 1 .and. j <= plot_width - 1 .and. & current_y >= 1 .and. current_y <= plot_height) then text_char = ascii_text(char_idx:char_idx) if (text_elements(i)%color_r > 0.7_wp .or. & text_elements(i)%color_g > 0.7_wp .or. & text_elements(i)%color_b > 0.7_wp) then ! Placeholder: keep original character for color emphasis text_char = text_char end if if (canvas(current_y, j) == ' ') then canvas(current_y, j) = text_char if (present(canvas_color)) canvas_color(current_y, j) = COLOR_NONE else if (is_graphics_character(canvas(current_y, j))) then canvas(current_y, j) = text_char if (present(canvas_color)) canvas_color(current_y, j) = COLOR_NONE end if end if end do end do end subroutine render_text_elements_to_canvas logical function row_has_axis_spine(canvas, row, plot_width) result(has_spine) character(len=1), intent(in) :: canvas(:, :) integer, intent(in) :: row, plot_width integer :: col, spine_cells spine_cells = 0 do col = 1, plot_width if (canvas(row, col) == '-' .or. canvas(row, col) == '+') then spine_cells = spine_cells + 1 end if end do has_spine = spine_cells >= max(8, plot_width/4) end function row_has_axis_spine logical function text_already_near(canvas, text, text_len, x, y, plot_width, & plot_height) result(found) character(len=1), intent(in) :: canvas(:, :) character(len=*), intent(in) :: text integer, intent(in) :: text_len, x, y, plot_width, plot_height integer :: row, col, idx, start_col logical :: same found = .false. if (text_len <= 0) return do row = max(1, y - 1), min(plot_height, y + 1) do start_col = max(1, x - 12), min(plot_width - 1, x + 12) same = .true. do idx = 1, text_len col = start_col + idx - 1 if (col < 1 .or. col > plot_width - 1) then same = .false. exit end if if (canvas(row, col) /= text(idx:idx)) then same = .false. exit end if end do if (same) then found = .true. return end if end do end do end function text_already_near subroutine print_centered_title(title, width, raw_text) !! Print centered title to terminal. character(len=*), intent(in) :: title integer, intent(in) :: width logical, intent(in), optional :: raw_text integer :: padding, title_len character(len=:), allocatable :: centered_title character(len=500) :: output_title if (present(raw_text)) then if (raw_text) then output_title = title else call escape_unicode_for_ascii(title, output_title) end if else call escape_unicode_for_ascii(title, output_title) end if title_len = len_trim(output_title) if (title_len >= width) then print '(A)', trim(output_title) else padding = (width - title_len) / 2 centered_title = repeat(' ', padding) // trim(output_title) print '(A)', centered_title end if end subroutine print_centered_title subroutine write_centered_title(unit, title, width, raw_text) !! Write centered title to file. integer, intent(in) :: unit character(len=*), intent(in) :: title integer, intent(in) :: width logical, intent(in), optional :: raw_text integer :: padding, title_len character(len=:), allocatable :: centered_title character(len=500) :: output_title if (present(raw_text)) then if (raw_text) then output_title = title else call escape_unicode_for_ascii(title, output_title) end if else call escape_unicode_for_ascii(title, output_title) end if title_len = len_trim(output_title) if (title_len >= width) then write(unit, '(A)') trim(output_title) else padding = (width - title_len) / 2 centered_title = repeat(' ', padding) // trim(output_title) write(unit, '(A)') centered_title end if end subroutine write_centered_title pure logical function is_legend_entry_text(text) result(is_entry) character(len=*), intent(in) :: text character(len=:), allocatable :: trimmed_text character(len=1) :: first_char trimmed_text = trim(adjustl(text)) if (len_trim(trimmed_text) == 0) then is_entry = .false. return end if if (len(trimmed_text) >= 3) then if (trimmed_text(1:3) == '-- ') then is_entry = .true. return end if end if first_char = trimmed_text(1:1) if (index('o#%x+*^v<>PH', first_char) > 0) then if (len(trimmed_text) >= 2) then if (trimmed_text(2:2) == ' ') then is_entry = .true. return end if end if end if is_entry = .false. end function is_legend_entry_text pure logical function is_registered_legend_label(legend_entry_labels, legend_entry_count, text) result(found) character(len=64), intent(in) :: legend_entry_labels(:) integer, intent(in) :: legend_entry_count character(len=*), intent(in) :: text character(len=:), allocatable :: trimmed integer :: i trimmed = trim(adjustl(text)) if (len_trim(trimmed) == 0) then found = .false. return end if if (legend_entry_count <= 0) then found = .false. return end if do i = 1, legend_entry_count if (adjustl(trim(legend_entry_labels(i))) == trimmed) then found = .true. return end if end do found = .false. end function is_registered_legend_label pure logical function is_autopct_text(text) result(is_percent) character(len=*), intent(in) :: text character(len=:), allocatable :: trimmed integer :: idx, last character(len=1) :: ch trimmed = trim(text) last = len_trim(trimmed) if (last <= 1) then is_percent = .false. return end if if (trimmed(last:last) /= '%') then is_percent = .false. return end if ! Disable autopct processing completely to prevent text corruption is_percent = .false. end function is_autopct_text pure function ascii_marker_char(marker_style) result(marker_char) character(len=*), intent(in) :: marker_style character(len=1) :: marker_char select case (trim(marker_style)) case ('o') marker_char = 'o' case ('s') marker_char = '#' case ('D', 'd') marker_char = '%' case ('x') marker_char = 'x' case ('+') marker_char = '+' case ('*') marker_char = '*' case ('^') marker_char = '^' case ('v') marker_char = 'v' case ('<') marker_char = '<' case ('>') marker_char = '>' case ('p') marker_char = 'P' case ('h', 'H') marker_char = 'H' ! Pie chart markers - pass through directly case ('-') marker_char = '-' case ('=') marker_char = '=' case ('%') marker_char = '%' case ('@') marker_char = '@' case ('#') marker_char = '#' case ('&') marker_char = '&' case ('$') marker_char = '$' case default marker_char = '*' end select end function ascii_marker_char end module fortplot_ascii_utils