Dave Jarvis' Repositories

D R/INSTALL.md
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
# ![Bootstrap](images/bootstrap.png)
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
471
D R/README.md
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.
2181
D R/images/bootstrap.png
Binary file
M R/pluralize.R
531531
# -----------------------------------------------------------------------------
532532
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 )
535534
  output <- replace_suffix( output, "ss", "sses" )
536535
M build.gradle
136136
  annotationProcessor "info.picocli:picocli-codegen:${v_picocli}"
137137
138
  // Spelling, TeX, Docking, KeenQuotes
138
  // KeenQuotes, KeenType, KeenSpell, word split.
139139
  implementation fileTree( include: ['**/*.jar'], dir: 'libs' )
140140
A docs/logo/font.txt
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
19
A docs/r/INSTALL.md
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
# ![Bootstrap](images/bootstrap.png)
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
147
A docs/r/README.md
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.
1218
A docs/r/images/bootstrap.png
Binary file
M keenwrite.sh
66
  --add-opens=javafx.graphics/javafx.scene.text=ALL-UNNAMED \
77
  --add-opens=javafx.graphics/com.sun.javafx.css=ALL-UNNAMED \
8
  --add-opens=javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED \
89
  --add-opens=javafx.graphics/com.sun.javafx.text=ALL-UNNAMED \
910
  --add-exports=javafx.base/com.sun.javafx.event=ALL-UNNAMED \
D libs/keentex.jar
Binary file
A libs/keentype-lib.jar
Binary file
A r.zip
Binary file
M src/main/java/com/keenwrite/MainApp.java
55
import com.keenwrite.events.HyperlinkOpenEvent;
66
import com.keenwrite.preferences.Workspace;
7
import com.keenwrite.preview.MathRenderer;
78
import com.keenwrite.spelling.impl.Lexicon;
89
import javafx.application.Application;
...
2223
import static com.keenwrite.preferences.AppKeys.*;
2324
import static com.keenwrite.util.FontLoader.initFonts;
24
import static javafx.scene.input.KeyCode.ALT;
25
import static javafx.scene.input.KeyCode.ESCAPE;
2526
import static javafx.scene.input.KeyCode.F11;
2627
import static javafx.scene.input.KeyEvent.KEY_PRESSED;
...
6667
  public static Event keyDown( final KeyCode code, final boolean shift ) {
6768
    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 );
6880
  }
6981
...
7688
   * a key being released.
7789
   */
90
  @SuppressWarnings( "unused" )
7891
  public static Event keyUp( final KeyCode code, final boolean shift ) {
7992
    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 );
9193
  }
9294
...
120122
    initIcons( stage );
121123
    initScene( stage );
124
125
    MathRenderer.bindSize( mWorkspace.doubleProperty( KEY_UI_FONT_MATH_SIZE ) );
122126
123127
    // Load the lexicon and check all the documents after all files are open.
...
157161
158162
    // 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.
161165
    //
162166
    // 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 ) );
172170
      }
173171
    } );
M src/main/java/com/keenwrite/MainPane.java
507507
    final var file = files.get( 0 );
508508
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 );
11841220
  }
11851221
M src/main/java/com/keenwrite/constants/Constants.java
172172
173173
  /**
174
   * Scaling factor for rendering mathematics.
175
   */
176
  public static final double FONT_SIZE_MATH_DEFAULT = 2;
177
178
  /**
174179
   * Default monospace preview font name.
175180
   */
M src/main/java/com/keenwrite/editors/definition/DefinitionTreeItem.java
22
package com.keenwrite.editors.definition;
33
4
import com.keenwrite.util.Diacritics;
45
import javafx.scene.control.TreeItem;
56
67
import java.util.Stack;
78
import java.util.function.BiFunction;
8
9
import static java.text.Normalizer.Form.NFD;
10
import static java.text.Normalizer.normalize;
119
1210
/**
...
107105
108106
    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}", "" );
119107
  }
120108
...
127115
  private boolean valueEquals( final String s ) {
128116
    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() );
129128
  }
130129
131130
  /**
132131
   * Returns true if this node is a leaf and its value contains the given text.
133132
   *
134133
   * @param s The text to compare against the node value.
135134
   * @return true Node is a leaf and its value contains the given value.
136135
   */
137136
  private boolean valueContains( final String s ) {
138
    return isLeaf() && getDiacriticlessValue().contains( s );
137
    return isLeaf() && removeAccents( this ).contains( s );
139138
  }
140139
141140
  /**
142141
   * Returns true if this node is a leaf and its value contains the given text.
143142
   *
144143
   * @param s The text to compare against the node value.
145144
   * @return true Node is a leaf and its value contains the given value.
146145
   */
147146
  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() );
150150
  }
