| 1 | # R Functions | |
| 2 | ||
| 3 | Import the files in this directory into the application, which include: | |
| 4 | ||
| 5 | * bootstrap.R | |
| 6 | * pluralize.R | |
| 7 | * possessive.R | |
| 8 | * conversion.R | |
| 9 | * csv.R | |
| 10 | ||
| 11 | # bootstrap.R | |
| 12 | ||
| 13 | Copy the contents of `bootstrap.R` into the R script preferences, shown in the | |
| 14 | following figure, then restart the application: | |
| 15 | ||
| 16 | #  | |
| 17 | ||
| 18 | Setting the **Working Directory** allows the startup script to load files | |
| 19 | using a relative to said directory. | |
| 20 | ||
| 21 | # pluralize.R | |
| 22 | ||
| 23 | This file defines a function that implements most of Damian Conway's [An Algorithmic Approach to English Pluralization](http://blob.perl.org/tpc/1998/User_Applications/Algorithmic%20Approach%20Plurals/Algorithmic_Plurals.html). | |
| 24 | ||
| 25 | ## Usage | |
| 26 | ||
| 27 | Example usages of the pluralize function include: | |
| 28 | ||
| 29 | `r#pluralize( "mouse" )` - mice | |
| 30 | `r#pluralize( "buzz" )` - buzzes | |
| 31 | `r#pluralize( "bus" )` - buses | |
| 32 | ||
| 33 | # possessive.R | |
| 34 | ||
| 35 | This file defines a function that applies possessives to English words. | |
| 36 | ||
| 37 | ## Usage | |
| 38 | ||
| 39 | Example usages of the possessive function include: | |
| 40 | ||
| 41 | `r#pos( "Ross" )` - Ross' | |
| 42 | `r#pos( "Ruby" )` - Ruby's | |
| 43 | `r#pos( "Lois" )` - Lois' | |
| 44 | `r#pos( "my" )` - mine | |
| 45 | `r#pos( "Your" )` - Yours | |
| 46 | ||
| 47 | 1 |
| 1 | # R Scripts | |
| 2 | ||
| 3 | These scripts illustrate how R can perform calculations using variables, to help automate repetitive tasks. Authors are free to write their own scripts. | |
| 4 | ||
| 5 | ## Configuration | |
| 6 | ||
| 7 | Configure the editor to use scripts as follows: | |
| 8 | ||
| 9 | 1. Copy the R scripts into same directory as your Markdown files. | |
| 10 | 1. Start the editor. | |
| 11 | 1. Click **Tools → R Script**. | |
| 12 | 1. Copy and paste the following: | |
| 13 | ||
| 14 | assign( 'anchor', as.Date( '$date.anchor$', format='%Y-%m-%d' ), envir = .GlobalEnv ); | |
| 15 | setwd( '$application.r.working.directory$' ); | |
| 16 | source( 'pluralize.R' ); | |
| 17 | source( 'csv.R' ); | |
| 18 | source( 'conversion.R' ); | |
| 19 | ||
| 20 | 1. Click **File → New** to create a new file. | |
| 21 | 1. Click **File → Save As** to set a filename. | |
| 22 | 1. Set **Name** to: `variables.yaml` | |
| 23 | 1. Click **OK**. | |
| 24 | 1. Paste the following definitions: | |
| 25 | ||
| 26 | date: | |
| 27 | anchor: 2017-01-01 | |
| 28 | editor: | |
| 29 | examples: | |
| 30 | season: 2017-09-02 | |
| 31 | math: | |
| 32 | x: 1 | |
| 33 | y: $editor.examples.math.x$ + 1 | |
| 34 | z: $editor.examples.math.y$ + 1 | |
| 35 | name: | |
| 36 | given: Josephene | |
| 37 | ||
| 38 | 1. Save and close the file. | |
| 39 | 1. Click **File → Open** | |
| 40 | 1. Change **Markdown Files** to **Definition Files**. | |
| 41 | 1. Select `variables.yaml`. | |
| 42 | 1. Click **Open**. | |
| 43 | ||
| 44 | R functionality is configured. | |
| 45 | ||
| 46 | ## Definitions | |
| 47 | ||
| 48 | The variables definitions within `variables.yaml` are available to R using the R syntax. An additional variable, `application.r.working.directory` is added to the list of variables. The value is set to the working directory of the file being edited. Hover the mouse cursor over the file tab in the editor to see the full path to the file. | |
| 49 | ||
| 50 | ## Examples | |
| 51 | ||
| 52 | This section demonstrates how to use the R functions when editing. Complete the following steps to begin: | |
| 53 | ||
| 54 | 1. Click **File → New** to create a new file. | |
| 55 | 1. Click **File → Save As** to set a filename. | |
| 56 | 1. Set **Name** to: `example.Rmd` | |
| 57 | 1. Click **OK**. | |
| 58 | ||
| 59 | The examples are ready for use within the editor. | |
| 60 | ||
| 61 | ### Arithmetic | |
| 62 | ||
| 63 | Type the following to perform a simple calculation: | |
| 64 | ||
| 65 | `r# 1+1` | |
| 66 | ||
| 67 | The preview pane shows `2.0`. | |
| 68 | ||
| 69 | ### Functions | |
| 70 | ||
| 71 | Call the [format](https://stat.ethz.ch/R-manual/R-devel/library/base/html/format.html) function to truncate unwanted decimal places as follows: | |
| 72 | ||
| 73 | `r# format(1+1,digits=1)` | |
| 74 | ||
| 75 | The preview pane shows `2`. | |
| 76 | ||
| 77 | ### Pluralize | |
| 78 | ||
| 79 | Many English words can be pluralized as follows: | |
| 80 | ||
| 81 | `r# pl('wolf',2)` | |
| 82 | ||
| 83 | The preview pane shows `wolves`. The `pluralize.R` file contains a partial implementation of Damian Conway's algorithmic approach to English pluralization. | |
| 84 | ||
| 85 | ### Chicago Manual of Style | |
| 86 | ||
| 87 | Apply the Chicago Manual of Style for words less than one-hundred as follows: | |
| 88 | ||
| 89 | `r# cms(1)` `r# cms(99)` `r# cms(101)` | |
| 90 | ||
| 91 | The preview pane shows numbers written out as `one` and `ninety-nine`, followed by the digits 101. | |
| 92 | ||
| 93 | ### Data Import | |
| 94 | ||
| 95 | Import and display information from a CSV file as follows: | |
| 96 | ||
| 97 | 1. Click **File → New** to create a new file. | |
| 98 | 1. Click **File → Save As** to rename the file. | |
| 99 | 1. Set the filename to: `data.csv` | |
| 100 | 1. Paste the following into `data.csv`: | |
| 101 | ||
| 102 | Animal,Quantity,Country | |
| 103 | Aardwolf,1,Africa | |
| 104 | Keel-billed toucan,1,Belize | |
| 105 | Beaver,2,Canada | |
| 106 | Mute swan,3,Denmark | |
| 107 | Lion,5,Ethiopia | |
| 108 | Brown bear,8,Finland | |
| 109 | Dolphin,13,Greece | |
| 110 | Turul,21,Hungary | |
| 111 | Gyrfalcon,34,Iceland | |
| 112 | Red-billed streamertail,55,Jamaica | |
| 113 | ||
| 114 | 1. Click the `example.Rmd` tab. | |
| 115 | 1. Type the following: | |
| 116 | ||
| 117 | `r# csv2md('data.csv',total=F)` | |
| 118 | ||
| 119 | 1. Type the following to calculate a total for all numeric columns: | |
| 120 | ||
| 121 | `r# csv2md('data.csv')` | |
| 122 | ||
| 123 | This imports the data from an external file and formats the information into a table, automatically. Update the data as follows: | |
| 124 | ||
| 125 | 1. Click the `data.csv` tab to edit the data. | |
| 126 | 1. Change the data by adding a new row. | |
| 127 | 1. Save the file. | |
| 128 | 1. Click the `example.Rmd` tab. | |
| 129 | ||
| 130 | The preview pane shows the revised contents. | |
| 131 | ||
| 132 | ### Elapsed Time | |
| 133 | ||
| 134 | The duration of a timeline, given in numbers of days, can be computed into English as follows: | |
| 135 | ||
| 136 | `r# elapsed(1,1)` | |
| 137 | ||
| 138 | The preview pane shows `same day`. Change the expression to: | |
| 139 | ||
| 140 | `r# elapsed(1,2)` | |
| 141 | ||
| 142 | The preview pane shows `one day`. Change the expression to: | |
| 143 | ||
| 144 | `r# elapsed(1,112358)` | |
| 145 | ||
| 146 | The preview pane shows `307 years, seven months, and sixteen days`, combined using the Chicago Manual of Style, the pluralization function, and a [serial comma](https://www.behance.net/gallery/19417363/The-Oxford-Comma). | |
| 147 | ||
| 148 | ### Variable Syntax | |
| 149 | ||
| 150 | The syntax for a variable changes when using an R Markdown file (denoted by the `.Rmd` filename extension), as opposed to a regular Markdown file (`.md`). Return to the example file and type the following: | |
| 151 | ||
| 152 | `r# v$date$anchor` | |
| 153 | ||
| 154 | The preview pane shows the date. | |
| 155 | ||
| 156 | ### Autocomplete | |
| 157 | ||
| 158 | Automatically insert a variable reference into the text as follows: | |
| 159 | ||
| 160 | 1. Type: `Jos` | |
| 161 | * Note the capital letter, matches are case sensitive. | |
| 162 | 1. Hold down the `Control` key. | |
| 163 | 1. Tap the `Spacebar` | |
| 164 | ||
| 165 | The editor shows: | |
| 166 | ||
| 167 | `r#x( v$editor$examples$name$given )` | |
| 168 | ||
| 169 | The preview pane shows: | |
| 170 | ||
| 171 | Josephine | |
| 172 | ||
| 173 | Here, the `x` function evaluates its parameter as an expression. This allows variables to include expressions in their definition. | |
| 174 | ||
| 175 | ### Variable Definition Expressions | |
| 176 | ||
| 177 | Definition file variables are have the ability to reference other definitions. Try the following: | |
| 178 | ||
| 179 | x = `r#x( v$editor$examples$math$x )`; | |
| 180 | y = `r#x( v$editor$examples$math$y )`; | |
| 181 | z = `r#x( v$editor$examples$math$z )` | |
| 182 | ||
| 183 | The preview pane shows: | |
| 184 | ||
| 185 | x = 1.0; y = 2.0; z = 3.0 | |
| 186 | ||
| 187 | ### Case | |
| 188 | ||
| 189 | Ensure words begin with a lowercase letter as follows: | |
| 190 | ||
| 191 | `r#lc( v$editor$examples$name$given )` | |
| 192 | ||
| 193 | The preview pane shows: | |
| 194 | ||
| 195 | josephine | |
| 196 | ||
| 197 | Similarly, ensure an uppercase letter as follows: | |
| 198 | ||
| 199 | `r#uc( 'hello, world!' )` | |
| 200 | ||
| 201 | The preview pane shows: | |
| 202 | ||
| 203 | Hello, world! | |
| 204 | ||
| 205 | ### Month | |
| 206 | ||
| 207 | Display the month name given a month number as follows: | |
| 208 | ||
| 209 | `r# month( 1 )` | |
| 210 | ||
| 211 | The preview pane shows: | |
| 212 | ||
| 213 | January | |
| 214 | ||
| 215 | ## Summary | |
| 216 | ||
| 217 | Authors can inline R statements into documents, directly, so long as those statements generate text. Plots, graphs, and images must be referenced as external image files or URLs. | |
| 218 | 1 |
| 531 | 531 | # ----------------------------------------------------------------------------- |
| 532 | 532 | pluralize_ch_sh_ss_suffixes <- function( word ) { |
| 533 | output <- sub( "([cs]h)$", "\\1es", word ) | |
| 534 | output <- sub( "(x|z)$", "\\1es", word ) | |
| 533 | output <- sub( "(([cs]h)|(x|z))$", "\\1es", word ) | |
| 535 | 534 | output <- replace_suffix( output, "ss", "sses" ) |
| 536 | 535 |
| 136 | 136 | annotationProcessor "info.picocli:picocli-codegen:${v_picocli}" |
| 137 | 137 | |
| 138 | // Spelling, TeX, Docking, KeenQuotes | |
| 138 | // KeenQuotes, KeenType, KeenSpell, word split. | |
| 139 | 139 | implementation fileTree( include: ['**/*.jar'], dir: 'libs' ) |
| 140 | 140 |
| 1 | Merriweather Sans ExtraBold Italic | |
| 2 | ||
| 3 | https://github.com/SorkinType/Merriweather-Sans/blob/master/fonts/otf/MerriweatherSans-ExtraBoldItalic.otf | |
| 4 | ||
| 5 | Weight 800 | |
| 6 | ||
| 7 | https://fonts.google.com/specimen/Merriweather+Sans | |
| 8 | ||
| 1 | 9 |
| 1 | # R Functions | |
| 2 | ||
| 3 | Import the files in this directory into the application, which include: | |
| 4 | ||
| 5 | * bootstrap.R | |
| 6 | * pluralize.R | |
| 7 | * possessive.R | |
| 8 | * conversion.R | |
| 9 | * csv.R | |
| 10 | ||
| 11 | # bootstrap.R | |
| 12 | ||
| 13 | Copy the contents of `bootstrap.R` into the R script preferences, shown in the | |
| 14 | following figure, then restart the application: | |
| 15 | ||
| 16 | #  | |
| 17 | ||
| 18 | Setting the **Working Directory** allows the startup script to load files | |
| 19 | using a relative to said directory. | |
| 20 | ||
| 21 | # pluralize.R | |
| 22 | ||
| 23 | This file defines a function that implements most of Damian Conway's [An Algorithmic Approach to English Pluralization](http://blob.perl.org/tpc/1998/User_Applications/Algorithmic%20Approach%20Plurals/Algorithmic_Plurals.html). | |
| 24 | ||
| 25 | ## Usage | |
| 26 | ||
| 27 | Example usages of the pluralize function include: | |
| 28 | ||
| 29 | `r#pluralize( "mouse" )` - mice | |
| 30 | `r#pluralize( "buzz" )` - buzzes | |
| 31 | `r#pluralize( "bus" )` - buses | |
| 32 | ||
| 33 | # possessive.R | |
| 34 | ||
| 35 | This file defines a function that applies possessives to English words. | |
| 36 | ||
| 37 | ## Usage | |
| 38 | ||
| 39 | Example usages of the possessive function include: | |
| 40 | ||
| 41 | `r#pos( "Ross" )` - Ross' | |
| 42 | `r#pos( "Ruby" )` - Ruby's | |
| 43 | `r#pos( "Lois" )` - Lois' | |
| 44 | `r#pos( "my" )` - mine | |
| 45 | `r#pos( "Your" )` - Yours | |
| 46 | ||
| 1 | 47 |
| 1 | # R Scripts | |
| 2 | ||
| 3 | These scripts illustrate how R can perform calculations using variables, to help automate repetitive tasks. Authors are free to write their own scripts. | |
| 4 | ||
| 5 | ## Configuration | |
| 6 | ||
| 7 | Configure the editor to use scripts as follows: | |
| 8 | ||
| 9 | 1. Copy the R scripts into same directory as your Markdown files. | |
| 10 | 1. Start the editor. | |
| 11 | 1. Click **Tools → R Script**. | |
| 12 | 1. Copy and paste the following: | |
| 13 | ||
| 14 | assign( 'anchor', as.Date( '$date.anchor$', format='%Y-%m-%d' ), envir = .GlobalEnv ); | |
| 15 | setwd( '$application.r.working.directory$' ); | |
| 16 | source( 'pluralize.R' ); | |
| 17 | source( 'csv.R' ); | |
| 18 | source( 'conversion.R' ); | |
| 19 | ||
| 20 | 1. Click **File → New** to create a new file. | |
| 21 | 1. Click **File → Save As** to set a filename. | |
| 22 | 1. Set **Name** to: `variables.yaml` | |
| 23 | 1. Click **OK**. | |
| 24 | 1. Paste the following definitions: | |
| 25 | ||
| 26 | date: | |
| 27 | anchor: 2017-01-01 | |
| 28 | editor: | |
| 29 | examples: | |
| 30 | season: 2017-09-02 | |
| 31 | math: | |
| 32 | x: 1 | |
| 33 | y: $editor.examples.math.x$ + 1 | |
| 34 | z: $editor.examples.math.y$ + 1 | |
| 35 | name: | |
| 36 | given: Josephene | |
| 37 | ||
| 38 | 1. Save and close the file. | |
| 39 | 1. Click **File → Open** | |
| 40 | 1. Change **Markdown Files** to **Definition Files**. | |
| 41 | 1. Select `variables.yaml`. | |
| 42 | 1. Click **Open**. | |
| 43 | ||
| 44 | R functionality is configured. | |
| 45 | ||
| 46 | ## Definitions | |
| 47 | ||
| 48 | The variables definitions within `variables.yaml` are available to R using the R syntax. An additional variable, `application.r.working.directory` is added to the list of variables. The value is set to the working directory of the file being edited. Hover the mouse cursor over the file tab in the editor to see the full path to the file. | |
| 49 | ||
| 50 | ## Examples | |
| 51 | ||
| 52 | This section demonstrates how to use the R functions when editing. Complete the following steps to begin: | |
| 53 | ||
| 54 | 1. Click **File → New** to create a new file. | |
| 55 | 1. Click **File → Save As** to set a filename. | |
| 56 | 1. Set **Name** to: `example.Rmd` | |
| 57 | 1. Click **OK**. | |
| 58 | ||
| 59 | The examples are ready for use within the editor. | |
| 60 | ||
| 61 | ### Arithmetic | |
| 62 | ||
| 63 | Type the following to perform a simple calculation: | |
| 64 | ||
| 65 | `r# 1+1` | |
| 66 | ||
| 67 | The preview pane shows `2.0`. | |
| 68 | ||
| 69 | ### Functions | |
| 70 | ||
| 71 | Call the [format](https://stat.ethz.ch/R-manual/R-devel/library/base/html/format.html) function to truncate unwanted decimal places as follows: | |
| 72 | ||
| 73 | `r# format(1+1,digits=1)` | |
| 74 | ||
| 75 | The preview pane shows `2`. | |
| 76 | ||
| 77 | ### Pluralize | |
| 78 | ||
| 79 | Many English words can be pluralized as follows: | |
| 80 | ||
| 81 | `r# pl('wolf',2)` | |
| 82 | ||
| 83 | The preview pane shows `wolves`. The `pluralize.R` file contains a partial implementation of Damian Conway's algorithmic approach to English pluralization. | |
| 84 | ||
| 85 | ### Chicago Manual of Style | |
| 86 | ||
| 87 | Apply the Chicago Manual of Style for words less than one-hundred as follows: | |
| 88 | ||
| 89 | `r# cms(1)` `r# cms(99)` `r# cms(101)` | |
| 90 | ||
| 91 | The preview pane shows numbers written out as `one` and `ninety-nine`, followed by the digits 101. | |
| 92 | ||
| 93 | ### Data Import | |
| 94 | ||
| 95 | Import and display information from a CSV file as follows: | |
| 96 | ||
| 97 | 1. Click **File → New** to create a new file. | |
| 98 | 1. Click **File → Save As** to rename the file. | |
| 99 | 1. Set the filename to: `data.csv` | |
| 100 | 1. Paste the following into `data.csv`: | |
| 101 | ||
| 102 | Animal,Quantity,Country | |
| 103 | Aardwolf,1,Africa | |
| 104 | Keel-billed toucan,1,Belize | |
| 105 | Beaver,2,Canada | |
| 106 | Mute swan,3,Denmark | |
| 107 | Lion,5,Ethiopia | |
| 108 | Brown bear,8,Finland | |
| 109 | Dolphin,13,Greece | |
| 110 | Turul,21,Hungary | |
| 111 | Gyrfalcon,34,Iceland | |
| 112 | Red-billed streamertail,55,Jamaica | |
| 113 | ||
| 114 | 1. Click the `example.Rmd` tab. | |
| 115 | 1. Type the following: | |
| 116 | ||
| 117 | `r# csv2md('data.csv',total=F)` | |
| 118 | ||
| 119 | 1. Type the following to calculate a total for all numeric columns: | |
| 120 | ||
| 121 | `r# csv2md('data.csv')` | |
| 122 | ||
| 123 | This imports the data from an external file and formats the information into a table, automatically. Update the data as follows: | |
| 124 | ||
| 125 | 1. Click the `data.csv` tab to edit the data. | |
| 126 | 1. Change the data by adding a new row. | |
| 127 | 1. Save the file. | |
| 128 | 1. Click the `example.Rmd` tab. | |
| 129 | ||
| 130 | The preview pane shows the revised contents. | |
| 131 | ||
| 132 | ### Elapsed Time | |
| 133 | ||
| 134 | The duration of a timeline, given in numbers of days, can be computed into English as follows: | |
| 135 | ||
| 136 | `r# elapsed(1,1)` | |
| 137 | ||
| 138 | The preview pane shows `same day`. Change the expression to: | |
| 139 | ||
| 140 | `r# elapsed(1,2)` | |
| 141 | ||
| 142 | The preview pane shows `one day`. Change the expression to: | |
| 143 | ||
| 144 | `r# elapsed(1,112358)` | |
| 145 | ||
| 146 | The preview pane shows `307 years, seven months, and sixteen days`, combined using the Chicago Manual of Style, the pluralization function, and a [serial comma](https://www.behance.net/gallery/19417363/The-Oxford-Comma). | |
| 147 | ||
| 148 | ### Variable Syntax | |
| 149 | ||
| 150 | The syntax for a variable changes when using an R Markdown file (denoted by the `.Rmd` filename extension), as opposed to a regular Markdown file (`.md`). Return to the example file and type the following: | |
| 151 | ||
| 152 | `r# v$date$anchor` | |
| 153 | ||
| 154 | The preview pane shows the date. | |
| 155 | ||
| 156 | ### Autocomplete | |
| 157 | ||
| 158 | Automatically insert a variable reference into the text as follows: | |
| 159 | ||
| 160 | 1. Type: `Jos` | |
| 161 | * Note the capital letter, matches are case sensitive. | |
| 162 | 1. Hold down the `Control` key. | |
| 163 | 1. Tap the `Spacebar` | |
| 164 | ||
| 165 | The editor shows: | |
| 166 | ||
| 167 | `r#x( v$editor$examples$name$given )` | |
| 168 | ||
| 169 | The preview pane shows: | |
| 170 | ||
| 171 | Josephine | |
| 172 | ||
| 173 | Here, the `x` function evaluates its parameter as an expression. This allows variables to include expressions in their definition. | |
| 174 | ||
| 175 | ### Variable Definition Expressions | |
| 176 | ||
| 177 | Definition file variables are have the ability to reference other definitions. Try the following: | |
| 178 | ||
| 179 | x = `r#x( v$editor$examples$math$x )`; | |
| 180 | y = `r#x( v$editor$examples$math$y )`; | |
| 181 | z = `r#x( v$editor$examples$math$z )` | |
| 182 | ||
| 183 | The preview pane shows: | |
| 184 | ||
| 185 | x = 1.0; y = 2.0; z = 3.0 | |
| 186 | ||
| 187 | ### Case | |
| 188 | ||
| 189 | Ensure words begin with a lowercase letter as follows: | |
| 190 | ||
| 191 | `r#lc( v$editor$examples$name$given )` | |
| 192 | ||
| 193 | The preview pane shows: | |
| 194 | ||
| 195 | josephine | |
| 196 | ||
| 197 | Similarly, ensure an uppercase letter as follows: | |
| 198 | ||
| 199 | `r#uc( 'hello, world!' )` | |
| 200 | ||
| 201 | The preview pane shows: | |
| 202 | ||
| 203 | Hello, world! | |
| 204 | ||
| 205 | ### Month | |
| 206 | ||
| 207 | Display the month name given a month number as follows: | |
| 208 | ||
| 209 | `r# month( 1 )` | |
| 210 | ||
| 211 | The preview pane shows: | |
| 212 | ||
| 213 | January | |
| 214 | ||
| 215 | ## Summary | |
| 216 | ||
| 217 | Authors can inline R statements into documents, directly, so long as those statements generate text. Plots, graphs, and images must be referenced as external image files or URLs. | |
| 1 | 218 |
| 6 | 6 | --add-opens=javafx.graphics/javafx.scene.text=ALL-UNNAMED \ |
| 7 | 7 | --add-opens=javafx.graphics/com.sun.javafx.css=ALL-UNNAMED \ |
| 8 | --add-opens=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED \ | |
| 8 | 9 | --add-opens=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \ |
| 9 | 10 | --add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED \ |
| 5 | 5 | import com.keenwrite.events.HyperlinkOpenEvent; |
| 6 | 6 | import com.keenwrite.preferences.Workspace; |
| 7 | import com.keenwrite.preview.MathRenderer; | |
| 7 | 8 | import com.keenwrite.spelling.impl.Lexicon; |
| 8 | 9 | import javafx.application.Application; |
| ... | ||
| 22 | 23 | import static com.keenwrite.preferences.AppKeys.*; |
| 23 | 24 | import static com.keenwrite.util.FontLoader.initFonts; |
| 24 | import static javafx.scene.input.KeyCode.ALT; | |
| 25 | import static javafx.scene.input.KeyCode.ESCAPE; | |
| 25 | 26 | import static javafx.scene.input.KeyCode.F11; |
| 26 | 27 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; |
| ... | ||
| 66 | 67 | public static Event keyDown( final KeyCode code, final boolean shift ) { |
| 67 | 68 | return keyEvent( KEY_PRESSED, code, shift ); |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Creates an instance of {@link KeyEvent} that represents a key released | |
| 73 | * event without any modifier keys held. | |
| 74 | * | |
| 75 | * @param code The key code representing a key to simulate releasing. | |
| 76 | * @return An instance of {@link KeyEvent}. | |
| 77 | */ | |
| 78 | public static Event keyDown( final KeyCode code ) { | |
| 79 | return keyDown( code, false ); | |
| 68 | 80 | } |
| 69 | 81 | |
| ... | ||
| 76 | 88 | * a key being released. |
| 77 | 89 | */ |
| 90 | @SuppressWarnings( "unused" ) | |
| 78 | 91 | public static Event keyUp( final KeyCode code, final boolean shift ) { |
| 79 | 92 | return keyEvent( KEY_RELEASED, code, shift ); |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Creates an instance of {@link KeyEvent} that represents a key released | |
| 84 | * event without any modifier keys held. | |
| 85 | * | |
| 86 | * @param code The key code representing a key to simulate releasing. | |
| 87 | * @return An instance of {@link KeyEvent}. | |
| 88 | */ | |
| 89 | public static Event keyUp( final KeyCode code ) { | |
| 90 | return keyUp( code, false ); | |
| 91 | 93 | } |
| 92 | 94 | |
| ... | ||
| 120 | 122 | initIcons( stage ); |
| 121 | 123 | initScene( stage ); |
| 124 | ||
| 125 | MathRenderer.bindSize( mWorkspace.doubleProperty( KEY_UI_FONT_MATH_SIZE ) ); | |
| 122 | 126 | |
| 123 | 127 | // Load the lexicon and check all the documents after all files are open. |
| ... | ||
| 157 | 161 | |
| 158 | 162 | // After the app loses focus, when the user switches back using Alt+Tab, |
| 159 | // the menu mnemonic is sometimes engaged, swallowing the first letter that | |
| 160 | // the user types---if it is a menu mnemonic. See MainScene::createScene(). | |
| 163 | // the menu is engaged on Windows. Simulate an ESC keypress to the menu | |
| 164 | // to disable the menu, giving focus back to the application proper. | |
| 161 | 165 | // |
| 162 | 166 | // JavaFX Bug: https://bugs.openjdk.java.net/browse/JDK-8090647 |
| 163 | stage.focusedProperty().addListener( ( c, lost, show ) -> { | |
| 164 | for( final var menu : mMainScene.getMenuBar().getMenus() ) { | |
| 165 | menu.hide(); | |
| 166 | } | |
| 167 | ||
| 168 | for( final var mnemonics : stage.getScene().getMnemonics().values() ) { | |
| 169 | for( final var mnemonic : mnemonics ) { | |
| 170 | mnemonic.getNode().fireEvent( keyUp( ALT ) ); | |
| 171 | } | |
| 167 | stage.focusedProperty().addListener( ( c, lost, found ) -> { | |
| 168 | if( found ) { | |
| 169 | mMainScene.getMenuBar().fireEvent( keyDown( ESCAPE ) ); | |
| 172 | 170 | } |
| 173 | 171 | } ); |
| 507 | 507 | final var file = files.get( 0 ); |
| 508 | 508 | |
| 509 | editor.rename( file ); | |
| 510 | tab.ifPresent( t -> { | |
| 511 | t.setText( editor.getFilename() ); | |
| 512 | t.setTooltip( createTooltip( file ) ); | |
| 513 | } ); | |
| 514 | ||
| 515 | save(); | |
| 516 | } | |
| 517 | ||
| 518 | /** | |
| 519 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 520 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 521 | * | |
| 522 | * @param resource The resource to export. | |
| 523 | */ | |
| 524 | private void save( final TextResource resource ) { | |
| 525 | try { | |
| 526 | resource.save(); | |
| 527 | } catch( final Exception ex ) { | |
| 528 | clue( ex ); | |
| 529 | sNotifier.alert( | |
| 530 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 531 | ); | |
| 532 | } | |
| 533 | } | |
| 534 | ||
| 535 | /** | |
| 536 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 537 | * | |
| 538 | * @return {@code true} when all editors, modified or otherwise, were | |
| 539 | * permitted to close; {@code false} when one or more editors were modified | |
| 540 | * and the user requested no closing. | |
| 541 | */ | |
| 542 | public boolean closeAll() { | |
| 543 | var closable = true; | |
| 544 | ||
| 545 | for( final var tabPane : mTabPanes ) { | |
| 546 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 547 | ||
| 548 | while( tabIterator.hasNext() ) { | |
| 549 | final var tab = tabIterator.next(); | |
| 550 | final var resource = tab.getContent(); | |
| 551 | ||
| 552 | // The definition panes auto-save, so being specific here prevents | |
| 553 | // closing the definitions in the situation where the user wants to | |
| 554 | // continue editing (i.e., possibly save unsaved work). | |
| 555 | if( !(resource instanceof TextEditor) ) { | |
| 556 | continue; | |
| 557 | } | |
| 558 | ||
| 559 | if( canClose( (TextEditor) resource ) ) { | |
| 560 | tabIterator.remove(); | |
| 561 | close( tab ); | |
| 562 | } | |
| 563 | else { | |
| 564 | closable = false; | |
| 565 | } | |
| 566 | } | |
| 567 | } | |
| 568 | ||
| 569 | return closable; | |
| 570 | } | |
| 571 | ||
| 572 | /** | |
| 573 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 574 | * event. | |
| 575 | * | |
| 576 | * @param tab The {@link Tab} that was closed. | |
| 577 | */ | |
| 578 | private void close( final Tab tab ) { | |
| 579 | assert tab != null; | |
| 580 | ||
| 581 | final var handler = tab.getOnClosed(); | |
| 582 | ||
| 583 | if( handler != null ) { | |
| 584 | handler.handle( new ActionEvent() ); | |
| 585 | } | |
| 586 | } | |
| 587 | ||
| 588 | /** | |
| 589 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 590 | */ | |
| 591 | public void close() { | |
| 592 | final var editor = getTextEditor(); | |
| 593 | ||
| 594 | if( canClose( editor ) ) { | |
| 595 | close( editor ); | |
| 596 | } | |
| 597 | } | |
| 598 | ||
| 599 | /** | |
| 600 | * Closes the given {@link TextResource}. This must not be called from within | |
| 601 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 602 | * concurrent modification exception be thrown. | |
| 603 | * | |
| 604 | * @param resource The {@link TextResource} to close, without confirming with | |
| 605 | * the user. | |
| 606 | */ | |
| 607 | private void close( final TextResource resource ) { | |
| 608 | getTab( resource ).ifPresent( | |
| 609 | tab -> { | |
| 610 | close( tab ); | |
| 611 | tab.getTabPane().getTabs().remove( tab ); | |
| 612 | } | |
| 613 | ); | |
| 614 | } | |
| 615 | ||
| 616 | /** | |
| 617 | * Answers whether the given {@link TextResource} may be closed. | |
| 618 | * | |
| 619 | * @param editor The {@link TextResource} to try closing. | |
| 620 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 621 | * the user has requested to keep the editor open. | |
| 622 | */ | |
| 623 | private boolean canClose( final TextResource editor ) { | |
| 624 | final var editorTab = getTab( editor ); | |
| 625 | final var canClose = new AtomicBoolean( true ); | |
| 626 | ||
| 627 | if( editor.isModified() ) { | |
| 628 | final var filename = new StringBuilder(); | |
| 629 | editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); | |
| 630 | ||
| 631 | final var message = sNotifier.createNotification( | |
| 632 | Messages.get( "Alert.file.close.title" ), | |
| 633 | Messages.get( "Alert.file.close.text" ), | |
| 634 | filename.toString() | |
| 635 | ); | |
| 636 | ||
| 637 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 638 | ||
| 639 | dialog.showAndWait().ifPresent( | |
| 640 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 641 | ); | |
| 642 | } | |
| 643 | ||
| 644 | return canClose.get(); | |
| 645 | } | |
| 646 | ||
| 647 | private void iterateEditors( final Consumer<TextEditor> consumer ) { | |
| 648 | mTabPanes.forEach( | |
| 649 | tp -> tp.getTabs().forEach( tab -> { | |
| 650 | final var node = tab.getContent(); | |
| 651 | ||
| 652 | if( node instanceof final TextEditor editor ) { | |
| 653 | consumer.accept( editor ); | |
| 654 | } | |
| 655 | } ) | |
| 656 | ); | |
| 657 | } | |
| 658 | ||
| 659 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 660 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 661 | ||
| 662 | editor.addListener( ( c, o, n ) -> { | |
| 663 | if( n != null ) { | |
| 664 | mPreview.setBaseUri( n.getPath() ); | |
| 665 | process( n ); | |
| 666 | } | |
| 667 | } ); | |
| 668 | ||
| 669 | return editor; | |
| 670 | } | |
| 671 | ||
| 672 | /** | |
| 673 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 674 | */ | |
| 675 | public void viewPreview() { | |
| 676 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 677 | } | |
| 678 | ||
| 679 | /** | |
| 680 | * Adds the document outline tab to its own, singular tab pane. | |
| 681 | */ | |
| 682 | public void viewOutline() { | |
| 683 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 684 | } | |
| 685 | ||
| 686 | public void viewStatistics() { | |
| 687 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 688 | } | |
| 689 | ||
| 690 | public void viewFiles() { | |
| 691 | try { | |
| 692 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 693 | final var fileManager = factory.createModeless(); | |
| 694 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 695 | } catch( final Exception ex ) { | |
| 696 | clue( ex ); | |
| 697 | } | |
| 698 | } | |
| 699 | ||
| 700 | private void viewTab( | |
| 701 | final Node node, final MediaType mediaType, final String key ) { | |
| 702 | final var tabPane = obtainTabPane( mediaType ); | |
| 703 | ||
| 704 | for( final var tab : tabPane.getTabs() ) { | |
| 705 | if( tab.getContent() == node ) { | |
| 706 | return; | |
| 707 | } | |
| 708 | } | |
| 709 | ||
| 710 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 711 | addTabPane( tabPane ); | |
| 712 | } | |
| 713 | ||
| 714 | public void viewRefresh() { | |
| 715 | mPreview.refresh(); | |
| 716 | Engine.clear(); | |
| 717 | mRBootstrapController.update(); | |
| 718 | } | |
| 719 | ||
| 720 | /** | |
| 721 | * Returns the tab that contains the given {@link TextEditor}. | |
| 722 | * | |
| 723 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 724 | * @return The first tab having content that matches the given tab. | |
| 725 | */ | |
| 726 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 727 | return mTabPanes.stream() | |
| 728 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 729 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 730 | .findFirst(); | |
| 731 | } | |
| 732 | ||
| 733 | /** | |
| 734 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 735 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 736 | * Upon changing, the variables are interpolated and the active text editor | |
| 737 | * is refreshed. | |
| 738 | * | |
| 739 | * @param textEditor Text editor to update with the revised resolved map. | |
| 740 | * @return A newly configured property that represents the active | |
| 741 | * {@link DefinitionEditor}, never null. | |
| 742 | */ | |
| 743 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 744 | final ObjectProperty<TextEditor> textEditor ) { | |
| 745 | final var defEditor = new SimpleObjectProperty<>( | |
| 746 | createDefinitionEditor() | |
| 747 | ); | |
| 748 | ||
| 749 | defEditor.addListener( ( c, o, n ) -> { | |
| 750 | final var editor = textEditor.get(); | |
| 751 | ||
| 752 | if( editor.isMediaType( TEXT_R_MARKDOWN ) ) { | |
| 753 | // Initialize R before the editor is added. | |
| 754 | mRBootstrapController.update(); | |
| 755 | } | |
| 756 | ||
| 757 | process( editor ); | |
| 758 | } ); | |
| 759 | ||
| 760 | return defEditor; | |
| 761 | } | |
| 762 | ||
| 763 | private Tab createTab( final String filename, final Node node ) { | |
| 764 | return new DetachableTab( filename, node ); | |
| 765 | } | |
| 766 | ||
| 767 | private Tab createTab( final File file ) { | |
| 768 | final var r = createTextResource( file ); | |
| 769 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 770 | ||
| 771 | r.modifiedProperty().addListener( | |
| 772 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 773 | ); | |
| 774 | ||
| 775 | // This is called when either the tab is closed by the user clicking on | |
| 776 | // the tab's close icon or when closing (all) from the file menu. | |
| 777 | tab.setOnClosed( | |
| 778 | __ -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 779 | ); | |
| 780 | ||
| 781 | // When closing a tab, give focus to the newly revealed tab. | |
| 782 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 783 | if( n != null && n ) { | |
| 784 | final var pane = tab.getTabPane(); | |
| 785 | ||
| 786 | if( pane != null ) { | |
| 787 | pane.requestFocus(); | |
| 788 | } | |
| 789 | } | |
| 790 | } ); | |
| 791 | ||
| 792 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 793 | if( nPane != null ) { | |
| 794 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 795 | if( n != null && n ) { | |
| 796 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 797 | final var node = selected.getContent(); | |
| 798 | node.requestFocus(); | |
| 799 | } | |
| 800 | } ); | |
| 801 | } | |
| 802 | } ); | |
| 803 | ||
| 804 | return tab; | |
| 805 | } | |
| 806 | ||
| 807 | /** | |
| 808 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 809 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 810 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 811 | * be replaced by such a class. | |
| 812 | * <p> | |
| 813 | * When binning the files, this makes sure that at least one file exists | |
| 814 | * for every type. If the user has opted to close a particular type (such | |
| 815 | * as the definition pane), the view will suppressed elsewhere. | |
| 816 | * </p> | |
| 817 | * <p> | |
| 818 | * The order that the binned files are returned will be reflected in the | |
| 819 | * order that the corresponding panes are rendered in the UI. | |
| 820 | * </p> | |
| 821 | * | |
| 822 | * @param paths The file paths to bin according to their type. | |
| 823 | * @return An in-order list of files, first by structured definition files, | |
| 824 | * then by plain text documents. | |
| 825 | */ | |
| 826 | private List<File> collect( final SetProperty<String> paths ) { | |
| 827 | // Treat all files destined for the text editor as plain text documents | |
| 828 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 829 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 830 | final Function<MediaType, MediaType> bin = | |
| 831 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 832 | ||
| 833 | // Create two groups: YAML files and plain text files. The order that | |
| 834 | // the elements are listed in the enumeration for media types determines | |
| 835 | // what files are loaded first. Variable definitions come before all other | |
| 836 | // plain text documents. | |
| 837 | final var bins = paths | |
| 838 | .stream() | |
| 839 | .collect( | |
| 840 | groupingBy( | |
| 841 | path -> bin.apply( MediaType.fromFilename( path ) ), | |
| 842 | () -> new TreeMap<>( Enum::compareTo ), | |
| 843 | Collectors.toList() | |
| 844 | ) | |
| 845 | ); | |
| 846 | ||
| 847 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 848 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 849 | ||
| 850 | final var result = new LinkedList<File>(); | |
| 851 | ||
| 852 | // Ensure that the same types are listed together (keep insertion order). | |
| 853 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 854 | files.stream().map( File::new ).toList() ) | |
| 855 | ); | |
| 856 | ||
| 857 | return result; | |
| 858 | } | |
| 859 | ||
| 860 | /** | |
| 861 | * Force the active editor to update, which will cause the processor | |
| 862 | * to re-evaluate the interpolated definition map thereby updating the | |
| 863 | * preview pane. | |
| 864 | * | |
| 865 | * @param editor Contains the source document to update in the preview pane. | |
| 866 | */ | |
| 867 | private void process( final TextEditor editor ) { | |
| 868 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 869 | // text editor immediately for caret movement. The preview will have a | |
| 870 | // slight delay when catching up to the caret position. | |
| 871 | final var task = new Task<Void>() { | |
| 872 | @Override | |
| 873 | public Void call() { | |
| 874 | try { | |
| 875 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 876 | p.apply( editor == null ? "" : editor.getText() ); | |
| 877 | } catch( final Exception ex ) { | |
| 878 | clue( ex ); | |
| 879 | } | |
| 880 | ||
| 881 | return null; | |
| 882 | } | |
| 883 | }; | |
| 884 | ||
| 885 | // TODO: Each time the editor successfully runs the processor the task is | |
| 886 | // considered successful. Due to the rapid-fire nature of processing | |
| 887 | // (e.g., keyboard navigation, fast typing), it isn't necessary to | |
| 888 | // scroll each time. | |
| 889 | // The algorithm: | |
| 890 | // 1. Peek at the oldest time. | |
| 891 | // 2. If the difference between the oldest time and current time exceeds | |
| 892 | // 250 milliseconds, then invoke the scrolling. | |
| 893 | // 3. Insert the current time into the circular queue. | |
| 894 | task.setOnSucceeded( | |
| 895 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 896 | ); | |
| 897 | ||
| 898 | // Prevents multiple process requests from executing simultaneously (due | |
| 899 | // to having a restricted queue size). | |
| 900 | sExecutor.execute( task ); | |
| 901 | } | |
| 902 | ||
| 903 | /** | |
| 904 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 905 | * events. The tab pane is associated with a given media type so that | |
| 906 | * similar files can be grouped together. | |
| 907 | * | |
| 908 | * @param mediaType The media type to associate with the tab pane. | |
| 909 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 910 | */ | |
| 911 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 912 | for( final var pane : mTabPanes ) { | |
| 913 | for( final var tab : pane.getTabs() ) { | |
| 914 | final var node = tab.getContent(); | |
| 915 | ||
| 916 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 917 | return pane; | |
| 918 | } | |
| 919 | } | |
| 920 | } | |
| 921 | ||
| 922 | final var pane = createTabPane(); | |
| 923 | mTabPanes.add( pane ); | |
| 924 | return pane; | |
| 925 | } | |
| 926 | ||
| 927 | /** | |
| 928 | * Creates an initialized {@link TabPane} instance. | |
| 929 | * | |
| 930 | * @return A new {@link TabPane} with all listeners configured. | |
| 931 | */ | |
| 932 | private TabPane createTabPane() { | |
| 933 | final var tabPane = new DetachableTabPane(); | |
| 934 | ||
| 935 | initStageOwnerFactory( tabPane ); | |
| 936 | initTabListener( tabPane ); | |
| 937 | ||
| 938 | return tabPane; | |
| 939 | } | |
| 940 | ||
| 941 | /** | |
| 942 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 943 | * the stage owner factory must be given its parent window, which will | |
| 944 | * own the child window. The parent window is the {@link MainPane}'s | |
| 945 | * {@link Scene}'s {@link Window} instance. | |
| 946 | * | |
| 947 | * <p> | |
| 948 | * This will derives the new title from the main window title, incrementing | |
| 949 | * the window count to help uniquely identify the child windows. | |
| 950 | * </p> | |
| 951 | * | |
| 952 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 953 | */ | |
| 954 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 955 | tabPane.setStageOwnerFactory( stage -> { | |
| 956 | final var title = get( | |
| 957 | "Detach.tab.title", | |
| 958 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 959 | ); | |
| 960 | stage.setTitle( title ); | |
| 961 | ||
| 962 | return getScene().getWindow(); | |
| 963 | } ); | |
| 964 | } | |
| 965 | ||
| 966 | /** | |
| 967 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 968 | * it is added to the given {@link DetachableTabPane} instance. | |
| 969 | * <p> | |
| 970 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 971 | * is initialized to perform synchronized scrolling between the editor and | |
| 972 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 973 | * tabs is given focus. | |
| 974 | * </p> | |
| 975 | * <p> | |
| 976 | * Note that multiple tabs can be added simultaneously. | |
| 977 | * </p> | |
| 978 | * | |
| 979 | * @param tabPane A new {@link TabPane} to configure. | |
| 980 | */ | |
| 981 | private void initTabListener( final TabPane tabPane ) { | |
| 982 | tabPane.getTabs().addListener( | |
| 983 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 984 | while( listener.next() ) { | |
| 985 | if( listener.wasAdded() ) { | |
| 986 | final var tabs = listener.getAddedSubList(); | |
| 987 | ||
| 988 | tabs.forEach( tab -> { | |
| 989 | final var node = tab.getContent(); | |
| 990 | ||
| 991 | if( node instanceof TextEditor ) { | |
| 992 | initScrollEventListener( tab ); | |
| 993 | } | |
| 994 | } ); | |
| 995 | ||
| 996 | // Select and give focus to the last tab opened. | |
| 997 | final var index = tabs.size() - 1; | |
| 998 | if( index >= 0 ) { | |
| 999 | final var tab = tabs.get( index ); | |
| 1000 | tabPane.getSelectionModel().select( tab ); | |
| 1001 | tab.getContent().requestFocus(); | |
| 1002 | } | |
| 1003 | } | |
| 1004 | } | |
| 1005 | } | |
| 1006 | ); | |
| 1007 | } | |
| 1008 | ||
| 1009 | /** | |
| 1010 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 1011 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 1012 | * | |
| 1013 | * @param tab The container for an instance of {@link TextEditor}. | |
| 1014 | */ | |
| 1015 | private void initScrollEventListener( final Tab tab ) { | |
| 1016 | final var editor = (TextEditor) tab.getContent(); | |
| 1017 | final var scrollPane = editor.getScrollPane(); | |
| 1018 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 1019 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 1020 | ||
| 1021 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 1022 | } | |
| 1023 | ||
| 1024 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 1025 | final var items = getItems(); | |
| 1026 | ||
| 1027 | if( !items.contains( tabPane ) ) { | |
| 1028 | items.add( index, tabPane ); | |
| 1029 | } | |
| 1030 | } | |
| 1031 | ||
| 1032 | private void addTabPane( final TabPane tabPane ) { | |
| 1033 | addTabPane( getItems().size(), tabPane ); | |
| 1034 | } | |
| 1035 | ||
| 1036 | private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { | |
| 1037 | final var w = getWorkspace(); | |
| 1038 | ||
| 1039 | return builder() | |
| 1040 | .with( Mutator::setDefinitions, this::getDefinitions ) | |
| 1041 | .with( Mutator::setLocale, w::getLocale ) | |
| 1042 | .with( Mutator::setMetadata, w::getMetadata ) | |
| 1043 | .with( Mutator::setThemesPath, w::getThemesPath ) | |
| 1044 | .with( Mutator::setCachesPath, | |
| 1045 | () -> w.getFile( KEY_CACHES_DIR ) ) | |
| 1046 | .with( Mutator::setImagesPath, | |
| 1047 | () -> w.getFile( KEY_IMAGES_DIR ) ) | |
| 1048 | .with( Mutator::setImageOrder, | |
| 1049 | () -> w.getString( KEY_IMAGES_ORDER ) ) | |
| 1050 | .with( Mutator::setImageServer, | |
| 1051 | () -> w.getString( KEY_IMAGES_SERVER ) ) | |
| 1052 | .with( Mutator::setFontsPath, | |
| 1053 | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) | |
| 1054 | .with( Mutator::setCaret, | |
| 1055 | () -> getTextEditor().getCaret() ) | |
| 1056 | .with( Mutator::setSigilBegan, | |
| 1057 | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | |
| 1058 | .with( Mutator::setSigilEnded, | |
| 1059 | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | |
| 1060 | .with( Mutator::setRScript, | |
| 1061 | () -> w.getString( KEY_R_SCRIPT ) ) | |
| 1062 | .with( Mutator::setRWorkingDir, | |
| 1063 | () -> w.getFile( KEY_R_DIR ).toPath() ) | |
| 1064 | .with( Mutator::setCurlQuotes, | |
| 1065 | () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 1066 | .with( Mutator::setAutoRemove, | |
| 1067 | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | |
| 1068 | } | |
| 1069 | ||
| 1070 | public ProcessorContext createProcessorContext() { | |
| 1071 | return createProcessorContext( null, NONE ); | |
| 1072 | } | |
| 1073 | ||
| 1074 | /** | |
| 1075 | * @param targetPath Used when exporting to a PDF file (binary). | |
| 1076 | * @param format Used when processors export to a new text format. | |
| 1077 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1078 | * {@link Processor}. | |
| 1079 | */ | |
| 1080 | public ProcessorContext createProcessorContext( | |
| 1081 | final Path targetPath, final ExportFormat format ) { | |
| 1082 | final var textEditor = getTextEditor(); | |
| 1083 | final var sourcePath = textEditor.getPath(); | |
| 1084 | ||
| 1085 | return processorContextBuilder() | |
| 1086 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1087 | .with( Mutator::setTargetPath, targetPath ) | |
| 1088 | .with( Mutator::setExportFormat, format ) | |
| 1089 | .build(); | |
| 1090 | } | |
| 1091 | ||
| 1092 | /** | |
| 1093 | * @param sourcePath Used by {@link ProcessorFactory} to determine | |
| 1094 | * {@link Processor} type to create based on file type. | |
| 1095 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1096 | * {@link Processor}. | |
| 1097 | */ | |
| 1098 | private ProcessorContext createProcessorContext( final Path sourcePath ) { | |
| 1099 | return processorContextBuilder() | |
| 1100 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1101 | .with( Mutator::setExportFormat, NONE ) | |
| 1102 | .build(); | |
| 1103 | } | |
| 1104 | ||
| 1105 | private TextResource createTextResource( final File file ) { | |
| 1106 | // TODO: Create PlainTextEditor that's returned by default. | |
| 1107 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 1108 | ? createDefinitionEditor( file ) | |
| 1109 | : createMarkdownEditor( file ); | |
| 1110 | } | |
| 1111 | ||
| 1112 | /** | |
| 1113 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 1114 | * caret change events and text change events. Text change events must | |
| 1115 | * take priority over caret change events because it's possible to change | |
| 1116 | * the text without moving the caret (e.g., delete selected text). | |
| 1117 | * | |
| 1118 | * @param inputFile The file containing contents for the text editor. | |
| 1119 | * @return A non-null text editor. | |
| 1120 | */ | |
| 1121 | private MarkdownEditor createMarkdownEditor( final File inputFile ) { | |
| 1122 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 1123 | ||
| 1124 | mProcessors.computeIfAbsent( | |
| 1125 | editor, p -> createProcessors( | |
| 1126 | createProcessorContext( inputFile.toPath() ), | |
| 1127 | createHtmlPreviewProcessor() | |
| 1128 | ) | |
| 1129 | ); | |
| 1130 | ||
| 1131 | // Listener for editor modifications or caret position changes. | |
| 1132 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 1133 | if( n ) { | |
| 1134 | // Reset the status bar after changing the text. | |
| 1135 | clue(); | |
| 1136 | ||
| 1137 | // Processing the text may update the status bar. | |
| 1138 | process( getTextEditor() ); | |
| 1139 | ||
| 1140 | // Update the caret position in the status bar. | |
| 1141 | CaretMovedEvent.fire( editor.getCaret() ); | |
| 1142 | } | |
| 1143 | } ); | |
| 1144 | ||
| 1145 | editor.addEventListener( | |
| 1146 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1147 | ); | |
| 1148 | ||
| 1149 | editor.addEventListener( | |
| 1150 | keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor ) | |
| 1151 | ); | |
| 1152 | ||
| 1153 | final var textArea = editor.getTextArea(); | |
| 1154 | ||
| 1155 | // Spell check when the paragraph changes. | |
| 1156 | textArea | |
| 1157 | .plainTextChanges() | |
| 1158 | .filter( p -> !p.isIdentity() ) | |
| 1159 | .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); | |
| 1160 | ||
| 1161 | // Store the caret position to restore it after restarting the application. | |
| 1162 | textArea.caretPositionProperty().addListener( | |
| 1163 | ( c, o, n ) -> | |
| 1164 | getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) | |
| 1165 | ); | |
| 1166 | ||
| 1167 | // Set the active editor, which refreshes the preview panel. | |
| 1168 | mTextEditor.set( editor ); | |
| 1169 | ||
| 1170 | // Check the entire document after the spellchecker is initialized (with | |
| 1171 | // a valid lexicon) so that only the current paragraph need be scanned | |
| 1172 | // while editing. (Technically, only the most recently modified word must | |
| 1173 | // be scanned.) | |
| 1174 | mSpellChecker.addListener( | |
| 1175 | ( c, o, n ) -> runLater( | |
| 1176 | () -> iterateEditors( mEditorSpeller::checkDocument ) | |
| 1177 | ) | |
| 1178 | ); | |
| 1179 | ||
| 1180 | // Check the entire document after it has been loaded. | |
| 1181 | mEditorSpeller.checkDocument( mTextEditor.get() ); | |
| 1182 | ||
| 1183 | return editor; | |
| 509 | // If the file type has changed, refresh the processors. | |
| 510 | final var mediaType = MediaType.valueFrom( file ); | |
| 511 | final var typeChanged = !editor.isMediaType( mediaType ); | |
| 512 | ||
| 513 | if( typeChanged ) { | |
| 514 | removeProcessor( editor ); | |
| 515 | } | |
| 516 | ||
| 517 | editor.rename( file ); | |
| 518 | tab.ifPresent( t -> { | |
| 519 | t.setText( editor.getFilename() ); | |
| 520 | t.setTooltip( createTooltip( file ) ); | |
| 521 | } ); | |
| 522 | ||
| 523 | if( typeChanged ) { | |
| 524 | updateProcessors( editor ); | |
| 525 | process( editor ); | |
| 526 | } | |
| 527 | ||
| 528 | save(); | |
| 529 | } | |
| 530 | ||
| 531 | /** | |
| 532 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 533 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 534 | * | |
| 535 | * @param resource The resource to export. | |
| 536 | */ | |
| 537 | private void save( final TextResource resource ) { | |
| 538 | try { | |
| 539 | resource.save(); | |
| 540 | } catch( final Exception ex ) { | |
| 541 | clue( ex ); | |
| 542 | sNotifier.alert( | |
| 543 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 544 | ); | |
| 545 | } | |
| 546 | } | |
| 547 | ||
| 548 | /** | |
| 549 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 550 | * | |
| 551 | * @return {@code true} when all editors, modified or otherwise, were | |
| 552 | * permitted to close; {@code false} when one or more editors were modified | |
| 553 | * and the user requested no closing. | |
| 554 | */ | |
| 555 | public boolean closeAll() { | |
| 556 | var closable = true; | |
| 557 | ||
| 558 | for( final var tabPane : mTabPanes ) { | |
| 559 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 560 | ||
| 561 | while( tabIterator.hasNext() ) { | |
| 562 | final var tab = tabIterator.next(); | |
| 563 | final var resource = tab.getContent(); | |
| 564 | ||
| 565 | // The definition panes auto-save, so being specific here prevents | |
| 566 | // closing the definitions in the situation where the user wants to | |
| 567 | // continue editing (i.e., possibly save unsaved work). | |
| 568 | if( !(resource instanceof TextEditor) ) { | |
| 569 | continue; | |
| 570 | } | |
| 571 | ||
| 572 | if( canClose( (TextEditor) resource ) ) { | |
| 573 | tabIterator.remove(); | |
| 574 | close( tab ); | |
| 575 | } | |
| 576 | else { | |
| 577 | closable = false; | |
| 578 | } | |
| 579 | } | |
| 580 | } | |
| 581 | ||
| 582 | return closable; | |
| 583 | } | |
| 584 | ||
| 585 | /** | |
| 586 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 587 | * event. | |
| 588 | * | |
| 589 | * @param tab The {@link Tab} that was closed. | |
| 590 | */ | |
| 591 | private void close( final Tab tab ) { | |
| 592 | assert tab != null; | |
| 593 | ||
| 594 | final var handler = tab.getOnClosed(); | |
| 595 | ||
| 596 | if( handler != null ) { | |
| 597 | handler.handle( new ActionEvent() ); | |
| 598 | } | |
| 599 | } | |
| 600 | ||
| 601 | /** | |
| 602 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 603 | */ | |
| 604 | public void close() { | |
| 605 | final var editor = getTextEditor(); | |
| 606 | ||
| 607 | if( canClose( editor ) ) { | |
| 608 | close( editor ); | |
| 609 | } | |
| 610 | } | |
| 611 | ||
| 612 | /** | |
| 613 | * Closes the given {@link TextResource}. This must not be called from within | |
| 614 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 615 | * concurrent modification exception be thrown. | |
| 616 | * | |
| 617 | * @param resource The {@link TextResource} to close, without confirming with | |
| 618 | * the user. | |
| 619 | */ | |
| 620 | private void close( final TextResource resource ) { | |
| 621 | getTab( resource ).ifPresent( | |
| 622 | tab -> { | |
| 623 | close( tab ); | |
| 624 | tab.getTabPane().getTabs().remove( tab ); | |
| 625 | } | |
| 626 | ); | |
| 627 | } | |
| 628 | ||
| 629 | /** | |
| 630 | * Answers whether the given {@link TextResource} may be closed. | |
| 631 | * | |
| 632 | * @param editor The {@link TextResource} to try closing. | |
| 633 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 634 | * the user has requested to keep the editor open. | |
| 635 | */ | |
| 636 | private boolean canClose( final TextResource editor ) { | |
| 637 | final var editorTab = getTab( editor ); | |
| 638 | final var canClose = new AtomicBoolean( true ); | |
| 639 | ||
| 640 | if( editor.isModified() ) { | |
| 641 | final var filename = new StringBuilder(); | |
| 642 | editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); | |
| 643 | ||
| 644 | final var message = sNotifier.createNotification( | |
| 645 | Messages.get( "Alert.file.close.title" ), | |
| 646 | Messages.get( "Alert.file.close.text" ), | |
| 647 | filename.toString() | |
| 648 | ); | |
| 649 | ||
| 650 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 651 | ||
| 652 | dialog.showAndWait().ifPresent( | |
| 653 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 654 | ); | |
| 655 | } | |
| 656 | ||
| 657 | return canClose.get(); | |
| 658 | } | |
| 659 | ||
| 660 | private void iterateEditors( final Consumer<TextEditor> consumer ) { | |
| 661 | mTabPanes.forEach( | |
| 662 | tp -> tp.getTabs().forEach( tab -> { | |
| 663 | final var node = tab.getContent(); | |
| 664 | ||
| 665 | if( node instanceof final TextEditor editor ) { | |
| 666 | consumer.accept( editor ); | |
| 667 | } | |
| 668 | } ) | |
| 669 | ); | |
| 670 | } | |
| 671 | ||
| 672 | private ObjectProperty<TextEditor> createActiveTextEditor() { | |
| 673 | final var editor = new SimpleObjectProperty<TextEditor>(); | |
| 674 | ||
| 675 | editor.addListener( ( c, o, n ) -> { | |
| 676 | if( n != null ) { | |
| 677 | mPreview.setBaseUri( n.getPath() ); | |
| 678 | process( n ); | |
| 679 | } | |
| 680 | } ); | |
| 681 | ||
| 682 | return editor; | |
| 683 | } | |
| 684 | ||
| 685 | /** | |
| 686 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 687 | */ | |
| 688 | public void viewPreview() { | |
| 689 | viewTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 690 | } | |
| 691 | ||
| 692 | /** | |
| 693 | * Adds the document outline tab to its own, singular tab pane. | |
| 694 | */ | |
| 695 | public void viewOutline() { | |
| 696 | viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 697 | } | |
| 698 | ||
| 699 | public void viewStatistics() { | |
| 700 | viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 701 | } | |
| 702 | ||
| 703 | public void viewFiles() { | |
| 704 | try { | |
| 705 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 706 | final var fileManager = factory.createModeless(); | |
| 707 | viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 708 | } catch( final Exception ex ) { | |
| 709 | clue( ex ); | |
| 710 | } | |
| 711 | } | |
| 712 | ||
| 713 | private void viewTab( | |
| 714 | final Node node, final MediaType mediaType, final String key ) { | |
| 715 | final var tabPane = obtainTabPane( mediaType ); | |
| 716 | ||
| 717 | for( final var tab : tabPane.getTabs() ) { | |
| 718 | if( tab.getContent() == node ) { | |
| 719 | return; | |
| 720 | } | |
| 721 | } | |
| 722 | ||
| 723 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 724 | addTabPane( tabPane ); | |
| 725 | } | |
| 726 | ||
| 727 | public void viewRefresh() { | |
| 728 | mPreview.refresh(); | |
| 729 | Engine.clear(); | |
| 730 | mRBootstrapController.update(); | |
| 731 | } | |
| 732 | ||
| 733 | /** | |
| 734 | * Returns the tab that contains the given {@link TextEditor}. | |
| 735 | * | |
| 736 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 737 | * @return The first tab having content that matches the given tab. | |
| 738 | */ | |
| 739 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 740 | return mTabPanes.stream() | |
| 741 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 742 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 743 | .findFirst(); | |
| 744 | } | |
| 745 | ||
| 746 | /** | |
| 747 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 748 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 749 | * Upon changing, the variables are interpolated and the active text editor | |
| 750 | * is refreshed. | |
| 751 | * | |
| 752 | * @param textEditor Text editor to update with the revised resolved map. | |
| 753 | * @return A newly configured property that represents the active | |
| 754 | * {@link DefinitionEditor}, never null. | |
| 755 | */ | |
| 756 | private ObjectProperty<TextDefinition> createActiveDefinitionEditor( | |
| 757 | final ObjectProperty<TextEditor> textEditor ) { | |
| 758 | final var defEditor = new SimpleObjectProperty<>( | |
| 759 | createDefinitionEditor() | |
| 760 | ); | |
| 761 | ||
| 762 | defEditor.addListener( ( c, o, n ) -> { | |
| 763 | final var editor = textEditor.get(); | |
| 764 | ||
| 765 | if( editor.isMediaType( TEXT_R_MARKDOWN ) ) { | |
| 766 | // Initialize R before the editor is added. | |
| 767 | mRBootstrapController.update(); | |
| 768 | } | |
| 769 | ||
| 770 | process( editor ); | |
| 771 | } ); | |
| 772 | ||
| 773 | return defEditor; | |
| 774 | } | |
| 775 | ||
| 776 | private Tab createTab( final String filename, final Node node ) { | |
| 777 | return new DetachableTab( filename, node ); | |
| 778 | } | |
| 779 | ||
| 780 | private Tab createTab( final File file ) { | |
| 781 | final var r = createTextResource( file ); | |
| 782 | final var tab = createTab( r.getFilename(), r.getNode() ); | |
| 783 | ||
| 784 | r.modifiedProperty().addListener( | |
| 785 | ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") ) | |
| 786 | ); | |
| 787 | ||
| 788 | // This is called when either the tab is closed by the user clicking on | |
| 789 | // the tab's close icon or when closing (all) from the file menu. | |
| 790 | tab.setOnClosed( | |
| 791 | __ -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 792 | ); | |
| 793 | ||
| 794 | // When closing a tab, give focus to the newly revealed tab. | |
| 795 | tab.selectedProperty().addListener( ( c, o, n ) -> { | |
| 796 | if( n != null && n ) { | |
| 797 | final var pane = tab.getTabPane(); | |
| 798 | ||
| 799 | if( pane != null ) { | |
| 800 | pane.requestFocus(); | |
| 801 | } | |
| 802 | } | |
| 803 | } ); | |
| 804 | ||
| 805 | tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> { | |
| 806 | if( nPane != null ) { | |
| 807 | nPane.focusedProperty().addListener( ( c, o, n ) -> { | |
| 808 | if( n != null && n ) { | |
| 809 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 810 | final var node = selected.getContent(); | |
| 811 | node.requestFocus(); | |
| 812 | } | |
| 813 | } ); | |
| 814 | } | |
| 815 | } ); | |
| 816 | ||
| 817 | return tab; | |
| 818 | } | |
| 819 | ||
| 820 | /** | |
| 821 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 822 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 823 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 824 | * be replaced by such a class. | |
| 825 | * <p> | |
| 826 | * When binning the files, this makes sure that at least one file exists | |
| 827 | * for every type. If the user has opted to close a particular type (such | |
| 828 | * as the definition pane), the view will suppressed elsewhere. | |
| 829 | * </p> | |
| 830 | * <p> | |
| 831 | * The order that the binned files are returned will be reflected in the | |
| 832 | * order that the corresponding panes are rendered in the UI. | |
| 833 | * </p> | |
| 834 | * | |
| 835 | * @param paths The file paths to bin according to their type. | |
| 836 | * @return An in-order list of files, first by structured definition files, | |
| 837 | * then by plain text documents. | |
| 838 | */ | |
| 839 | private List<File> collect( final SetProperty<String> paths ) { | |
| 840 | // Treat all files destined for the text editor as plain text documents | |
| 841 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 842 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 843 | final Function<MediaType, MediaType> bin = | |
| 844 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 845 | ||
| 846 | // Create two groups: YAML files and plain text files. The order that | |
| 847 | // the elements are listed in the enumeration for media types determines | |
| 848 | // what files are loaded first. Variable definitions come before all other | |
| 849 | // plain text documents. | |
| 850 | final var bins = paths | |
| 851 | .stream() | |
| 852 | .collect( | |
| 853 | groupingBy( | |
| 854 | path -> bin.apply( MediaType.fromFilename( path ) ), | |
| 855 | () -> new TreeMap<>( Enum::compareTo ), | |
| 856 | Collectors.toList() | |
| 857 | ) | |
| 858 | ); | |
| 859 | ||
| 860 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 861 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 862 | ||
| 863 | final var result = new LinkedList<File>(); | |
| 864 | ||
| 865 | // Ensure that the same types are listed together (keep insertion order). | |
| 866 | bins.forEach( ( mediaType, files ) -> result.addAll( | |
| 867 | files.stream().map( File::new ).toList() ) | |
| 868 | ); | |
| 869 | ||
| 870 | return result; | |
| 871 | } | |
| 872 | ||
| 873 | /** | |
| 874 | * Force the active editor to update, which will cause the processor | |
| 875 | * to re-evaluate the interpolated definition map thereby updating the | |
| 876 | * preview pane. | |
| 877 | * | |
| 878 | * @param editor Contains the source document to update in the preview pane. | |
| 879 | */ | |
| 880 | private void process( final TextEditor editor ) { | |
| 881 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 882 | // text editor immediately for caret movement. The preview will have a | |
| 883 | // slight delay when catching up to the caret position. | |
| 884 | final var task = new Task<Void>() { | |
| 885 | @Override | |
| 886 | public Void call() { | |
| 887 | try { | |
| 888 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 889 | p.apply( editor == null ? "" : editor.getText() ); | |
| 890 | } catch( final Exception ex ) { | |
| 891 | clue( ex ); | |
| 892 | } | |
| 893 | ||
| 894 | return null; | |
| 895 | } | |
| 896 | }; | |
| 897 | ||
| 898 | // TODO: Each time the editor successfully runs the processor the task is | |
| 899 | // considered successful. Due to the rapid-fire nature of processing | |
| 900 | // (e.g., keyboard navigation, fast typing), it isn't necessary to | |
| 901 | // scroll each time. | |
| 902 | // The algorithm: | |
| 903 | // 1. Peek at the oldest time. | |
| 904 | // 2. If the difference between the oldest time and current time exceeds | |
| 905 | // 250 milliseconds, then invoke the scrolling. | |
| 906 | // 3. Insert the current time into the circular queue. | |
| 907 | task.setOnSucceeded( | |
| 908 | e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 909 | ); | |
| 910 | ||
| 911 | // Prevents multiple process requests from executing simultaneously (due | |
| 912 | // to having a restricted queue size). | |
| 913 | sExecutor.execute( task ); | |
| 914 | } | |
| 915 | ||
| 916 | /** | |
| 917 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 918 | * events. The tab pane is associated with a given media type so that | |
| 919 | * similar files can be grouped together. | |
| 920 | * | |
| 921 | * @param mediaType The media type to associate with the tab pane. | |
| 922 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 923 | */ | |
| 924 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 925 | for( final var pane : mTabPanes ) { | |
| 926 | for( final var tab : pane.getTabs() ) { | |
| 927 | final var node = tab.getContent(); | |
| 928 | ||
| 929 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 930 | return pane; | |
| 931 | } | |
| 932 | } | |
| 933 | } | |
| 934 | ||
| 935 | final var pane = createTabPane(); | |
| 936 | mTabPanes.add( pane ); | |
| 937 | return pane; | |
| 938 | } | |
| 939 | ||
| 940 | /** | |
| 941 | * Creates an initialized {@link TabPane} instance. | |
| 942 | * | |
| 943 | * @return A new {@link TabPane} with all listeners configured. | |
| 944 | */ | |
| 945 | private TabPane createTabPane() { | |
| 946 | final var tabPane = new DetachableTabPane(); | |
| 947 | ||
| 948 | initStageOwnerFactory( tabPane ); | |
| 949 | initTabListener( tabPane ); | |
| 950 | ||
| 951 | return tabPane; | |
| 952 | } | |
| 953 | ||
| 954 | /** | |
| 955 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 956 | * the stage owner factory must be given its parent window, which will | |
| 957 | * own the child window. The parent window is the {@link MainPane}'s | |
| 958 | * {@link Scene}'s {@link Window} instance. | |
| 959 | * | |
| 960 | * <p> | |
| 961 | * This will derives the new title from the main window title, incrementing | |
| 962 | * the window count to help uniquely identify the child windows. | |
| 963 | * </p> | |
| 964 | * | |
| 965 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 966 | */ | |
| 967 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 968 | tabPane.setStageOwnerFactory( stage -> { | |
| 969 | final var title = get( | |
| 970 | "Detach.tab.title", | |
| 971 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 972 | ); | |
| 973 | stage.setTitle( title ); | |
| 974 | ||
| 975 | return getScene().getWindow(); | |
| 976 | } ); | |
| 977 | } | |
| 978 | ||
| 979 | /** | |
| 980 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 981 | * it is added to the given {@link DetachableTabPane} instance. | |
| 982 | * <p> | |
| 983 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 984 | * is initialized to perform synchronized scrolling between the editor and | |
| 985 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 986 | * tabs is given focus. | |
| 987 | * </p> | |
| 988 | * <p> | |
| 989 | * Note that multiple tabs can be added simultaneously. | |
| 990 | * </p> | |
| 991 | * | |
| 992 | * @param tabPane A new {@link TabPane} to configure. | |
| 993 | */ | |
| 994 | private void initTabListener( final TabPane tabPane ) { | |
| 995 | tabPane.getTabs().addListener( | |
| 996 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 997 | while( listener.next() ) { | |
| 998 | if( listener.wasAdded() ) { | |
| 999 | final var tabs = listener.getAddedSubList(); | |
| 1000 | ||
| 1001 | tabs.forEach( tab -> { | |
| 1002 | final var node = tab.getContent(); | |
| 1003 | ||
| 1004 | if( node instanceof TextEditor ) { | |
| 1005 | initScrollEventListener( tab ); | |
| 1006 | } | |
| 1007 | } ); | |
| 1008 | ||
| 1009 | // Select and give focus to the last tab opened. | |
| 1010 | final var index = tabs.size() - 1; | |
| 1011 | if( index >= 0 ) { | |
| 1012 | final var tab = tabs.get( index ); | |
| 1013 | tabPane.getSelectionModel().select( tab ); | |
| 1014 | tab.getContent().requestFocus(); | |
| 1015 | } | |
| 1016 | } | |
| 1017 | } | |
| 1018 | } | |
| 1019 | ); | |
| 1020 | } | |
| 1021 | ||
| 1022 | /** | |
| 1023 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 1024 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 1025 | * | |
| 1026 | * @param tab The container for an instance of {@link TextEditor}. | |
| 1027 | */ | |
| 1028 | private void initScrollEventListener( final Tab tab ) { | |
| 1029 | final var editor = (TextEditor) tab.getContent(); | |
| 1030 | final var scrollPane = editor.getScrollPane(); | |
| 1031 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 1032 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 1033 | ||
| 1034 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 1035 | } | |
| 1036 | ||
| 1037 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 1038 | final var items = getItems(); | |
| 1039 | ||
| 1040 | if( !items.contains( tabPane ) ) { | |
| 1041 | items.add( index, tabPane ); | |
| 1042 | } | |
| 1043 | } | |
| 1044 | ||
| 1045 | private void addTabPane( final TabPane tabPane ) { | |
| 1046 | addTabPane( getItems().size(), tabPane ); | |
| 1047 | } | |
| 1048 | ||
| 1049 | private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { | |
| 1050 | final var w = getWorkspace(); | |
| 1051 | ||
| 1052 | return builder() | |
| 1053 | .with( Mutator::setDefinitions, this::getDefinitions ) | |
| 1054 | .with( Mutator::setLocale, w::getLocale ) | |
| 1055 | .with( Mutator::setMetadata, w::getMetadata ) | |
| 1056 | .with( Mutator::setThemesPath, w::getThemesPath ) | |
| 1057 | .with( Mutator::setCachesPath, | |
| 1058 | () -> w.getFile( KEY_CACHES_DIR ) ) | |
| 1059 | .with( Mutator::setImagesPath, | |
| 1060 | () -> w.getFile( KEY_IMAGES_DIR ) ) | |
| 1061 | .with( Mutator::setImageOrder, | |
| 1062 | () -> w.getString( KEY_IMAGES_ORDER ) ) | |
| 1063 | .with( Mutator::setImageServer, | |
| 1064 | () -> w.getString( KEY_IMAGES_SERVER ) ) | |
| 1065 | .with( Mutator::setFontsPath, | |
| 1066 | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) | |
| 1067 | .with( Mutator::setCaret, | |
| 1068 | () -> getTextEditor().getCaret() ) | |
| 1069 | .with( Mutator::setSigilBegan, | |
| 1070 | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | |
| 1071 | .with( Mutator::setSigilEnded, | |
| 1072 | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | |
| 1073 | .with( Mutator::setRScript, | |
| 1074 | () -> w.getString( KEY_R_SCRIPT ) ) | |
| 1075 | .with( Mutator::setRWorkingDir, | |
| 1076 | () -> w.getFile( KEY_R_DIR ).toPath() ) | |
| 1077 | .with( Mutator::setCurlQuotes, | |
| 1078 | () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 1079 | .with( Mutator::setAutoRemove, | |
| 1080 | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | |
| 1081 | } | |
| 1082 | ||
| 1083 | public ProcessorContext createProcessorContext() { | |
| 1084 | return createProcessorContext( null, NONE ); | |
| 1085 | } | |
| 1086 | ||
| 1087 | /** | |
| 1088 | * @param targetPath Used when exporting to a PDF file (binary). | |
| 1089 | * @param format Used when processors export to a new text format. | |
| 1090 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1091 | * {@link Processor}. | |
| 1092 | */ | |
| 1093 | public ProcessorContext createProcessorContext( | |
| 1094 | final Path targetPath, final ExportFormat format ) { | |
| 1095 | final var textEditor = getTextEditor(); | |
| 1096 | final var sourcePath = textEditor.getPath(); | |
| 1097 | ||
| 1098 | return processorContextBuilder() | |
| 1099 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1100 | .with( Mutator::setTargetPath, targetPath ) | |
| 1101 | .with( Mutator::setExportFormat, format ) | |
| 1102 | .build(); | |
| 1103 | } | |
| 1104 | ||
| 1105 | /** | |
| 1106 | * @param sourcePath Used by {@link ProcessorFactory} to determine | |
| 1107 | * {@link Processor} type to create based on file type. | |
| 1108 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1109 | * {@link Processor}. | |
| 1110 | */ | |
| 1111 | private ProcessorContext createProcessorContext( final Path sourcePath ) { | |
| 1112 | return processorContextBuilder() | |
| 1113 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1114 | .with( Mutator::setExportFormat, NONE ) | |
| 1115 | .build(); | |
| 1116 | } | |
| 1117 | ||
| 1118 | private TextResource createTextResource( final File file ) { | |
| 1119 | // TODO: Create PlainTextEditor that's returned by default. | |
| 1120 | return MediaType.valueFrom( file ) == TEXT_YAML | |
| 1121 | ? createDefinitionEditor( file ) | |
| 1122 | : createMarkdownEditor( file ); | |
| 1123 | } | |
| 1124 | ||
| 1125 | /** | |
| 1126 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 1127 | * caret change events and text change events. Text change events must | |
| 1128 | * take priority over caret change events because it's possible to change | |
| 1129 | * the text without moving the caret (e.g., delete selected text). | |
| 1130 | * | |
| 1131 | * @param inputFile The file containing contents for the text editor. | |
| 1132 | * @return A non-null text editor. | |
| 1133 | */ | |
| 1134 | private MarkdownEditor createMarkdownEditor( final File inputFile ) { | |
| 1135 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 1136 | ||
| 1137 | updateProcessors( editor ); | |
| 1138 | ||
| 1139 | // Listener for editor modifications or caret position changes. | |
| 1140 | editor.addDirtyListener( ( c, o, n ) -> { | |
| 1141 | if( n ) { | |
| 1142 | // Reset the status bar after changing the text. | |
| 1143 | clue(); | |
| 1144 | ||
| 1145 | // Processing the text may update the status bar. | |
| 1146 | process( getTextEditor() ); | |
| 1147 | ||
| 1148 | // Update the caret position in the status bar. | |
| 1149 | CaretMovedEvent.fire( editor.getCaret() ); | |
| 1150 | } | |
| 1151 | } ); | |
| 1152 | ||
| 1153 | editor.addEventListener( | |
| 1154 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1155 | ); | |
| 1156 | ||
| 1157 | editor.addEventListener( | |
| 1158 | keyPressed( ENTER, ALT_DOWN ), event -> mEditorSpeller.autofix( editor ) | |
| 1159 | ); | |
| 1160 | ||
| 1161 | final var textArea = editor.getTextArea(); | |
| 1162 | ||
| 1163 | // Spell check when the paragraph changes. | |
| 1164 | textArea | |
| 1165 | .plainTextChanges() | |
| 1166 | .filter( p -> !p.isIdentity() ) | |
| 1167 | .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); | |
| 1168 | ||
| 1169 | // Store the caret position to restore it after restarting the application. | |
| 1170 | textArea.caretPositionProperty().addListener( | |
| 1171 | ( c, o, n ) -> | |
| 1172 | getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) | |
| 1173 | ); | |
| 1174 | ||
| 1175 | // Set the active editor, which refreshes the preview panel. | |
| 1176 | mTextEditor.set( editor ); | |
| 1177 | ||
| 1178 | // Check the entire document after the spellchecker is initialized (with | |
| 1179 | // a valid lexicon) so that only the current paragraph need be scanned | |
| 1180 | // while editing. (Technically, only the most recently modified word must | |
| 1181 | // be scanned.) | |
| 1182 | mSpellChecker.addListener( | |
| 1183 | ( c, o, n ) -> runLater( | |
| 1184 | () -> iterateEditors( mEditorSpeller::checkDocument ) | |
| 1185 | ) | |
| 1186 | ); | |
| 1187 | ||
| 1188 | // Check the entire document after it has been loaded. | |
| 1189 | mEditorSpeller.checkDocument( mTextEditor.get() ); | |
| 1190 | ||
| 1191 | return editor; | |
| 1192 | } | |
| 1193 | ||
| 1194 | /** | |
| 1195 | * Creates a processor for an editor, provided one doesn't already exist. | |
| 1196 | * | |
| 1197 | * @param editor The editor that potentially requires an associated processor. | |
| 1198 | */ | |
| 1199 | private void updateProcessors( final TextEditor editor ) { | |
| 1200 | final var path = editor.getFile().toPath(); | |
| 1201 | ||
| 1202 | mProcessors.computeIfAbsent( | |
| 1203 | editor, p -> createProcessors( | |
| 1204 | createProcessorContext( path ), | |
| 1205 | createHtmlPreviewProcessor() | |
| 1206 | ) | |
| 1207 | ); | |
| 1208 | } | |
| 1209 | ||
| 1210 | /** | |
| 1211 | * Removes a processor for an editor. This is required because a file may | |
| 1212 | * change type while editing (e.g., from plain Markdown to R Markdown). | |
| 1213 | * In the case that an editor's type changes, its associated processor must | |
| 1214 | * be changed accordingly. | |
| 1215 | * | |
| 1216 | * @param editor The editor that potentially requires an associated processor. | |
| 1217 | */ | |
| 1218 | private void removeProcessor( final TextEditor editor ) { | |
| 1219 | mProcessors.remove( editor ); | |
| 1184 | 1220 | } |
| 1185 | 1221 |
| 172 | 172 | |
| 173 | 173 | /** |
| 174 | * Scaling factor for rendering mathematics. | |
| 175 | */ | |
| 176 | public static final double FONT_SIZE_MATH_DEFAULT = 2; | |
| 177 | ||
| 178 | /** | |
| 174 | 179 | * Default monospace preview font name. |
| 175 | 180 | */ |
| 2 | 2 | package com.keenwrite.editors.definition; |
| 3 | 3 | |
| 4 | import com.keenwrite.util.Diacritics; | |
| 4 | 5 | import javafx.scene.control.TreeItem; |
| 5 | 6 | |
| 6 | 7 | import java.util.Stack; |
| 7 | 8 | import java.util.function.BiFunction; |
| 8 | ||
| 9 | import static java.text.Normalizer.Form.NFD; | |
| 10 | import static java.text.Normalizer.normalize; | |
| 11 | 9 | |
| 12 | 10 | /** |
| ... | ||
| 107 | 105 | |
| 108 | 106 | return null; |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * Returns the value of the string without diacritic marks. | |
| 113 | * | |
| 114 | * @return A non-null, possibly empty string. | |
| 115 | */ | |
| 116 | private String getDiacriticlessValue() { | |
| 117 | return normalize( getValue().toString(), NFD ) | |
| 118 | .replaceAll( "\\p{M}", "" ); | |
| 119 | 107 | } |
| 120 | 108 | |
| ... | ||
| 127 | 115 | private boolean valueEquals( final String s ) { |
| 128 | 116 | return isLeaf() && getValue().equals( s ); |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Removes diacritic characters from the given definition item. | |
| 121 | * | |
| 122 | * @param item The {@link DefinitionTreeItem} to strip of diacritics. | |
| 123 | * @param <T> The type of item contained by {@link DefinitionTreeItem}s. | |
| 124 | * @return The given item, without any accented characters. | |
| 125 | */ | |
| 126 | private static <T> String removeAccents( final DefinitionTreeItem<T> item ) { | |
| 127 | return Diacritics.remove( item.getValue().toString() ); | |
| 129 | 128 | } |
| 130 | 129 | |
| 131 | 130 | /** |
| 132 | 131 | * Returns true if this node is a leaf and its value contains the given text. |
| 133 | 132 | * |
| 134 | 133 | * @param s The text to compare against the node value. |
| 135 | 134 | * @return true Node is a leaf and its value contains the given value. |
| 136 | 135 | */ |
| 137 | 136 | private boolean valueContains( final String s ) { |
| 138 | return isLeaf() && getDiacriticlessValue().contains( s ); | |
| 137 | return isLeaf() && removeAccents( this ).contains( s ); | |
| 139 | 138 | } |
| 140 | 139 | |
| 141 | 140 | /** |
| 142 | 141 | * Returns true if this node is a leaf and its value contains the given text. |
| 143 | 142 | * |
| 144 | 143 | * @param s The text to compare against the node value. |
| 145 | 144 | * @return true Node is a leaf and its value contains the given value. |
| 146 | 145 | */ |
| 147 | 146 | private boolean valueContainsNoCase( final String s ) { |
| 148 | return isLeaf() && | |
| 149 | getDiacriticlessValue().toLowerCase().contains( s.toLowerCase() ); | |
| 147 | return isLeaf() && removeAccents( this ) | |
| 148 | .toLowerCase() | |
| 149 | .contains( s.toLowerCase() ); | |
| 150 | 150 | } |
| 151 | 151 | |
| ... | ||
| 158 | 158 | */ |
| 159 | 159 | private boolean valueStartsWith( final String s ) { |
| 160 | return isLeaf() && getDiacriticlessValue().startsWith( s ); | |
| 160 | return isLeaf() && removeAccents( this ).startsWith( s ); | |
| 161 | 161 | } |
| 162 | 162 | |
| 520 | 520 | |
| 521 | 521 | mTextArea.replaceSelection( newText ); |
| 522 | mTextArea.requestFollowCaret(); | |
| 522 | 523 | } |
| 523 | 524 |
| 64 | 64 | public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" ); |
| 65 | 65 | public static final Key KEY_UI_FONT_PREVIEW_MONO_SIZE = key( KEY_UI_FONT_PREVIEW_MONO, "size" ); |
| 66 | public static final Key KEY_UI_FONT_MATH = key( KEY_UI_FONT, "math" ); | |
| 67 | public static final Key KEY_UI_FONT_MATH_SIZE = key( KEY_UI_FONT_MATH, "size" ); | |
| 66 | 68 | |
| 67 | 69 | public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" ); |
| 272 | 272 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ), |
| 273 | 273 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ) |
| 274 | ), | |
| 275 | Group.of( | |
| 276 | get( KEY_UI_FONT_MATH ), | |
| 277 | Setting.of( title( KEY_UI_FONT_MATH_SIZE ), | |
| 278 | doubleProperty( KEY_UI_FONT_MATH_SIZE ) ) | |
| 274 | 279 | ) |
| 275 | 280 | ), |
| ... | ||
| 404 | 409 | } |
| 405 | 410 | |
| 406 | @SuppressWarnings( "SameParameterValue" ) | |
| 407 | 411 | private IntegerProperty integerProperty( final Key key ) { |
| 408 | 412 | return mWorkspace.integerProperty( key ); |
| 409 | 413 | } |
| 410 | 414 | |
| 411 | @SuppressWarnings( "SameParameterValue" ) | |
| 412 | 415 | private DoubleProperty doubleProperty( final Key key ) { |
| 413 | 416 | return mWorkspace.doubleProperty( key ); |
| 112 | 112 | asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT ) |
| 113 | 113 | ), |
| 114 | entry( | |
| 115 | KEY_UI_FONT_MATH_SIZE, | |
| 116 | asDoubleProperty( FONT_SIZE_MATH_DEFAULT ) | |
| 117 | ), | |
| 114 | 118 | |
| 115 | 119 | entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ), |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 4 | import com.whitemagicsoftware.tex.*; | |
| 5 | import com.whitemagicsoftware.tex.graphics.SvgDomGraphics2D; | |
| 4 | import com.keenwrite.dom.DocumentParser; | |
| 5 | import com.whitemagicsoftware.keentype.lib.KeenType; | |
| 6 | import javafx.beans.property.DoubleProperty; | |
| 7 | import javafx.beans.property.SimpleDoubleProperty; | |
| 6 | 8 | import org.w3c.dom.Document; |
| 7 | ||
| 8 | import java.util.function.Supplier; | |
| 9 | 9 | |
| 10 | 10 | import static com.keenwrite.events.StatusEvent.clue; |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | 13 | * Responsible for rendering formulas as scalable vector graphics (SVG). |
| 14 | 14 | */ |
| 15 | 15 | public final class MathRenderer { |
| 16 | 16 | |
| 17 | /** | |
| 18 | * Singleton instance for rendering math symbols. | |
| 19 | */ | |
| 20 | public static final MathRenderer MATH_RENDERER = new MathRenderer(); | |
| 17 | private static KeenType sTypesetter; | |
| 21 | 18 | |
| 22 | /** | |
| 23 | * Default font size in points. | |
| 24 | */ | |
| 25 | private static final float FONT_SIZE = 20f; | |
| 19 | static { | |
| 20 | try { | |
| 21 | sTypesetter = new KeenType( false ); | |
| 22 | } catch( final Exception e ) { | |
| 23 | clue( e ); | |
| 24 | } | |
| 25 | } | |
| 26 | 26 | |
| 27 | private final TeXFont mTeXFont = createDefaultTeXFont( FONT_SIZE ); | |
| 28 | private final TeXEnvironment mEnvironment = createTeXEnvironment( mTeXFont ); | |
| 29 | private final SvgDomGraphics2D mGraphics = createSvgDomGraphics2D(); | |
| 27 | private static final DoubleProperty sSize = new SimpleDoubleProperty( 2 ); | |
| 30 | 28 | |
| 31 | private MathRenderer() { | |
| 32 | mGraphics.scale( FONT_SIZE, FONT_SIZE ); | |
| 29 | private MathRenderer() { } | |
| 30 | ||
| 31 | public static void bindSize( final DoubleProperty size ) { | |
| 32 | sSize.bind( size ); | |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | /** |
| 36 | * This method only takes a few seconds to generate | |
| 36 | * Converts a TeX-based equation into an SVG document. | |
| 37 | 37 | * |
| 38 | * @param equation A mathematical expression to render. | |
| 38 | * @param equation A mathematical expression to render, without sigils. | |
| 39 | 39 | * @return The given string with all formulas transformed into SVG format. |
| 40 | 40 | */ |
| 41 | public Document render( final String equation ) { | |
| 42 | final var formula = new TeXFormula( equation ); | |
| 43 | final var box = formula.createBox( mEnvironment ); | |
| 44 | final var l = new TeXLayout( box, FONT_SIZE ); | |
| 45 | ||
| 46 | mGraphics.initialize( l.getWidth(), l.getHeight() ); | |
| 47 | box.draw( mGraphics, l.getX(), l.getY() ); | |
| 48 | return mGraphics.toDom(); | |
| 49 | } | |
| 50 | ||
| 51 | @SuppressWarnings("SameParameterValue") | |
| 52 | private TeXFont createDefaultTeXFont( final float fontSize ) { | |
| 53 | return create( () -> new DefaultTeXFont( fontSize ) ); | |
| 54 | } | |
| 55 | ||
| 56 | private TeXEnvironment createTeXEnvironment( final TeXFont texFont ) { | |
| 57 | return create( () -> new TeXEnvironment( texFont ) ); | |
| 58 | } | |
| 59 | ||
| 60 | private SvgDomGraphics2D createSvgDomGraphics2D() { | |
| 61 | return create( SvgDomGraphics2D::new ); | |
| 41 | public static Document toDocument( final String equation ) { | |
| 42 | return DocumentParser.parse( toString( equation ) ); | |
| 62 | 43 | } |
| 63 | 44 | |
| 64 | 45 | /** |
| 65 | * Tries to instantiate a given object, returning {@code null} on failure. | |
| 66 | * The failure message is bubbled up to the user interface. | |
| 46 | * Converts a TeX-based equation into an SVG document. | |
| 67 | 47 | * |
| 68 | * @param supplier Creates an instance. | |
| 69 | * @param <T> The type of instance being created. | |
| 70 | * @return An instance of the parameterized type or {@code null} upon error. | |
| 48 | * @param equation A mathematical expression to render, without sigils. | |
| 49 | * @return The given string with all formulas transformed into SVG format. | |
| 71 | 50 | */ |
| 72 | private <T> T create( final Supplier<T> supplier ) { | |
| 73 | try { | |
| 74 | return supplier.get(); | |
| 75 | } catch( final Exception ex ) { | |
| 76 | clue( ex ); | |
| 77 | return null; | |
| 78 | } | |
| 51 | public static String toString( final String equation ) { | |
| 52 | return sTypesetter.toSvg( "$" + equation + "$", sSize.doubleValue() ); | |
| 79 | 53 | } |
| 80 | 54 | } |
| 8 | 8 | import io.sf.carte.echosvg.bridge.UserAgentAdapter; |
| 9 | 9 | import io.sf.carte.echosvg.gvt.renderer.ImageRenderer; |
| 10 | import io.sf.carte.echosvg.transcoder.ErrorHandler; | |
| 11 | import io.sf.carte.echosvg.transcoder.TranscoderException; | |
| 12 | import io.sf.carte.echosvg.transcoder.TranscoderInput; | |
| 13 | import io.sf.carte.echosvg.transcoder.TranscoderOutput; | |
| 14 | import io.sf.carte.echosvg.transcoder.image.ImageTranscoder; | |
| 15 | import org.w3c.dom.Document; | |
| 16 | import org.w3c.dom.Element; | |
| 17 | ||
| 18 | import java.awt.*; | |
| 19 | import java.awt.image.BufferedImage; | |
| 20 | import java.io.File; | |
| 21 | import java.io.InputStream; | |
| 22 | import java.io.StringReader; | |
| 23 | import java.net.URI; | |
| 24 | import java.nio.file.Path; | |
| 25 | import java.text.NumberFormat; | |
| 26 | import java.text.ParseException; | |
| 27 | ||
| 28 | import static com.keenwrite.dom.DocumentParser.transform; | |
| 29 | import static com.keenwrite.events.StatusEvent.clue; | |
| 30 | import static com.keenwrite.preview.HighQualityRenderingHints.RENDERING_HINTS; | |
| 31 | import static io.sf.carte.echosvg.bridge.UnitProcessor.*; | |
| 32 | import static io.sf.carte.echosvg.transcoder.SVGAbstractTranscoder.KEY_WIDTH; | |
| 33 | import static io.sf.carte.echosvg.transcoder.TranscodingHints.Key; | |
| 34 | import static io.sf.carte.echosvg.transcoder.image.ImageTranscoder.*; | |
| 35 | import static io.sf.carte.echosvg.util.SVGConstants.SVG_WIDTH_ATTRIBUTE; | |
| 36 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; | |
| 37 | import static java.text.NumberFormat.getIntegerInstance; | |
| 38 | ||
| 39 | /** | |
| 40 | * Responsible for converting SVG images into rasterized PNG images. | |
| 41 | */ | |
| 42 | public final class SvgRasterizer { | |
| 43 | ||
| 44 | /** | |
| 45 | * Prevent rudely barfing stack traces to the console. | |
| 46 | */ | |
| 47 | private static final class SvgErrorHandler implements ErrorHandler { | |
| 48 | @Override | |
| 49 | public void error( final TranscoderException ex ) { | |
| 50 | clue( ex ); | |
| 51 | } | |
| 52 | ||
| 53 | @Override | |
| 54 | public void fatalError( final TranscoderException ex ) { | |
| 55 | clue( ex ); | |
| 56 | } | |
| 57 | ||
| 58 | @Override | |
| 59 | public void warning( final TranscoderException ex ) { | |
| 60 | clue( ex ); | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | private static final UserAgent USER_AGENT = new UserAgentAdapter(); | |
| 65 | private static final BridgeContext BRIDGE_CONTEXT = new BridgeContext( | |
| 66 | USER_AGENT, new DocumentLoader( USER_AGENT ) | |
| 67 | ); | |
| 68 | private static final ErrorHandler sErrorHandler = new SvgErrorHandler(); | |
| 69 | ||
| 70 | private static final SAXSVGDocumentFactory FACTORY_DOM = | |
| 71 | new SAXSVGDocumentFactory(); | |
| 72 | ||
| 73 | private static final NumberFormat INT_FORMAT = getIntegerInstance(); | |
| 74 | ||
| 75 | public static final BufferedImage BROKEN_IMAGE_PLACEHOLDER; | |
| 76 | ||
| 77 | /** | |
| 78 | * A FontAwesome camera icon, cleft asunder. | |
| 79 | */ | |
| 80 | public static final String BROKEN_IMAGE_SVG = | |
| 81 | "<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www" + | |
| 82 | ".w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c" + | |
| 83 | ".332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path " + | |
| 84 | "d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531" + | |
| 85 | ".03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2" + | |
| 86 | ".511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-" + | |
| 87 | ".367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1" + | |
| 88 | ".699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 " + | |
| 89 | "2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094" + | |
| 90 | ".0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5" + | |
| 91 | ".0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-" + | |
| 92 | ".195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4" + | |
| 93 | ".191406-3.652344.207031-.015625.414063-.015625.617187-.007812l" + | |
| 94 | ".933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2" + | |
| 95 | ".820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3" + | |
| 96 | ".429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3" + | |
| 97 | ".429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 " + | |
| 98 | ".140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281" + | |
| 99 | ".808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2" + | |
| 100 | ".472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4" + | |
| 101 | ".285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1" + | |
| 102 | ".9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-" + | |
| 103 | ".667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1" + | |
| 104 | ".253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 " + | |
| 105 | "0'/></g></svg>"; | |
| 106 | ||
| 107 | static { | |
| 108 | // The width and height cannot be embedded in the SVG above because the | |
| 109 | // path element values are relative to the viewBox dimensions. | |
| 110 | final int w = 75; | |
| 111 | final int h = 75; | |
| 112 | BufferedImage image; | |
| 113 | ||
| 114 | try { | |
| 115 | image = rasterizeString( BROKEN_IMAGE_SVG, w ); | |
| 116 | } catch( final Exception ex ) { | |
| 117 | image = new BufferedImage( w, h, TYPE_INT_RGB ); | |
| 118 | final var graphics = (Graphics2D) image.getGraphics(); | |
| 119 | graphics.setRenderingHints( RENDERING_HINTS ); | |
| 120 | ||
| 121 | // Fall back to a (\) symbol. | |
| 122 | graphics.setColor( new Color( 204, 204, 204 ) ); | |
| 123 | graphics.fillRect( 0, 0, w, h ); | |
| 124 | graphics.setColor( new Color( 255, 204, 204 ) ); | |
| 125 | graphics.setStroke( new BasicStroke( 4 ) ); | |
| 126 | graphics.drawOval( w / 4, h / 4, w / 2, h / 2 ); | |
| 127 | graphics.drawLine( w / 4 + (int) (w / 4 / Math.PI), | |
| 128 | h / 4 + (int) (w / 4 / Math.PI), | |
| 129 | w / 2 + w / 4 - (int) (w / 4 / Math.PI), | |
| 130 | h / 2 + h / 4 - (int) (w / 4 / Math.PI) ); | |
| 131 | } | |
| 132 | ||
| 133 | BROKEN_IMAGE_PLACEHOLDER = image; | |
| 134 | } | |
| 135 | ||
| 136 | /** | |
| 137 | * Responsible for creating a new {@link ImageRenderer} implementation that | |
| 138 | * can render a DOM as an SVG image. | |
| 139 | */ | |
| 140 | private static class BufferedImageTranscoder extends ImageTranscoder { | |
| 141 | private BufferedImage mImage; | |
| 142 | ||
| 143 | /** | |
| 144 | * Prevent barfing a stack trace when the transcoder encounters problems | |
| 145 | * parsing SVG contents. | |
| 146 | */ | |
| 147 | @Override | |
| 148 | protected UserAgent createUserAgent() { | |
| 149 | return new SVGAbstractTranscoderUserAgent() { | |
| 150 | @Override | |
| 151 | public void displayError( final Exception ex ) { | |
| 152 | clue( ex ); | |
| 153 | } | |
| 154 | }; | |
| 155 | } | |
| 156 | ||
| 157 | @Override | |
| 158 | public BufferedImage createImage( final int w, final int h ) { | |
| 159 | return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB ); | |
| 160 | } | |
| 161 | ||
| 162 | @Override | |
| 163 | public void writeImage( | |
| 164 | final BufferedImage image, final TranscoderOutput output ) { | |
| 165 | mImage = image; | |
| 166 | } | |
| 167 | ||
| 168 | public BufferedImage getImage() { | |
| 169 | return mImage; | |
| 170 | } | |
| 171 | ||
| 172 | @Override | |
| 173 | protected ImageRenderer createRenderer() { | |
| 174 | final ImageRenderer renderer = super.createRenderer(); | |
| 175 | final RenderingHints hints = renderer.getRenderingHints(); | |
| 176 | hints.putAll( RENDERING_HINTS ); | |
| 177 | renderer.setRenderingHints( hints ); | |
| 178 | ||
| 179 | return renderer; | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | /** | |
| 184 | * Rasterizes the given SVG input stream into an image at 96 DPI. | |
| 185 | * | |
| 186 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 187 | * @return The given input stream converted to a rasterized image. | |
| 188 | */ | |
| 189 | public static BufferedImage rasterize( final InputStream svg ) | |
| 190 | throws TranscoderException { | |
| 191 | return rasterize( svg, 96 ); | |
| 192 | } | |
| 193 | ||
| 194 | /** | |
| 195 | * Rasterizes the given SVG input stream into an image. | |
| 196 | * | |
| 197 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 198 | * @param dpi Resolution to use when rasterizing (default is 96 DPI). | |
| 199 | * @return The given input stream converted to a rasterized image at the | |
| 200 | * given resolution. | |
| 201 | */ | |
| 202 | public static BufferedImage rasterize( | |
| 203 | final InputStream svg, final float dpi ) throws TranscoderException { | |
| 204 | return rasterize( | |
| 205 | new TranscoderInput( svg ), | |
| 206 | KEY_PIXEL_UNIT_TO_MILLIMETER, | |
| 207 | 1f / dpi * 25.4f | |
| 208 | ); | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Rasterizes the given document into an image. | |
| 213 | * | |
| 214 | * @param svg The SVG {@link Document} to rasterize. | |
| 215 | * @param width The rasterized image's width (in pixels). | |
| 216 | * @return The rasterized image. | |
| 217 | */ | |
| 218 | public static BufferedImage rasterize( | |
| 219 | final Document svg, final int width ) throws TranscoderException { | |
| 220 | return rasterize( | |
| 221 | new TranscoderInput( svg ), | |
| 222 | KEY_WIDTH, | |
| 223 | fit( svg.getDocumentElement(), width ) | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Rasterizes the given vector graphic file using the width dimension | |
| 229 | * specified by the document's width attribute. | |
| 230 | * | |
| 231 | * @param document The {@link Document} containing a vector graphic. | |
| 232 | * @return A rasterized image as an instance of {@link BufferedImage}, or | |
| 233 | * {@link #BROKEN_IMAGE_PLACEHOLDER} if the graphic could not be rasterized. | |
| 234 | */ | |
| 235 | public static BufferedImage rasterize( final Document document ) | |
| 236 | throws ParseException, TranscoderException { | |
| 237 | final var root = document.getDocumentElement(); | |
| 238 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 239 | return rasterize( document, INT_FORMAT.parse( width ).intValue() ); | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 244 | * happens, a broken image icon is returned instead. | |
| 245 | * | |
| 246 | * @param path The {@link Path} to a vector graphic file. | |
| 247 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 248 | * maintained. | |
| 249 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 250 | */ | |
| 251 | public static BufferedImage rasterize( final Path path, final int width ) { | |
| 252 | return rasterize( path.toUri(), width ); | |
| 253 | } | |
| 254 | ||
| 255 | /** | |
| 256 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 257 | * happens, a broken image icon is returned instead. | |
| 258 | * | |
| 259 | * @param uri The URI to a vector graphic file, which must include the | |
| 260 | * protocol scheme (such as <code>file://</code> or | |
| 261 | * <code>https://</code>). | |
| 262 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 263 | * maintained. | |
| 264 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 265 | */ | |
| 266 | public static BufferedImage rasterize( final String uri, final int width ) { | |
| 267 | return rasterize( new File( uri ).toURI(), width ); | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * Converts an SVG drawing into a rasterized image that can be drawn on | |
| 272 | * a graphics context. | |
| 273 | * | |
| 274 | * @param uri The path to the image (can be web address). | |
| 275 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 276 | * maintained. | |
| 277 | * @return The vector graphic transcoded into a raster image format. | |
| 278 | */ | |
| 279 | public static BufferedImage rasterize( final URI uri, final int width ) { | |
| 280 | try { | |
| 281 | return rasterize( FACTORY_DOM.createDocument( uri.toString() ), width ); | |
| 282 | } catch( final Exception ex ) { | |
| 283 | clue( ex ); | |
| 284 | } | |
| 285 | ||
| 286 | return BROKEN_IMAGE_PLACEHOLDER; | |
| 287 | } | |
| 288 | ||
| 289 | /** | |
| 290 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 291 | * a graphics context. The dimensions are determined from the document. | |
| 292 | * | |
| 293 | * @param xml The SVG xml document. | |
| 294 | * @return The vector graphic transcoded into a raster image format. | |
| 295 | */ | |
| 296 | public static BufferedImage rasterizeString( final String xml ) | |
| 297 | throws ParseException, TranscoderException { | |
| 298 | final var document = toDocument( xml ); | |
| 299 | final var root = document.getDocumentElement(); | |
| 300 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 301 | ||
| 302 | return rasterizeString( xml, INT_FORMAT.parse( width ).intValue() ); | |
| 303 | } | |
| 304 | ||
| 305 | /** | |
| 306 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 307 | * a graphics context. | |
| 308 | * | |
| 309 | * @param svg The SVG xml document. | |
| 310 | * @param w Scale the image width to this size (aspect ratio is | |
| 311 | * maintained). | |
| 312 | * @return The vector graphic transcoded into a raster image format. | |
| 313 | */ | |
| 314 | public static BufferedImage rasterizeString( final String svg, final int w ) | |
| 315 | throws TranscoderException { | |
| 316 | return rasterize( toDocument( svg ), w ); | |
| 317 | } | |
| 318 | ||
| 319 | /** | |
| 320 | * Given a document object model (DOM) {@link Element}, this will convert that | |
| 321 | * element to a string. | |
| 322 | * | |
| 323 | * @param root The DOM node to convert to a string. | |
| 324 | * @return The DOM node as an escaped, plain text string. | |
| 325 | */ | |
| 326 | public static String toSvg( final Element root ) { | |
| 327 | try { | |
| 328 | return transform( root ).replaceAll( "xmlns=\"\" ", "" ); | |
| 329 | } catch( final Exception ex ) { | |
| 330 | clue( ex ); | |
| 331 | } | |
| 332 | ||
| 333 | return BROKEN_IMAGE_SVG; | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Converts an SVG XML string into a new {@link Document} instance. | |
| 338 | * | |
| 339 | * @param xml The XML containing SVG elements. | |
| 340 | * @return The SVG contents parsed into a {@link Document} object model. | |
| 341 | */ | |
| 342 | private static Document toDocument( final String xml ) { | |
| 343 | try( final var reader = new StringReader( xml ) ) { | |
| 344 | return FACTORY_DOM.createSVGDocument( | |
| 345 | "http://www.w3.org/2000/svg", reader ); | |
| 346 | } catch( final Exception ex ) { | |
| 347 | throw new IllegalArgumentException( ex ); | |
| 348 | } | |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * Creates a rasterized image of the given source document. | |
| 353 | * | |
| 354 | * @param input The source document to transcode. | |
| 355 | * @param key Transcoding hint key. | |
| 356 | * @param width Transcoding hint value. | |
| 357 | * @return A new {@link BufferedImageTranscoder} instance with the given | |
| 358 | * transcoding hint applied. | |
| 359 | */ | |
| 360 | private static BufferedImage rasterize( | |
| 361 | final TranscoderInput input, final Key key, final float width ) | |
| 362 | throws TranscoderException { | |
| 363 | final var transcoder = new BufferedImageTranscoder(); | |
| 364 | ||
| 365 | transcoder.setErrorHandler( sErrorHandler ); | |
| 366 | transcoder.addTranscodingHint( key, width ); | |
| 367 | transcoder.transcode( input, null ); | |
| 368 | ||
| 369 | return transcoder.getImage(); | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Returns either the given element's SVG document width, or the display | |
| 374 | * width, whichever is smaller. | |
| 375 | * | |
| 376 | * @param root The SVG document's root node. | |
| 377 | * @param width The display width (e.g., rendering canvas width). | |
| 378 | * @return The lower value of the document's width or the display width. | |
| 379 | */ | |
| 380 | private static float fit( final Element root, final int width ) { | |
| 381 | final var w = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 382 | ||
| 383 | return w == null || w.isBlank() ? width : fit( root, w, width ); | |
| 10 | import io.sf.carte.echosvg.transcoder.*; | |
| 11 | import io.sf.carte.echosvg.transcoder.image.ImageTranscoder; | |
| 12 | import org.w3c.dom.Document; | |
| 13 | import org.w3c.dom.Element; | |
| 14 | ||
| 15 | import java.awt.*; | |
| 16 | import java.awt.image.BufferedImage; | |
| 17 | import java.io.File; | |
| 18 | import java.io.InputStream; | |
| 19 | import java.io.StringReader; | |
| 20 | import java.net.URI; | |
| 21 | import java.nio.file.Path; | |
| 22 | import java.text.NumberFormat; | |
| 23 | import java.text.ParseException; | |
| 24 | import java.util.HashMap; | |
| 25 | import java.util.Map; | |
| 26 | ||
| 27 | import static com.keenwrite.dom.DocumentParser.transform; | |
| 28 | import static com.keenwrite.events.StatusEvent.clue; | |
| 29 | import static com.keenwrite.preview.HighQualityRenderingHints.RENDERING_HINTS; | |
| 30 | import static io.sf.carte.echosvg.bridge.UnitProcessor.createContext; | |
| 31 | import static io.sf.carte.echosvg.bridge.UnitProcessor.svgHorizontalLengthToUserSpace; | |
| 32 | import static io.sf.carte.echosvg.transcoder.SVGAbstractTranscoder.KEY_HEIGHT; | |
| 33 | import static io.sf.carte.echosvg.transcoder.SVGAbstractTranscoder.KEY_WIDTH; | |
| 34 | import static io.sf.carte.echosvg.transcoder.TranscodingHints.Key; | |
| 35 | import static io.sf.carte.echosvg.transcoder.image.ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER; | |
| 36 | import static io.sf.carte.echosvg.util.SVGConstants.SVG_HEIGHT_ATTRIBUTE; | |
| 37 | import static io.sf.carte.echosvg.util.SVGConstants.SVG_WIDTH_ATTRIBUTE; | |
| 38 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; | |
| 39 | import static java.text.NumberFormat.getIntegerInstance; | |
| 40 | ||
| 41 | /** | |
| 42 | * Responsible for converting SVG images into rasterized PNG images. | |
| 43 | */ | |
| 44 | public final class SvgRasterizer { | |
| 45 | ||
| 46 | /** | |
| 47 | * Prevent rudely barfing stack traces to the console. | |
| 48 | */ | |
| 49 | private static final class SvgErrorHandler implements ErrorHandler { | |
| 50 | @Override | |
| 51 | public void error( final TranscoderException ex ) { | |
| 52 | clue( ex ); | |
| 53 | } | |
| 54 | ||
| 55 | @Override | |
| 56 | public void fatalError( final TranscoderException ex ) { | |
| 57 | clue( ex ); | |
| 58 | } | |
| 59 | ||
| 60 | @Override | |
| 61 | public void warning( final TranscoderException ex ) { | |
| 62 | clue( ex ); | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | private static final UserAgent USER_AGENT = new UserAgentAdapter(); | |
| 67 | private static final BridgeContext BRIDGE_CONTEXT = new BridgeContext( | |
| 68 | USER_AGENT, new DocumentLoader( USER_AGENT ) | |
| 69 | ); | |
| 70 | private static final ErrorHandler sErrorHandler = new SvgErrorHandler(); | |
| 71 | ||
| 72 | private static final SAXSVGDocumentFactory FACTORY_DOM = | |
| 73 | new SAXSVGDocumentFactory(); | |
| 74 | ||
| 75 | private static final NumberFormat INT_FORMAT = getIntegerInstance(); | |
| 76 | ||
| 77 | public static final BufferedImage BROKEN_IMAGE_PLACEHOLDER; | |
| 78 | ||
| 79 | /** | |
| 80 | * A FontAwesome camera icon, cleft asunder. | |
| 81 | */ | |
| 82 | public static final String BROKEN_IMAGE_SVG = | |
| 83 | "<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www" + | |
| 84 | ".w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c" + | |
| 85 | ".332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path " + | |
| 86 | "d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531" + | |
| 87 | ".03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2" + | |
| 88 | ".511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-" + | |
| 89 | ".367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1" + | |
| 90 | ".699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 " + | |
| 91 | "2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094" + | |
| 92 | ".0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5" + | |
| 93 | ".0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-" + | |
| 94 | ".195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4" + | |
| 95 | ".191406-3.652344.207031-.015625.414063-.015625.617187-.007812l" + | |
| 96 | ".933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2" + | |
| 97 | ".820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3" + | |
| 98 | ".429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3" + | |
| 99 | ".429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 " + | |
| 100 | ".140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281" + | |
| 101 | ".808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2" + | |
| 102 | ".472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4" + | |
| 103 | ".285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1" + | |
| 104 | ".9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-" + | |
| 105 | ".667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1" + | |
| 106 | ".253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 " + | |
| 107 | "0'/></g></svg>"; | |
| 108 | ||
| 109 | static { | |
| 110 | // The width and height cannot be embedded in the SVG above because the | |
| 111 | // path element values are relative to the viewBox dimensions. | |
| 112 | final int w = 75; | |
| 113 | final int h = 75; | |
| 114 | BufferedImage image; | |
| 115 | ||
| 116 | try { | |
| 117 | image = rasterizeImage( BROKEN_IMAGE_SVG, w ); | |
| 118 | } catch( final Exception ex ) { | |
| 119 | image = new BufferedImage( w, h, TYPE_INT_RGB ); | |
| 120 | final var graphics = (Graphics2D) image.getGraphics(); | |
| 121 | graphics.setRenderingHints( RENDERING_HINTS ); | |
| 122 | ||
| 123 | // Fall back to a (\) symbol. | |
| 124 | graphics.setColor( new Color( 204, 204, 204 ) ); | |
| 125 | graphics.fillRect( 0, 0, w, h ); | |
| 126 | graphics.setColor( new Color( 255, 204, 204 ) ); | |
| 127 | graphics.setStroke( new BasicStroke( 4 ) ); | |
| 128 | graphics.drawOval( w / 4, h / 4, w / 2, h / 2 ); | |
| 129 | graphics.drawLine( w / 4 + (int) (w / 4 / Math.PI), | |
| 130 | h / 4 + (int) (w / 4 / Math.PI), | |
| 131 | w / 2 + w / 4 - (int) (w / 4 / Math.PI), | |
| 132 | h / 2 + h / 4 - (int) (w / 4 / Math.PI) ); | |
| 133 | } | |
| 134 | ||
| 135 | BROKEN_IMAGE_PLACEHOLDER = image; | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Responsible for creating a new {@link ImageRenderer} implementation that | |
| 140 | * can render a DOM as an SVG image. | |
| 141 | */ | |
| 142 | private static class BufferedImageTranscoder extends ImageTranscoder { | |
| 143 | private BufferedImage mImage; | |
| 144 | ||
| 145 | /** | |
| 146 | * Prevent barfing a stack trace when the transcoder encounters problems | |
| 147 | * parsing SVG contents. | |
| 148 | */ | |
| 149 | @Override | |
| 150 | protected UserAgent createUserAgent() { | |
| 151 | return new SVGAbstractTranscoderUserAgent() { | |
| 152 | @Override | |
| 153 | public void displayError( final Exception ex ) { | |
| 154 | clue( ex ); | |
| 155 | } | |
| 156 | }; | |
| 157 | } | |
| 158 | ||
| 159 | @Override | |
| 160 | public BufferedImage createImage( final int w, final int h ) { | |
| 161 | return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB ); | |
| 162 | } | |
| 163 | ||
| 164 | @Override | |
| 165 | public void writeImage( | |
| 166 | final BufferedImage image, final TranscoderOutput output ) { | |
| 167 | mImage = image; | |
| 168 | } | |
| 169 | ||
| 170 | public BufferedImage getImage() { | |
| 171 | return mImage; | |
| 172 | } | |
| 173 | ||
| 174 | @Override | |
| 175 | protected ImageRenderer createRenderer() { | |
| 176 | final ImageRenderer renderer = super.createRenderer(); | |
| 177 | final RenderingHints hints = renderer.getRenderingHints(); | |
| 178 | hints.putAll( RENDERING_HINTS ); | |
| 179 | renderer.setRenderingHints( hints ); | |
| 180 | ||
| 181 | return renderer; | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | /** | |
| 186 | * Rasterizes the given SVG input stream into an image. | |
| 187 | * | |
| 188 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 189 | * @return The given input stream converted to a rasterized image. | |
| 190 | */ | |
| 191 | public static BufferedImage rasterize( final String svg ) | |
| 192 | throws TranscoderException, ParseException { | |
| 193 | return rasterize( toDocument( svg ) ); | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Rasterizes the given SVG input stream into an image at 96 DPI. | |
| 198 | * | |
| 199 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 200 | * @return The given input stream converted to a rasterized image. | |
| 201 | */ | |
| 202 | public static BufferedImage rasterize( final InputStream svg ) | |
| 203 | throws TranscoderException { | |
| 204 | return rasterize( svg, 96 ); | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * Rasterizes the given SVG input stream into an image. | |
| 209 | * | |
| 210 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 211 | * @param dpi Resolution to use when rasterizing (default is 96 DPI). | |
| 212 | * @return The given input stream converted to a rasterized image at the | |
| 213 | * given resolution. | |
| 214 | */ | |
| 215 | public static BufferedImage rasterize( | |
| 216 | final InputStream svg, final float dpi ) throws TranscoderException { | |
| 217 | return rasterize( | |
| 218 | new TranscoderInput( svg ), | |
| 219 | KEY_PIXEL_UNIT_TO_MILLIMETER, | |
| 220 | 1f / dpi * 25.4f | |
| 221 | ); | |
| 222 | } | |
| 223 | ||
| 224 | /** | |
| 225 | * Rasterizes the given document into an image. | |
| 226 | * | |
| 227 | * @param svg The SVG {@link Document} to rasterize. | |
| 228 | * @param width The rasterized image's width (in pixels). | |
| 229 | * @return The rasterized image. | |
| 230 | */ | |
| 231 | public static BufferedImage rasterize( | |
| 232 | final Document svg, final int width ) throws TranscoderException { | |
| 233 | return rasterize( | |
| 234 | new TranscoderInput( svg ), | |
| 235 | KEY_WIDTH, | |
| 236 | fit( svg.getDocumentElement(), width ) | |
| 237 | ); | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Rasterizes the given vector graphic file using the width dimension | |
| 242 | * specified by the document's width attribute. | |
| 243 | * | |
| 244 | * @param document The {@link Document} containing a vector graphic. | |
| 245 | * @return A rasterized image as an instance of {@link BufferedImage}, or | |
| 246 | * {@link #BROKEN_IMAGE_PLACEHOLDER} if the graphic could not be rasterized. | |
| 247 | */ | |
| 248 | public static BufferedImage rasterize( final Document document ) | |
| 249 | throws ParseException, TranscoderException { | |
| 250 | final var root = document.getDocumentElement(); | |
| 251 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 252 | ||
| 253 | return rasterize( document, INT_FORMAT.parse( width ).intValue() ); | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 258 | * happens, a broken image icon is returned instead. | |
| 259 | * | |
| 260 | * @param path The {@link Path} to a vector graphic file. | |
| 261 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 262 | * maintained. | |
| 263 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 264 | */ | |
| 265 | public static BufferedImage rasterize( final Path path, final int width ) { | |
| 266 | return rasterize( path.toUri(), width ); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 271 | * happens, a broken image icon is returned instead. | |
| 272 | * | |
| 273 | * @param uri The URI to a vector graphic file, which must include the | |
| 274 | * protocol scheme (such as <code>file://</code> or | |
| 275 | * <code>https://</code>). | |
| 276 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 277 | * maintained. | |
| 278 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 279 | */ | |
| 280 | public static BufferedImage rasterize( final String uri, final int width ) { | |
| 281 | return rasterize( new File( uri ).toURI(), width ); | |
| 282 | } | |
| 283 | ||
| 284 | /** | |
| 285 | * Converts an SVG drawing into a rasterized image that can be drawn on | |
| 286 | * a graphics context. | |
| 287 | * | |
| 288 | * @param uri The path to the image (can be web address). | |
| 289 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 290 | * maintained. | |
| 291 | * @return The vector graphic transcoded into a raster image format. | |
| 292 | */ | |
| 293 | public static BufferedImage rasterize( final URI uri, final int width ) { | |
| 294 | try { | |
| 295 | return rasterize( FACTORY_DOM.createDocument( uri.toString() ), width ); | |
| 296 | } catch( final Exception ex ) { | |
| 297 | clue( ex ); | |
| 298 | } | |
| 299 | ||
| 300 | return BROKEN_IMAGE_PLACEHOLDER; | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 305 | * a graphics context. The dimensions are determined from the document. | |
| 306 | * | |
| 307 | * @param svg The SVG xml document. | |
| 308 | * @param scale The scaling factor to apply when transcoding. | |
| 309 | * @return The vector graphic transcoded into a raster image format. | |
| 310 | */ | |
| 311 | public static BufferedImage rasterizeImage( | |
| 312 | final String svg, final double scale ) | |
| 313 | throws ParseException, TranscoderException { | |
| 314 | final var document = toDocument( svg ); | |
| 315 | final var root = document.getDocumentElement(); | |
| 316 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 317 | final var height = root.getAttribute( SVG_HEIGHT_ATTRIBUTE ); | |
| 318 | final var w = INT_FORMAT.parse( width ).intValue() * scale; | |
| 319 | final var h = INT_FORMAT.parse( height ).intValue() * scale; | |
| 320 | ||
| 321 | return rasterize( svg, w, h ); | |
| 322 | } | |
| 323 | ||
| 324 | /** | |
| 325 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 326 | * a graphics context. | |
| 327 | * | |
| 328 | * @param svg The SVG xml document. | |
| 329 | * @param w Scale the image width to this size (aspect ratio is | |
| 330 | * maintained). | |
| 331 | * @return The vector graphic transcoded into a raster image format. | |
| 332 | */ | |
| 333 | public static BufferedImage rasterizeImage( final String svg, final int w ) | |
| 334 | throws TranscoderException { | |
| 335 | return rasterize( toDocument( svg ), w ); | |
| 336 | } | |
| 337 | ||
| 338 | /** | |
| 339 | * Given a document object model (DOM) {@link Element}, this will convert that | |
| 340 | * element to a string. | |
| 341 | * | |
| 342 | * @param root The DOM node to convert to a string. | |
| 343 | * @return The DOM node as an escaped, plain text string. | |
| 344 | */ | |
| 345 | public static String toSvg( final Element root ) { | |
| 346 | try { | |
| 347 | return transform( root ).replaceAll( "xmlns=\"\" ", "" ); | |
| 348 | } catch( final Exception ex ) { | |
| 349 | clue( ex ); | |
| 350 | } | |
| 351 | ||
| 352 | return BROKEN_IMAGE_SVG; | |
| 353 | } | |
| 354 | ||
| 355 | /** | |
| 356 | * Converts an SVG XML string into a new {@link Document} instance. | |
| 357 | * | |
| 358 | * @param xml The XML containing SVG elements. | |
| 359 | * @return The SVG contents parsed into a {@link Document} object model. | |
| 360 | */ | |
| 361 | private static Document toDocument( final String xml ) { | |
| 362 | try( final var reader = new StringReader( xml ) ) { | |
| 363 | return FACTORY_DOM.createSVGDocument( | |
| 364 | "http://www.w3.org/2000/svg", reader ); | |
| 365 | } catch( final Exception ex ) { | |
| 366 | throw new IllegalArgumentException( ex ); | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * Creates a rasterized image of the given source document. | |
| 372 | * | |
| 373 | * @param input The source document to transcode. | |
| 374 | * @param hintKey Transcoding hint key. | |
| 375 | * @param hintValue Transcoding hint value. | |
| 376 | * @return A new {@link BufferedImageTranscoder} instance with the given | |
| 377 | * transcoding hint applied. | |
| 378 | */ | |
| 379 | private static BufferedImage rasterize( | |
| 380 | final TranscoderInput input, final Key hintKey, final float hintValue ) | |
| 381 | throws TranscoderException { | |
| 382 | final var hints = new HashMap<Key, Object>(); | |
| 383 | hints.put( hintKey, hintValue ); | |
| 384 | ||
| 385 | return rasterize( input, hints ); | |
| 386 | } | |
| 387 | ||
| 388 | private static BufferedImage rasterize( | |
| 389 | final String svg, final double w, final double h ) | |
| 390 | throws TranscoderException { | |
| 391 | final var hints = new HashMap<Key, Object>(); | |
| 392 | hints.put( KEY_WIDTH, (float) w ); | |
| 393 | hints.put( KEY_HEIGHT, (float) h ); | |
| 394 | ||
| 395 | return rasterize( new TranscoderInput( toDocument( svg ) ), hints ); | |
| 396 | } | |
| 397 | ||
| 398 | public static BufferedImage rasterize( | |
| 399 | final TranscoderInput input, | |
| 400 | final Map<TranscodingHints.Key, Object> hints ) throws TranscoderException { | |
| 401 | final var transcoder = new BufferedImageTranscoder(); | |
| 402 | ||
| 403 | for( final var hint : hints.entrySet() ) { | |
| 404 | transcoder.addTranscodingHint( hint.getKey(), hint.getValue() ); | |
| 405 | } | |
| 406 | ||
| 407 | transcoder.setErrorHandler( sErrorHandler ); | |
| 408 | transcoder.transcode( input, null ); | |
| 409 | ||
| 410 | return transcoder.getImage(); | |
| 411 | } | |
| 412 | ||
| 413 | /** | |
| 414 | * Returns either the given element's SVG document width, or the display | |
| 415 | * width, whichever is smaller. | |
| 416 | * | |
| 417 | * @param root The SVG document's root node. | |
| 418 | * @param width The display width (e.g., rendering canvas width). | |
| 419 | * @return The lower value of the document's width or the display width. | |
| 420 | */ | |
| 421 | @SuppressWarnings( "ConstantValue" ) | |
| 422 | private static float fit( final Element root, final int width ) { | |
| 423 | final var w = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 424 | ||
| 425 | return w == null || w.isBlank() | |
| 426 | ? width | |
| 427 | : fit( root, w, width ); | |
| 384 | 428 | } |
| 385 | 429 |
| 11 | 11 | |
| 12 | 12 | import java.awt.image.BufferedImage; |
| 13 | import java.io.File; | |
| 13 | 14 | import java.net.URI; |
| 14 | 15 | import java.nio.file.Path; |
| 15 | 16 | |
| 16 | 17 | import static com.keenwrite.events.StatusEvent.clue; |
| 17 | 18 | import static com.keenwrite.io.downloads.DownloadManager.open; |
| 18 | import static com.keenwrite.preview.MathRenderer.MATH_RENDERER; | |
| 19 | import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER; | |
| 20 | import static com.keenwrite.preview.SvgRasterizer.rasterize; | |
| 19 | import static com.keenwrite.preview.SvgRasterizer.*; | |
| 21 | 20 | import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX; |
| 22 | 21 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| ... | ||
| 69 | 68 | else if( MediaType.fromFilename( source ).isSvg() ) { |
| 70 | 69 | // Attempt to rasterize based on file name. |
| 71 | final var path = Path.of( new URI( source ).getPath() ); | |
| 70 | final var path = new File( source ).toPath(); | |
| 72 | 71 | |
| 73 | 72 | if( path.isAbsolute() ) { |
| ... | ||
| 86 | 85 | case HTML_TEX -> |
| 87 | 86 | // Convert the TeX element to a raster graphic. |
| 88 | raster = rasterize( MATH_RENDERER.render( e.getTextContent() ) ); | |
| 87 | raster = rasterize( MathRenderer.toString( e.getTextContent() ) ); | |
| 89 | 88 | } |
| 90 | 89 | |
| 18 | 18 | */ |
| 19 | 19 | @Override |
| 20 | public void rendererOptions( @NotNull final MutableDataHolder options ) { | |
| 21 | } | |
| 20 | public void rendererOptions( @NotNull final MutableDataHolder options ) { } | |
| 22 | 21 | } |
| 23 | 22 |
| 19 | 19 | import org.jetbrains.annotations.NotNull; |
| 20 | 20 | |
| 21 | import java.nio.file.Paths; | |
| 21 | import java.nio.file.Path; | |
| 22 | 22 | import java.util.HashSet; |
| 23 | 23 | import java.util.Set; |
| ... | ||
| 200 | 200 | final var hash = Integer.toHexString( text.hashCode() ); |
| 201 | 201 | final var filename = format( "%s-%s.svg", APP_TITLE_LOWERCASE, hash ); |
| 202 | final var svg = Paths.get( TEMP_DIR, filename ).toString(); | |
| 202 | ||
| 203 | // The URI helps convert backslashes to forward slashes. | |
| 204 | final var uri = Path.of( TEMP_DIR, filename ).toUri(); | |
| 205 | final var svg = uri.getPath(); | |
| 203 | 206 | final var link = context.resolveLink( LINK, svg, false ); |
| 204 | 207 | final var dimensions = getAttributes( node.getInfo() ); |
| ... | ||
| 327 | 330 | |
| 328 | 331 | private class Factory implements DelegatingNodeRendererFactory { |
| 329 | public Factory() {} | |
| 332 | public Factory() { } | |
| 330 | 333 | |
| 331 | 334 | @NotNull |
| 31 | 31 | |
| 32 | 32 | /** |
| 33 | * @return Either '$' or '$$. | |
| 33 | * @return Either '$' or '$$'. | |
| 34 | 34 | */ |
| 35 | 35 | public String getOpeningDelimiter() { return mOpener; } |
| 36 | 36 | |
| 37 | 37 | /** |
| 38 | * @return Either '$' or '$$. | |
| 38 | * @return Either '$' or '$$'. | |
| 39 | 39 | */ |
| 40 | 40 | public String getClosingDelimiter() { return mCloser; } |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.ExportFormat; |
| 5 | import com.keenwrite.preview.MathRenderer; | |
| 5 | 6 | import com.keenwrite.preview.SvgRasterizer; |
| 6 | 7 | import com.vladsch.flexmark.html.HtmlWriter; |
| ... | ||
| 19 | 20 | |
| 20 | 21 | import static com.keenwrite.ExportFormat.*; |
| 21 | import static com.keenwrite.preview.MathRenderer.MATH_RENDERER; | |
| 22 | 22 | import static com.keenwrite.processors.markdown.extensions.tex.TexNode.*; |
| 23 | 23 | |
| ... | ||
| 118 | 118 | final HtmlWriter html ) { |
| 119 | 119 | final var tex = node.getText().toStringOrNull(); |
| 120 | final var doc = MATH_RENDERER.render( | |
| 121 | tex == null ? "" : getEvaluator().apply( tex ) ); | |
| 120 | final var doc = MathRenderer.toDocument( | |
| 121 | tex == null ? "" : getEvaluator().apply( tex ) | |
| 122 | ); | |
| 122 | 123 | final var svg = SvgRasterizer.toSvg( doc.getDocumentElement() ); |
| 123 | 124 | html.raw( svg ); |
| 24 | 24 | import java.nio.charset.StandardCharsets; |
| 25 | 25 | import java.nio.file.Path; |
| 26 | import java.util.Collections; | |
| 27 | import java.util.LinkedList; | |
| 28 | import java.util.List; | |
| 26 | 29 | import java.util.Properties; |
| 27 | import java.util.TreeMap; | |
| 28 | 30 | import java.util.concurrent.atomic.AtomicReference; |
| 29 | 31 | |
| ... | ||
| 37 | 39 | import static java.lang.Math.max; |
| 38 | 40 | import static java.nio.charset.StandardCharsets.UTF_8; |
| 41 | import static java.text.Normalizer.Form.NFKD; | |
| 42 | import static java.text.Normalizer.normalize; | |
| 39 | 43 | import static javafx.application.Platform.runLater; |
| 40 | 44 | import static javafx.geometry.Pos.CENTER; |
| ... | ||
| 47 | 51 | */ |
| 48 | 52 | public final class ExportDialog extends AbstractDialog<ExportSettings> { |
| 53 | private static final String UNCRITIC = "\\p{InCombiningDiacriticalMarks}+"; | |
| 54 | ||
| 55 | private record Theme( Path path, String name ) implements Comparable<Theme> { | |
| 56 | /** | |
| 57 | * Answers whether the given theme directory name matches the theme name | |
| 58 | * that the user selected. | |
| 59 | * | |
| 60 | * @param themeDir The user-selected directory to compare with the | |
| 61 | * corresponding path of this {@link Theme}. | |
| 62 | * @return {@code true} if the given directory matches the ending portion | |
| 63 | * of the {@link Path} associated with this {@link Theme} instance. | |
| 64 | */ | |
| 65 | public boolean matches( final String themeDir ) { | |
| 66 | final var normalized = normalize( themeDir, NFKD ); | |
| 67 | final var name = normalized.replaceAll( UNCRITIC, "" ); | |
| 68 | final var path = path().getFileName().toString(); | |
| 69 | ||
| 70 | return path.equalsIgnoreCase( name ); | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Returns the theme's display name. | |
| 75 | * | |
| 76 | * @return The name of the theme presented to users. | |
| 77 | */ | |
| 78 | @Override | |
| 79 | public String toString() { | |
| 80 | return abbreviate( name(), THEME_NAME_LENGTH ); | |
| 81 | } | |
| 82 | ||
| 83 | @Override | |
| 84 | public int compareTo( final Theme o ) { | |
| 85 | return name().compareTo( o.name() ); | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 49 | 89 | private final File mThemes; |
| 50 | 90 | private final ExportSettings mSettings; |
| 51 | 91 | private GridPane mPane; |
| 52 | private ComboBox<String> mComboBox; | |
| 92 | private ComboBox<Theme> mComboBox; | |
| 53 | 93 | private TextField mChapters; |
| 54 | 94 | private final boolean mMissingThemes; |
| ... | ||
| 88 | 128 | } |
| 89 | 129 | |
| 90 | initComboBox( mComboBox, mSettings, readThemes( themesDir ) ); | |
| 130 | final var previousTheme = mSettings.themeProperty().get(); | |
| 131 | ||
| 132 | initComboBox( mComboBox, previousTheme, themes ); | |
| 91 | 133 | |
| 92 | 134 | mPane.add( createLabel( "Dialog.typesetting.settings.theme" ), 0, 1 ); |
| ... | ||
| 153 | 195 | if( result.isPresent() ) { |
| 154 | 196 | final var theme = mComboBox.getSelectionModel().getSelectedItem(); |
| 155 | mSettings.themeProperty().setValue( theme.toLowerCase() ); | |
| 197 | final var path = theme.path().getFileName().toString(); | |
| 198 | mSettings.themeProperty().setValue( path ); | |
| 156 | 199 | |
| 157 | 200 | return true; |
| ... | ||
| 197 | 240 | |
| 198 | 241 | private void initComboBox( |
| 199 | final ComboBox<String> comboBox, | |
| 200 | final ExportSettings settings, | |
| 201 | final TreeMap<String, String> choices | |
| 242 | final ComboBox<Theme> comboBox, | |
| 243 | final String previousTheme, | |
| 244 | final List<Theme> choices | |
| 202 | 245 | ) { |
| 203 | 246 | assert comboBox != null; |
| 204 | assert settings != null; | |
| 247 | assert previousTheme != null; | |
| 205 | 248 | assert choices != null; |
| 206 | 249 | |
| 207 | final var selection = new String[]{""}; | |
| 208 | final var theme = settings.themeProperty().get(); | |
| 250 | final var items = comboBox.getItems(); | |
| 251 | items.clear(); | |
| 252 | items.addAll( choices ); | |
| 209 | 253 | |
| 210 | 254 | // Set the selected item to user's settings value. |
| 211 | for( final var key : choices.keySet() ) { | |
| 212 | if( key.equalsIgnoreCase( theme ) ) { | |
| 213 | selection[ 0 ] = key; | |
| 255 | for( final var choice : choices ) { | |
| 256 | if( choice.matches( previousTheme ) ) { | |
| 257 | comboBox.getSelectionModel().select( | |
| 258 | items.get( max( items.indexOf( choice ), 0 ) ) | |
| 259 | ); | |
| 260 | ||
| 214 | 261 | break; |
| 215 | 262 | } |
| 216 | 263 | } |
| 217 | ||
| 218 | final var items = comboBox.getItems(); | |
| 219 | items.addAll( choices.keySet() ); | |
| 220 | comboBox.getSelectionModel().select( | |
| 221 | items.get( max( items.indexOf( selection[ 0 ] ), 0 ) ) | |
| 222 | ); | |
| 223 | 264 | } |
| 224 | 265 | |
| 225 | private TreeMap<String, String> readThemes( final File themesDir ) { | |
| 266 | private List<Theme> readThemes( final File themesDir ) { | |
| 226 | 267 | try { |
| 227 | 268 | // List themes in alphabetical order (human-readable by directory name). |
| 228 | final var choices = new TreeMap<String, String>(); | |
| 269 | final var choices = new LinkedList<Theme>(); | |
| 229 | 270 | |
| 230 | 271 | // Populate the choices with themes detected on the system. |
| 231 | 272 | walk( themesDir.toPath(), "**/theme.properties", path -> { |
| 232 | 273 | try { |
| 233 | final var displayed = readThemeName( path ); | |
| 234 | final var themeName = path.getParent().toFile().getName(); | |
| 235 | choices.put( abbreviate( displayed, THEME_NAME_LENGTH ), themeName ); | |
| 274 | final var themeName = readThemeName( path ); | |
| 275 | final var themePath = path.getParent(); | |
| 276 | choices.add( new Theme( themePath, themeName ) ); | |
| 236 | 277 | } catch( final Exception ex ) { |
| 237 | 278 | clue( "Main.status.error.theme.name", path ); |
| 238 | 279 | } |
| 239 | 280 | } ); |
| 281 | ||
| 282 | Collections.sort( choices ); | |
| 240 | 283 | |
| 241 | 284 | return choices; |
| 242 | 285 | } catch( final Exception ex ) { |
| 243 | 286 | clue( ex ); |
| 244 | 287 | } |
| 245 | 288 | |
| 246 | return new TreeMap<>(); | |
| 289 | return Collections.emptyList(); | |
| 247 | 290 | } |
| 248 | 291 | |
| 249 | private ComboBox<String> createComboBox() { | |
| 292 | private ComboBox<Theme> createComboBox() { | |
| 250 | 293 | return new ComboBox<>(); |
| 251 | 294 | } |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite.util; | |
| 3 | ||
| 4 | import static java.text.Normalizer.Form.NFD; | |
| 5 | import static java.text.Normalizer.normalize; | |
| 6 | ||
| 7 | /** | |
| 8 | * Responsible for modifying diacritics. | |
| 9 | */ | |
| 10 | public class Diacritics { | |
| 11 | private static final String UNCRITIC = "\\p{M}+"; | |
| 12 | ||
| 13 | /** | |
| 14 | * Returns the value of the string without diacritic marks. | |
| 15 | * | |
| 16 | * @param text The text to normalize. | |
| 17 | * @return A non-null, possibly empty string. | |
| 18 | */ | |
| 19 | public static String remove( final String text ) { | |
| 20 | return normalize( text, NFD ).replaceAll( UNCRITIC, "" ); | |
| 21 | } | |
| 22 | } | |
| 1 | 23 |
| 111 | 111 | workspace.ui.font.preview.mono.size.desc=Monospace font size. |
| 112 | 112 | workspace.ui.font.preview.mono.size.title=Points |
| 113 | workspace.ui.font.math=Math Font | |
| 114 | workspace.ui.font.math.size.title=Scale | |
| 113 | 115 | |
| 114 | 116 | workspace.language=Language |
| 252 | 252 | |
| 253 | 253 | div.todo { |
| 254 | border-color: #c00; | |
| 255 | background-color: #f8f8f8; | |
| 256 | } | |
| 257 | ||
| 258 | div.todo, div.terminal { | |
| 254 | 259 | padding: .5em; |
| 255 | 260 | padding-top: .25em; |
| 256 | 261 | padding-bottom: .25em; |
| 257 | 262 | border-style: solid; |
| 258 | 263 | border-width: 0.05em; |
| 259 | 264 | border-radius: .25em; |
| 260 | border-color: #c00; | |
| 261 | background-color: #f8f8f8; | |
| 265 | } | |
| 266 | ||
| 267 | /* TERMINAL ***/ | |
| 268 | div.terminal { | |
| 269 | font-family: 'Source Code Pro'; | |
| 270 | font-size: 90%; | |
| 271 | border-color: #222; | |
| 262 | 272 | } |
| 263 | 273 |
| 1 | /* | |
| 2 | * Copyright 2020-2021 White Magic Software, Ltd. | |
| 3 | * | |
| 4 | * All rights reserved. | |
| 5 | * | |
| 6 | * Redistribution and use in source and binary forms, with or without | |
| 7 | * modification, are permitted provided that the following conditions are met: | |
| 8 | * | |
| 9 | * o Redistributions of source code must retain the above copyright | |
| 10 | * notice, this list of conditions and the following disclaimer. | |
| 11 | * | |
| 12 | * o Redistributions in binary form must reproduce the above copyright | |
| 13 | * notice, this list of conditions and the following disclaimer in the | |
| 14 | * documentation and/or other materials provided with the distribution. | |
| 15 | * | |
| 16 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| 17 | * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| 18 | * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| 19 | * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| 20 | * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| 21 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| 22 | * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| 23 | * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| 24 | * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| 26 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| 27 | */ | |
| 28 | package com.keenwrite.tex; | |
| 29 | ||
| 30 | import com.whitemagicsoftware.tex.DefaultTeXFont; | |
| 31 | import com.whitemagicsoftware.tex.TeXEnvironment; | |
| 32 | import com.whitemagicsoftware.tex.TeXFormula; | |
| 33 | import com.whitemagicsoftware.tex.TeXLayout; | |
| 34 | import com.whitemagicsoftware.tex.graphics.AbstractGraphics2D; | |
| 35 | import com.whitemagicsoftware.tex.graphics.SvgDomGraphics2D; | |
| 36 | import com.whitemagicsoftware.tex.graphics.SvgGraphics2D; | |
| 37 | import io.sf.carte.echosvg.transcoder.TranscoderException; | |
| 38 | import org.junit.jupiter.api.Test; | |
| 39 | import org.xml.sax.SAXException; | |
| 40 | ||
| 41 | import javax.imageio.ImageIO; | |
| 42 | import java.awt.image.BufferedImage; | |
| 43 | import java.io.ByteArrayInputStream; | |
| 44 | import java.io.File; | |
| 45 | import java.io.IOException; | |
| 46 | import java.nio.file.Path; | |
| 47 | import java.text.ParseException; | |
| 48 | ||
| 49 | import static com.keenwrite.dom.DocumentParser.parse; | |
| 50 | import static com.keenwrite.preview.SvgRasterizer.*; | |
| 51 | import static java.lang.System.getProperty; | |
| 52 | import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 53 | ||
| 54 | /** | |
| 55 | * Test that TeX rasterization produces a readable image. | |
| 56 | */ | |
| 57 | public class TeXRasterizationTest { | |
| 58 | private static final String EQUATION = | |
| 59 | "G_{\\mu \\nu} = \\frac{8 \\pi G}{c^4} T_{{\\mu \\nu}}"; | |
| 60 | ||
| 61 | private static final String DIR_TEMP = getProperty( "java.io.tmpdir" ); | |
| 62 | ||
| 63 | private static final long FILESIZE = 12364; | |
| 64 | ||
| 65 | /** | |
| 66 | * Test that an equation can be converted to a raster image and the | |
| 67 | * final raster image size corresponds to the input equation. This is | |
| 68 | * a simple way to verify that the rasterization process is correct, | |
| 69 | * albeit if any aspect of the SVG algorithm changes (such as padding | |
| 70 | * around the equation), it will cause this test to fail, which is a bit | |
| 71 | * misleading. | |
| 72 | */ | |
| 73 | @Test | |
| 74 | public void test_Rasterize_SimpleFormula_CorrectImageSize() | |
| 75 | throws IOException, ParseException, TranscoderException { | |
| 76 | final var g = new SvgGraphics2D(); | |
| 77 | ||
| 78 | drawGraphics( g ); | |
| 79 | verifyImage( rasterizeString( g.toString() ) ); | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Test that an SVG document object model can be parsed and rasterized into | |
| 84 | * an image. | |
| 85 | */ | |
| 86 | @Test | |
| 87 | public void getTest_SvgDomGraphics2D_InputElement_OutputRasterizedImage() | |
| 88 | throws IOException, SAXException, ParseException, TranscoderException { | |
| 89 | final var g = new SvgGraphics2D(); | |
| 90 | drawGraphics( g ); | |
| 91 | ||
| 92 | final var expectedSvg = g.toString(); | |
| 93 | final var bytes = expectedSvg.getBytes(); | |
| 94 | ||
| 95 | try( final var in = new ByteArrayInputStream( bytes ) ) { | |
| 96 | final var doc = parse( in ); | |
| 97 | final var actualSvg = toSvg( doc.getDocumentElement() ); | |
| 98 | ||
| 99 | verifyImage( rasterizeString( actualSvg ) ); | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Test that an SVG image from a DOM element can be rasterized. | |
| 105 | * | |
| 106 | * @throws IOException Could not write the image. | |
| 107 | */ | |
| 108 | @Test | |
| 109 | public void test_SvgDomGraphics2D_InputDom_OutputRasterizedImage() | |
| 110 | throws IOException, ParseException, TranscoderException { | |
| 111 | final var g = new SvgDomGraphics2D(); | |
| 112 | ||
| 113 | drawGraphics( g ); | |
| 114 | verifyImage( rasterize( g.toDom() ) ); | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * Asserts that the given image matches an expected file size. | |
| 119 | * | |
| 120 | * @param image The image to check against the file size. | |
| 121 | * @throws IOException Could not write the image. | |
| 122 | */ | |
| 123 | private void verifyImage( final BufferedImage image ) throws IOException { | |
| 124 | final var file = export( image, "dom.png" ); | |
| 125 | assertEquals( FILESIZE, file.length() ); | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Creates an SVG string for the default equation and font size. | |
| 130 | */ | |
| 131 | private void drawGraphics( final AbstractGraphics2D g ) { | |
| 132 | final var size = 100f; | |
| 133 | final var texFont = new DefaultTeXFont( size ); | |
| 134 | final var env = new TeXEnvironment( texFont ); | |
| 135 | g.scale( size, size ); | |
| 136 | ||
| 137 | final var formula = new TeXFormula( EQUATION ); | |
| 138 | final var box = formula.createBox( env ); | |
| 139 | final var layout = new TeXLayout( box, size ); | |
| 140 | ||
| 141 | g.initialize( layout.getWidth(), layout.getHeight() ); | |
| 142 | box.draw( g, layout.getX(), layout.getY() ); | |
| 143 | } | |
| 144 | ||
| 145 | @SuppressWarnings( "SameParameterValue" ) | |
| 146 | private File export( final BufferedImage image, final String filename ) | |
| 147 | throws IOException { | |
| 148 | final var path = Path.of( DIR_TEMP, filename ); | |
| 149 | final var file = path.toFile(); | |
| 150 | ImageIO.write( image, "png", file ); | |
| 151 | file.deleteOnExit(); | |
| 152 | return file; | |
| 153 | } | |
| 154 | } | |
| 155 | 1 |