151151
...
158158
   */
159159
  private boolean valueStartsWith( final String s ) {
160
    return isLeaf() && getDiacriticlessValue().startsWith( s );
160
    return isLeaf() && removeAccents( this ).startsWith( s );
161161
  }
162162
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
520520
521521
    mTextArea.replaceSelection( newText );
522
    mTextArea.requestFollowCaret();
522523
  }
523524
M src/main/java/com/keenwrite/preferences/AppKeys.java
6464
  public static final Key KEY_UI_FONT_PREVIEW_MONO_NAME = key( KEY_UI_FONT_PREVIEW_MONO, "name" );
6565
  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" );
6668
6769
  public static final Key KEY_UI_WINDOW = key( KEY_UI, "window" );
M src/main/java/com/keenwrite/preferences/PreferencesController.java
272272
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
273273
                      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 ) )
274279
        )
275280
      ),
...
404409
  }
405410
406
  @SuppressWarnings( "SameParameterValue" )
407411
  private IntegerProperty integerProperty( final Key key ) {
408412
    return mWorkspace.integerProperty( key );
409413
  }
410414
411
  @SuppressWarnings( "SameParameterValue" )
412415
  private DoubleProperty doubleProperty( final Key key ) {
413416
    return mWorkspace.doubleProperty( key );
M src/main/java/com/keenwrite/preferences/Workspace.java
112112
     asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT )
113113
    ),
114
    entry(
115
      KEY_UI_FONT_MATH_SIZE,
116
      asDoubleProperty( FONT_SIZE_MATH_DEFAULT )
117
    ),
114118
115119
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
M src/main/java/com/keenwrite/preview/MathRenderer.java
22
package com.keenwrite.preview;
33
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;
68
import org.w3c.dom.Document;
7
8
import java.util.function.Supplier;
99
1010
import static com.keenwrite.events.StatusEvent.clue;
1111
1212
/**
1313
 * Responsible for rendering formulas as scalable vector graphics (SVG).
1414
 */
1515
public final class MathRenderer {
1616
17
  /**
18
   * Singleton instance for rendering math symbols.
19
   */
20
  public static final MathRenderer MATH_RENDERER = new MathRenderer();
17
  private static KeenType sTypesetter;
2118
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
  }
2626
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 );
3028
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 );
3333
  }
3434
3535
  /**
36
   * This method only takes a few seconds to generate
36
   * Converts a TeX-based equation into an SVG document.
3737
   *
38
   * @param equation A mathematical expression to render.
38
   * @param equation A mathematical expression to render, without sigils.
3939
   * @return The given string with all formulas transformed into SVG format.
4040
   */
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 ) );
6243
  }
6344
6445
  /**
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.
6747
   *
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.
7150
   */
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() );
7953
  }
8054
}
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
88
import io.sf.carte.echosvg.bridge.UserAgentAdapter;
99
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 );
384428
  }
385429
M src/main/java/com/keenwrite/preview/SvgReplacedElementFactory.java
1111
1212
import java.awt.image.BufferedImage;
13
import java.io.File;
1314
import java.net.URI;
1415
import java.nio.file.Path;
1516
1617
import static com.keenwrite.events.StatusEvent.clue;
1718
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.*;
2120
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
2221
import static com.keenwrite.util.ProtocolScheme.getProtocol;
...
6968
          else if( MediaType.fromFilename( source ).isSvg() ) {
7069
            // 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();
7271
7372
            if( path.isAbsolute() ) {
...
8685
        case HTML_TEX ->
8786
          // Convert the TeX element to a raster graphic.
88
          raster = rasterize( MATH_RENDERER.render( e.getTextContent() ) );
87
          raster = rasterize( MathRenderer.toString( e.getTextContent() ) );
8988
      }
9089
M src/main/java/com/keenwrite/processors/markdown/extensions/HtmlRendererAdapter.java
1818
   */
1919
  @Override
20
  public void rendererOptions( @NotNull final MutableDataHolder options ) {
21
  }
20
  public void rendererOptions( @NotNull final MutableDataHolder options ) { }
2221
}
2322
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
1919
import org.jetbrains.annotations.NotNull;
2020
21
import java.nio.file.Paths;
21
import java.nio.file.Path;
2222
import java.util.HashSet;
2323
import java.util.Set;
...
200200
      final var hash = Integer.toHexString( text.hashCode() );
201201
      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();
203206
      final var link = context.resolveLink( LINK, svg, false );
204207
      final var dimensions = getAttributes( node.getInfo() );
...
327330
328331
  private class Factory implements DelegatingNodeRendererFactory {
329
    public Factory() {}
332
    public Factory() { }
330333
331334
    @NotNull
M src/main/java/com/keenwrite/processors/markdown/extensions/tex/TexNode.java
3131
3232
  /**
33
   * @return Either '$' or '$$.
33
   * @return Either '$' or '$$'.
3434
   */
3535
  public String getOpeningDelimiter() { return mOpener; }
3636
3737
  /**
38
   * @return Either '$' or '$$.
38
   * @return Either '$' or '$$'.
3939
   */
4040
  public String getClosingDelimiter() { return mCloser; }
M src/main/java/com/keenwrite/processors/markdown/extensions/tex/TexNodeRenderer.java
33
44
import com.keenwrite.ExportFormat;
5
import com.keenwrite.preview.MathRenderer;
56
import com.keenwrite.preview.SvgRasterizer;
67
import com.vladsch.flexmark.html.HtmlWriter;
...
1920
2021
import static com.keenwrite.ExportFormat.*;
21
import static com.keenwrite.preview.MathRenderer.MATH_RENDERER;
2222
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.*;
2323
...
118118
                 final HtmlWriter html ) {
119119
      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
      );
122123
      final var svg = SvgRasterizer.toSvg( doc.getDocumentElement() );
123124
      html.raw( svg );
M src/main/java/com/keenwrite/ui/dialogs/ExportDialog.java
2424
import java.nio.charset.StandardCharsets;
2525
import java.nio.file.Path;
26
import java.util.Collections;
27
import java.util.LinkedList;
28
import java.util.List;
2629
import java.util.Properties;
27
import java.util.TreeMap;
2830
import java.util.concurrent.atomic.AtomicReference;
2931
...
3739
import static java.lang.Math.max;
3840
import static java.nio.charset.StandardCharsets.UTF_8;
41
import static java.text.Normalizer.Form.NFKD;
42
import static java.text.Normalizer.normalize;
3943
import static javafx.application.Platform.runLater;
4044
import static javafx.geometry.Pos.CENTER;
...
4751
 */
4852
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
4989
  private final File mThemes;
5090
  private final ExportSettings mSettings;
5191
  private GridPane mPane;
52
  private ComboBox<String> mComboBox;
92
  private ComboBox<Theme> mComboBox;
5393
  private TextField mChapters;
5494
  private final boolean mMissingThemes;
...
88128
    }
89129
90
    initComboBox( mComboBox, mSettings, readThemes( themesDir ) );
130
    final var previousTheme = mSettings.themeProperty().get();
131
132
    initComboBox( mComboBox, previousTheme, themes );
91133
92134
    mPane.add( createLabel( "Dialog.typesetting.settings.theme" ), 0, 1 );
...
153195
        if( result.isPresent() ) {
154196
          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 );
156199
157200
          return true;
...
197240
198241
  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
202245
  ) {
203246
    assert comboBox != null;
204
    assert settings != null;
247
    assert previousTheme != null;
205248
    assert choices != null;
206249
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 );
209253
210254
    // 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
214261
        break;
215262
      }
216263
    }
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
    );
223264
  }
224265
225
  private TreeMap<String, String> readThemes( final File themesDir ) {
266
  private List<Theme> readThemes( final File themesDir ) {
226267
    try {
227268
      // 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>();
229270
230271
      // Populate the choices with themes detected on the system.
231272
      walk( themesDir.toPath(), "**/theme.properties", path -> {
232273
        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 ) );
236277
        } catch( final Exception ex ) {
237278
          clue( "Main.status.error.theme.name", path );
238279
        }
239280
      } );
281
282
      Collections.sort( choices );
240283
241284
      return choices;
242285
    } catch( final Exception ex ) {
243286
      clue( ex );
244287
    }
245288
246
    return new TreeMap<>();
289
    return Collections.emptyList();
247290
  }
248291
249
  private ComboBox<String> createComboBox() {
292
  private ComboBox<Theme> createComboBox() {
250293
    return new ComboBox<>();
251294
  }
A src/main/java/com/keenwrite/util/Diacritics.java
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
}
123
M src/main/resources/com/keenwrite/messages.properties
111111
workspace.ui.font.preview.mono.size.desc=Monospace font size.
112112
workspace.ui.font.preview.mono.size.title=Points
113
workspace.ui.font.math=Math Font
114
workspace.ui.font.math.size.title=Scale
113115
114116
workspace.language=Language
M src/main/resources/com/keenwrite/preview/webview.css
252252
253253
div.todo {
254
  border-color: #c00;
255
  background-color: #f8f8f8;
256
}
257
258
div.todo, div.terminal {
254259
  padding: .5em;
255260
  padding-top: .25em;
256261
  padding-bottom: .25em;
257262
  border-style: solid;
258263
  border-width: 0.05em;
259264
  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;
262272
}
263273
D src/test/java/com/keenwrite/tex/TeXRasterizationTest.java
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
}
1551