Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M BUILD.md
106106
Run `./installer -h` to see all command-line options.
107107
108
# Releases
109
110
After installing `scripts/build-template`, build release binaries as follows:
111
112
    git tag -a 2.0.0 -m "Release name"
113
    git push origin --tags
114
    ./release.sh
115
116
When finished, browse to the project releases page to draft a new release.
117
108118
# Versioning
109119
M README.md
3636
### Other
3737
38
Download and install a full version of [JDK 15](https://bell-sw.com/pages/downloads/?version=java-15) that includes JavaFX module support, then run:
38
Download and install a full version of [JRE 16](https://bell-sw.com/pages/downloads/?version=java-16&package=jre-full) that includes JavaFX module support, then run:
3939
4040
``` bash
41
java -jar keenwrite.jar
41
java --illegal-access=permit -jar build/libs/keenwrite.jar 2> /dev/null
4242
```
43
44
The `--illegal-access=permit` is a temporary option until third-party libraries used by the text editor are updated or replaced.
4345
4446
## Features
4547
4648
The application offers:
4749
4850
* User-defined interpolated strings
4951
* Auto-complete variable names based on variable values
52
* High-quality PDF exports
5053
* Real-time spell check
5154
* Real-time rendering of math using TeX notation
...
7275
7376
## Screenshots
74
75
Diagrams that include variables:
76
77
![GraphViz diagram screenshot](docs/images/screenshots/01.png)
78
79
![Family tree diagram screenshot](docs/images/screenshots/05.png)
80
81
Poem with locale settings:
82
83
![Korean poem screenshot](docs/images/screenshots/02.png)
84
85
TeX equations with detached preview:
86
87
![TeX equations screenshot](docs/images/screenshots/03.png)
88
89
Document outline opened and docked in bottom-left corner:
9077
91
![Document outline](docs/images/screenshots/04.png)
78
See [screenshots](docs/screenshots.md) for visuals.
9279
9380
## License
A docs/images/screenshots/08.png
Binary file
A docs/licenses/JAVA-IMAGE-SCALING.md
1
Java Image Scaling
2
3
Copyright (c) 2013, Morten Nobel-Joergensen
4
All rights reserved.
5
6
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
7
8
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.  Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9
10
Neither the name of the <ORGANIZATION> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
11
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
12
113
A docs/screenshots.md
1
# Variables
2
3
Diagrams that include variables:
4
5
![GraphViz diagram screenshot](images/screenshots/01.png)
6
7
![Family tree diagram screenshot](images/screenshots/05.png)
8
9
# PDF themes
10
11
In the background of the following screenshot, the editor shows a novel
12
being edited:
13
14
![PDF themes](images/screenshots/08.png)
15
16
Highlighted items of note:
17
18
* PDF icon in the upper-left
19
* Novel metadata as integrated variables towards the top-left
20
* Theme selection dialog in the upper-middle
21
* Three different styles, including:
22
    * Boschet, based on Baskerville font, nicely styled
23
    * Handrit, based on Courier font, double-spaced, manuscript format
24
    * Tarmes, based on Times Roman font, minimal styling
25
* Variations in page numbers
26
* Manuscript includes word count, automatically
27
* Preferences dialog in the middle
28
29
# Internationalization
30
31
Poem with locale settings:
32
33
![Korean poem screenshot](images/screenshots/02.png)
34
35
# Equations
36
37
TeX equations with detached preview:
38
39
![TeX equations screenshot](images/screenshots/03.png)
40
41
# Dockable tabs
42
43
Document outline opened and docked in bottom-left corner:
44
45
![Document outline](images/screenshots/04.png)
46
147
M docs/typesetting.md
3030
## Windows
3131
32
Proceed with a Windows installation of typesetting software as follows:
32
Proceed with a Windows installation of the typesetting software as follows:
3333
3434
1. Extract the `.zip` file into `C:\Users\%USERNAME%\AppData\Local\context` (the "root" directory)
...
104104
The theme is configured.
105105
106
# Typeset document
106
# Typeset single document
107107
108108
Typeset a document as follows:
...
118118
119119
The document is typeset; open the PDF file in a PDF reader to view the result.
120
121
# Typeset multiple documents
122
123
Typeset multiple documents similar to single documents, with one difference:
124
125
* Click **File → Export As → Joined PDF** (or type `Ctrl+Shift+p`).
126
127
All documents having the same file name extension in the same directory
128
(or sub-directories) as the actively edited file are first concatenated then
129
typeset into a single PDF document. The order that files are concatenated
130
is numeric and alphabetic.
131
132
For example, if `1.Rmd` is a sibling of the following files in the same
133
directory, then all the files will be included in the PDF, as expected:
134
135
    chapter_1.Rmd
136
    chapter_2.Rmd
137
    chapter_2a.Rmd
138
    chapter_2b.Rmd
139
    chapter_3.Rmd
140
    chapter_10.Rmd
141
142
Basically, sorting honours numbers and letters in file names.
120143
121144
# Background 
M installer.sh
159159
160160
set SCRIPT_DIR=%~dp0
161
"%SCRIPT_DIR%\\${ARG_JAVA_DIR}\\bin\\java" ${OPT_JAVA} -jar "%SCRIPT_DIR%\\${APP_NAME}.jar" %*
161
"%SCRIPT_DIR%\\${ARG_JAVA_DIR}\\bin\\java" ${OPT_JAVA} -jar "%SCRIPT_DIR%\\${APP_NAME}.jar" %* 2>nul
162162
__EOT
163163
D scripts/bash-template
1
#!/usr/bin/env bash
2
3
# -----------------------------------------------------------------------------
4
# Copyright 2020 Dave Jarvis
5
#
6
# Permission is hereby granted, free of charge, to any person obtaining a
7
# copy of this software and associated documentation files (the
8
# "Software"), to deal in the Software without restriction, including
9
# without limitation the rights to use, copy, modify, merge, publish,
10
# distribute, sublicense, and/or sell copies of the Software, and to
11
# permit persons to whom the Software is furnished to do so, subject to
12
# the following conditions:
13
#
14
# The above copyright notice and this permission notice shall be included
15
# in all copies or substantial portions of the Software.
16
#
17
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
18
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
20
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
21
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
22
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
23
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24
# -----------------------------------------------------------------------------
25
26
set -o errexit
27
set -o nounset
28
29
readonly SCRIPT_SRC="$(dirname "${BASH_SOURCE[${#BASH_SOURCE[@]} - 1]}")"
30
readonly SCRIPT_DIR="$(cd "${SCRIPT_SRC}" >/dev/null 2>&1 && pwd)"
31
readonly SCRIPT_NAME=$(basename "$0")
32
33
# -----------------------------------------------------------------------------
34
# The main entry point is responsible for parsing command-line arguments,
35
# changing to the appropriate directory, and running all commands requested
36
# by the user.
37
#
38
# $@ - Command-line arguments
39
# -----------------------------------------------------------------------------
40
main() {
41
  arguments "$@"
42
43
  $usage       && terminate 3
44
  requirements && terminate 4
45
  traps        && terminate 5
46
47
  directory    && terminate 6
48
  preprocess   && terminate 7
49
  execute      && terminate 8
50
  postprocess  && terminate 9
51
52
  terminate 0
53
}
54
55
# -----------------------------------------------------------------------------
56
# Perform all commands that the script requires.
57
#
58
# @return 0 - Indicate to terminate the script with non-zero exit level
59
# @return 1 - All tasks completed successfully (default)
60
# -----------------------------------------------------------------------------
61
execute() {
62
  return 1
63
}
64
65
# -----------------------------------------------------------------------------
66
# Changes to the script's working directory, provided it exists.
67
#
68
# @return 0 - Change directory failed
69
# @return 1 - Change directory succeeded
70
# -----------------------------------------------------------------------------
71
directory() {
72
  $log "Change directory"
73
  local result=1
74
75
  # Track whether change directory failed.
76
  cd "${SCRIPT_DIR}" > /dev/null 2>&1 || result=0
77
78
  return "${result}"
79
}
80
81
# -----------------------------------------------------------------------------
82
# Perform any initialization required prior to executing tasks.
83
#
84
# @return 0 - Preprocessing failed
85
# @return 1 - Preprocessing succeeded
86
# -----------------------------------------------------------------------------
87
preprocess() {
88
  $log "Preprocess"
89
90
  return 1
91
}
92
93
# -----------------------------------------------------------------------------
94
# Perform any clean up required prior to executing tasks.
95
#
96
# @return 0 - Postprocessing failed
97
# @return 1 - Postprocessing succeeded
98
# -----------------------------------------------------------------------------
99
postprocess() {
100
  $log "Postprocess"
101
102
  return 1
103
}
104
105
# -----------------------------------------------------------------------------
106
# Check that all required commands are available.
107
#
108
# @return 0 - At least one command is missing
109
# @return 1 - All commands are available
110
# -----------------------------------------------------------------------------
111
requirements() {
112
  $log "Verify requirements"
113
  local -r expected_count=${#DEPENDENCIES[@]}
114
  local total_count=0
115
116
  # Verify that each command exists.
117
  for dependency in "${DEPENDENCIES[@]}"; do
118
    # Extract the command name [0] and URL [1].
119
    IFS=',' read -ra dependent <<< "${dependency}"
120
121
    required "${dependent[0]}" "${dependent[1]}"
122
    total_count=$(( total_count + $? ))
123
  done
124
125
  unset IFS
126
127
  # Total dependencies found must match the expected number.
128
  # Integer-only division rounds down.
129
  return $(( total_count / expected_count ))
130
}
131
132
# -----------------------------------------------------------------------------
133
# Called before terminating the script.
134
# -----------------------------------------------------------------------------
135
cleanup() {
136
  $log "Cleanup"
137
}
138
139
# -----------------------------------------------------------------------------
140
# Terminates the program immediately.
141
# -----------------------------------------------------------------------------
142
trap_control_c() {
143
  $log "Interrupted"
144
  cleanup
145
  error "⯃"
146
  terminate 1
147
}
148
149
# -----------------------------------------------------------------------------
150
# Configure signal traps.
151
#
152
# @return 1 - Signal traps are set.
153
# -----------------------------------------------------------------------------
154
traps() {
155
  # Suppress echoing ^C if pressed.
156
  stty -echoctl
157
  trap trap_control_c INT
158
159
  return 1
160
}
161
162
# -----------------------------------------------------------------------------
163
# Check for a required command.
164
#
165
# $1 - Command or file to check for existence
166
# $2 - Command's website (e.g., download for binaries and source code)
167
#
168
# @return 0 - Command is missing
169
# @return 1 - Command exists
170
# -----------------------------------------------------------------------------
171
required() {
172
  local result=0
173
174
  test -f "$1" || \
175
  command -v "$1" > /dev/null 2>&1 && result=1 || \
176
    warning "Missing: $1 ($2)"
177
178
  return ${result}
179
}
180
181
# -----------------------------------------------------------------------------
182
# Show acceptable command-line arguments.
183
#
184
# @return 0 - Indicate script may not continue
185
# -----------------------------------------------------------------------------
186
utile_usage() {
187
  printf "Usage: %s [OPTIONS...]\n\n" "${SCRIPT_NAME}" >&2
188
189
  # Number of spaces to pad after the longest long argument.
190
  local -r PADDING=2
191
192
  # Determine the longest long argument to adjust spacing.
193
  local -r LEN=$(printf '%s\n' "${ARGUMENTS[@]}" | \
194
    awk -F"," '{print length($2)+'${PADDING}'}' | sort -n | tail -1)
195
196
  local duplicates
197
198
  for argument in "${ARGUMENTS[@]}"; do
199
    # Extract the short [0] and long [1] arguments and description [2].
200
    arg=("$(echo ${argument} | cut -d ',' -f1)" \
201
         "$(echo ${argument} | cut -d ',' -f2)" \
202
         "$(echo ${argument} | cut -d ',' -f3-)")
203
204
    duplicates+=("${arg[0]}")
205
206
    printf "  -%s, --%-${LEN}s%s\n" "${arg[0]}" "${arg[1]}" "${arg[2]}" >&2
207
  done
208
209
  # Sort the arguments to make sure no duplicates exist.
210
  duplicates=$(echo "${duplicates[@]}" | tr ' ' '\n' | sort | uniq -c -d)
211
212
  # Warn the developer that there's a duplicate command-line option.
213
  if [ -n "${duplicates}" ]; then
214
    # Trim all the whitespaces
215
    duplicates=$(echo "${duplicates}" | xargs echo -n)
216
    error "Duplicate command-line argument exists: ${duplicates}"
217
  fi
218
219
  return 0
220
}
221
222
# -----------------------------------------------------------------------------
223
# Write coloured text to standard output.
224
#
225
# $1 - Text to write
226
# $2 - Text's colour
227
# -----------------------------------------------------------------------------
228
coloured_text() {
229
  printf "%b%s%b\n" "$2" "$1" "${COLOUR_OFF}"
230
}
231
232
# -----------------------------------------------------------------------------
233
# Write a warning message to standard output.
234
#
235
# $1 - Text to write
236
# -----------------------------------------------------------------------------
237
warning() {
238
  coloured_text "$1" "${COLOUR_WARNING}"
239
}
240
241
# -----------------------------------------------------------------------------
242
# Write an error message to standard output.
243
#
244
# $1 - Text to write
245
# -----------------------------------------------------------------------------
246
error() {
247
  coloured_text "$1" "${COLOUR_ERROR}"
248
}
249
250
# -----------------------------------------------------------------------------
251
# Write a timestamp and message to standard output.
252
#
253
# $1 - Text to write
254
# -----------------------------------------------------------------------------
255
utile_log() {
256
  printf "[%s] " "$(date +%H:%M:%S.%4N)"
257
  coloured_text "$1" "${COLOUR_LOGGING}"
258
}
259
260
# -----------------------------------------------------------------------------
261
# Perform no operations.
262
#
263
# return 1 - Success
264
# -----------------------------------------------------------------------------
265
noop() {
266
  return 1
267
}
268
269
# -----------------------------------------------------------------------------
270
# Exit the program with a given exit code.
271
#
272
# $1 - Exit code
273
# -----------------------------------------------------------------------------
274
terminate() {
275
  exit "$1"
276
}
277
278
# -----------------------------------------------------------------------------
279
# Set global variables from command-line arguments.
280
# -----------------------------------------------------------------------------
281
arguments() {
282
  while [ "$#" -gt "0" ]; do
283
    local consume=1
284
285
    case "$1" in
286
      -V|--verbose)
287
        log=utile_log
288
      ;;
289
      -h|-\?|--help)
290
        usage=utile_usage
291
      ;;
292
      *)
293
        set +e
294
        argument "$@"
295
        consume=$?
296
        set -e
297
      ;;
298
    esac
299
300
    shift ${consume}
301
  done
302
}
303
304
# -----------------------------------------------------------------------------
305
# Parses a single command-line argument. This must return a value greater
306
# than or equal to 1, otherwise parsing the command-line arguments will
307
# loop indefinitely.
308
#
309
# @return The number of arguments to consume (1 by default).
310
# -----------------------------------------------------------------------------
311
argument() {
312
  return 1
313
}
314
315
# ANSI colour escape sequences.
316
readonly COLOUR_BLUE='\033[1;34m'
317
readonly COLOUR_PINK='\033[1;35m'
318
readonly COLOUR_DKGRAY='\033[30m'
319
readonly COLOUR_DKRED='\033[31m'
320
readonly COLOUR_LTRED='\033[1;31m'
321
readonly COLOUR_YELLOW='\033[1;33m'
322
readonly COLOUR_OFF='\033[0m'
323
324
# Colour definitions used by script.
325
COLOUR_LOGGING=${COLOUR_BLUE}
326
COLOUR_WARNING=${COLOUR_YELLOW}
327
COLOUR_ERROR=${COLOUR_LTRED}
328
329
# Define required commands to check when script starts.
330
DEPENDENCIES=(
331
  "awk,https://www.gnu.org/software/gawk/manual/gawk.html"
332
  "cut,https://www.gnu.org/software/coreutils"
333
)
334
335
# Define help for command-line arguments.
336
ARGUMENTS=(
337
  "V,verbose,Log messages while processing"
338
  "h,help,Show this help message then exit"
339
)
340
341
# These functions may be set to utile delegates while parsing arguments.
342
usage=noop
343
log=noop
344
3451
A scripts/build-template
1
#!/usr/bin/env bash
2
3
# -----------------------------------------------------------------------------
4
# Copyright 2020 Dave Jarvis
5
#
6
# Permission is hereby granted, free of charge, to any person obtaining a
7
# copy of this software and associated documentation files (the
8
# "Software"), to deal in the Software without restriction, including
9
# without limitation the rights to use, copy, modify, merge, publish,
10
# distribute, sublicense, and/or sell copies of the Software, and to
11
# permit persons to whom the Software is furnished to do so, subject to
12
# the following conditions:
13
#
14
# The above copyright notice and this permission notice shall be included
15
# in all copies or substantial portions of the Software.
16
#
17
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
18
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
20
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
21
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
22
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
23
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24
# -----------------------------------------------------------------------------
25
26
set -o errexit
27
set -o nounset
28
29
readonly SCRIPT_SRC="$(dirname "${BASH_SOURCE[${#BASH_SOURCE[@]} - 1]}")"
30
readonly SCRIPT_DIR="$(cd "${SCRIPT_SRC}" >/dev/null 2>&1 && pwd)"
31
readonly SCRIPT_NAME=$(basename "$0")
32
33
# -----------------------------------------------------------------------------
34
# The main entry point is responsible for parsing command-line arguments,
35
# changing to the appropriate directory, and running all commands requested
36
# by the user.
37
#
38
# $@ - Command-line arguments
39
# -----------------------------------------------------------------------------
40
main() {
41
  arguments "$@"
42
43
  $usage       && terminate 3
44
  requirements && terminate 4
45
  traps        && terminate 5
46
47
  directory    && terminate 6
48
  preprocess   && terminate 7
49
  execute      && terminate 8
50
  postprocess  && terminate 9
51
52
  terminate 0
53
}
54
55
# -----------------------------------------------------------------------------
56
# Perform all commands that the script requires.
57
#
58
# @return 0 - Indicate to terminate the script with non-zero exit level
59
# @return 1 - All tasks completed successfully (default)
60
# -----------------------------------------------------------------------------
61
execute() {
62
  return 1
63
}
64
65
# -----------------------------------------------------------------------------
66
# Changes to the script's working directory, provided it exists.
67
#
68
# @return 0 - Change directory failed
69
# @return 1 - Change directory succeeded
70
# -----------------------------------------------------------------------------
71
directory() {
72
  $log "Change directory"
73
  local result=1
74
75
  # Track whether change directory failed.
76
  cd "${SCRIPT_DIR}" > /dev/null 2>&1 || result=0
77
78
  return "${result}"
79
}
80
81
# -----------------------------------------------------------------------------
82
# Perform any initialization required prior to executing tasks.
83
#
84
# @return 0 - Preprocessing failed
85
# @return 1 - Preprocessing succeeded
86
# -----------------------------------------------------------------------------
87
preprocess() {
88
  $log "Preprocess"
89
90
  return 1
91
}
92
93
# -----------------------------------------------------------------------------
94
# Perform any clean up required prior to executing tasks.
95
#
96
# @return 0 - Postprocessing failed
97
# @return 1 - Postprocessing succeeded
98
# -----------------------------------------------------------------------------
99
postprocess() {
100
  $log "Postprocess"
101
102
  return 1
103
}
104
105
# -----------------------------------------------------------------------------
106
# Check that all required commands are available.
107
#
108
# @return 0 - At least one command is missing
109
# @return 1 - All commands are available
110
# -----------------------------------------------------------------------------
111
requirements() {
112
  $log "Verify requirements"
113
  local -r expected_count=${#DEPENDENCIES[@]}
114
  local total_count=0
115
116
  # Verify that each command exists.
117
  for dependency in "${DEPENDENCIES[@]}"; do
118
    # Extract the command name [0] and URL [1].
119
    IFS=',' read -ra dependent <<< "${dependency}"
120
121
    required "${dependent[0]}" "${dependent[1]}"
122
    total_count=$(( total_count + $? ))
123
  done
124
125
  unset IFS
126
127
  # Total dependencies found must match the expected number.
128
  # Integer-only division rounds down.
129
  return $(( total_count / expected_count ))
130
}
131
132
# -----------------------------------------------------------------------------
133
# Called before terminating the script.
134
# -----------------------------------------------------------------------------
135
cleanup() {
136
  $log "Cleanup"
137
}
138
139
# -----------------------------------------------------------------------------
140
# Terminates the program immediately.
141
# -----------------------------------------------------------------------------
142
trap_control_c() {
143
  $log "Interrupted"
144
  cleanup
145
  error "⯃"
146
  terminate 1
147
}
148
149
# -----------------------------------------------------------------------------
150
# Configure signal traps.
151
#
152
# @return 1 - Signal traps are set.
153
# -----------------------------------------------------------------------------
154
traps() {
155
  # Suppress echoing ^C if pressed.
156
  stty -echoctl
157
  trap trap_control_c INT
158
159
  return 1
160
}
161
162
# -----------------------------------------------------------------------------
163
# Check for a required command.
164
#
165
# $1 - Command or file to check for existence
166
# $2 - Command's website (e.g., download for binaries and source code)
167
#
168
# @return 0 - Command is missing
169
# @return 1 - Command exists
170
# -----------------------------------------------------------------------------
171
required() {
172
  local result=0
173
174
  test -f "$1" || \
175
  command -v "$1" > /dev/null 2>&1 && result=1 || \
176
    warning "Missing: $1 ($2)"
177
178
  return ${result}
179
}
180
181
# -----------------------------------------------------------------------------
182
# Show acceptable command-line arguments.
183
#
184
# @return 0 - Indicate script may not continue
185
# -----------------------------------------------------------------------------
186
utile_usage() {
187
  printf "Usage: %s [OPTIONS...]\n\n" "${SCRIPT_NAME}" >&2
188
189
  # Number of spaces to pad after the longest long argument.
190
  local -r PADDING=2
191
192
  # Determine the longest long argument to adjust spacing.
193
  local -r LEN=$(printf '%s\n' "${ARGUMENTS[@]}" | \
194
    awk -F"," '{print length($2)+'${PADDING}'}' | sort -n | tail -1)
195
196
  local duplicates
197
198
  for argument in "${ARGUMENTS[@]}"; do
199
    # Extract the short [0] and long [1] arguments and description [2].
200
    arg=("$(echo ${argument} | cut -d ',' -f1)" \
201
         "$(echo ${argument} | cut -d ',' -f2)" \
202
         "$(echo ${argument} | cut -d ',' -f3-)")
203
204
    duplicates+=("${arg[0]}")
205
206
    printf "  -%s, --%-${LEN}s%s\n" "${arg[0]}" "${arg[1]}" "${arg[2]}" >&2
207
  done
208
209
  # Sort the arguments to make sure no duplicates exist.
210
  duplicates=$(echo "${duplicates[@]}" | tr ' ' '\n' | sort | uniq -c -d)
211
212
  # Warn the developer that there's a duplicate command-line option.
213
  if [ -n "${duplicates}" ]; then
214
    # Trim all the whitespaces
215
    duplicates=$(echo "${duplicates}" | xargs echo -n)
216
    error "Duplicate command-line argument exists: ${duplicates}"
217
  fi
218
219
  return 0
220
}
221
222
# -----------------------------------------------------------------------------
223
# Write coloured text to standard output.
224
#
225
# $1 - Text to write
226
# $2 - Text's colour
227
# -----------------------------------------------------------------------------
228
coloured_text() {
229
  printf "%b%s%b\n" "$2" "$1" "${COLOUR_OFF}"
230
}
231
232
# -----------------------------------------------------------------------------
233
# Write a warning message to standard output.
234
#
235
# $1 - Text to write
236
# -----------------------------------------------------------------------------
237
warning() {
238
  coloured_text "$1" "${COLOUR_WARNING}"
239
}
240
241
# -----------------------------------------------------------------------------
242
# Write an error message to standard output.
243
#
244
# $1 - Text to write
245
# -----------------------------------------------------------------------------
246
error() {
247
  coloured_text "$1" "${COLOUR_ERROR}"
248
}
249
250
# -----------------------------------------------------------------------------
251
# Write a timestamp and message to standard output.
252
#
253
# $1 - Text to write
254
# -----------------------------------------------------------------------------
255
utile_log() {
256
  printf "[%s] " "$(date +%H:%M:%S.%4N)"
257
  coloured_text "$1" "${COLOUR_LOGGING}"
258
}
259
260
# -----------------------------------------------------------------------------
261
# Perform no operations.
262
#
263
# return 1 - Success
264
# -----------------------------------------------------------------------------
265
noop() {
266
  return 1
267
}
268
269
# -----------------------------------------------------------------------------
270
# Exit the program with a given exit code.
271
#
272
# $1 - Exit code
273
# -----------------------------------------------------------------------------
274
terminate() {
275
  exit "$1"
276
}
277
278
# -----------------------------------------------------------------------------
279
# Set global variables from command-line arguments.
280
# -----------------------------------------------------------------------------
281
arguments() {
282
  while [ "$#" -gt "0" ]; do
283
    local consume=1
284
285
    case "$1" in
286
      -V|--verbose)
287
        log=utile_log
288
      ;;
289
      -h|-\?|--help)
290
        usage=utile_usage
291
      ;;
292
      *)
293
        set +e
294
        argument "$@"
295
        consume=$?
296
        set -e
297
      ;;
298
    esac
299
300
    shift ${consume}
301
  done
302
}
303
304
# -----------------------------------------------------------------------------
305
# Parses a single command-line argument. This must return a value greater
306
# than or equal to 1, otherwise parsing the command-line arguments will
307
# loop indefinitely.
308
#
309
# @return The number of arguments to consume (1 by default).
310
# -----------------------------------------------------------------------------
311
argument() {
312
  return 1
313
}
314
315
# ANSI colour escape sequences.
316
readonly COLOUR_BLUE='\033[1;34m'
317
readonly COLOUR_PINK='\033[1;35m'
318
readonly COLOUR_DKGRAY='\033[30m'
319
readonly COLOUR_DKRED='\033[31m'
320
readonly COLOUR_LTRED='\033[1;31m'
321
readonly COLOUR_YELLOW='\033[1;33m'
322
readonly COLOUR_OFF='\033[0m'
323
324
# Colour definitions used by script.
325
COLOUR_LOGGING=${COLOUR_BLUE}
326
COLOUR_WARNING=${COLOUR_YELLOW}
327
COLOUR_ERROR=${COLOUR_LTRED}
328
329
# Define required commands to check when script starts.
330
DEPENDENCIES=(
331
  "awk,https://www.gnu.org/software/gawk/manual/gawk.html"
332
  "cut,https://www.gnu.org/software/coreutils"
333
)
334
335
# Define help for command-line arguments.
336
ARGUMENTS=(
337
  "V,verbose,Log messages while processing"
338
  "h,help,Show this help message then exit"
339
)
340
341
# These functions may be set to utile delegates while parsing arguments.
342
usage=noop
343
log=noop
344
1345
M src/main/java/com/keenwrite/Launcher.java
77
88
import static com.keenwrite.Bootstrap.*;
9
import static com.keenwrite.PermissiveCertificate.installTrustManager;
910
import static java.lang.String.format;
1011
...
2425
   */
2526
  public static void main( final String[] args ) {
26
    showAppInfo();
27
    MainApp.main( args );
27
    try {
28
      installTrustManager();
29
      showAppInfo();
30
      MainApp.main( args );
31
    } catch( final Throwable t ) {
32
      log( t );
33
    }
2834
  }
2935
30
  @SuppressWarnings("RedundantStringFormatCall")
36
  @SuppressWarnings( "RedundantStringFormatCall" )
3137
  private static void showAppInfo() {
3238
    out( format( "%s version %s", APP_TITLE, APP_VERSION ) );
...
5662
  }
5763
58
  @SuppressWarnings("SameParameterValue")
64
  @SuppressWarnings( "SameParameterValue" )
5965
  private static Properties loadProperties( final String resource )
6066
    throws IOException {
...
7480
  private static InputStream getResourceAsStream( final String resource ) {
7581
    return Launcher.class.getClassLoader().getResourceAsStream( resource );
82
  }
83
84
  /**
85
   * Logs the message of an error to the console.
86
   *
87
   * @param error The fatal error that could not be handled.
88
   */
89
  private static void log( final Throwable error ) {
90
    var message = error.getMessage();
91
92
    if( message != null && message.toLowerCase().contains( "javafx" ) ) {
93
      message = "Re-run using a Java Runtime Environment that includes JavaFX.";
94
    }
95
96
    out( format( "ERROR: %s", message ) );
7697
  }
7798
}
M src/main/java/com/keenwrite/MainApp.java
6060
  @Override
6161
  public void start( final Stage stage ) {
62
    // Must be instantiated after the UI is initialized (i.e., not in main).
62
    // Must be instantiated after the UI is initialized (i.e., not in main)
63
    // because it interacts with GUI properties.
6364
    mWorkspace = new Workspace();
6465
M src/main/java/com/keenwrite/MainPane.java
173173
    mPreview = new HtmlPreview( workspace );
174174
    mStatistics = new DocumentStatistics( workspace );
175
    mActiveTextEditor.set( new MarkdownEditor( workspace ) );
175176
176177
    open( bin( getRecentFiles() ) );
177178
    viewPreview();
178179
    setDividerPositions( calculateDividerPositions() );
179180
180181
    // Once the main scene's window regains focus, update the active definition
181182
    // editor to the currently selected tab.
182
    runLater(
183
      () -> getWindow().setOnCloseRequest( ( event ) -> {
184
        // Order matters here. We want to close all the tabs to ensure each
185
        // is saved, but after they are closed, the workspace should still
186
        // retain the list of files that were open. If this line came after
187
        // closing, then restarting the application would list no files.
188
        mWorkspace.save();
183
    runLater( () -> getWindow().setOnCloseRequest( ( event ) -> {
184
      // Order matters here. We want to close all the tabs to ensure each
185
      // is saved, but after they are closed, the workspace should still
186
      // retain the list of files that were open. If this line came after
187
      // closing, then restarting the application would list no files.
188
      mWorkspace.save();
189189
190
        if( closeAll() ) {
191
          Platform.exit();
192
          System.exit( 0 );
193
        }
194
        else {
195
          event.consume();
196
        }
197
      } )
198
    );
190
      if( closeAll() ) {
191
        Platform.exit();
192
        System.exit( 0 );
193
      }
194
      else {
195
        event.consume();
196
      }
197
    } ) );
199198
200199
    register( this );
A src/main/java/com/keenwrite/PermissiveCertificate.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite;
3
4
import javax.net.ssl.*;
5
import java.security.SecureRandom;
6
import java.security.cert.X509Certificate;
7
8
import static javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier;
9
import static javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory;
10
11
/**
12
 * Responsible for trusting all certificate chains. The purpose of this class
13
 * is to work-around certificate issues caused by software that blocks
14
 * HTTP requests. For example, zscaler may block HTTP requests to kroki.io
15
 * when generating diagrams.
16
 */
17
public final class PermissiveCertificate {
18
  /**
19
   * Create a trust manager that does not validate certificate chains.
20
   */
21
  private final static TrustManager[] TRUST_ALL_CERTS = new TrustManager[]{
22
    new X509TrustManager() {
23
      @Override
24
      public X509Certificate[] getAcceptedIssuers() {
25
        return new X509Certificate[ 0 ];
26
      }
27
28
      @Override
29
      public void checkClientTrusted(
30
        X509Certificate[] certs, String authType ) {
31
      }
32
33
      @Override
34
      public void checkServerTrusted(
35
        X509Certificate[] certs, String authType ) {
36
      }
37
    }
38
  };
39
40
  /**
41
   * Responsible for permitting all hostnames for making HTTP requests.
42
   */
43
  private static class PermissiveHostNameVerifier implements HostnameVerifier {
44
    @Override
45
    public boolean verify( final String hostname, final SSLSession session ) {
46
      return true;
47
    }
48
  }
49
50
  /**
51
   * Use {@link #installTrustManager()}.
52
   */
53
  private PermissiveCertificate() {
54
  }
55
56
  /**
57
   * Install the all-trusting trust manager. If this fails it means that in
58
   * certain situations the HTML preview may fail to render diagrams. A way
59
   * to work-around the issue is to install a local server for generating
60
   * diagrams.
61
   */
62
  public static boolean installTrustManager() {
63
    try {
64
      final var context = SSLContext.getInstance( "SSL" );
65
      context.init( null, TRUST_ALL_CERTS, new SecureRandom() );
66
      setDefaultSSLSocketFactory( context.getSocketFactory() );
67
      setDefaultHostnameVerifier( new PermissiveHostNameVerifier() );
68
      return true;
69
    } catch( final Exception ex ) {
70
      return false;
71
    }
72
  }
73
}
174
M src/main/java/com/keenwrite/constants/Constants.java
210210
  /**
211211
   * Default server name for rendering diagrams.
212
   * <p>
213
   * TODO: Make this a preference so that local installs are possible.
214212
   */
215213
  public static final String DIAGRAM_SERVER_NAME = "kroki.io";
216214
217215
  /**
218216
   * Application action messages properties prefix.
219217
   */
220218
  public static final String ACTION_PREFIX = "Action.";
219
220
  /**
221
   * Restrict theme names when displaying.
222
   */
223
  public static final byte THEME_NAME_LENGTH = 30;
221224
222225
  /**
M src/main/java/com/keenwrite/editors/TextEditor.java
157157
158158
  /**
159
   * Requests making the selected text, or word at caret, monospace.
160
   */
161
  default void monospace() { }
162
163
  /**
159164
   * Requests making the selected text, or word at caret, a superscript.
160165
   */
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
315315
316316
  @Override
317
  public void monospace() {
318
    enwrap( "`" );
319
  }
320
321
  @Override
317322
  public void superscript() {
318323
    enwrap( "^" );
M src/main/java/com/keenwrite/events/StatusEvent.java
44
import com.keenwrite.MainApp;
55
6
import java.util.List;
67
import java.util.stream.Collectors;
78
...
1718
 * exceptions, state problems, parsing errors, and so forth.
1819
 */
19
public class StatusEvent implements AppEvent {
20
public final class StatusEvent implements AppEvent {
2021
  private static final String PACKAGE_NAME = MainApp.class.getPackageName();
2122
...
4041
   */
4142
  public StatusEvent( final String message ) {
42
    this( message, null );
43
    assert message != null;
44
    mMessage = message;
45
    mProblem = null;
4346
  }
4447
45
  /**
46
   * Constructs a new event that contains a problem description to help the
47
   * user resolve an issue encountered while using the application.
48
   *
49
   * @param message The human-readable message, typically displayed on-screen.
50
   * @param problem Stack trace to pin-point the problem, may be {@code null}.
51
   */
48
  public StatusEvent( final Throwable problem ) {
49
    this( "", problem );
50
  }
51
5252
  public StatusEvent( final String message, final Throwable problem ) {
5353
    assert message != null;
54
    assert problem != null;
5455
    mMessage = message;
5556
    mProblem = problem;
...
7879
  }
7980
80
  public String getException() {
81
    return mProblem == null ? "" : toEnglish( mProblem );
81
  @Override
82
  public String toString() {
83
    return format( "%s%s%s",
84
                   mMessage,
85
                   mMessage.isBlank() ? "" : " ",
86
                   mProblem == null ? "" : toEnglish( mProblem ) );
8287
  }
8388
...
105110
106111
    // Subclasses of RuntimeException must be subject to Englishification.
107
    if( problem.getClass().equals( RuntimeException.class ) &&
108
      (problem = problem.getCause()) == null ) {
109
      return "";
112
    if( problem.getClass().equals( RuntimeException.class ) ) {
113
      final var cause = problem.getCause();
114
      return cause == null ? problem.getMessage() : cause.getMessage();
110115
    }
111116
112117
    final var className = problem.getClass().getSimpleName();
113118
    final var words = join( " ", className.split( ENGLISHIFY ) );
114
    return format( " (%s: %s)", words.toLowerCase(), problem.getMessage() );
119
    return format( "(%s: %s)", words.toLowerCase(), problem.getMessage() );
115120
  }
116121
...
133138
134139
  /**
135
   * Updates the status bar with a custom message.
140
   * Notifies listeners of a series of messages. This is useful when providing
141
   * users feedback of how third-party executables have failed.
136142
   *
137
   * @param key  The property key having a value to populate with arguments.
138
   * @param args The placeholder values to substitute into the key's value.
143
   * @param messages The lines of text to display.
139144
   */
140
  public static void clue( final String key, final Object... args ) {
141
    fireStatusEvent( get( key, args ) );
145
  public static void clue( final List<String> messages ) {
146
    messages.forEach( StatusEvent::fireStatusEvent );
142147
  }
143148
144149
  /**
145
   * Update the status bar with a pre-parsed message and exception.
150
   * Notifies listeners of an error.
146151
   *
147
   * @param message The custom message to log.
148
   * @param problem The exception that triggered the status update.
152
   * @param key The message bundle key to look up.
153
   * @param t   The exception that caused the error.
149154
   */
150
  public static void clue( final String message, final Throwable problem ) {
151
    fireStatusEvent( message, problem );
155
  public static void clue( final String key, final Throwable t ) {
156
    fireStatusEvent( get( key ), t );
152157
  }
153158
154159
  /**
155
   * Called when an exception occurs that warrants the user's attention.
160
   * Notifies listeners of a custom message.
161
   *
162
   * @param key  The property key having a value to populate with arguments.
163
   * @param args The placeholder values to substitute into the key's value.
164
   */
165
  public static void clue( final String key, final Object... args ) {
166
    fireStatusEvent( get( key, args ) );
167
  }
168
169
  /**
170
   * Notifies listeners of an exception occurs that warrants the user's
171
   * attention.
156172
   *
157173
   * @param problem The exception with a message to display to the user.
158174
   */
159175
  public static void clue( final Throwable problem ) {
160
    fireStatusEvent( "", problem );
176
    fireStatusEvent( problem );
161177
  }
162178
163179
  private static void fireStatusEvent( final String message ) {
164180
    new StatusEvent( message ).fire();
181
  }
182
183
  private static void fireStatusEvent( final Throwable problem ) {
184
    new StatusEvent( problem ).fire();
165185
  }
166186
M src/main/java/com/keenwrite/io/HttpFacade.java
3838
   */
3939
  public static Response httpGet( final URL url ) throws Exception {
40
    return new Response(url);
40
    return new Response( url );
4141
  }
4242
...
7474
7575
      clue( "Main.status.image.request.init" );
76
      final var connection = url.openConnection();
7776
78
      if( connection instanceof HttpURLConnection ) {
79
        mConn = (HttpURLConnection) connection;
80
        mConn.setUseCaches( false );
81
        mConn.setInstanceFollowRedirects( true );
82
        mConn.setRequestProperty( "Accept-Encoding", "gzip" );
83
        mConn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) );
84
        mConn.setRequestMethod( "GET" );
85
        mConn.setConnectTimeout( 15000 );
86
        mConn.setRequestProperty( "connection", "close" );
87
        mConn.connect();
77
      if( url.openConnection() instanceof HttpURLConnection conn ) {
78
        conn.setUseCaches( false );
79
        conn.setInstanceFollowRedirects( true );
80
        conn.setRequestProperty( "Accept-Encoding", "gzip" );
81
        conn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) );
82
        conn.setRequestMethod( "GET" );
83
        conn.setConnectTimeout( 15000 );
84
        conn.setRequestProperty( "connection", "close" );
85
        conn.connect();
86
8887
        clue( "Main.status.image.request.fetch", url.getHost() );
8988
90
        final var code = mConn.getResponseCode();
89
        final var code = conn.getResponseCode();
9190
9291
        // Even though there are other "okay" error codes, tell the user when
9392
        // a resource has changed in any unexpected way.
9493
        if( code != HTTP_OK ) {
95
          throw new IOException( url.toString() + " [HTTP " + code + "]" );
94
          throw new IOException( url + " [HTTP " + code + "]" );
9695
        }
9796
97
        mConn = conn;
9898
        mStream = openBufferedInputStream();
9999
      }
M src/main/java/com/keenwrite/preferences/PreferencesController.java
1010
import com.dlsc.preferencesfx.util.StorageHandler;
1111
import com.dlsc.preferencesfx.view.NavigationView;
12
import javafx.beans.property.DoubleProperty;
13
import javafx.beans.property.ObjectProperty;
14
import javafx.beans.property.StringProperty;
15
import javafx.event.EventHandler;
16
import javafx.scene.Node;
17
import javafx.scene.control.Button;
18
import javafx.scene.control.DialogPane;
19
import javafx.scene.control.Label;
20
import org.controlsfx.control.MasterDetailPane;
21
22
import java.io.File;
23
24
import static com.dlsc.formsfx.model.structure.Field.ofStringType;
25
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
26
import static com.keenwrite.Messages.get;
27
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
28
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
29
import static com.keenwrite.preferences.SkinProperty.skinListProperty;
30
import static com.keenwrite.preferences.WorkspaceKeys.*;
31
import static javafx.scene.control.ButtonType.CANCEL;
32
import static javafx.scene.control.ButtonType.OK;
33
34
/**
35
 * Provides the ability for users to configure their preferences. This links
36
 * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
37
 */
38
@SuppressWarnings( "SameParameterValue" )
39
public final class PreferencesController {
40
41
  private final Workspace mWorkspace;
42
  private final PreferencesFx mPreferencesFx;
43
44
  public PreferencesController( final Workspace workspace ) {
45
    mWorkspace = workspace;
46
47
    // All properties must be initialized before creating the dialog.
48
    mPreferencesFx = createPreferencesFx();
49
50
    initKeyEventHandler( mPreferencesFx );
51
  }
52
53
  /**
54
   * Display the user preferences settings dialog (non-modal).
55
   */
56
  public void show() {
57
    getPreferencesFx().show( false );
58
  }
59
60
  /**
61
   * Call to persist the settings. Strictly speaking, this could watch on
62
   * all values for external changes then save automatically.
63
   */
64
  public void save() {
65
    getPreferencesFx().saveSettings();
66
  }
67
68
  /**
69
   * Delegates to the {@link PreferencesFx} event handler for monitoring
70
   * save events.
71
   *
72
   * @param eventHandler The handler to call when the preferences are saved.
73
   */
74
  public void addSaveEventHandler(
75
    final EventHandler<? super PreferencesFxEvent> eventHandler ) {
76
    getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler );
77
  }
78
79
  private StringField createFontNameField(
80
    final StringProperty fontName, final DoubleProperty fontSize ) {
81
    final var control = new SimpleFontControl( "Change" );
82
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
83
      if( n != null ) {
84
        fontSize.set( n.doubleValue() );
85
      }
86
    } );
87
    return ofStringType( fontName ).render( control );
88
  }
89
90
  /**
91
   * Creates the preferences dialog based using {@link XmlStorageHandler} and
92
   * numerous {@link Category} objects.
93
   *
94
   * @return A component for editing preferences.
95
   * @throws RuntimeException Could not construct the {@link PreferencesFx}
96
   *                          object (e.g., illegal access permissions,
97
   *                          unmapped XML resource).
98
   */
99
  private PreferencesFx createPreferencesFx() {
100
    return PreferencesFx.of( createStorageHandler(), createCategories() )
101
                        .instantPersistent( false )
102
                        .dialogIcon( ICON_DIALOG );
103
  }
104
105
  private StorageHandler createStorageHandler() {
106
    return new XmlStorageHandler();
107
  }
108
109
  private Category[] createCategories() {
110
    return new Category[]{
111
      Category.of(
112
        get( KEY_DOC ),
113
        Group.of(
114
          get( KEY_DOC_TITLE ),
115
          Setting.of( label( KEY_DOC_TITLE ) ),
116
          Setting.of( title( KEY_DOC_TITLE ),
117
                      stringProperty( KEY_DOC_TITLE ) )
118
        ),
119
        Group.of(
120
          get( KEY_DOC_AUTHOR ),
121
          Setting.of( label( KEY_DOC_AUTHOR ) ),
122
          Setting.of( title( KEY_DOC_AUTHOR ),
123
                      stringProperty( KEY_DOC_AUTHOR ) )
124
        ),
125
        Group.of(
126
          get( KEY_DOC_BYLINE ),
127
          Setting.of( label( KEY_DOC_BYLINE ) ),
128
          Setting.of( title( KEY_DOC_BYLINE ),
129
                      stringProperty( KEY_DOC_BYLINE ) )
130
        ),
131
        Group.of(
132
          get( KEY_DOC_ADDRESS ),
133
          Setting.of( label( KEY_DOC_ADDRESS ) ),
134
          createMultilineSetting( "Address", KEY_DOC_ADDRESS )
135
        ),
136
        Group.of(
137
          get( KEY_DOC_PHONE ),
138
          Setting.of( label( KEY_DOC_PHONE ) ),
139
          Setting.of( title( KEY_DOC_PHONE ),
140
                      stringProperty( KEY_DOC_PHONE ) )
141
        ),
142
        Group.of(
143
          get( KEY_DOC_EMAIL ),
144
          Setting.of( label( KEY_DOC_EMAIL ) ),
145
          Setting.of( title( KEY_DOC_EMAIL ),
146
                      stringProperty( KEY_DOC_EMAIL ) )
147
        ),
148
        Group.of(
149
          get( KEY_DOC_KEYWORDS ),
150
          Setting.of( label( KEY_DOC_KEYWORDS ) ),
151
          Setting.of( title( KEY_DOC_KEYWORDS ),
152
                      stringProperty( KEY_DOC_KEYWORDS ) )
153
        ),
154
        Group.of(
155
          get( KEY_DOC_COPYRIGHT ),
156
          Setting.of( label( KEY_DOC_COPYRIGHT ) ),
157
          Setting.of( title( KEY_DOC_COPYRIGHT ),
158
                      stringProperty( KEY_DOC_COPYRIGHT ) )
159
        ),
160
        Group.of(
161
          get( KEY_DOC_DATE ),
162
          Setting.of( label( KEY_DOC_DATE ) ),
163
          Setting.of( title( KEY_DOC_DATE ),
164
                      stringProperty( KEY_DOC_DATE ) )
165
        )
166
      ),
167
      Category.of(
168
        get( KEY_TYPESET ),
169
        Group.of(
170
          get( KEY_TYPESET_CONTEXT ),
171
          Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
172
          Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
173
                      fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true )
174
        )
175
      ),
176
      Category.of(
177
        get( KEY_R ),
178
        Group.of(
179
          get( KEY_R_DIR ),
180
          Setting.of( label( KEY_R_DIR,
181
                             stringProperty( KEY_DEF_DELIM_BEGAN ).get(),
182
                             stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ),
183
          Setting.of( title( KEY_R_DIR ),
184
                      fileProperty( KEY_R_DIR ), true )
185
        ),
186
        Group.of(
187
          get( KEY_R_SCRIPT ),
188
          Setting.of( label( KEY_R_SCRIPT ) ),
189
          createMultilineSetting( "Script", KEY_R_SCRIPT )
190
        ),
191
        Group.of(
192
          get( KEY_R_DELIM_BEGAN ),
193
          Setting.of( label( KEY_R_DELIM_BEGAN ) ),
194
          Setting.of( title( KEY_R_DELIM_BEGAN ),
195
                      stringProperty( KEY_R_DELIM_BEGAN ) )
196
        ),
197
        Group.of(
198
          get( KEY_R_DELIM_ENDED ),
199
          Setting.of( label( KEY_R_DELIM_ENDED ) ),
200
          Setting.of( title( KEY_R_DELIM_ENDED ),
201
                      stringProperty( KEY_R_DELIM_ENDED ) )
202
        )
203
      ),
204
      Category.of(
205
        get( KEY_IMAGES ),
206
        Group.of(
207
          get( KEY_IMAGES_DIR ),
208
          Setting.of( label( KEY_IMAGES_DIR ) ),
209
          Setting.of( title( KEY_IMAGES_DIR ),
210
                      fileProperty( KEY_IMAGES_DIR ), true )
211
        ),
212
        Group.of(
213
          get( KEY_IMAGES_ORDER ),
214
          Setting.of( label( KEY_IMAGES_ORDER ) ),
215
          Setting.of( title( KEY_IMAGES_ORDER ),
216
                      stringProperty( KEY_IMAGES_ORDER ) )
217
        )
218
      ),
219
      Category.of(
220
        get( KEY_DEF ),
221
        Group.of(
222
          get( KEY_DEF_PATH ),
223
          Setting.of( label( KEY_DEF_PATH ) ),
224
          Setting.of( title( KEY_DEF_PATH ),
225
                      fileProperty( KEY_DEF_PATH ), false )
226
        ),
227
        Group.of(
228
          get( KEY_DEF_DELIM_BEGAN ),
229
          Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
230
          Setting.of( title( KEY_DEF_DELIM_BEGAN ),
231
                      stringProperty( KEY_DEF_DELIM_BEGAN ) )
232
        ),
233
        Group.of(
234
          get( KEY_DEF_DELIM_ENDED ),
235
          Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
236
          Setting.of( title( KEY_DEF_DELIM_ENDED ),
237
                      stringProperty( KEY_DEF_DELIM_ENDED ) )
238
        )
239
      ),
240
      Category.of(
241
        get( KEY_UI_FONT ),
242
        Group.of(
243
          get( KEY_UI_FONT_EDITOR ),
244
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
245
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
246
                      createFontNameField(
247
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
248
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
249
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
250
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
251
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
252
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
253
        ),
254
        Group.of(
255
          get( KEY_UI_FONT_PREVIEW ),
256
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
257
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
258
                      createFontNameField(
259
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
260
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
261
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
262
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
263
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
264
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
265
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
266
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
267
                      createFontNameField(
268
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
269
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
270
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
271
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
272
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
273
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
274
        )
275
      ),
276
      Category.of(
277
        get( KEY_UI_SKIN ),
278
        Group.of(
279
          get( KEY_UI_SKIN_SELECTION ),
280
          Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
281
          Setting.of( title( KEY_UI_SKIN_SELECTION ),
282
                      skinListProperty(),
283
                      skinProperty( KEY_UI_SKIN_SELECTION ) )
284
        ),
285
        Group.of(
286
          get( KEY_UI_SKIN_CUSTOM ),
287
          Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
288
          Setting.of( title( KEY_UI_SKIN_CUSTOM ),
289
                      fileProperty( KEY_UI_SKIN_CUSTOM ), false )
290
        )
291
      ),
292
      Category.of(
293
        get( KEY_LANGUAGE ),
294
        Group.of(
295
          get( KEY_LANGUAGE_LOCALE ),
296
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
297
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
298
                      localeListProperty(),
299
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
300
        )
301
      )};
302
  }
303
304
  @SuppressWarnings( "unchecked" )
305
  private Setting<StringField, StringProperty> createMultilineSetting(
306
    final String description, final Key property ) {
307
    final Setting<StringField, StringProperty> setting =
308
      Setting.of( description, stringProperty( property ) );
309
    final var field = setting.getElement();
310
    field.multiline( true );
311
312
    return setting;
313
  }
314
315
  private void initKeyEventHandler( final PreferencesFx preferences ) {
316
    final var view = preferences.getView();
317
    final var nodes = view.getChildrenUnmodifiable();
318
    final var master = (MasterDetailPane) nodes.get( 0 );
319
    final var detail = (NavigationView) master.getDetailNode();
320
    final var pane = (DialogPane) view.getParent();
321
322
    detail.setOnKeyReleased( ( key ) -> {
323
      switch( key.getCode() ) {
324
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
325
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
326
      }
327
    } );
328
  }
329
330
  /**
331
   * Creates a label for the given key after interpolating its value.
332
   *
333
   * @param key The key to find in the resource bundle.
334
   * @return The value of the key as a label.
335
   */
336
  private Node label( final Key key ) {
337
    return label( key, (String[]) null );
338
  }
339
340
  private Node label( final Key key, final String... values ) {
341
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
342
  }
343
344
  private String title( final Key key ) {
345
    return get( key.toString() + ".title" );
346
  }
347
348
  private ObjectProperty<File> fileProperty( final Key key ) {
349
    return mWorkspace.fileProperty( key );
350
  }
351
352
  private StringProperty stringProperty( final Key key ) {
353
    return mWorkspace.stringProperty( key );
12
import javafx.beans.property.BooleanProperty;
13
import javafx.beans.property.DoubleProperty;
14
import javafx.beans.property.ObjectProperty;
15
import javafx.beans.property.StringProperty;
16
import javafx.event.EventHandler;
17
import javafx.scene.Node;
18
import javafx.scene.control.Button;
19
import javafx.scene.control.DialogPane;
20
import javafx.scene.control.Label;
21
import org.controlsfx.control.MasterDetailPane;
22
23
import java.io.File;
24
25
import static com.dlsc.formsfx.model.structure.Field.ofStringType;
26
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
27
import static com.keenwrite.Messages.get;
28
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
29
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
30
import static com.keenwrite.preferences.SkinProperty.skinListProperty;
31
import static com.keenwrite.preferences.WorkspaceKeys.*;
32
import static javafx.scene.control.ButtonType.CANCEL;
33
import static javafx.scene.control.ButtonType.OK;
34
35
/**
36
 * Provides the ability for users to configure their preferences. This links
37
 * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
38
 */
39
@SuppressWarnings( "SameParameterValue" )
40
public final class PreferencesController {
41
42
  private final Workspace mWorkspace;
43
  private final PreferencesFx mPreferencesFx;
44
45
  public PreferencesController( final Workspace workspace ) {
46
    mWorkspace = workspace;
47
48
    // All properties must be initialized before creating the dialog.
49
    mPreferencesFx = createPreferencesFx();
50
51
    initKeyEventHandler( mPreferencesFx );
52
  }
53
54
  /**
55
   * Display the user preferences settings dialog (non-modal).
56
   */
57
  public void show() {
58
    getPreferencesFx().show( false );
59
  }
60
61
  /**
62
   * Call to persist the settings. Strictly speaking, this could watch on
63
   * all values for external changes then save automatically.
64
   */
65
  public void save() {
66
    getPreferencesFx().saveSettings();
67
  }
68
69
  /**
70
   * Delegates to the {@link PreferencesFx} event handler for monitoring
71
   * save events.
72
   *
73
   * @param eventHandler The handler to call when the preferences are saved.
74
   */
75
  public void addSaveEventHandler(
76
    final EventHandler<? super PreferencesFxEvent> eventHandler ) {
77
    getPreferencesFx().addEventHandler( EVENT_PREFERENCES_SAVED, eventHandler );
78
  }
79
80
  private StringField createFontNameField(
81
    final StringProperty fontName, final DoubleProperty fontSize ) {
82
    final var control = new SimpleFontControl( "Change" );
83
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
84
      if( n != null ) {
85
        fontSize.set( n.doubleValue() );
86
      }
87
    } );
88
    return ofStringType( fontName ).render( control );
89
  }
90
91
  /**
92
   * Creates the preferences dialog based using {@link XmlStorageHandler} and
93
   * numerous {@link Category} objects.
94
   *
95
   * @return A component for editing preferences.
96
   * @throws RuntimeException Could not construct the {@link PreferencesFx}
97
   *                          object (e.g., illegal access permissions,
98
   *                          unmapped XML resource).
99
   */
100
  private PreferencesFx createPreferencesFx() {
101
    return PreferencesFx.of( createStorageHandler(), createCategories() )
102
                        .instantPersistent( false )
103
                        .dialogIcon( ICON_DIALOG );
104
  }
105
106
  private StorageHandler createStorageHandler() {
107
    return new XmlStorageHandler();
108
  }
109
110
  private Category[] createCategories() {
111
    return new Category[]{
112
      Category.of(
113
        get( KEY_DOC ),
114
        Group.of(
115
          get( KEY_DOC_TITLE ),
116
          Setting.of( label( KEY_DOC_TITLE ) ),
117
          Setting.of( title( KEY_DOC_TITLE ),
118
                      stringProperty( KEY_DOC_TITLE ) )
119
        ),
120
        Group.of(
121
          get( KEY_DOC_AUTHOR ),
122
          Setting.of( label( KEY_DOC_AUTHOR ) ),
123
          Setting.of( title( KEY_DOC_AUTHOR ),
124
                      stringProperty( KEY_DOC_AUTHOR ) )
125
        ),
126
        Group.of(
127
          get( KEY_DOC_BYLINE ),
128
          Setting.of( label( KEY_DOC_BYLINE ) ),
129
          Setting.of( title( KEY_DOC_BYLINE ),
130
                      stringProperty( KEY_DOC_BYLINE ) )
131
        ),
132
        Group.of(
133
          get( KEY_DOC_ADDRESS ),
134
          Setting.of( label( KEY_DOC_ADDRESS ) ),
135
          createMultilineSetting( "Address", KEY_DOC_ADDRESS )
136
        ),
137
        Group.of(
138
          get( KEY_DOC_PHONE ),
139
          Setting.of( label( KEY_DOC_PHONE ) ),
140
          Setting.of( title( KEY_DOC_PHONE ),
141
                      stringProperty( KEY_DOC_PHONE ) )
142
        ),
143
        Group.of(
144
          get( KEY_DOC_EMAIL ),
145
          Setting.of( label( KEY_DOC_EMAIL ) ),
146
          Setting.of( title( KEY_DOC_EMAIL ),
147
                      stringProperty( KEY_DOC_EMAIL ) )
148
        ),
149
        Group.of(
150
          get( KEY_DOC_KEYWORDS ),
151
          Setting.of( label( KEY_DOC_KEYWORDS ) ),
152
          Setting.of( title( KEY_DOC_KEYWORDS ),
153
                      stringProperty( KEY_DOC_KEYWORDS ) )
154
        ),
155
        Group.of(
156
          get( KEY_DOC_COPYRIGHT ),
157
          Setting.of( label( KEY_DOC_COPYRIGHT ) ),
158
          Setting.of( title( KEY_DOC_COPYRIGHT ),
159
                      stringProperty( KEY_DOC_COPYRIGHT ) )
160
        ),
161
        Group.of(
162
          get( KEY_DOC_DATE ),
163
          Setting.of( label( KEY_DOC_DATE ) ),
164
          Setting.of( title( KEY_DOC_DATE ),
165
                      stringProperty( KEY_DOC_DATE ) )
166
        )
167
      ),
168
      Category.of(
169
        get( KEY_TYPESET ),
170
        Group.of(
171
          get( KEY_TYPESET_CONTEXT ),
172
          Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
173
          Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
174
                      fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
175
          Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
176
          Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
177
                      booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
178
        )
179
      ),
180
      Category.of(
181
        get( KEY_R ),
182
        Group.of(
183
          get( KEY_R_DIR ),
184
          Setting.of( label( KEY_R_DIR,
185
                             stringProperty( KEY_DEF_DELIM_BEGAN ).get(),
186
                             stringProperty( KEY_DEF_DELIM_ENDED ).get() ) ),
187
          Setting.of( title( KEY_R_DIR ),
188
                      fileProperty( KEY_R_DIR ), true )
189
        ),
190
        Group.of(
191
          get( KEY_R_SCRIPT ),
192
          Setting.of( label( KEY_R_SCRIPT ) ),
193
          createMultilineSetting( "Script", KEY_R_SCRIPT )
194
        ),
195
        Group.of(
196
          get( KEY_R_DELIM_BEGAN ),
197
          Setting.of( label( KEY_R_DELIM_BEGAN ) ),
198
          Setting.of( title( KEY_R_DELIM_BEGAN ),
199
                      stringProperty( KEY_R_DELIM_BEGAN ) )
200
        ),
201
        Group.of(
202
          get( KEY_R_DELIM_ENDED ),
203
          Setting.of( label( KEY_R_DELIM_ENDED ) ),
204
          Setting.of( title( KEY_R_DELIM_ENDED ),
205
                      stringProperty( KEY_R_DELIM_ENDED ) )
206
        )
207
      ),
208
      Category.of(
209
        get( KEY_IMAGES ),
210
        Group.of(
211
          get( KEY_IMAGES_DIR ),
212
          Setting.of( label( KEY_IMAGES_DIR ) ),
213
          Setting.of( title( KEY_IMAGES_DIR ),
214
                      fileProperty( KEY_IMAGES_DIR ), true )
215
        ),
216
        Group.of(
217
          get( KEY_IMAGES_ORDER ),
218
          Setting.of( label( KEY_IMAGES_ORDER ) ),
219
          Setting.of( title( KEY_IMAGES_ORDER ),
220
                      stringProperty( KEY_IMAGES_ORDER ) )
221
        ),
222
        Group.of(
223
          get( KEY_IMAGES_RESIZE ),
224
          Setting.of( label( KEY_IMAGES_RESIZE ) ),
225
          Setting.of( title( KEY_IMAGES_RESIZE ),
226
                      booleanProperty( KEY_IMAGES_RESIZE ) )
227
        ),
228
        Group.of(
229
          get( KEY_IMAGES_SERVER ),
230
          Setting.of( label( KEY_IMAGES_SERVER ) ),
231
          Setting.of( title( KEY_IMAGES_SERVER ),
232
                      stringProperty( KEY_IMAGES_SERVER ) )
233
        )
234
      ),
235
      Category.of(
236
        get( KEY_DEF ),
237
        Group.of(
238
          get( KEY_DEF_PATH ),
239
          Setting.of( label( KEY_DEF_PATH ) ),
240
          Setting.of( title( KEY_DEF_PATH ),
241
                      fileProperty( KEY_DEF_PATH ), false )
242
        ),
243
        Group.of(
244
          get( KEY_DEF_DELIM_BEGAN ),
245
          Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
246
          Setting.of( title( KEY_DEF_DELIM_BEGAN ),
247
                      stringProperty( KEY_DEF_DELIM_BEGAN ) )
248
        ),
249
        Group.of(
250
          get( KEY_DEF_DELIM_ENDED ),
251
          Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
252
          Setting.of( title( KEY_DEF_DELIM_ENDED ),
253
                      stringProperty( KEY_DEF_DELIM_ENDED ) )
254
        )
255
      ),
256
      Category.of(
257
        get( KEY_UI_FONT ),
258
        Group.of(
259
          get( KEY_UI_FONT_EDITOR ),
260
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
261
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
262
                      createFontNameField(
263
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
264
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
265
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
266
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
267
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
268
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
269
        ),
270
        Group.of(
271
          get( KEY_UI_FONT_PREVIEW ),
272
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
273
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
274
                      createFontNameField(
275
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
276
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
277
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
278
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
279
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
280
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
281
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
282
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
283
                      createFontNameField(
284
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
285
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
286
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
287
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
288
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
289
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
290
        )
291
      ),
292
      Category.of(
293
        get( KEY_UI_SKIN ),
294
        Group.of(
295
          get( KEY_UI_SKIN_SELECTION ),
296
          Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
297
          Setting.of( title( KEY_UI_SKIN_SELECTION ),
298
                      skinListProperty(),
299
                      skinProperty( KEY_UI_SKIN_SELECTION ) )
300
        ),
301
        Group.of(
302
          get( KEY_UI_SKIN_CUSTOM ),
303
          Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
304
          Setting.of( title( KEY_UI_SKIN_CUSTOM ),
305
                      fileProperty( KEY_UI_SKIN_CUSTOM ), false )
306
        )
307
      ),
308
      Category.of(
309
        get( KEY_LANGUAGE ),
310
        Group.of(
311
          get( KEY_LANGUAGE_LOCALE ),
312
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
313
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
314
                      localeListProperty(),
315
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
316
        )
317
      )};
318
  }
319
320
  @SuppressWarnings( "unchecked" )
321
  private Setting<StringField, StringProperty> createMultilineSetting(
322
    final String description, final Key property ) {
323
    final Setting<StringField, StringProperty> setting =
324
      Setting.of( description, stringProperty( property ) );
325
    final var field = setting.getElement();
326
    field.multiline( true );
327
328
    return setting;
329
  }
330
331
  private void initKeyEventHandler( final PreferencesFx preferences ) {
332
    final var view = preferences.getView();
333
    final var nodes = view.getChildrenUnmodifiable();
334
    final var master = (MasterDetailPane) nodes.get( 0 );
335
    final var detail = (NavigationView) master.getDetailNode();
336
    final var pane = (DialogPane) view.getParent();
337
338
    detail.setOnKeyReleased( ( key ) -> {
339
      switch( key.getCode() ) {
340
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
341
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
342
      }
343
    } );
344
  }
345
346
  /**
347
   * Creates a label for the given key after interpolating its value.
348
   *
349
   * @param key The key to find in the resource bundle.
350
   * @return The value of the key as a label.
351
   */
352
  private Node label( final Key key ) {
353
    return label( key, (String[]) null );
354
  }
355
356
  private Node label( final Key key, final String... values ) {
357
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
358
  }
359
360
  private String title( final Key key ) {
361
    return get( key.toString() + ".title" );
362
  }
363
364
  private ObjectProperty<File> fileProperty( final Key key ) {
365
    return mWorkspace.fileProperty( key );
366
  }
367
368
  private StringProperty stringProperty( final Key key ) {
369
    return mWorkspace.stringProperty( key );
370
  }
371
372
  private BooleanProperty booleanProperty( final Key key ) {
373
    return mWorkspace.booleanProperty( key );
354374
  }
355375
M src/main/java/com/keenwrite/preferences/Workspace.java
8787
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
8888
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
89
    entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
90
    entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
8991
9092
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
...
116118
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
117119
120
    entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty(true) ),
118121
    entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
119122
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) )
...
131134
  private BooleanProperty asBooleanProperty() {
132135
    return new SimpleBooleanProperty();
136
  }
137
138
  @SuppressWarnings( "SameParameterValue" )
139
  private BooleanProperty asBooleanProperty( final boolean defaultValue ) {
140
    return new SimpleBooleanProperty( defaultValue );
133141
  }
134142
...
316324
317325
  public StringProperty stringProperty( final Key key ) {
326
    assert key != null;
327
    return valuesProperty( key );
328
  }
329
330
  public BooleanProperty booleanProperty( final Key key ) {
318331
    assert key != null;
319332
    return valuesProperty( key );
M src/main/java/com/keenwrite/preferences/WorkspaceKeys.java
3737
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
3838
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
39
  public static final Key KEY_IMAGES_RESIZE = key( KEY_IMAGES, "resize" );
40
  public static final Key KEY_IMAGES_SERVER = key( KEY_IMAGES, "server" );
3941
4042
  public static final Key KEY_DEF = key( KEY_ROOT, "definition" );
...
8587
  public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" );
8688
  public static final Key KEY_TYPESET_CONTEXT_THEME_SELECTION = key( KEY_TYPESET_CONTEXT_THEMES, "selection" );
89
  public static final Key KEY_TYPESET_CONTEXT_CLEAN = key( KEY_TYPESET_CONTEXT, "clean" );
8790
  //@formatter:on
8891
M src/main/java/com/keenwrite/preview/ChainedReplacedElementFactory.java
22
package com.keenwrite.preview;
33
4
import com.keenwrite.preferences.Workspace;
45
import com.keenwrite.ui.adapters.ReplacedElementAdapter;
56
import com.keenwrite.util.BoundedCache;
67
import org.w3c.dom.Element;
78
import org.xhtmlrenderer.extend.ReplacedElement;
89
import org.xhtmlrenderer.extend.ReplacedElementFactory;
910
import org.xhtmlrenderer.extend.UserAgentCallback;
1011
import org.xhtmlrenderer.layout.LayoutContext;
1112
import org.xhtmlrenderer.render.BlockBox;
13
import org.xhtmlrenderer.swing.ImageReplacedElement;
1214
15
import java.awt.event.ComponentEvent;
16
import java.awt.event.ComponentListener;
1317
import java.util.LinkedHashSet;
1418
import java.util.Map;
1519
import java.util.Set;
1620
21
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_RESIZE;
1722
import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE;
1823
import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE_SRC;
1924
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
2025
import static java.util.Arrays.asList;
2126
2227
/**
2328
 * Responsible for running one or more factories to perform post-processing on
2429
 * the HTML document prior to displaying it.
2530
 */
26
public final class ChainedReplacedElementFactory extends ReplacedElementAdapter {
31
public final class ChainedReplacedElementFactory
32
  extends ReplacedElementAdapter implements ComponentListener {
2733
  /**
2834
   * Retain insertion order so that client classes can control the order that
...
3743
   */
3844
  private final Map<String, ReplacedElement> mCache = new BoundedCache<>( 150 );
45
46
  private final Workspace mWorkspace;
3947
4048
  public ChainedReplacedElementFactory(
41
    final ReplacedElementFactory... factories ) {
49
    final Workspace workspace, final ReplacedElementFactory... factories ) {
50
    assert workspace != null;
51
    assert factories != null;
52
    assert factories.length > 0;
53
    mWorkspace = workspace;
4254
    mFactories.addAll( asList( factories ) );
4355
  }
...
7385
7486
      final var replaced = mCache.computeIfAbsent(
75
        source, k -> f.createReplacedElement( c, box, uac, width, height )
87
        source, k -> {
88
          final var r = f.createReplacedElement( c, box, uac, width, height );
89
          return r instanceof final ImageReplacedElement ire
90
            ? new SmoothImageReplacedElement(
91
            ire.getImage(), box.getWidth(), -1 )
92
            : r;
93
        }
7694
      );
7795
...
105123
    mCache.clear();
106124
  }
107
}
125
126
  @Override
127
  public void componentResized( final ComponentEvent e ) {
128
    if( mWorkspace.toBoolean( KEY_IMAGES_RESIZE ) ) {
129
      clearCache();
130
    }
131
  }
132
133
  @Override
134
  public void componentMoved( final ComponentEvent e ) { }
135
136
  @Override
137
  public void componentShown( final ComponentEvent e ) { }
108138
139
  @Override
140
  public void componentHidden( final ComponentEvent e ) { }
141
}
A src/main/java/com/keenwrite/preview/HighQualityRenderingHints.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import java.util.HashMap;
5
import java.util.Map;
6
7
import static java.awt.RenderingHints.*;
8
import static java.awt.Toolkit.getDefaultToolkit;
9
10
/**
11
 * Responsible for initializing settings to produce high-quality image
12
 * transformations.
13
 */
14
@SuppressWarnings( "rawtypes" )
15
public class HighQualityRenderingHints {
16
  /**
17
   * Default hints for high-quality rendering that may be changed by
18
   * the system's rendering hints.
19
   */
20
  private static final Map<Object, Object> DEFAULT_HINTS = Map.of(
21
    KEY_ANTIALIASING, VALUE_ANTIALIAS_ON,
22
    KEY_ALPHA_INTERPOLATION, VALUE_ALPHA_INTERPOLATION_QUALITY,
23
    KEY_COLOR_RENDERING, VALUE_COLOR_RENDER_QUALITY,
24
    KEY_DITHERING, VALUE_DITHER_DISABLE,
25
    KEY_FRACTIONALMETRICS, VALUE_FRACTIONALMETRICS_ON,
26
    KEY_INTERPOLATION, VALUE_INTERPOLATION_BICUBIC,
27
    KEY_RENDERING, VALUE_RENDER_QUALITY,
28
    KEY_STROKE_CONTROL, VALUE_STROKE_PURE,
29
    KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_ON
30
  );
31
32
  /**
33
   * Shared hints for high-quality rendering.
34
   */
35
  static final Map<Object, Object> RENDERING_HINTS = new HashMap<>(
36
    DEFAULT_HINTS
37
  );
38
39
  static {
40
    final var toolkit = getDefaultToolkit();
41
    final var hints = toolkit.getDesktopProperty( "awt.font.desktophints" );
42
43
    if( hints instanceof final Map map ) {
44
      for( final var key : map.keySet() ) {
45
        final var hint = map.get( key );
46
        RENDERING_HINTS.put( key, hint );
47
      }
48
    }
49
  }
50
51
  /**
52
   * Defines a reusable constant, nothing more.
53
   */
54
  private HighQualityRenderingHints() {
55
  }
56
}
157
M src/main/java/com/keenwrite/preview/HtmlPreview.java
4343
4444
  /**
45
   * The order is important: Swing factory will replace SVG images with
46
   * a blank image, which will cause the chained factory to cache the image
47
   * and exit. Instead, the SVG must execute first to rasterize the content.
48
   * Consequently, the chained factory must maintain insertion order.
49
   */
50
  private static final ChainedReplacedElementFactory FACTORY
51
    = new ChainedReplacedElementFactory(
52
    new SvgReplacedElementFactory(),
53
    new SwingReplacedElementFactory()
54
  );
55
56
  /**
5745
   * Used to populate the {@link #HTML_HEAD} with stylesheet file references.
5846
   */
...
8775
8876
  private static final URL HTML_STYLE_PREVIEW = toUrl( STYLESHEET_PREVIEW );
77
78
  /**
79
   * The order is important: Swing factory will replace SVG images with
80
   * a blank image, which will cause the chained factory to cache the image
81
   * and exit. Instead, the SVG must execute first to rasterize the content.
82
   * Consequently, the chained factory must maintain insertion order.
83
   */
84
  private final ChainedReplacedElementFactory mFactory;
8985
9086
  /**
...
10096
  private volatile boolean mLocked;
10197
  private final JButton mScrollLockButton = new JButton();
102
10398
  private final Workspace mWorkspace;
10499
...
111106
  public HtmlPreview( final Workspace workspace ) {
112107
    mWorkspace = workspace;
108
    mFactory = new ChainedReplacedElementFactory(
109
      mWorkspace,
110
      new SvgReplacedElementFactory(),
111
      new SwingReplacedElementFactory()
112
    );
113113
114114
    // Attempts to prevent a flash of black un-styled content upon load.
...
141141
      setCacheHint( SPEED );
142142
      setContent( wrapper );
143
      wrapper.addComponentListener( mFactory );
143144
144145
      final var context = mView.getSharedContext();
145146
      final var textRenderer = context.getTextRenderer();
146
      context.setReplacedElementFactory( FACTORY );
147
      context.setReplacedElementFactory( mFactory );
147148
      textRenderer.setSmoothingThreshold( 0 );
148149
...
174175
   */
175176
  public void refresh() {
176
    FACTORY.clearCache();
177
    mFactory.clearCache();
177178
    rerender();
178179
  }
A src/main/java/com/keenwrite/preview/SmoothImageReplacedElement.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import com.keenwrite.preview.images.Lanczos3Filter;
5
import com.keenwrite.preview.images.ResampleOp;
6
import org.xhtmlrenderer.swing.ImageReplacedElement;
7
8
import java.awt.*;
9
import java.awt.image.BufferedImage;
10
11
/**
12
 * Responsible for scaling an image using a Lanczos-3 filter, typically for
13
 * down-sampling.
14
 */
15
public final class SmoothImageReplacedElement extends ImageReplacedElement {
16
  private final static Lanczos3Filter FILTER = new Lanczos3Filter();
17
18
  /**
19
   * Creates a high-quality rescaled version of the given image. The
20
   * aspect ratio is maintained if either width or height is less than 1.
21
   *
22
   * @param source An instance of {@link BufferedImage} to rescale.
23
   * @param width  Rescale the given image to this width (px).
24
   * @param height Rescale the given image to this height (px).
25
   */
26
  public SmoothImageReplacedElement(
27
    final Image source, final int width, final int height ) {
28
    super._image = rescale( source, width, height );
29
  }
30
31
  private BufferedImage rescale(
32
    final Image source, final int w, final int h ) {
33
    final var bi = (BufferedImage) source;
34
    final var dim = rescaleDimensions( bi, w, h );
35
36
    final var resampleOp = new ResampleOp( FILTER, dim.width, dim.height );
37
    return resampleOp.filter( bi, null );
38
  }
39
40
  private Dimension rescaleDimensions(
41
    final BufferedImage bi, final int width, final int height ) {
42
    final var oldW = bi.getWidth();
43
    final var oldH = bi.getHeight();
44
45
    int newW = width;
46
    int newH = height;
47
48
    if( newW <= 0 ) {
49
      newW = (int) (oldW * ((double) newH / oldH));
50
    }
51
52
    if( newH <= 0 ) {
53
      newH = (int) (oldH * ((double) newW / oldW));
54
    }
55
56
    return new Dimension( newW, newH );
57
  }
58
}
159
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
2525
import java.text.NumberFormat;
2626
import java.text.ParseException;
27
import java.util.HashMap;
28
import java.util.Map;
2927
3028
import static com.keenwrite.events.StatusEvent.clue;
31
import static java.awt.RenderingHints.*;
32
import static java.awt.Toolkit.getDefaultToolkit;
29
import static com.keenwrite.preview.HighQualityRenderingHints.RENDERING_HINTS;
3330
import static java.awt.image.BufferedImage.TYPE_INT_RGB;
3431
import static java.nio.charset.StandardCharsets.UTF_8;
...
4239
 * Responsible for converting SVG images into rasterized PNG images.
4340
 */
44
@SuppressWarnings( "rawtypes" )
4541
public final class SvgRasterizer {
46
  /**
47
   * Default hints for high-quality rendering that may be changed by
48
   * the system's rendering hints.
49
   */
50
  private static final Map<Object, Object> DEFAULT_HINTS = Map.of(
51
    KEY_ANTIALIASING, VALUE_ANTIALIAS_ON,
52
    KEY_ALPHA_INTERPOLATION, VALUE_ALPHA_INTERPOLATION_QUALITY,
53
    KEY_COLOR_RENDERING, VALUE_COLOR_RENDER_QUALITY,
54
    KEY_DITHERING, VALUE_DITHER_DISABLE,
55
    KEY_FRACTIONALMETRICS, VALUE_FRACTIONALMETRICS_ON,
56
    KEY_INTERPOLATION, VALUE_INTERPOLATION_BICUBIC,
57
    KEY_RENDERING, VALUE_RENDER_QUALITY,
58
    KEY_STROKE_CONTROL, VALUE_STROKE_PURE,
59
    KEY_TEXT_ANTIALIASING, VALUE_TEXT_ANTIALIAS_ON
60
  );
61
62
  /**
63
   * Shared hints for high-quality rendering.
64
   */
65
  private static final Map<Object, Object> RENDERING_HINTS = new HashMap<>(
66
    DEFAULT_HINTS
67
  );
68
69
  static {
70
    final var toolkit = getDefaultToolkit();
71
    final var hints = toolkit.getDesktopProperty( "awt.font.desktophints" );
72
73
    if( hints instanceof Map ) {
74
      final var map = (Map) hints;
75
76
      for( final var key : map.keySet() ) {
77
        final var hint = map.get( key );
78
        RENDERING_HINTS.put( key, hint );
79
      }
80
    }
81
  }
82
8342
  /**
8443
   * <a href="https://issues.apache.org/jira/browse/BATIK-1112">Bug fix</a>
A src/main/java/com/keenwrite/preview/images/AdvancedResizeOp.java
1
/*
2
 * Copyright 2013, Morten Nobel-Joergensen
3
 *
4
 * License: The BSD 3-Clause License
5
 * http://opensource.org/licenses/BSD-3-Clause
6
 */
7
package com.keenwrite.preview.images;
8
9
import java.awt.*;
10
import java.awt.geom.Point2D;
11
import java.awt.geom.Rectangle2D;
12
import java.awt.image.BufferedImage;
13
import java.awt.image.BufferedImageOp;
14
import java.awt.image.ColorModel;
15
16
/**
17
 * @author Morten Nobel-Joergensen
18
 */
19
public abstract class AdvancedResizeOp implements BufferedImageOp {
20
  private final ConstrainedDimension dimensionConstrain;
21
22
  public AdvancedResizeOp( ConstrainedDimension dimensionConstrain ) {
23
    this.dimensionConstrain = dimensionConstrain;
24
  }
25
26
  public final BufferedImage filter( BufferedImage src, BufferedImage dest ) {
27
    Dimension dstDimension = dimensionConstrain.getDimension(
28
      new Dimension( src.getWidth(), src.getHeight() ) );
29
    int dstWidth = dstDimension.width;
30
    int dstHeight = dstDimension.height;
31
32
    return doFilter( src, dest, dstWidth, dstHeight );
33
  }
34
35
  protected abstract BufferedImage doFilter(
36
    BufferedImage src, BufferedImage dest, int dstWidth, int dstHeight );
37
38
  @Override
39
  public final Rectangle2D getBounds2D( BufferedImage src ) {
40
    return new Rectangle( 0, 0, src.getWidth(), src.getHeight() );
41
  }
42
43
  @Override
44
  public final BufferedImage createCompatibleDestImage(
45
    BufferedImage src, ColorModel destCM ) {
46
    if( destCM == null ) {
47
      destCM = src.getColorModel();
48
    }
49
50
    return new BufferedImage(
51
      destCM,
52
      destCM.createCompatibleWritableRaster( src.getWidth(), src.getHeight() ),
53
      destCM.isAlphaPremultiplied(),
54
      null );
55
  }
56
57
  @Override
58
  public final Point2D getPoint2D( Point2D srcPt, Point2D dstPt ) {
59
    return (Point2D) srcPt.clone();
60
  }
61
62
  @Override
63
  public final RenderingHints getRenderingHints() {
64
    return null;
65
  }
66
}
167
A src/main/java/com/keenwrite/preview/images/ConstrainedDimension.java
1
/*
2
 * Copyright 2013, Morten Nobel-Joergensen
3
 *
4
 * License: The BSD 3-Clause License
5
 * http://opensource.org/licenses/BSD-3-Clause
6
 */
7
package com.keenwrite.preview.images;
8
9
import java.awt.*;
10
11
/**
12
 * This class let you create dimension constrains based on a actual image.
13
 */
14
public class ConstrainedDimension {
15
  private ConstrainedDimension() {
16
  }
17
18
  /**
19
   * Will always return a dimension with positive width and height;
20
   *
21
   * @param dimension of the unscaled image
22
   * @return the dimension of the scaled image
23
   */
24
  public Dimension getDimension( Dimension dimension ) {
25
    return dimension;
26
  }
27
28
  /**
29
   * Used when the destination size is fixed. This may not keep the image
30
   * aspect radio.
31
   *
32
   * @param width  destination dimension width
33
   * @param height destination dimension height
34
   * @return destination dimension (width x height)
35
   */
36
  public static ConstrainedDimension createAbsolutionDimension(
37
    final int width, final int height ) {
38
    assert width > 0 && height > 0 : "Dimensions must be positive integers";
39
    return new ConstrainedDimension() {
40
      public Dimension getDimension( Dimension dimension ) {
41
        return new Dimension( width, height );
42
      }
43
    };
44
  }
45
}
146
A src/main/java/com/keenwrite/preview/images/ImageUtils.java
1
/*
2
 * Copyright 2013, Morten Nobel-Joergensen
3
 *
4
 * License: The BSD 3-Clause License
5
 * http://opensource.org/licenses/BSD-3-Clause
6
 */
7
package com.keenwrite.preview.images;
8
9
import java.awt.*;
10
import java.awt.image.BufferedImage;
11
import java.awt.image.Raster;
12
import java.awt.image.WritableRaster;
13
14
import static java.awt.image.BufferedImage.*;
15
16
/**
17
 * @author Heinz Doerr
18
 * @author Morten Nobel-Joergensen
19
 */
20
public final class ImageUtils {
21
  @SuppressWarnings( "DuplicateBranchesInSwitch" )
22
  static int nrChannels( final BufferedImage img ) {
23
    return switch( img.getType() ) {
24
      case TYPE_3BYTE_BGR -> 3;
25
      case TYPE_4BYTE_ABGR -> 4;
26
      case TYPE_BYTE_GRAY -> 1;
27
      case TYPE_INT_BGR -> 3;
28
      case TYPE_INT_ARGB -> 4;
29
      case TYPE_INT_RGB -> 3;
30
      case TYPE_CUSTOM -> 4;
31
      case TYPE_4BYTE_ABGR_PRE -> 4;
32
      case TYPE_INT_ARGB_PRE -> 4;
33
      case TYPE_USHORT_555_RGB -> 3;
34
      case TYPE_USHORT_565_RGB -> 3;
35
      case TYPE_USHORT_GRAY -> 1;
36
      default -> 0;
37
    };
38
  }
39
40
  /**
41
   * returns one row (height == 1) of byte packed image data in BGR or AGBR form
42
   *
43
   * @param temp must be either null or a array with length of w*h
44
   */
45
  static void getPixelsBGR(
46
    BufferedImage img, int y, int w, byte[] array, int[] temp ) {
47
    final int x = 0;
48
    final int h = 1;
49
50
    assert array.length == temp.length * nrChannels( img );
51
    assert (temp.length == w);
52
53
    final Raster raster;
54
    switch( img.getType() ) {
55
      case TYPE_3BYTE_BGR, TYPE_4BYTE_ABGR,
56
        TYPE_4BYTE_ABGR_PRE, TYPE_BYTE_GRAY -> {
57
        raster = img.getRaster();
58
        //int ttype= raster.getTransferType();
59
        raster.getDataElements( x, y, w, h, array );
60
      }
61
      case TYPE_INT_BGR -> {
62
        raster = img.getRaster();
63
        raster.getDataElements( x, y, w, h, temp );
64
        ints2bytes( temp, array, 0, 1, 2 );  // bgr -->  bgr
65
      }
66
      case TYPE_INT_RGB -> {
67
        raster = img.getRaster();
68
        raster.getDataElements( x, y, w, h, temp );
69
        ints2bytes( temp, array, 2, 1, 0 );  // rgb -->  bgr
70
      }
71
      case TYPE_INT_ARGB, TYPE_INT_ARGB_PRE -> {
72
        raster = img.getRaster();
73
        raster.getDataElements( x, y, w, h, temp );
74
        ints2bytes( temp, array, 2, 1, 0, 3 );  // argb -->  abgr
75
      }
76
      case TYPE_CUSTOM -> {
77
        // loader, but else ???
78
        img.getRGB( x, y, w, h, temp, 0, w );
79
        ints2bytes( temp, array, 2, 1, 0, 3 );  // argb -->  abgr
80
      }
81
      default -> {
82
        img.getRGB( x, y, w, h, temp, 0, w );
83
        ints2bytes( temp, array, 2, 1, 0 );  // rgb -->  bgr
84
      }
85
    }
86
  }
87
88
  /**
89
   * converts and copies byte packed  BGR or ABGR into the img buffer,
90
   * the img type may vary (e.g. RGB or BGR, int or byte packed)
91
   * but the number of components (w/o alpha, w alpha, gray) must match
92
   * <p>
93
   * does not unmange the image for all (A)RGN and (A)BGR and gray imaged
94
   */
95
  public static void setBGRPixels( byte[] bgrPixels, BufferedImage img, int x,
96
                                   int y, int w, int h ) {
97
    int imageType = img.getType();
98
    WritableRaster raster = img.getRaster();
99
100
    if( imageType == TYPE_3BYTE_BGR ||
101
      imageType == TYPE_4BYTE_ABGR ||
102
      imageType == TYPE_4BYTE_ABGR_PRE ||
103
      imageType == TYPE_BYTE_GRAY ) {
104
      raster.setDataElements( x, y, w, h, bgrPixels );
105
    }
106
    else {
107
      int[] pixels;
108
      if( imageType == TYPE_INT_BGR ) {
109
        pixels = bytes2int( bgrPixels, 2, 1, 0 );  // bgr -->  bgr
110
      }
111
      else if( imageType == TYPE_INT_ARGB ||
112
        imageType == TYPE_INT_ARGB_PRE ) {
113
        pixels = bytes2int( bgrPixels, 3, 0, 1, 2 );  // abgr -->  argb
114
      }
115
      else {
116
        pixels = bytes2int( bgrPixels, 0, 1, 2 );  // bgr -->  rgb
117
      }
118
      if( w == 0 || h == 0 ) {
119
        return;
120
      }
121
      else if( pixels.length < w * h ) {
122
        throw new IllegalArgumentException( "pixels array must have a length" + " >= w*h" );
123
      }
124
      if( imageType == TYPE_INT_ARGB ||
125
        imageType == TYPE_INT_RGB ||
126
        imageType == TYPE_INT_ARGB_PRE ||
127
        imageType == TYPE_INT_BGR ) {
128
        raster.setDataElements( x, y, w, h, pixels );
129
      }
130
      else {
131
        // Unmanages the image
132
        img.setRGB( x, y, w, h, pixels, 0, w );
133
      }
134
    }
135
  }
136
137
  public static void ints2bytes( int[] in, byte[] out, int index1, int index2,
138
                                 int index3 ) {
139
    for( int i = 0; i < in.length; i++ ) {
140
      int index = i * 3;
141
      int value = in[ i ];
142
      out[ index + index1 ] = (byte) value;
143
      value = value >> 8;
144
      out[ index + index2 ] = (byte) value;
145
      value = value >> 8;
146
      out[ index + index3 ] = (byte) value;
147
    }
148
  }
149
150
  public static void ints2bytes( int[] in, byte[] out, int index1, int index2,
151
                                 int index3, int index4 ) {
152
    for( int i = 0; i < in.length; i++ ) {
153
      int index = i * 4;
154
      int value = in[ i ];
155
      out[ index + index1 ] = (byte) value;
156
      value = value >> 8;
157
      out[ index + index2 ] = (byte) value;
158
      value = value >> 8;
159
      out[ index + index3 ] = (byte) value;
160
      value = value >> 8;
161
      out[ index + index4 ] = (byte) value;
162
    }
163
  }
164
165
  public static int[] bytes2int( byte[] in, int index1, int index2,
166
                                 int index3 ) {
167
    int[] out = new int[ in.length / 3 ];
168
    for( int i = 0; i < out.length; i++ ) {
169
      int index = i * 3;
170
      int b1 = (in[ index + index1 ] & 0xff) << 16;
171
      int b2 = (in[ index + index2 ] & 0xff) << 8;
172
      int b3 = in[ index + index3 ] & 0xff;
173
      out[ i ] = b1 | b2 | b3;
174
    }
175
    return out;
176
  }
177
178
  public static int[] bytes2int( byte[] in, int index1, int index2, int index3,
179
                                 int index4 ) {
180
    int[] out = new int[ in.length / 4 ];
181
    for( int i = 0; i < out.length; i++ ) {
182
      int index = i * 4;
183
      int b1 = (in[ index + index1 ] & 0xff) << 24;
184
      int b2 = (in[ index + index2 ] & 0xff) << 16;
185
      int b3 = (in[ index + index3 ] & 0xff) << 8;
186
      int b4 = in[ index + index4 ] & 0xff;
187
      out[ i ] = b1 | b2 | b3 | b4;
188
    }
189
    return out;
190
  }
191
192
  public static BufferedImage convert( BufferedImage src, int bufImgType ) {
193
    BufferedImage img = new BufferedImage( src.getWidth(),
194
                                           src.getHeight(),
195
                                           bufImgType );
196
    Graphics2D g2d = img.createGraphics();
197
    g2d.drawImage( src, 0, 0, null );
198
    g2d.dispose();
199
    return img;
200
  }
201
}
1202
A src/main/java/com/keenwrite/preview/images/Lanczos3.java
1
package com.keenwrite.preview.images;
2
3
import java.awt.image.BufferedImage;
4
5
/**
6
 * Unused. Needs to extract image data from {@link BufferedImage} and create
7
 * down-sampled version.
8
 */
9
public class Lanczos3 {
10
  static double sinc( double x ) {
11
    x *= Math.PI;
12
13
    if( (x < 0.01f) && (x > -0.01f) ) {
14
      return 1.0f + x * x * (-1.0f / 6.0f + x * x * 1.0f / 120.0f);
15
    }
16
17
    return Math.sin( x ) / x;
18
  }
19
20
  static float clip( double t ) {
21
    final float eps = .0000125f;
22
23
    if( Math.abs( t ) < eps ) { return 0.0f; }
24
25
    return (float) t;
26
  }
27
28
  static float lancos( float t ) {
29
    if( t < 0.0f ) { t = -t; }
30
31
    if( t < 3.0f ) { return clip( sinc( t ) * sinc( t / 3.0f ) ); }
32
    else { return (0.0f); }
33
  }
34
35
  static float lancos3_resample_x(
36
    int[][] arr, int src_w, int src_h, int y, int x, float xscale ) {
37
    float s = 0;
38
    float coef_sum = 0.0f;
39
    float coef;
40
    float pix;
41
    int i;
42
43
    int l, r;
44
    float c;
45
    float hw;
46
47
    // For the reduction of the situation hw is equivalent to expanding the
48
    // number of pixels in the field, if you do not do this, the final
49
    // reduction of the image effect is not much different from the recent
50
    // field interpolation method, the effect is equivalent to the first
51
    // low-pass filtering, and then interpolate
52
    if( xscale > 1.0f ) { hw = 3.0f; }
53
    else { hw = 3.0f / xscale; }
54
55
    c = (float) x / xscale;
56
    l = (int) Math.floor( c - hw );
57
    r = (int) Math.ceil( c + hw );
58
59
    if( y < 0 ) { y = 0; }
60
    if( y >= src_h ) { y = src_h - 1; }
61
    if( xscale > 1.0f ) { xscale = 1.0f; }
62
    for( i = l; i <= r; i++ ) {
63
      x = Math.max( i, 0 );
64
      if( i >= src_w ) { x = src_w - 1; }
65
      pix = arr[ y ][ x ];
66
      coef = lancos( (c - i) * xscale );
67
      s += pix * coef;
68
      coef_sum += coef;
69
    }
70
    s /= coef_sum;
71
    return s;
72
  }
73
74
  static class uint8_2d {
75
    int[][] arr;
76
    int rows;
77
    int cols;
78
79
    public uint8_2d( final int h1, final int w1 ) {
80
      arr = new int[ h1 ][ w1 ];
81
      rows = h1;
82
      cols = w1;
83
    }
84
  }
85
86
  void img_resize_using_lancos3( uint8_2d src, uint8_2d dst ) {
87
    if( src == null || dst == null ) { return; }
88
89
    int src_rows, src_cols;
90
    int dst_rows, dst_cols;
91
    int i, j;
92
    int[][] src_arr;
93
    int[][] dst_arr;
94
    float xratio;
95
    float yratio;
96
    int val;
97
    int k;
98
    float hw;
99
100
    src_arr = src.arr;
101
    dst_arr = dst.arr;
102
    src_rows = src.rows;
103
    src_cols = src.cols;
104
    dst_rows = dst.rows;
105
    dst_cols = dst.cols;
106
107
    xratio = (float) (dst_cols) / (float) src_cols;
108
    yratio = (float) (dst_rows) / (float) src_rows;
109
110
    float scale;
111
112
    if( yratio > 1.0f ) {
113
      hw = 3.0f;
114
      scale = 1.0f;
115
    }
116
    else {
117
      hw = 3.0f / yratio;
118
      scale = yratio;
119
    }
120
121
    for( i = 0; i < dst_rows; i++ ) {
122
      for( j = 0; j < dst_cols; j++ ) {
123
        int t, b;
124
        float c;
125
126
        float s = 0;
127
        float coef_sum = 0.0f;
128
        float coef;
129
        float pix;
130
131
        c = (float) i / yratio;
132
        t = (int) Math.floor( c - hw );
133
        b = (int) Math.ceil( c + hw );
134
        // Interpolate in the x direction first, then interpolate in the y
135
        // direction.
136
        for( k = t; k <= b; k++ ) {
137
          pix = lancos3_resample_x( src_arr, src_cols, src_rows, k, j, xratio );
138
          coef = lancos( (c - k) * scale );
139
          coef_sum += coef;
140
          pix *= coef;
141
          s += pix;
142
        }
143
        val = (int) (s / coef_sum);
144
        if( val < 0 ) { val = 0; }
145
        if( val > 255 ) { val = 255; }
146
        dst_arr[ i ][ j ] = val;
147
      }
148
    }
149
  }
150
151
  BufferedImage test_lancos3_resize( BufferedImage img, float factor ) {
152
    assert img != null;
153
154
    uint8_2d r = null;
155
    uint8_2d g = null;
156
    uint8_2d b = null;
157
158
    BufferedImage out = null;
159
    // TODO: Split buffered image into RGB components.
160
    //split_img_data( img, r, g, b );
161
162
    int w, h;
163
    int w1, h1;
164
    w = img.getWidth();
165
    h = img.getHeight();
166
167
    // TODO: Maintain aspect ratio.
168
    w1 = (int) (factor * w);
169
    h1 = (int) (factor * h);
170
171
    uint8_2d r1 = new uint8_2d( h1, w1 );
172
    uint8_2d g1 = new uint8_2d( h1, w1 );
173
    uint8_2d b1 = new uint8_2d( h1, w1 );
174
175
    img_resize_using_lancos3( r, r1 );
176
    img_resize_using_lancos3( g, g1 );
177
    img_resize_using_lancos3( b, b1 );
178
179
    // TODO: Combine rescaled image into RGB components.
180
    //merge_img_data( r1, g1, b1, out);
181
182
    return out;
183
  }
184
}
1185
A src/main/java/com/keenwrite/preview/images/Lanczos3Filter.java
1
/*
2
 * Copyright 2013, Morten Nobel-Joergensen
3
 *
4
 * License: The BSD 3-Clause License
5
 * http://opensource.org/licenses/BSD-3-Clause
6
 */
7
package com.keenwrite.preview.images;
8
9
public final class Lanczos3Filter implements ResampleFilter {
10
  private final static float PI_FLOAT = (float) Math.PI;
11
12
  private float sincModified( float value ) {
13
    return (float) Math.sin( value ) / value;
14
  }
15
16
  public final float apply( float value ) {
17
    if( value == 0 ) {
18
      return 1.0f;
19
    }
20
21
    if( value < 0.0f ) {
22
      value = -value;
23
    }
24
25
    if( value < 3.0f ) {
26
      value *= PI_FLOAT;
27
      return sincModified( value ) * sincModified( value / 3.0f );
28
    }
29
30
    return 0.0f;
31
  }
32
33
  public float getSamplingRadius() {
34
    return 3.0f;
35
  }
36
}
137
A src/main/java/com/keenwrite/preview/images/ResampleFilter.java
1
/*
2
 * Copyright 2013, Morten Nobel-Joergensen
3
 *
4
 * License: The BSD 3-Clause License
5
 * http://opensource.org/licenses/BSD-3-Clause
6
 */
7
package com.keenwrite.preview.images;
8
9
public interface ResampleFilter {
10
  float getSamplingRadius();
11
12
  float apply(float v);
13
}
114
A src/main/java/com/keenwrite/preview/images/ResampleOp.java
1
/*
2
 * Copyright 2013, Morten Nobel-Joergensen
3
 *
4
 * License: The BSD 3-Clause License
5
 * http://opensource.org/licenses/BSD-3-Clause
6
 */
7
package com.keenwrite.preview.images;
8
9
import java.awt.image.BufferedImage;
10
import java.util.concurrent.atomic.AtomicInteger;
11
12
import static com.keenwrite.preview.images.ConstrainedDimension.createAbsolutionDimension;
13
import static java.awt.image.BufferedImage.*;
14
import static java.awt.image.DataBuffer.TYPE_USHORT;
15
import static java.lang.Runtime.getRuntime;
16
import static java.lang.String.format;
17
import static java.lang.Thread.currentThread;
18
19
/**
20
 * Based on <a href="http://schmidt.devlib.org/jiu/">Java Image Util</a>.
21
 * <p>
22
 * Note that the filter method is not thread-safe.
23
 * </p>
24
 *
25
 * @author Morten Nobel-Joergensen
26
 * @author Heinz Doerr
27
 */
28
public class ResampleOp extends AdvancedResizeOp {
29
  private static final int MAX_CHANNEL_VALUE = 255;
30
31
  private int nrChannels;
32
  private int srcWidth;
33
  private int srcHeight;
34
  private int dstWidth;
35
  private int dstHeight;
36
37
  static class SubSamplingData {
38
    // individual - per row or per column - nr of contributions
39
    private final int[] arrN;
40
    // 2Dim: [wid or hei][contrib]
41
    private final int[] arrPixel;
42
    // 2Dim: [wid or hei][contrib]
43
    private final float[] arrWeight;
44
    // the primary index length for the 2Dim arrays : arrPixel and arrWeight
45
    private final int numContributors;
46
47
    private SubSamplingData( int[] arrN, int[] arrPixel, float[] arrWeight,
48
                             int numContributors ) {
49
      this.arrN = arrN;
50
      this.arrPixel = arrPixel;
51
      this.arrWeight = arrWeight;
52
      this.numContributors = numContributors;
53
    }
54
55
    public int getNumContributors() {
56
      return numContributors;
57
    }
58
59
    public int[] getArrN() {
60
      return arrN;
61
    }
62
63
    public float[] getArrWeight() {
64
      return arrWeight;
65
    }
66
  }
67
68
  private SubSamplingData horizontalSubsamplingData;
69
  private SubSamplingData verticalSubsamplingData;
70
71
  private final int threadCount = getRuntime().availableProcessors();
72
  private final AtomicInteger multipleInvocationLock = new AtomicInteger();
73
  private final ResampleFilter mFilter;
74
75
  public ResampleOp(
76
    final ResampleFilter filter, final int destWidth, final int destHeight ) {
77
    this( filter,
78
          createAbsolutionDimension( destWidth, destHeight ) );
79
  }
80
81
  public ResampleOp(
82
    final ResampleFilter filter, ConstrainedDimension dimensionConstrain ) {
83
    super( dimensionConstrain );
84
    mFilter = filter;
85
  }
86
87
  public BufferedImage doFilter(
88
    BufferedImage srcImg, BufferedImage dest, int dstWidth, int dstHeight ) {
89
    this.dstWidth = dstWidth;
90
    this.dstHeight = dstHeight;
91
92
    if( dstWidth < 3 || dstHeight < 3 ) {
93
      throw new IllegalArgumentException( "Target must be at least 3x3." );
94
    }
95
96
    assert multipleInvocationLock.incrementAndGet() == 1 :
97
      "Multiple concurrent invocations detected";
98
99
    final var srcType = srcImg.getType();
100
101
    if( srcType == TYPE_BYTE_BINARY ||
102
      srcType == TYPE_BYTE_INDEXED ||
103
      srcType == TYPE_CUSTOM ) {
104
      srcImg = ImageUtils.convert(
105
        srcImg,
106
        srcImg.getColorModel().hasAlpha() ? TYPE_4BYTE_ABGR : TYPE_3BYTE_BGR );
107
    }
108
109
    this.nrChannels = ImageUtils.nrChannels( srcImg );
110
    assert nrChannels > 0;
111
    this.srcWidth = srcImg.getWidth();
112
    this.srcHeight = srcImg.getHeight();
113
114
    byte[][] workPixels = new byte[ srcHeight ][ dstWidth * nrChannels ];
115
116
    // Pre-calculate  sub-sampling
117
    horizontalSubsamplingData = createSubSampling(
118
      mFilter, srcWidth, dstWidth );
119
    verticalSubsamplingData = createSubSampling(
120
      mFilter, srcHeight, dstHeight );
121
122
    final BufferedImage scrImgCopy = srcImg;
123
    final byte[][] workPixelsCopy = workPixels;
124
    final Thread[] threads = new Thread[ threadCount - 1 ];
125
126
    for( int i = 1; i < threadCount; i++ ) {
127
      final int finalI = i;
128
      threads[ i - 1 ] = new Thread( () -> horizontallyFromSrcToWork(
129
        scrImgCopy, workPixelsCopy, finalI, threadCount ) );
130
      threads[ i - 1 ].start();
131
    }
132
133
    horizontallyFromSrcToWork( scrImgCopy, workPixelsCopy, 0, threadCount );
134
    waitForAllThreads( threads );
135
136
    byte[] outPixels = new byte[ dstWidth * dstHeight * nrChannels ];
137
138
    // --------------------------------------------------
139
    // Apply filter to sample vertically from Work to Dst
140
    // --------------------------------------------------
141
    final byte[] outPixelsCopy = outPixels;
142
    for( int i = 1; i < threadCount; i++ ) {
143
      final int finalI = i;
144
      threads[ i - 1 ] = new Thread( () -> verticalFromWorkToDst(
145
        workPixelsCopy, outPixelsCopy, finalI, threadCount ) );
146
      threads[ i - 1 ].start();
147
    }
148
    verticalFromWorkToDst( workPixelsCopy, outPixelsCopy, 0, threadCount );
149
    waitForAllThreads( threads );
150
151
    //noinspection UnusedAssignment
152
    workPixels = null; // free memory
153
    final BufferedImage out;
154
    if( dest != null && dstWidth == dest.getWidth() && dstHeight == dest.getHeight() ) {
155
      out = dest;
156
      int nrDestChannels = ImageUtils.nrChannels( dest );
157
      if( nrDestChannels != nrChannels ) {
158
        final var errorMgs = format(
159
          "Destination image must be compatible width source image. Source " +
160
            "image had %d channels destination image had %d channels",
161
          nrChannels, nrDestChannels );
162
        throw new RuntimeException( errorMgs );
163
      }
164
    }
165
    else {
166
      out = new BufferedImage(
167
        dstWidth, dstHeight, getResultBufferedImageType( srcImg ) );
168
    }
169
170
    ImageUtils.setBGRPixels( outPixels, out, 0, 0, dstWidth, dstHeight );
171
172
    assert multipleInvocationLock.decrementAndGet() == 0 : "Multiple " +
173
      "concurrent invocations detected";
174
175
    return out;
176
  }
177
178
  private void waitForAllThreads( final Thread[] threads ) {
179
    try {
180
      for( final Thread thread : threads ) {
181
        thread.join( Long.MAX_VALUE );
182
      }
183
    } catch( final InterruptedException e ) {
184
      currentThread().interrupt();
185
      throw new RuntimeException( e );
186
    }
187
  }
188
189
  static SubSamplingData createSubSampling(
190
    ResampleFilter filter, int srcSize, int dstSize ) {
191
    final float scale = (float) dstSize / (float) srcSize;
192
    final int[] arrN = new int[ dstSize ];
193
    final int numContributors;
194
    final float[] arrWeight;
195
    final int[] arrPixel;
196
197
    final float fwidth = filter.getSamplingRadius();
198
199
    float centerOffset = 0.5f / scale;
200
201
    if( scale < 1.0f ) {
202
      final float width = fwidth / scale;
203
      // Add 2 to be safe with the ceiling
204
      numContributors = (int) (width * 2.0f + 2);
205
      arrWeight = new float[ dstSize * numContributors ];
206
      arrPixel = new int[ dstSize * numContributors ];
207
208
      final float fNormFac = (float) (1f / (Math.ceil( width ) / fwidth));
209
210
      for( int i = 0; i < dstSize; i++ ) {
211
        final int subindex = i * numContributors;
212
        float center = i / scale + centerOffset;
213
        int left = (int) Math.floor( center - width );
214
        int right = (int) Math.ceil( center + width );
215
        for( int j = left; j <= right; j++ ) {
216
          float weight;
217
          weight = filter.apply( (center - j) * fNormFac );
218
219
          if( weight == 0.0f ) {
220
            continue;
221
          }
222
          int n;
223
          if( j < 0 ) {
224
            n = -j;
225
          }
226
          else if( j >= srcSize ) {
227
            n = srcSize - j + srcSize - 1;
228
          }
229
          else {
230
            n = j;
231
          }
232
          int k = arrN[ i ];
233
          //assert k == j-left:String.format("%s = %s %s", k,j,left);
234
          arrN[ i ]++;
235
          if( n < 0 || n >= srcSize ) {
236
            weight = 0.0f;// Flag that cell should not be used
237
          }
238
          arrPixel[ subindex + k ] = n;
239
          arrWeight[ subindex + k ] = weight;
240
        }
241
        // normalize the filter's weight's so the sum equals to 1.0, very
242
        // important for avoiding box type of artifacts
243
        final int max = arrN[ i ];
244
        float tot = 0;
245
        for( int k = 0; k < max; k++ ) { tot += arrWeight[ subindex + k ]; }
246
        if( tot != 0f ) { // 0 should never happen except bug in filter
247
          for( int k = 0; k < max; k++ ) { arrWeight[ subindex + k ] /= tot; }
248
        }
249
      }
250
    }
251
    else {
252
      // super-sampling
253
      // Scales from smaller to bigger height
254
      numContributors = (int) (fwidth * 2.0f + 1);
255
      arrWeight = new float[ dstSize * numContributors ];
256
      arrPixel = new int[ dstSize * numContributors ];
257
      //
258
      for( int i = 0; i < dstSize; i++ ) {
259
        final int subindex = i * numContributors;
260
        float center = i / scale + centerOffset;
261
        int left = (int) Math.floor( center - fwidth );
262
        int right = (int) Math.ceil( center + fwidth );
263
        for( int j = left; j <= right; j++ ) {
264
          float weight = filter.apply( center - j );
265
          if( weight == 0.0f ) {
266
            continue;
267
          }
268
          int n;
269
          if( j < 0 ) {
270
            n = -j;
271
          }
272
          else if( j >= srcSize ) {
273
            n = srcSize - j + srcSize - 1;
274
          }
275
          else {
276
            n = j;
277
          }
278
          int k = arrN[ i ];
279
          arrN[ i ]++;
280
          if( n < 0 || n >= srcSize ) {
281
            weight = 0.0f;// Flag that cell should not be used
282
          }
283
          arrPixel[ subindex + k ] = n;
284
          arrWeight[ subindex + k ] = weight;
285
        }
286
        // normalize the filter's weight's so the sum equals to 1.0, very
287
        // important for avoiding box type of artifacts
288
        final int max = arrN[ i ];
289
        float tot = 0;
290
        for( int k = 0; k < max; k++ ) { tot += arrWeight[ subindex + k ]; }
291
        assert tot != 0 : "should never happen except bug in filter";
292
        if( tot != 0f ) {
293
          for( int k = 0; k < max; k++ ) { arrWeight[ subindex + k ] /= tot; }
294
        }
295
      }
296
    }
297
    return new SubSamplingData( arrN, arrPixel, arrWeight, numContributors );
298
  }
299
300
  private void verticalFromWorkToDst( byte[][] workPixels, byte[] outPixels,
301
                                      int start, int delta ) {
302
    if( nrChannels == 1 ) {
303
      verticalFromWorkToDstGray(
304
        workPixels, outPixels, start, threadCount );
305
      return;
306
    }
307
    boolean useChannel3 = nrChannels > 3;
308
    for( int x = start; x < dstWidth; x += delta ) {
309
      final int xLocation = x * nrChannels;
310
      for( int y = dstHeight - 1; y >= 0; y-- ) {
311
        final int yTimesNumContributors =
312
          y * verticalSubsamplingData.numContributors;
313
        final int max = verticalSubsamplingData.arrN[ y ];
314
        final int sampleLocation = (y * dstWidth + x) * nrChannels;
315
316
        float sample0 = 0.0f;
317
        float sample1 = 0.0f;
318
        float sample2 = 0.0f;
319
        float sample3 = 0.0f;
320
        int index = yTimesNumContributors;
321
        for( int j = max - 1; j >= 0; j-- ) {
322
          int valueLocation = verticalSubsamplingData.arrPixel[ index ];
323
          float arrWeight = verticalSubsamplingData.arrWeight[ index ];
324
          sample0 += (workPixels[ valueLocation ][ xLocation ] & 0xff) * arrWeight;
325
          sample1 += (workPixels[ valueLocation ][ xLocation + 1 ] & 0xff) * arrWeight;
326
          sample2 += (workPixels[ valueLocation ][ xLocation + 2 ] & 0xff) * arrWeight;
327
          if( useChannel3 ) {
328
            sample3 += (workPixels[ valueLocation ][ xLocation + 3 ] & 0xff) * arrWeight;
329
          }
330
331
          index++;
332
        }
333
334
        outPixels[ sampleLocation ] = toByte( sample0 );
335
        outPixels[ sampleLocation + 1 ] = toByte( sample1 );
336
        outPixels[ sampleLocation + 2 ] = toByte( sample2 );
337
338
        if( useChannel3 ) {
339
          outPixels[ sampleLocation + 3 ] = toByte( sample3 );
340
        }
341
      }
342
    }
343
  }
344
345
  private void verticalFromWorkToDstGray(
346
    byte[][] workPixels, byte[] outPixels, int start, int delta ) {
347
    for( int x = start; x < dstWidth; x += delta ) {
348
      for( int y = dstHeight - 1; y >= 0; y-- ) {
349
        final int yTimesNumContributors =
350
          y * verticalSubsamplingData.numContributors;
351
        final int max = verticalSubsamplingData.arrN[ y ];
352
        final int sampleLocation = y * dstWidth + x;
353
        float sample0 = 0.0f;
354
        int index = yTimesNumContributors;
355
356
        for( int j = max - 1; j >= 0; j-- ) {
357
          int valueLocation = verticalSubsamplingData.arrPixel[ index ];
358
          float arrWeight = verticalSubsamplingData.arrWeight[ index ];
359
          sample0 += (workPixels[ valueLocation ][ x ] & 0xff) * arrWeight;
360
361
          index++;
362
        }
363
364
        outPixels[ sampleLocation ] = toByte( sample0 );
365
      }
366
    }
367
  }
368
369
  /**
370
   * Apply filter to sample horizontally from Src to Work
371
   */
372
  private void horizontallyFromSrcToWork(
373
    BufferedImage srcImg, byte[][] workPixels, int start, int delta ) {
374
    if( nrChannels == 1 ) {
375
      horizontallyFromSrcToWorkGray( srcImg, workPixels, start, delta );
376
      return;
377
    }
378
379
    // Used if we work on int based bitmaps, later used to keep channel values
380
    final int[] tempPixels = new int[ srcWidth ];
381
    // create reusable row to minimize memory overhead
382
    final byte[] srcPixels = new byte[ srcWidth * nrChannels ];
383
    final boolean useChannel3 = nrChannels > 3;
384
385
    for( int k = start; k < srcHeight; k = k + delta ) {
386
      ImageUtils.getPixelsBGR( srcImg, k, srcWidth, srcPixels, tempPixels );
387
388
      for( int i = dstWidth - 1; i >= 0; i-- ) {
389
        int sampleLocation = i * nrChannels;
390
        final int max = horizontalSubsamplingData.arrN[ i ];
391
392
        float sample0 = 0.0f;
393
        float sample1 = 0.0f;
394
        float sample2 = 0.0f;
395
        float sample3 = 0.0f;
396
        int index = i * horizontalSubsamplingData.numContributors;
397
        for( int j = max - 1; j >= 0; j-- ) {
398
          float arrWeight = horizontalSubsamplingData.arrWeight[ index ];
399
          int pixelIndex =
400
            horizontalSubsamplingData.arrPixel[ index ] * nrChannels;
401
402
          sample0 += (srcPixels[ pixelIndex ] & 0xff) * arrWeight;
403
          sample1 += (srcPixels[ pixelIndex + 1 ] & 0xff) * arrWeight;
404
          sample2 += (srcPixels[ pixelIndex + 2 ] & 0xff) * arrWeight;
405
          if( useChannel3 ) {
406
            sample3 += (srcPixels[ pixelIndex + 3 ] & 0xff) * arrWeight;
407
          }
408
          index++;
409
        }
410
411
        workPixels[ k ][ sampleLocation ] = toByte( sample0 );
412
        workPixels[ k ][ sampleLocation + 1 ] = toByte( sample1 );
413
        workPixels[ k ][ sampleLocation + 2 ] = toByte( sample2 );
414
        if( useChannel3 ) {
415
          workPixels[ k ][ sampleLocation + 3 ] = toByte( sample3 );
416
        }
417
      }
418
    }
419
  }
420
421
  /**
422
   * Apply filter to sample horizontally from Src to Work
423
   */
424
  private void horizontallyFromSrcToWorkGray(
425
    BufferedImage srcImg, byte[][] workPixels, int start, int delta ) {
426
    // Used if we work on int based bitmaps, later used to keep channel values
427
    final int[] tempPixels = new int[ srcWidth ];
428
    // create reusable row to minimize memory overhead
429
    final byte[] srcPixels = new byte[ srcWidth ];
430
431
    for( int k = start; k < srcHeight; k = k + delta ) {
432
      ImageUtils.getPixelsBGR( srcImg, k, srcWidth, srcPixels, tempPixels );
433
434
      for( int i = dstWidth - 1; i >= 0; i-- ) {
435
        final int max = horizontalSubsamplingData.arrN[ i ];
436
437
        float sample0 = 0.0f;
438
        int index = i * horizontalSubsamplingData.numContributors;
439
        for( int j = max - 1; j >= 0; j-- ) {
440
          float arrWeight = horizontalSubsamplingData.arrWeight[ index ];
441
          int pixelIndex = horizontalSubsamplingData.arrPixel[ index ];
442
443
          sample0 += (srcPixels[ pixelIndex ] & 0xff) * arrWeight;
444
          index++;
445
        }
446
447
        workPixels[ k ][ i ] = toByte( sample0 );
448
      }
449
    }
450
  }
451
452
  private static byte toByte( final float f ) {
453
    if( f < 0 ) {
454
      return 0;
455
    }
456
457
    return (byte) (f > MAX_CHANNEL_VALUE ? MAX_CHANNEL_VALUE : f + 0.5f);
458
  }
459
460
  protected int getResultBufferedImageType( BufferedImage srcImg ) {
461
    return nrChannels == 3
462
      ? TYPE_3BYTE_BGR
463
      : nrChannels == 4
464
      ? TYPE_4BYTE_ABGR
465
      : srcImg.getSampleModel().getDataType() == TYPE_USHORT
466
      ? TYPE_USHORT_GRAY
467
      : TYPE_BYTE_GRAY;
468
  }
469
}
1470
M src/main/java/com/keenwrite/processors/PdfProcessor.java
77
88
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
9
import static com.keenwrite.Messages.get;
109
import static com.keenwrite.events.StatusEvent.clue;
1110
import static com.keenwrite.io.MediaType.TEXT_XML;
...
3534
  public String apply( final String xhtml ) {
3635
    try {
37
      clue( get( "Main.status.typeset.create" ) );
36
      clue( "Main.status.typeset.create" );
3837
      final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE );
3938
      final var pathInput = writeString( document, xhtml );
...
4746
    } catch( final IOException | InterruptedException ex ) {
4847
      // Typesetter runtime exceptions will pass up the call stack.
49
      clue( get( "Main.status.typeset.failed" ), ex );
48
      clue( "Main.status.typeset.failed", ex );
5049
    }
5150
M src/main/java/com/keenwrite/processors/XhtmlProcessor.java
2020
2121
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
22
import static com.keenwrite.Messages.get;
2322
import static com.keenwrite.events.StatusEvent.clue;
2423
import static com.keenwrite.io.HttpFacade.httpGet;
...
6564
  @Override
6665
  public String apply( final String html ) {
67
    clue( get( "Main.status.typeset.xhtml" ) );
66
    clue( "Main.status.typeset.xhtml" );
6867
6968
    final var doc = parse( html );
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
7373
    extensions.add( ImageLinkExtension.create( context ) );
7474
    extensions.add( TeXExtension.create( processor, context ) );
75
    extensions.add( FencedBlockExtension.create( processor ) );
75
    extensions.add( FencedBlockExtension.create( processor, context ) );
7676
    extensions.add( CaretExtension.create( context ) );
7777
    extensions.add( DocumentOutlineExtension.create( processor ) );
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
33
44
import com.keenwrite.ExportFormat;
5
import com.keenwrite.exceptions.MissingFileException;
65
import com.keenwrite.preferences.Workspace;
76
import com.keenwrite.processors.ProcessorContext;
...
7877
      @NotNull final LinkResolverBasicContext context,
7978
      @NotNull final ResolvedLink link ) {
80
      return node instanceof Image ? forImage( link ) : link;
79
      return node instanceof Image ? forImage( link, node ) : link;
8180
    }
8281
...
9291
     *
9392
     * @param link The link URL to resolve.
93
     * @param node The document node containing the URL.
9494
     * @return The {@link ResolvedLink} instance used to render the link.
9595
     */
96
    private ResolvedLink forImage( final ResolvedLink link ) {
96
    private ResolvedLink forImage( final ResolvedLink link, final Node node ) {
9797
      var uri = link.getUrl();
9898
      final var protocol = getProtocol( uri );
...
133133
        }
134134
135
        throw new MissingFileException( imageFile + ".*" );
135
        clue( "Main.status.error.file.missing.near",
136
              imageFile + ".*", node.getLineNumber()
137
        );
136138
      } catch( final Exception ex ) {
137139
        clue( ex );
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
22
package com.keenwrite.processors.markdown.extensions.fences;
33
4
import com.keenwrite.preferences.Workspace;
45
import com.keenwrite.processors.DefinitionProcessor;
56
import com.keenwrite.processors.Processor;
7
import com.keenwrite.processors.ProcessorContext;
68
import com.keenwrite.processors.markdown.MarkdownProcessor;
79
import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter;
...
1921
import java.util.zip.Deflater;
2022
21
import static com.keenwrite.constants.Constants.DIAGRAM_SERVER_NAME;
2223
import static com.keenwrite.events.StatusEvent.clue;
24
import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_SERVER;
2325
import static com.vladsch.flexmark.html.HtmlRenderer.Builder;
2426
import static com.vladsch.flexmark.html.renderer.LinkType.LINK;
...
3739
3840
  private final Processor<String> mProcessor;
41
  private final ProcessorContext mContext;
3942
40
  public FencedBlockExtension( final Processor<String> processor ) {
43
  public FencedBlockExtension(
44
    final Processor<String> processor, final ProcessorContext context ) {
4145
    assert processor != null;
46
    assert context != null;
4247
    mProcessor = processor;
48
    mContext = context;
4349
  }
4450
...
6268
   */
6369
  public static FencedBlockExtension create(
64
    final Processor<String> processor ) {
65
    return new FencedBlockExtension( processor );
70
    final Processor<String> processor, final ProcessorContext context ) {
71
    return new FencedBlockExtension( processor, context );
6672
  }
6773
...
8389
  }
8490
91
  /**
92
   * Responsible for generating images from a fenced block that contains a
93
   * diagram reference.
94
   */
8595
  private class CustomRenderer implements NodeRenderer {
8696
...
98108
          final var text = mProcessor.apply( content );
99109
          final var encoded = encode( text );
100
          final var source = format(
101
            "https://%s/%s/svg/%s", DIAGRAM_SERVER_NAME, type, encoded );
102
110
          final var source = getSourceUrl( type, encoded );
103111
          final var link = context.resolveLink( LINK, source, false );
104112
...
136144
    private String encode( final String decoded ) {
137145
      return getUrlEncoder().encodeToString( compress( decoded.getBytes() ) );
146
    }
147
148
    private String getSourceUrl( final String type, final String encoded ) {
149
      return
150
        format( "https://%s/%s/svg/%s", getDiagramServerName(), type, encoded );
151
    }
152
153
    private Workspace getWorkspace() {
154
      return mContext.getWorkspace();
155
    }
156
157
    private String getDiagramServerName() {
158
      return getWorkspace().toString( KEY_IMAGES_SERVER );
138159
    }
139160
  }
M src/main/java/com/keenwrite/typesetting/Typesetter.java
44
import com.keenwrite.io.SysFile;
55
import com.keenwrite.preferences.Workspace;
6
7
import java.io.*;
8
import java.nio.file.NoSuchFileException;
9
import java.nio.file.Path;
10
import java.util.ArrayList;
11
import java.util.List;
12
import java.util.Scanner;
13
import java.util.concurrent.Callable;
14
import java.util.regex.Pattern;
15
16
import static com.keenwrite.Messages.get;
17
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
18
import static com.keenwrite.events.StatusEvent.clue;
19
import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH;
20
import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION;
21
import static java.lang.ProcessBuilder.Redirect.DISCARD;
22
import static java.lang.String.format;
23
import static java.lang.System.currentTimeMillis;
24
import static java.nio.file.Files.deleteIfExists;
25
import static java.nio.file.Files.newDirectoryStream;
26
import static java.util.concurrent.TimeUnit.*;
27
import static org.apache.commons.io.FilenameUtils.removeExtension;
28
29
/**
30
 * Responsible for invoking an executable to typeset text. This will
31
 * construct suitable command-line arguments to invoke the typesetting engine.
32
 */
33
public class Typesetter {
34
  private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
35
36
  private final Workspace mWorkspace;
37
38
  /**
39
   * Creates a new {@link Typesetter} instance capable of configuring the
40
   * typesetter used to generate a typeset document.
41
   */
42
  public Typesetter( final Workspace workspace ) {
43
    mWorkspace = workspace;
44
  }
45
46
  public static boolean canRun() {
47
    return TYPESETTER.canRun();
48
  }
49
50
  /**
51
   * This will typeset the document using a new process. The return value only
52
   * indicates whether the typesetter exists, not whether the typesetting was
53
   * successful.
54
   *
55
   * @param in  The input document to typeset.
56
   * @param out Path to the finished typeset document.
57
   * @throws IOException                 If the process could not be started.
58
   * @throws InterruptedException        If the process was killed.
59
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
60
   */
61
  public void typeset( final Path in, final Path out )
62
    throws IOException, InterruptedException, TypesetterNotFoundException {
63
    if( TYPESETTER.canRun() ) {
64
      clue( get( "Main.status.typeset.began", out ) );
65
      final var task = new TypesetTask( in, out );
66
      final var time = currentTimeMillis();
67
      final var success = task.typeset();
68
69
      clue( get(
70
        "Main.status.typeset.ended." + (success ? "success" : "failure"),
71
        out, since( time ) )
72
      );
73
    }
74
    else {
75
      throw new TypesetterNotFoundException( TYPESETTER.toString() );
76
    }
77
  }
78
79
  /**
80
   * Calculates the time that has elapsed from the current time to the
81
   * given moment in time.
82
   *
83
   * @param start The starting time, which should be before the current time.
84
   * @return A human-readable formatted time.
85
   * @see #asElapsed(long)
86
   */
87
  private static String since( final long start ) {
88
    return asElapsed( currentTimeMillis() - start );
89
  }
90
91
  /**
92
   * Converts an elapsed time to a human-readable format (hours, minutes,
93
   * seconds, and milliseconds).
94
   *
95
   * @param elapsed An elapsed time, in milliseconds.
96
   * @return Human-readable elapsed time.
97
   */
98
  private static String asElapsed( final long elapsed ) {
99
    final var hours = MILLISECONDS.toHours( elapsed );
100
    final var eHours = elapsed - HOURS.toMillis( hours );
101
    final var minutes = MILLISECONDS.toMinutes( eHours );
102
    final var eMinutes = eHours - MINUTES.toMillis( minutes );
103
    final var seconds = MILLISECONDS.toSeconds( eMinutes );
104
    final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
105
    final var milliseconds = MILLISECONDS.toMillis( eSeconds );
106
107
    return format( "%02d:%02d:%02d.%03d",
108
                   hours, minutes, seconds, milliseconds );
109
  }
110
111
  /**
112
   * Launches a task to typeset a document.
113
   */
114
  private class TypesetTask implements Callable<Boolean> {
115
    private final List<String> mArgs = new ArrayList<>();
116
    private final Path mInput;
117
    private final Path mOutput;
118
119
    /**
120
     * Working directory must be set because ConTeXt cannot write the
121
     * result to an arbitrary location.
122
     */
123
    private final Path mDirectory;
124
125
    private TypesetTask( final Path input, final Path output ) {
126
      assert input != null;
127
      assert output != null;
128
129
      final var parentDir = output.getParent();
130
      mInput = input;
131
      mOutput = output;
132
      mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
133
    }
134
135
    /**
136
     * Initializes ConTeXt, which means creating the cache directory if it
137
     * doesn't already exist. The theme entry point must be named 'main.tex'.
138
     *
139
     * @return {@code true} if the cache directory exists.
140
     */
141
    private boolean reinitialize() {
142
      final var filename = mOutput.getFileName();
143
      final var themes = getThemesPath();
144
      final var theme = getThemesSelection();
145
      final var cacheExists = !isEmpty( getCacheDir().toPath() );
146
147
      // Ensure invoking multiple times will load the correct arguments.
148
      mArgs.clear();
149
      mArgs.add( TYPESETTER.getName() );
150
151
      if( cacheExists ) {
152
        mArgs.add( "--autogenerate" );
153
        mArgs.add( "--script" );
154
        mArgs.add( "mtx-context" );
155
        mArgs.add( "--batchmode" );
156
        mArgs.add( "--nonstopmode" );
157
        mArgs.add( "--purgeall" );
158
        mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" );
159
        mArgs.add( "--environment='main'" );
160
        mArgs.add( "--result='" + filename + "'" );
161
        mArgs.add( mInput.toString() );
162
163
        final var sb = new StringBuilder( 128 );
164
        mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
165
        clue( sb.toString() );
166
      }
167
      else {
168
        mArgs.add( "--generate" );
169
      }
170
171
      return cacheExists;
172
    }
173
174
    /**
175
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on first
176
     * run. If the cache directory doesn't exist, attempt to create it, then
177
     * call ConTeXt to generate the PDF. This is brittle because if the
178
     * directory is empty, or not populated with cached data, a false positive
179
     * will be returned, resulting in no PDF being created.
180
     *
181
     * @return {@code true} if the document was typeset successfully.
182
     * @throws IOException          If the process could not be started.
183
     * @throws InterruptedException If the process was killed.
184
     */
185
    private boolean typeset() throws IOException, InterruptedException {
186
      return reinitialize() ? call() : call() && reinitialize() && call();
187
    }
188
189
    @Override
190
    public Boolean call() throws IOException, InterruptedException {
191
      final var builder = new ProcessBuilder( mArgs );
192
      builder.directory( mDirectory.toFile() );
193
      builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
194
195
      // Without redirecting (or draining) stderr, the command may not
196
      // terminate successfully.
197
      builder.redirectError( DISCARD );
198
199
      final var process = builder.start();
200
201
      // Reading from stdout allows slurping page numbers while generating.
202
      final var listener = new PaginationListener( process.getInputStream() );
203
      listener.start();
204
205
      process.waitFor();
206
      final var exit = process.exitValue();
207
      process.destroy();
208
209
      // If there was an error, the typesetter will leave behind log, pdf, and
210
      // error files.
211
      if( exit != 0 ) {
212
        final var xmlName = mInput.getFileName().toString();
213
        final var srcName = mOutput.getFileName().toString();
214
        final var logName = newExtension( xmlName, ".log" );
215
        final var errName = newExtension( xmlName, "-error.log" );
216
        final var pdfName = newExtension( xmlName, ".pdf" );
217
        final var badName = newExtension( srcName, ".log" );
218
219
        deleteIfExists( badName );
220
        deleteIfExists( logName );
221
        deleteIfExists( errName );
222
        deleteIfExists( pdfName );
223
      }
224
225
      // Exit value for a successful invocation of the typesetter. This value
226
      // value is returned when creating the cache on the first run as well as
227
      // creating PDFs on subsequent runs (after the cache has been created).
228
      // Users don't care about exit codes, only whether the PDF was generated.
229
      return exit == 0;
230
    }
231
232
    private Path newExtension( final String baseName, final String ext ) {
233
      return mOutput.resolveSibling( removeExtension( baseName ) + ext );
234
    }
235
236
    /**
237
     * Returns the location of the cache directory.
238
     *
239
     * @return A fully qualified path to the location to store temporary
240
     * files between typesetting runs.
241
     */
242
    private java.io.File getCacheDir() {
243
      final var temp = System.getProperty( "java.io.tmpdir" );
244
      final var cache = Path.of( temp, "luatex-cache" );
245
      return cache.toFile();
246
    }
247
248
    /**
249
     * Answers whether the given directory is empty. The typesetting software
250
     * creates a non-empty directory by default. The return value from this
251
     * method is a proxy to answering whether the typesetter has been run for
252
     * the first time or not.
253
     *
254
     * @param path The directory to check for emptiness.
255
     * @return {@code true} if the directory is empty.
256
     */
257
    private boolean isEmpty( final Path path ) {
258
      try( final var stream = newDirectoryStream( path ) ) {
259
        return !stream.iterator().hasNext();
260
      } catch( final NoSuchFileException | FileNotFoundException ex ) {
261
        // A missing directory means it doesn't exist, ergo is empty.
262
        return true;
263
      } catch( final IOException ex ) {
264
        throw new RuntimeException( ex );
265
      }
266
    }
267
  }
268
269
  /**
270
   * Responsible for parsing the output from the typesetting engine and
271
   * updating the status bar to provide assurance that typesetting is
272
   * executing.
273
   *
274
   * <p>
275
   * Example lines written to standard output:
276
   * </p>
277
   * <pre>{@code
278
   * pages           > flushing realpage 15, userpage 15, subpage 15
279
   * pages           > flushing realpage 16, userpage 16, subpage 16
280
   * pages           > flushing realpage 1, userpage 1, subpage 1
281
   * pages           > flushing realpage 2, userpage 2, subpage 2
282
   * }</pre>
283
   * <p>
284
   * The lines are parsed; the first number is displayed in a status bar
285
   * message.
286
   * </p>
287
   */
288
  private static class PaginationListener extends Thread {
289
    private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" );
290
291
    private final InputStream mInputStream;
292
293
    public PaginationListener( final InputStream in ) {
294
      mInputStream = in;
295
    }
296
297
    @Override
298
    public void run() {
299
      try( final var reader = createReader() ) {
300
        int pageCount = 1;
301
        int passCount = 1;
302
        int pageTotal = 0;
303
        String line;
304
305
        while( (line = reader.readLine()) != null ) {
306
          if( line.startsWith( "pages" ) ) {
307
            // The bottleneck will be the typesetting engine writing to stdout,
308
            // not the parsing of stdout.
309
            final var scanner = new Scanner( line ).useDelimiter( DIGITS );
310
            final var digits = scanner.next();
311
            final var page = Integer.parseInt( digits );
312
313
            // If the page number is less than the previous page count, it
314
            // means that the typesetting engine has started another pass.
315
            if( page < pageCount ) {
316
              passCount++;
317
              pageTotal = pageCount;
318
            }
319
320
            pageCount = page;
321
322
            // Let the user know that something is happening in the background.
323
            clue( get(
324
              "Main.status.typeset.page",
325
              pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
326
            ) );
327
          }
328
        }
329
      } catch( final IOException ex ) {
330
        throw new RuntimeException( ex );
331
      }
332
    }
333
334
    private BufferedReader createReader() {
335
      return new BufferedReader( new InputStreamReader( mInputStream ) );
336
    }
337
  }
338
339
  private File getThemesPath() {
340
    return mWorkspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
341
  }
342
343
  private String getThemesSelection() {
344
    return mWorkspace.toString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
6
import com.keenwrite.util.BoundedCache;
7
8
import java.io.*;
9
import java.nio.file.NoSuchFileException;
10
import java.nio.file.Path;
11
import java.util.ArrayList;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Scanner;
15
import java.util.concurrent.Callable;
16
import java.util.regex.Pattern;
17
18
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
19
import static com.keenwrite.events.StatusEvent.clue;
20
import static com.keenwrite.preferences.WorkspaceKeys.*;
21
import static java.lang.ProcessBuilder.Redirect.DISCARD;
22
import static java.lang.String.format;
23
import static java.lang.System.currentTimeMillis;
24
import static java.lang.System.getProperty;
25
import static java.nio.file.Files.*;
26
import static java.util.Arrays.asList;
27
import static java.util.concurrent.TimeUnit.*;
28
import static org.apache.commons.io.FilenameUtils.removeExtension;
29
30
/**
31
 * Responsible for invoking an executable to typeset text. This will
32
 * construct suitable command-line arguments to invoke the typesetting engine.
33
 */
34
public class Typesetter {
35
  private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
36
37
  private final Workspace mWorkspace;
38
39
  /**
40
   * Creates a new {@link Typesetter} instance capable of configuring the
41
   * typesetter used to generate a typeset document.
42
   */
43
  public Typesetter( final Workspace workspace ) {
44
    mWorkspace = workspace;
45
  }
46
47
  public static boolean canRun() {
48
    return TYPESETTER.canRun();
49
  }
50
51
  /**
52
   * This will typeset the document using a new process. The return value only
53
   * indicates whether the typesetter exists, not whether the typesetting was
54
   * successful.
55
   *
56
   * @param in  The input document to typeset.
57
   * @param out Path to the finished typeset document.
58
   * @throws IOException                 If the process could not be started.
59
   * @throws InterruptedException        If the process was killed.
60
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
61
   */
62
  public void typeset( final Path in, final Path out )
63
    throws IOException, InterruptedException, TypesetterNotFoundException {
64
    if( TYPESETTER.canRun() ) {
65
      clue( "Main.status.typeset.began", out );
66
      final var task = new TypesetTask( in, out );
67
      final var time = currentTimeMillis();
68
      final var success = task.typeset();
69
70
      clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
71
            out, since( time )
72
      );
73
    }
74
    else {
75
      throw new TypesetterNotFoundException( TYPESETTER.toString() );
76
    }
77
  }
78
79
  /**
80
   * Calculates the time that has elapsed from the current time to the
81
   * given moment in time.
82
   *
83
   * @param start The starting time, which should be before the current time.
84
   * @return A human-readable formatted time.
85
   * @see #asElapsed(long)
86
   */
87
  private static String since( final long start ) {
88
    return asElapsed( currentTimeMillis() - start );
89
  }
90
91
  /**
92
   * Converts an elapsed time to a human-readable format (hours, minutes,
93
   * seconds, and milliseconds).
94
   *
95
   * @param elapsed An elapsed time, in milliseconds.
96
   * @return Human-readable elapsed time.
97
   */
98
  private static String asElapsed( final long elapsed ) {
99
    final var hours = MILLISECONDS.toHours( elapsed );
100
    final var eHours = elapsed - HOURS.toMillis( hours );
101
    final var minutes = MILLISECONDS.toMinutes( eHours );
102
    final var eMinutes = eHours - MINUTES.toMillis( minutes );
103
    final var seconds = MILLISECONDS.toSeconds( eMinutes );
104
    final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
105
    final var milliseconds = MILLISECONDS.toMillis( eSeconds );
106
107
    return format( "%02d:%02d:%02d.%03d",
108
                   hours, minutes, seconds, milliseconds );
109
  }
110
111
  /**
112
   * Launches a task to typeset a document.
113
   */
114
  private class TypesetTask implements Callable<Boolean> {
115
    private final List<String> mArgs = new ArrayList<>();
116
    private final Path mInput;
117
    private final Path mOutput;
118
119
    /**
120
     * Working directory must be set because ConTeXt cannot write the
121
     * result to an arbitrary location.
122
     */
123
    private final Path mDirectory;
124
125
    private TypesetTask( final Path input, final Path output ) {
126
      assert input != null;
127
      assert output != null;
128
129
      final var parentDir = output.getParent();
130
      mInput = input;
131
      mOutput = output;
132
      mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
133
    }
134
135
    /**
136
     * Initializes ConTeXt, which means creating the cache directory if it
137
     * doesn't already exist. The theme entry point must be named 'main.tex'.
138
     *
139
     * @return {@code true} if the cache directory exists.
140
     */
141
    private boolean reinitialize() {
142
      final var filename = mOutput.getFileName();
143
      final var themes = getThemesPath();
144
      final var theme = getThemesSelection();
145
      final var cacheExists = !isEmpty( getCacheDir().toPath() );
146
147
      // Ensure invoking multiple times will load the correct arguments.
148
      mArgs.clear();
149
      mArgs.add( TYPESETTER.getName() );
150
151
      if( cacheExists ) {
152
        mArgs.add( "--autogenerate" );
153
        mArgs.add( "--script" );
154
        mArgs.add( "mtx-context" );
155
        mArgs.add( "--batchmode" );
156
        mArgs.add( "--nonstopmode" );
157
        mArgs.add( "--purgeall" );
158
        mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" );
159
        mArgs.add( "--environment='main'" );
160
        mArgs.add( "--result='" + filename + "'" );
161
        mArgs.add( mInput.toString() );
162
163
        final var sb = new StringBuilder( 128 );
164
        mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
165
        clue( sb.toString() );
166
      }
167
      else {
168
        mArgs.add( "--generate" );
169
      }
170
171
      return cacheExists;
172
    }
173
174
    /**
175
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on first
176
     * run. If the cache directory doesn't exist, attempt to create it, then
177
     * call ConTeXt to generate the PDF. This is brittle because if the
178
     * directory is empty, or not populated with cached data, a false positive
179
     * will be returned, resulting in no PDF being created.
180
     *
181
     * @return {@code true} if the document was typeset successfully.
182
     * @throws IOException          If the process could not be started.
183
     * @throws InterruptedException If the process was killed.
184
     */
185
    private boolean typeset() throws IOException, InterruptedException {
186
      return reinitialize() ? call() : call() && reinitialize() && call();
187
    }
188
189
    @Override
190
    public Boolean call() throws IOException, InterruptedException {
191
      final var stdout = new BoundedCache<String, String>( 150 );
192
      final var builder = new ProcessBuilder( mArgs );
193
      builder.directory( mDirectory.toFile() );
194
      builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
195
196
      // Without redirecting (or draining) stderr, the command may not
197
      // terminate successfully.
198
      builder.redirectError( DISCARD );
199
200
      final var process = builder.start();
201
202
      // Reading from stdout allows slurping page numbers while generating.
203
      final var listener = new PaginationListener(
204
        process.getInputStream(), stdout );
205
      listener.start();
206
207
      process.waitFor();
208
      final var exit = process.exitValue();
209
      process.destroy();
210
211
      // If there was an error, the typesetter will leave behind log, pdf, and
212
      // error files.
213
      if( exit > 0 ) {
214
        final var xmlName = mInput.getFileName().toString();
215
        final var srcName = mOutput.getFileName().toString();
216
        final var logName = newExtension( xmlName, ".log" );
217
        final var errName = newExtension( xmlName, "-error.log" );
218
        final var pdfName = newExtension( xmlName, ".pdf" );
219
        final var tuaName = newExtension( xmlName, ".tua" );
220
        final var badName = newExtension( srcName, ".log" );
221
222
        log( badName );
223
        log( logName );
224
        log( errName );
225
        log( stdout.keySet().stream().toList() );
226
227
        // Users may opt to keep these files around for debugging purposes.
228
        if( autoclean() ) {
229
          deleteIfExists( logName );
230
          deleteIfExists( errName );
231
          deleteIfExists( pdfName );
232
          deleteIfExists( badName );
233
          deleteIfExists( tuaName );
234
        }
235
      }
236
237
      // Exit value for a successful invocation of the typesetter. This value
238
      // value is returned when creating the cache on the first run as well as
239
      // creating PDFs on subsequent runs (after the cache has been created).
240
      // Users don't care about exit codes, only whether the PDF was generated.
241
      return exit == 0;
242
    }
243
244
    private Path newExtension( final String baseName, final String ext ) {
245
      return mOutput.resolveSibling( removeExtension( baseName ) + ext );
246
    }
247
248
    /**
249
     * Fires a status message for each line in the given file. The file format
250
     * is somewhat machine-readable, but no effort beyond line splitting is
251
     * made to parse the text.
252
     *
253
     * @param path Path to the file containing error messages.
254
     */
255
    private void log( final Path path ) throws IOException {
256
      if( exists( path ) ) {
257
        log( readAllLines( path ) );
258
      }
259
    }
260
261
    private void log( final List<String> lines ) {
262
      final var splits = new ArrayList<String>( lines.size() * 2 );
263
264
      for( final var line : lines ) {
265
        splits.addAll( asList( line.split( "\\\\n" ) ) );
266
      }
267
268
      clue( splits );
269
    }
270
271
    /**
272
     * Returns the location of the cache directory.
273
     *
274
     * @return A fully qualified path to the location to store temporary
275
     * files between typesetting runs.
276
     */
277
    private java.io.File getCacheDir() {
278
      final var temp = getProperty( "java.io.tmpdir" );
279
      final var cache = Path.of( temp, "luatex-cache" );
280
      return cache.toFile();
281
    }
282
283
    /**
284
     * Answers whether the given directory is empty. The typesetting software
285
     * creates a non-empty directory by default. The return value from this
286
     * method is a proxy to answering whether the typesetter has been run for
287
     * the first time or not.
288
     *
289
     * @param path The directory to check for emptiness.
290
     * @return {@code true} if the directory is empty.
291
     */
292
    private boolean isEmpty( final Path path ) {
293
      try( final var stream = newDirectoryStream( path ) ) {
294
        return !stream.iterator().hasNext();
295
      } catch( final NoSuchFileException | FileNotFoundException ex ) {
296
        // A missing directory means it doesn't exist, ergo is empty.
297
        return true;
298
      } catch( final IOException ex ) {
299
        throw new RuntimeException( ex );
300
      }
301
    }
302
  }
303
304
  /**
305
   * Responsible for parsing the output from the typesetting engine and
306
   * updating the status bar to provide assurance that typesetting is
307
   * executing.
308
   *
309
   * <p>
310
   * Example lines written to standard output:
311
   * </p>
312
   * <pre>{@code
313
   * pages           > flushing realpage 15, userpage 15, subpage 15
314
   * pages           > flushing realpage 16, userpage 16, subpage 16
315
   * pages           > flushing realpage 1, userpage 1, subpage 1
316
   * pages           > flushing realpage 2, userpage 2, subpage 2
317
   * }</pre>
318
   * <p>
319
   * The lines are parsed; the first number is displayed in a status bar
320
   * message.
321
   * </p>
322
   */
323
  private static class PaginationListener extends Thread {
324
    private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" );
325
326
    private final InputStream mInputStream;
327
328
    private final Map<String, String> mCache;
329
330
    public PaginationListener(
331
      final InputStream in, final Map<String, String> cache ) {
332
      mInputStream = in;
333
      mCache = cache;
334
    }
335
336
    @Override
337
    public void run() {
338
      try( final var reader = createReader() ) {
339
        int pageCount = 1;
340
        int passCount = 1;
341
        int pageTotal = 0;
342
        String line;
343
344
        while( (line = reader.readLine()) != null ) {
345
          mCache.put( line, "" );
346
347
          if( line.startsWith( "pages" ) ) {
348
            // The bottleneck will be the typesetting engine writing to stdout,
349
            // not the parsing of stdout.
350
            final var scanner = new Scanner( line ).useDelimiter( DIGITS );
351
            final var digits = scanner.next();
352
            final var page = Integer.parseInt( digits );
353
354
            // If the page number is less than the previous page count, it
355
            // means that the typesetting engine has started another pass.
356
            if( page < pageCount ) {
357
              passCount++;
358
              pageTotal = pageCount;
359
            }
360
361
            pageCount = page;
362
363
            // Let the user know that something is happening in the background.
364
            clue( "Main.status.typeset.page",
365
                  pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
366
            );
367
          }
368
        }
369
      } catch( final IOException ex ) {
370
        throw new RuntimeException( ex );
371
      }
372
    }
373
374
    private BufferedReader createReader() {
375
      return new BufferedReader( new InputStreamReader( mInputStream ) );
376
    }
377
  }
378
379
  private File getThemesPath() {
380
    return mWorkspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
381
  }
382
383
  private String getThemesSelection() {
384
    return mWorkspace.toString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
385
  }
386
387
  /**
388
   * Answers whether logs and other files should be deleted upon error. The
389
   * log files are useful for debugging.
390
   *
391
   * @return {@code true} to delete generated files.
392
   */
393
  private boolean autoclean() {
394
    return mWorkspace.toBoolean( KEY_TYPESET_CONTEXT_CLEAN );
345395
  }
346396
}
M src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
2222
import com.keenwrite.ui.explorer.FilePickerFactory;
2323
import com.keenwrite.ui.logging.LogView;
24
import com.vladsch.flexmark.ast.Link;
25
import javafx.concurrent.Task;
26
import javafx.scene.control.Alert;
27
import javafx.scene.control.Dialog;
28
import javafx.stage.Window;
29
import javafx.stage.WindowEvent;
30
31
import java.io.File;
32
import java.nio.file.Path;
33
import java.util.List;
34
import java.util.Optional;
35
import java.util.concurrent.ExecutorService;
36
37
import static com.keenwrite.Bootstrap.*;
38
import static com.keenwrite.ExportFormat.*;
39
import static com.keenwrite.Messages.get;
40
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
41
import static com.keenwrite.events.StatusEvent.clue;
42
import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH;
43
import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION;
44
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
45
import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
46
import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
47
import static java.nio.file.Files.writeString;
48
import static java.util.concurrent.Executors.newFixedThreadPool;
49
import static javafx.application.Platform.runLater;
50
import static javafx.event.Event.fireEvent;
51
import static javafx.scene.control.Alert.AlertType.INFORMATION;
52
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
53
54
/**
55
 * Responsible for abstracting how functionality is mapped to the application.
56
 * This allows users to customize accelerator keys and will provide pluggable
57
 * functionality so that different text markup languages can change documents
58
 * using their respective syntax.
59
 */
60
@SuppressWarnings( "NonAsciiCharacters" )
61
public final class ApplicationActions {
62
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
63
64
  private static final String STYLE_SEARCH = "search";
65
66
  /**
67
   * When an action is executed, this is one of the recipients.
68
   */
69
  private final MainPane mMainPane;
70
71
  private final MainScene mMainScene;
72
73
  private final LogView mLogView;
74
75
  /**
76
   * Tracks finding text in the active document.
77
   */
78
  private final SearchModel mSearchModel;
79
80
  public ApplicationActions( final MainScene scene, final MainPane pane ) {
81
    mMainScene = scene;
82
    mMainPane = pane;
83
    mLogView = new LogView();
84
    mSearchModel = new SearchModel();
85
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
86
      final var editor = getActiveTextEditor();
87
88
      // Clear highlighted areas before highlighting a new region.
89
      if( o != null ) {
90
        editor.unstylize( STYLE_SEARCH );
91
      }
92
93
      if( n != null ) {
94
        editor.moveTo( n.getStart() );
95
        editor.stylize( n, STYLE_SEARCH );
96
      }
97
    } );
98
99
    // When the active text editor changes, update the haystack.
100
    mMainPane.activeTextEditorProperty().addListener(
101
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
102
    );
103
  }
104
105
  public void file‿new() {
106
    getMainPane().newTextEditor();
107
  }
108
109
  public void file‿open() {
110
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
111
  }
112
113
  public void file‿close() {
114
    getMainPane().close();
115
  }
116
117
  public void file‿close_all() {
118
    getMainPane().closeAll();
119
  }
120
121
  public void file‿save() {
122
    getMainPane().save();
123
  }
124
125
  public void file‿save_as() {
126
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
127
  }
128
129
  public void file‿save_all() {
130
    getMainPane().saveAll();
131
  }
132
133
  private void file‿export( final ExportFormat format ) {
134
    final var main = getMainPane();
135
    final var editor = main.getActiveTextEditor();
136
    final var filename = format.toExportFilename( editor.getPath() );
137
    final var selection = pickFiles( filename, FILE_EXPORT );
138
139
    selection.ifPresent( ( files ) -> {
140
      final var file = files.get( 0 );
141
      final var path = file.toPath();
142
      final var document = editor.getText();
143
      final var context = main.createProcessorContext( path, format );
144
145
      final var task = new Task<Path>() {
146
        @Override
147
        protected Path call() throws Exception {
148
          final var chain = createProcessors( context );
149
          final var export = chain.apply( document );
150
151
          // Processors can export binary files. In such cases, processors
152
          // return null to prevent further processing.
153
          return export == null ? null : writeString( path, export );
154
        }
155
      };
156
157
      task.setOnSucceeded(
158
        e -> {
159
          final var result = task.getValue();
160
161
          // Binary formats must notify users of success independently.
162
          if( result != null ) {
163
            clue( get( "Main.status.export.success", result ) );
164
          }
165
        }
166
      );
167
168
      task.setOnFailed( e -> {
169
        final var ex = task.getException();
170
        clue( ex );
171
172
        if( ex instanceof TypeNotPresentException ) {
173
          fireExportFailedEvent();
174
        }
175
      } );
176
177
      sExecutor.execute( task );
178
    } );
179
  }
180
181
  public void file‿export‿pdf() {
182
    final var workspace = getWorkspace();
183
    final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
184
    final var theme = workspace.stringProperty(
185
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
186
187
    if( Typesetter.canRun() ) {
188
      // If the typesetter is installed, allow the user to select a theme. If
189
      // the themes aren't installed, a status message will appear.
190
      if( ThemePicker.choose( themes, theme ) ) {
191
        file‿export( APPLICATION_PDF );
192
      }
193
    }
194
    else {
195
      fireExportFailedEvent();
196
    }
197
  }
198
199
  public void file‿export‿html_svg() {
200
    file‿export( HTML_TEX_SVG );
201
  }
202
203
  public void file‿export‿html_tex() {
204
    file‿export( HTML_TEX_DELIMITED );
205
  }
206
207
  public void file‿export‿xhtml_tex() {
208
    file‿export( XHTML_TEX );
209
  }
210
211
  public void file‿export‿markdown() {
212
    file‿export( MARKDOWN_PLAIN );
213
  }
214
215
  private void fireExportFailedEvent() {
216
    runLater( ExportFailedEvent::fireExportFailedEvent );
217
  }
218
219
  public void file‿exit() {
220
    final var window = getWindow();
221
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
222
  }
223
224
  public void edit‿undo() {
225
    getActiveTextEditor().undo();
226
  }
227
228
  public void edit‿redo() {
229
    getActiveTextEditor().redo();
230
  }
231
232
  public void edit‿cut() {
233
    getActiveTextEditor().cut();
234
  }
235
236
  public void edit‿copy() {
237
    getActiveTextEditor().copy();
238
  }
239
240
  public void edit‿paste() {
241
    getActiveTextEditor().paste();
242
  }
243
244
  public void edit‿select_all() {
245
    getActiveTextEditor().selectAll();
246
  }
247
248
  public void edit‿find() {
249
    final var nodes = getMainScene().getStatusBar().getLeftItems();
250
251
    if( nodes.isEmpty() ) {
252
      final var searchBar = new SearchBar();
253
254
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
255
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
256
257
      searchBar.setOnCancelAction( ( event ) -> {
258
        final var editor = getActiveTextEditor();
259
        nodes.remove( searchBar );
260
        editor.unstylize( STYLE_SEARCH );
261
        editor.getNode().requestFocus();
262
      } );
263
264
      searchBar.addInputListener( ( c, o, n ) -> {
265
        if( n != null && !n.isEmpty() ) {
266
          mSearchModel.search( n, getActiveTextEditor().getText() );
267
        }
268
      } );
269
270
      searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
271
      searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
272
273
      nodes.add( searchBar );
274
      searchBar.requestFocus();
275
    }
276
    else {
277
      nodes.clear();
278
    }
279
  }
280
281
  public void edit‿find_next() {
282
    mSearchModel.advance();
283
  }
284
285
  public void edit‿find_prev() {
286
    mSearchModel.retreat();
287
  }
288
289
  public void edit‿preferences() {
290
    try {
291
      new PreferencesController( getWorkspace() ).show();
292
    } catch( final Exception ex ) {
293
      clue( ex );
294
    }
295
  }
296
297
  public void format‿bold() {
298
    getActiveTextEditor().bold();
299
  }
300
301
  public void format‿italic() {
302
    getActiveTextEditor().italic();
303
  }
304
305
  public void format‿superscript() {
306
    getActiveTextEditor().superscript();
307
  }
308
309
  public void format‿subscript() {
310
    getActiveTextEditor().subscript();
311
  }
312
313
  public void format‿strikethrough() {
314
    getActiveTextEditor().strikethrough();
315
  }
316
317
  public void insert‿blockquote() {
318
    getActiveTextEditor().blockquote();
319
  }
320
321
  public void insert‿code() {
322
    getActiveTextEditor().code();
323
  }
324
325
  public void insert‿fenced_code_block() {
326
    getActiveTextEditor().fencedCodeBlock();
327
  }
328
329
  public void insert‿link() {
330
    insertObject( createLinkDialog() );
331
  }
332
333
  public void insert‿image() {
334
    insertObject( createImageDialog() );
335
  }
336
337
  private void insertObject( final Dialog<String> dialog ) {
338
    final var textArea = getActiveTextEditor().getTextArea();
339
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
340
  }
341
342
  private Dialog<String> createLinkDialog() {
343
    return new LinkDialog( getWindow(), createHyperlinkModel() );
344
  }
345
346
  private Dialog<String> createImageDialog() {
347
    final var path = getActiveTextEditor().getPath();
348
    final var parentDir = path.getParent();
349
    return new ImageDialog( getWindow(), parentDir );
350
  }
351
352
  /**
353
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
354
   * the Markdown AST.
355
   *
356
   * @return An instance containing the link URL and display text.
357
   */
358
  private HyperlinkModel createHyperlinkModel() {
359
    final var context = getMainPane().createProcessorContext();
360
    final var editor = getActiveTextEditor();
361
    final var textArea = editor.getTextArea();
362
    final var selectedText = textArea.getSelectedText();
363
364
    // Convert current paragraph to Markdown nodes.
365
    final var mp = MarkdownProcessor.create( context );
366
    final var p = textArea.getCurrentParagraph();
367
    final var paragraph = textArea.getText( p );
368
    final var node = mp.toNode( paragraph );
369
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
370
    final var link = visitor.process( node );
371
372
    if( link != null ) {
373
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
374
    }
375
376
    return createHyperlinkModel( link, selectedText );
377
  }
378
379
  private HyperlinkModel createHyperlinkModel(
380
    final Link link, final String selection ) {
381
382
    return link == null
383
      ? new HyperlinkModel( selection, "https://localhost" )
384
      : new HyperlinkModel( link );
385
  }
386
387
  public void insert‿heading_1() {
388
    insert‿heading( 1 );
389
  }
390
391
  public void insert‿heading_2() {
392
    insert‿heading( 2 );
393
  }
394
395
  public void insert‿heading_3() {
396
    insert‿heading( 3 );
397
  }
398
399
  private void insert‿heading( final int level ) {
400
    getActiveTextEditor().heading( level );
401
  }
402
403
  public void insert‿unordered_list() {
404
    getActiveTextEditor().unorderedList();
405
  }
406
407
  public void insert‿ordered_list() {
408
    getActiveTextEditor().orderedList();
409
  }
410
411
  public void insert‿horizontal_rule() {
412
    getActiveTextEditor().horizontalRule();
413
  }
414
415
  public void definition‿create() {
416
    getActiveTextDefinition().createDefinition();
417
  }
418
419
  public void definition‿rename() {
420
    getActiveTextDefinition().renameDefinition();
421
  }
422
423
  public void definition‿delete() {
424
    getActiveTextDefinition().deleteDefinitions();
425
  }
426
427
  public void definition‿autoinsert() {
428
    getMainPane().autoinsert();
429
  }
430
431
  public void view‿refresh() {
432
    getMainPane().viewRefresh();
433
  }
434
435
  public void view‿preview() {
436
    getMainPane().viewPreview();
437
  }
438
439
  public void view‿outline() {
440
    getMainPane().viewOutline();
441
  }
442
443
  public void view‿files() { getMainPane().viewFiles(); }
444
445
  public void view‿statistics() {
446
    getMainPane().viewStatistics();
447
  }
448
449
  public void view‿menubar() {
450
    getMainScene().toggleMenuBar();
451
  }
452
453
  public void view‿toolbar() {
454
    getMainScene().toggleToolBar();
455
  }
456
457
  public void view‿statusbar() {
458
    getMainScene().toggleStatusBar();
459
  }
460
461
  public void view‿issues() {
462
    mLogView.view();
463
  }
464
465
  public void help‿about() {
466
    final var alert = new Alert( INFORMATION );
467
    final var prefix = "Dialog.about.";
468
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
469
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
470
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
471
    alert.setGraphic( ICON_DIALOG_NODE );
472
    alert.initOwner( getWindow() );
473
    alert.showAndWait();
24
import com.keenwrite.util.AlphanumComparator;
25
import com.vladsch.flexmark.ast.Link;
26
import javafx.concurrent.Task;
27
import javafx.scene.control.Alert;
28
import javafx.scene.control.Dialog;
29
import javafx.stage.Window;
30
import javafx.stage.WindowEvent;
31
32
import java.io.File;
33
import java.io.IOException;
34
import java.nio.file.Path;
35
import java.util.ArrayList;
36
import java.util.List;
37
import java.util.Optional;
38
import java.util.concurrent.ExecutorService;
39
40
import static com.keenwrite.Bootstrap.*;
41
import static com.keenwrite.ExportFormat.*;
42
import static com.keenwrite.Messages.get;
43
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
44
import static com.keenwrite.events.StatusEvent.clue;
45
import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH;
46
import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION;
47
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
48
import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
49
import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
50
import static com.keenwrite.util.FileWalker.walk;
51
import static java.nio.file.Files.readString;
52
import static java.nio.file.Files.writeString;
53
import static java.util.concurrent.Executors.newFixedThreadPool;
54
import static javafx.application.Platform.runLater;
55
import static javafx.event.Event.fireEvent;
56
import static javafx.scene.control.Alert.AlertType.INFORMATION;
57
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
58
import static org.apache.commons.io.FilenameUtils.getExtension;
59
60
/**
61
 * Responsible for abstracting how functionality is mapped to the application.
62
 * This allows users to customize accelerator keys and will provide pluggable
63
 * functionality so that different text markup languages can change documents
64
 * using their respective syntax.
65
 */
66
@SuppressWarnings( "NonAsciiCharacters" )
67
public final class ApplicationActions {
68
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
69
70
  private static final String STYLE_SEARCH = "search";
71
72
  /**
73
   * Sci-fi genres, which are can be longer than other genres, typically fall
74
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
75
   * memory when concatenating files together when exporting novels.
76
   */
77
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
78
79
  /**
80
   * When an action is executed, this is one of the recipients.
81
   */
82
  private final MainPane mMainPane;
83
84
  private final MainScene mMainScene;
85
86
  private final LogView mLogView;
87
88
  /**
89
   * Tracks finding text in the active document.
90
   */
91
  private final SearchModel mSearchModel;
92
93
  public ApplicationActions( final MainScene scene, final MainPane pane ) {
94
    mMainScene = scene;
95
    mMainPane = pane;
96
    mLogView = new LogView();
97
    mSearchModel = new SearchModel();
98
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
99
      final var editor = getActiveTextEditor();
100
101
      // Clear highlighted areas before highlighting a new region.
102
      if( o != null ) {
103
        editor.unstylize( STYLE_SEARCH );
104
      }
105
106
      if( n != null ) {
107
        editor.moveTo( n.getStart() );
108
        editor.stylize( n, STYLE_SEARCH );
109
      }
110
    } );
111
112
    // When the active text editor changes, update the haystack.
113
    mMainPane.activeTextEditorProperty().addListener(
114
      ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
115
    );
116
  }
117
118
  public void file‿new() {
119
    getMainPane().newTextEditor();
120
  }
121
122
  public void file‿open() {
123
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
124
  }
125
126
  public void file‿close() {
127
    getMainPane().close();
128
  }
129
130
  public void file‿close_all() {
131
    getMainPane().closeAll();
132
  }
133
134
  public void file‿save() {
135
    getMainPane().save();
136
  }
137
138
  public void file‿save_as() {
139
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
140
  }
141
142
  public void file‿save_all() {
143
    getMainPane().saveAll();
144
  }
145
146
  /**
147
   * Converts the actively edited file in the given file format.
148
   *
149
   * @param format The destination file format.
150
   */
151
  private void file‿export( final ExportFormat format ) {
152
    file‿export( format, false );
153
  }
154
155
  /**
156
   * Converts one or more files into the given file format. If {@code dir}
157
   * is set to true, this will first append all files in the same directory
158
   * as the actively edited file.
159
   *
160
   * @param format The destination file format.
161
   * @param dir    Export all files in the actively edited file's directory.
162
   */
163
  private void file‿export( final ExportFormat format, final boolean dir ) {
164
    final var main = getMainPane();
165
    final var editor = main.getActiveTextEditor();
166
    final var filename = format.toExportFilename( editor.getPath() );
167
    final var selection = pickFiles( filename, FILE_EXPORT );
168
169
    selection.ifPresent( ( files ) -> {
170
      final var file = files.get( 0 );
171
      final var path = file.toPath();
172
      final var document = dir ? append( editor ) : editor.getText();
173
      final var context = main.createProcessorContext( path, format );
174
175
      final var task = new Task<Path>() {
176
        @Override
177
        protected Path call() throws Exception {
178
          final var chain = createProcessors( context );
179
          final var export = chain.apply( document );
180
181
          // Processors can export binary files. In such cases, processors
182
          // return null to prevent further processing.
183
          return export == null ? null : writeString( path, export );
184
        }
185
      };
186
187
      task.setOnSucceeded(
188
        e -> {
189
          final var result = task.getValue();
190
191
          // Binary formats must notify users of success independently.
192
          if( result != null ) {
193
            clue( "Main.status.export.success", result );
194
          }
195
        }
196
      );
197
198
      task.setOnFailed( e -> {
199
        final var ex = task.getException();
200
        clue( ex );
201
202
        if( ex instanceof TypeNotPresentException ) {
203
          fireExportFailedEvent();
204
        }
205
      } );
206
207
      sExecutor.execute( task );
208
    } );
209
  }
210
211
  /**
212
   * @param dir {@code true} means to export all files in the active file
213
   *            editor's directory; {@code false} means to export only the
214
   *            actively edited file.
215
   */
216
  private void file‿export‿pdf( final boolean dir ) {
217
    final var workspace = getWorkspace();
218
    final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
219
    final var theme = workspace.stringProperty(
220
      KEY_TYPESET_CONTEXT_THEME_SELECTION );
221
222
    if( Typesetter.canRun() ) {
223
      // If the typesetter is installed, allow the user to select a theme. If
224
      // the themes aren't installed, a status message will appear.
225
      if( ThemePicker.choose( themes, theme ) ) {
226
        file‿export( APPLICATION_PDF, dir );
227
      }
228
    }
229
    else {
230
      fireExportFailedEvent();
231
    }
232
  }
233
234
  public void file‿export‿pdf() {
235
    file‿export‿pdf( false );
236
  }
237
238
  public void file‿export‿pdf‿dir() {
239
    file‿export‿pdf( true );
240
  }
241
242
  public void file‿export‿html_svg() {
243
    file‿export( HTML_TEX_SVG );
244
  }
245
246
  public void file‿export‿html_tex() {
247
    file‿export( HTML_TEX_DELIMITED );
248
  }
249
250
  public void file‿export‿xhtml_tex() {
251
    file‿export( XHTML_TEX );
252
  }
253
254
  public void file‿export‿markdown() {
255
    file‿export( MARKDOWN_PLAIN );
256
  }
257
258
  private void fireExportFailedEvent() {
259
    runLater( ExportFailedEvent::fireExportFailedEvent );
260
  }
261
262
  public void file‿exit() {
263
    final var window = getWindow();
264
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
265
  }
266
267
  public void edit‿undo() {
268
    getActiveTextEditor().undo();
269
  }
270
271
  public void edit‿redo() {
272
    getActiveTextEditor().redo();
273
  }
274
275
  public void edit‿cut() {
276
    getActiveTextEditor().cut();
277
  }
278
279
  public void edit‿copy() {
280
    getActiveTextEditor().copy();
281
  }
282
283
  public void edit‿paste() {
284
    getActiveTextEditor().paste();
285
  }
286
287
  public void edit‿select_all() {
288
    getActiveTextEditor().selectAll();
289
  }
290
291
  public void edit‿find() {
292
    final var nodes = getMainScene().getStatusBar().getLeftItems();
293
294
    if( nodes.isEmpty() ) {
295
      final var searchBar = new SearchBar();
296
297
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
298
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
299
300
      searchBar.setOnCancelAction( ( event ) -> {
301
        final var editor = getActiveTextEditor();
302
        nodes.remove( searchBar );
303
        editor.unstylize( STYLE_SEARCH );
304
        editor.getNode().requestFocus();
305
      } );
306
307
      searchBar.addInputListener( ( c, o, n ) -> {
308
        if( n != null && !n.isEmpty() ) {
309
          mSearchModel.search( n, getActiveTextEditor().getText() );
310
        }
311
      } );
312
313
      searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
314
      searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
315
316
      nodes.add( searchBar );
317
      searchBar.requestFocus();
318
    }
319
    else {
320
      nodes.clear();
321
    }
322
  }
323
324
  public void edit‿find_next() {
325
    mSearchModel.advance();
326
  }
327
328
  public void edit‿find_prev() {
329
    mSearchModel.retreat();
330
  }
331
332
  public void edit‿preferences() {
333
    try {
334
      new PreferencesController( getWorkspace() ).show();
335
    } catch( final Exception ex ) {
336
      clue( ex );
337
    }
338
  }
339
340
  public void format‿bold() {
341
    getActiveTextEditor().bold();
342
  }
343
344
  public void format‿italic() {
345
    getActiveTextEditor().italic();
346
  }
347
348
  public void format‿monospace() {
349
    getActiveTextEditor().monospace();
350
  }
351
352
  public void format‿superscript() {
353
    getActiveTextEditor().superscript();
354
  }
355
356
  public void format‿subscript() {
357
    getActiveTextEditor().subscript();
358
  }
359
360
  public void format‿strikethrough() {
361
    getActiveTextEditor().strikethrough();
362
  }
363
364
  public void insert‿blockquote() {
365
    getActiveTextEditor().blockquote();
366
  }
367
368
  public void insert‿code() {
369
    getActiveTextEditor().code();
370
  }
371
372
  public void insert‿fenced_code_block() {
373
    getActiveTextEditor().fencedCodeBlock();
374
  }
375
376
  public void insert‿link() {
377
    insertObject( createLinkDialog() );
378
  }
379
380
  public void insert‿image() {
381
    insertObject( createImageDialog() );
382
  }
383
384
  private void insertObject( final Dialog<String> dialog ) {
385
    final var textArea = getActiveTextEditor().getTextArea();
386
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
387
  }
388
389
  private Dialog<String> createLinkDialog() {
390
    return new LinkDialog( getWindow(), createHyperlinkModel() );
391
  }
392
393
  private Dialog<String> createImageDialog() {
394
    final var path = getActiveTextEditor().getPath();
395
    final var parentDir = path.getParent();
396
    return new ImageDialog( getWindow(), parentDir );
397
  }
398
399
  /**
400
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
401
   * the Markdown AST.
402
   *
403
   * @return An instance containing the link URL and display text.
404
   */
405
  private HyperlinkModel createHyperlinkModel() {
406
    final var context = getMainPane().createProcessorContext();
407
    final var editor = getActiveTextEditor();
408
    final var textArea = editor.getTextArea();
409
    final var selectedText = textArea.getSelectedText();
410
411
    // Convert current paragraph to Markdown nodes.
412
    final var mp = MarkdownProcessor.create( context );
413
    final var p = textArea.getCurrentParagraph();
414
    final var paragraph = textArea.getText( p );
415
    final var node = mp.toNode( paragraph );
416
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
417
    final var link = visitor.process( node );
418
419
    if( link != null ) {
420
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
421
    }
422
423
    return createHyperlinkModel( link, selectedText );
424
  }
425
426
  private HyperlinkModel createHyperlinkModel(
427
    final Link link, final String selection ) {
428
429
    return link == null
430
      ? new HyperlinkModel( selection, "https://localhost" )
431
      : new HyperlinkModel( link );
432
  }
433
434
  public void insert‿heading_1() {
435
    insert‿heading( 1 );
436
  }
437
438
  public void insert‿heading_2() {
439
    insert‿heading( 2 );
440
  }
441
442
  public void insert‿heading_3() {
443
    insert‿heading( 3 );
444
  }
445
446
  private void insert‿heading( final int level ) {
447
    getActiveTextEditor().heading( level );
448
  }
449
450
  public void insert‿unordered_list() {
451
    getActiveTextEditor().unorderedList();
452
  }
453
454
  public void insert‿ordered_list() {
455
    getActiveTextEditor().orderedList();
456
  }
457
458
  public void insert‿horizontal_rule() {
459
    getActiveTextEditor().horizontalRule();
460
  }
461
462
  public void definition‿create() {
463
    getActiveTextDefinition().createDefinition();
464
  }
465
466
  public void definition‿rename() {
467
    getActiveTextDefinition().renameDefinition();
468
  }
469
470
  public void definition‿delete() {
471
    getActiveTextDefinition().deleteDefinitions();
472
  }
473
474
  public void definition‿autoinsert() {
475
    getMainPane().autoinsert();
476
  }
477
478
  public void view‿refresh() {
479
    getMainPane().viewRefresh();
480
  }
481
482
  public void view‿preview() {
483
    getMainPane().viewPreview();
484
  }
485
486
  public void view‿outline() {
487
    getMainPane().viewOutline();
488
  }
489
490
  public void view‿files() { getMainPane().viewFiles(); }
491
492
  public void view‿statistics() {
493
    getMainPane().viewStatistics();
494
  }
495
496
  public void view‿menubar() {
497
    getMainScene().toggleMenuBar();
498
  }
499
500
  public void view‿toolbar() {
501
    getMainScene().toggleToolBar();
502
  }
503
504
  public void view‿statusbar() {
505
    getMainScene().toggleStatusBar();
506
  }
507
508
  public void view‿log() {
509
    mLogView.view();
510
  }
511
512
  public void help‿about() {
513
    final var alert = new Alert( INFORMATION );
514
    final var prefix = "Dialog.about.";
515
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
516
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
517
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
518
    alert.setGraphic( ICON_DIALOG_NODE );
519
    alert.initOwner( getWindow() );
520
    alert.showAndWait();
521
  }
522
523
  /**
524
   * Concatenates all the files in the same directory as the given file into
525
   * a string. The extension is determined by the given file name pattern; the
526
   * order files are concatenated is based on their numeric sort order (this
527
   * avoids lexicographic sorting).
528
   * <p>
529
   * If the parent path to the file being edited in the text editor cannot
530
   * be found then this will return the editor's text, without iterating through
531
   * the parent directory. (Should never happen, but who knows?)
532
   * </p>
533
   * <p>
534
   * New lines are automatically appended to separate each file.
535
   * </p>
536
   *
537
   * @param editor The text editor containing
538
   * @return All files in the same directory as the file being edited
539
   * concatenated into a single string.
540
   */
541
  private String append( final TextEditor editor ) {
542
    final var pattern = editor.getPath();
543
    final var parent = pattern.getParent();
544
545
    // Short-circuit because nothing else can be done.
546
    if( parent == null ) {
547
      clue( "Main.status.export.concat.parent", pattern );
548
      return editor.getText();
549
    }
550
551
    final var filename = pattern.getFileName().toString();
552
    final var extension = getExtension( filename );
553
554
    if( extension == null || extension.isBlank() ) {
555
      clue( "Main.status.export.concat.extension", filename );
556
      return editor.getText();
557
    }
558
559
    try {
560
      final var glob = "**/*." + extension;
561
      final ArrayList<Path> files = new ArrayList<>();
562
      walk( parent, glob, files::add );
563
      files.sort( new AlphanumComparator<>() );
564
565
      final var text = new StringBuilder( DOCUMENT_LENGTH );
566
567
      files.forEach( ( file ) -> {
568
        try {
569
          clue( "Main.status.export.concat", file );
570
          text.append( readString( file ) );
571
        } catch( final IOException ex ) {
572
          clue( "Main.status.export.concat.io", file );
573
        }
574
      } );
575
576
      return text.toString();
577
    } catch( final Throwable t ) {
578
      clue( t );
579
      return editor.getText();
580
    }
474581
  }
475582
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
5858
        .addSubActions(
5959
          addAction( "file.export.pdf", e -> actions.file‿export‿pdf() ),
60
          addAction( "file.export.pdf.dir", e -> actions.file‿export‿pdf‿dir() ),
6061
          addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ),
6162
          addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ),
...
8788
      addAction( "format.bold", e -> actions.format‿bold() ),
8889
      addAction( "format.italic", e -> actions.format‿italic() ),
90
      addAction( "format.monospace", e -> actions.format‿monospace() ),
8991
      addAction( "format.superscript", e -> actions.format‿superscript() ),
9092
      addAction( "format.subscript", e -> actions.format‿subscript() ),
...
129131
      addAction( "view.statusbar", e -> actions.view‿statusbar() ),
130132
      SEPARATOR_ACTION,
131
      addAction( "view.issues", e -> actions.view‿issues() )
133
      addAction( "view.log", e -> actions.view‿log() )
132134
    ),
133135
    createMenu(
M src/main/java/com/keenwrite/ui/controls/EventedStatusBar.java
2828
  @Subscribe
2929
  public void handle( final StatusEvent event ) {
30
    final var m = event.getMessage() + event.getException();
30
    final var m = event.toString();
3131
3232
    // Don't burden the repaint thread if there's no status bar change.
M src/main/java/com/keenwrite/ui/dialogs/ThemePicker.java
88
import javafx.scene.control.ComboBox;
99
import javafx.scene.input.KeyCode;
10
import javafx.stage.Stage;
1011
1112
import java.io.File;
1213
import java.io.FileInputStream;
1314
import java.io.IOException;
1415
import java.nio.file.Path;
1516
import java.util.Properties;
1617
import java.util.TreeMap;
1718
1819
import static com.keenwrite.Messages.get;
20
import static com.keenwrite.constants.Constants.THEME_NAME_LENGTH;
21
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
1922
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
2023
import static com.keenwrite.events.StatusEvent.clue;
2124
import static com.keenwrite.util.FileWalker.walk;
2225
import static java.lang.Math.max;
26
import static org.codehaus.plexus.util.StringUtils.abbreviate;
2327
2428
/**
...
4347
    mThemes = themes;
4448
    mTheme = theme;
45
    setGraphic( ICON_DIALOG_NODE );
49
    initIcon();
4650
    setTitle( get( "Dialog.theme.title" ) );
4751
    setHeaderText( get( "Dialog.theme.header" ) );
...
5660
      }
5761
    } );
62
  }
63
64
  private void initIcon() {
65
    setGraphic( ICON_DIALOG_NODE );
66
67
    final var window = getDialogPane().getScene().getWindow();
68
    if( window instanceof Stage ) {
69
      ((Stage) window).getIcons().add( ICON_DIALOG );
70
    }
5871
  }
5972
...
88101
      walk( mThemes.toPath(), "**/theme.properties", ( path ) -> {
89102
        try {
90
          final var themeDisplay = readThemeName( path );
103
          final var displayed = readThemeName( path );
91104
          final var themeName = path.getParent().toFile().getName();
92
          choices.put( themeDisplay, themeName );
105
          choices.put( abbreviate( displayed, THEME_NAME_LENGTH ), themeName );
93106
94
          // Used to set the selected item to value from user's settings.
107
          // Set the selected item to user's settings value.
95108
          if( themeName.equals( mTheme.get() ) ) {
96
            selection[ 0 ] = themeDisplay;
109
            selection[ 0 ] = displayed;
97110
          }
98111
        } catch( final Exception ex ) {
99
          clue( get( "Main.status.error.theme.name", path ) );
112
          clue( "Main.status.error.theme.name", path );
100113
        }
101114
      } );
M src/main/java/com/keenwrite/ui/logging/LogView.java
1616
import java.util.TreeSet;
1717
18
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
18
import static com.keenwrite.Messages.get;
1919
import static com.keenwrite.constants.Constants.ACTION_PREFIX;
2020
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
21
import static com.keenwrite.Messages.get;
2221
import static com.keenwrite.events.Bus.register;
2322
import static com.keenwrite.events.StatusEvent.clue;
24
import static java.nio.file.Files.createTempFile;
25
import static java.nio.file.Files.write;
26
import static java.nio.file.StandardOpenOption.APPEND;
27
import static java.nio.file.StandardOpenOption.CREATE;
2823
import static java.time.LocalDateTime.now;
2924
import static java.time.format.DateTimeFormatter.ofPattern;
...
5550
  public LogView() {
5651
    super( INFORMATION );
57
    setTitle( get( ACTION_PREFIX + "view.issues.text" ) );
52
    setTitle( get( ACTION_PREFIX + "view.log.text" ) );
5853
    initModality( NONE );
5954
    initTableView();
...
150145
151146
  private void initIcon() {
152
    final var stage = getStage();
153
    stage.getIcons().add( ICON_DIALOG );
147
    getStage().getIcons().add( ICON_DIALOG );
154148
  }
155149
...
187181
    private StringProperty traceProperty() {
188182
      return mTrace;
189
    }
190
191
    /**
192
     * Call from constructor to save log message for debugging purposes.
193
     */
194
    @SuppressWarnings( "unused" )
195
    private void persist() {
196
      try {
197
        final var file = createTempFile( APP_TITLE_LOWERCASE, ".log" );
198
        write( file, toString().getBytes(), CREATE, APPEND );
199
      } catch( final Exception ignored ) {
200
        System.out.println( toString() );
201
      }
202183
    }
203184
A src/main/java/com/keenwrite/util/AlphanumComparator.java
1
/*
2
 * The Alphanum Algorithm is an improved sorting algorithm for strings
3
 * containing numbers. Rather than sort numbers in ASCII order like
4
 * a standard sort, this algorithm sorts numbers in numeric order.
5
 *
6
 * The Alphanum Algorithm is discussed at http://www.DaveKoelle.com
7
 *
8
 * Released under the MIT License - https://opensource.org/licenses/MIT
9
 *
10
 * Copyright 2007-2017 David Koelle
11
 *
12
 * Permission is hereby granted, free of charge, to any person obtaining
13
 * a copy of this software and associated documentation files (the "Software"),
14
 * to deal in the Software without restriction, including without limitation
15
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
16
 * and/or sell copies of the Software, and to permit persons to whom the
17
 * Software is furnished to do so, subject to the following conditions:
18
 *
19
 * The above copyright notice and this permission notice shall be included
20
 * in all copies or substantial portions of the Software.
21
 *
22
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
23
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
24
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
25
 * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
26
 * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
27
 * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
28
 * USE OR OTHER DEALINGS IN THE SOFTWARE.
29
 */
30
package com.keenwrite.util;
31
32
import java.util.Comparator;
33
34
import static java.lang.Character.isDigit;
35
36
/**
37
 * Responsible for sorting lists that may contain numeric values. Usage:
38
 * <pre>
39
 *   Collections.sort(list, new AlphanumComparator());
40
 * </pre>
41
 * <p>
42
 * Where "list" is the list to sort alphanumerically, not lexicographically.
43
 * </p>
44
 */
45
public final class AlphanumComparator<T> implements Comparator<T> {
46
  /**
47
   * Returns a chunk of text that is continuous with respect to digits or
48
   * non-digits.
49
   *
50
   * @param s      The string to compare.
51
   * @param length The string length, for improved efficiency.
52
   * @param marker The current index into a subset of the given string.
53
   * @return The substring {@code s} that is a continuous text chunk of the
54
   * same character type.
55
   */
56
  private StringBuilder chunk( final String s, final int length, int marker ) {
57
    assert s != null;
58
    assert length >= 0;
59
    assert marker < length;
60
61
    // Prevent any possible memory re-allocations by using the length.
62
    final var chunk = new StringBuilder( length );
63
    var c = s.charAt( marker );
64
    final var chunkType = isDigit( c );
65
66
    // While the character at the current position is the same type (numeric or
67
    // alphabetic), append the character to the current chunk.
68
    while( marker < length &&
69
      isDigit( c = s.charAt( marker++ ) ) == chunkType ) {
70
      chunk.append( c );
71
    }
72
73
    return chunk;
74
  }
75
76
  /**
77
   * Performs an alphanumeric comparison of two strings, sorting numerically
78
   * first when numbers are found within the string. If either argument is
79
   * {@code null}, this will return zero.
80
   *
81
   * @param o1 The object to compare against {@code s2}, converted to string.
82
   * @param o2 The object to compare against {@code s1}, converted to string.
83
   * @return a negative integer, zero, or a positive integer if the first
84
   * argument is less than, equal to, or greater than the second, respectively.
85
   */
86
  @Override
87
  public int compare( final T o1, final T o2 ) {
88
    if( o1 == null || o2 == null ) {
89
      return 0;
90
    }
91
92
    final var s1 = o1.toString();
93
    final var s2 = o2.toString();
94
    final var s1Length = s1.length();
95
    final var s2Length = s2.length();
96
97
    var thisMarker = 0;
98
    var thatMarker = 0;
99
100
    while( thisMarker < s1Length && thatMarker < s2Length ) {
101
      final var thisChunk = chunk( s1, s1Length, thisMarker );
102
      final var thisChunkLength = thisChunk.length();
103
      thisMarker += thisChunkLength;
104
      final var thatChunk = chunk( s2, s2Length, thatMarker );
105
      final var thatChunkLength = thatChunk.length();
106
      thatMarker += thatChunkLength;
107
108
      // If both chunks contain numeric characters, sort them numerically
109
      int result;
110
111
      if( isDigit( thisChunk.charAt( 0 ) ) &&
112
        isDigit( thatChunk.charAt( 0 ) ) ) {
113
        // If equal, the first different number counts
114
        if( (result = thisChunkLength - thatChunkLength) == 0 ) {
115
          for( var i = 0; i < thisChunkLength; i++ ) {
116
            if( (result = thisChunk.charAt( i ) - thatChunk.charAt( i )) != 0 ) {
117
              return result;
118
            }
119
          }
120
        }
121
      }
122
      else {
123
        result = thisChunk.compareTo( thatChunk );
124
      }
125
126
      if( result != 0 ) {
127
        return result;
128
      }
129
    }
130
131
    return s1Length - s2Length;
132
  }
133
}
1134
M src/main/resources/com/keenwrite/messages.properties
3838
workspace.document.date.title=Timestamp
3939
40
workspace.r=R
41
workspace.r.script=Startup Script
42
workspace.r.script.desc=Script runs prior to executing R statements within the document.
43
workspace.r.dir=Working Directory
44
workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script.
45
workspace.r.dir.title=Directory
46
workspace.r.delimiter.began=Delimiter Prefix
47
workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables.
48
workspace.r.delimiter.began.title=Opening
49
workspace.r.delimiter.ended=Delimiter Suffix
50
workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables.
51
workspace.r.delimiter.ended.title=Closing
52
53
workspace.images=Images
54
workspace.images.dir=Absolute Directory
55
workspace.images.dir.desc=Path to search for local file system images.
56
workspace.images.dir.title=Directory
57
workspace.images.order=Extensions
58
workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
59
workspace.images.order.title=Extensions
60
61
workspace.definition=Variable
62
workspace.definition.path=File name
63
workspace.definition.path.desc=Absolute path to interpolated string variables.
64
workspace.definition.path.title=Path
65
workspace.definition.delimiter.began=Delimiter Prefix
66
workspace.definition.delimiter.began.desc=Indicates when a variable name is starting.
67
workspace.definition.delimiter.began.title=Opening
68
workspace.definition.delimiter.ended=Delimiter Suffix
69
workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending.
70
workspace.definition.delimiter.ended.title=Closing
71
72
workspace.ui.skin=Skins
73
workspace.ui.skin.selection=Bundled
74
workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light).
75
workspace.ui.skin.selection.title=Name
76
workspace.ui.skin.custom=Custom
77
workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
78
workspace.ui.skin.custom.title=Path
79
80
workspace.ui.font=Fonts
81
workspace.ui.font.editor=Editor Font
82
workspace.ui.font.editor.name=Name
83
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
84
workspace.ui.font.editor.name.title=Family
85
workspace.ui.font.editor.size=Size
86
workspace.ui.font.editor.size.desc=Font size.
87
workspace.ui.font.editor.size.title=Points
88
workspace.ui.font.preview=Preview Font
89
workspace.ui.font.preview.name=Name
90
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
91
workspace.ui.font.preview.name.title=Family
92
workspace.ui.font.preview.size=Size
93
workspace.ui.font.preview.size.desc=Font size.
94
workspace.ui.font.preview.size.title=Points
95
workspace.ui.font.preview.mono.name=Name
96
workspace.ui.font.preview.mono.name.desc=Monospace font name.
97
workspace.ui.font.preview.mono.name.title=Family
98
workspace.ui.font.preview.mono.size=Size
99
workspace.ui.font.preview.mono.size.desc=Monospace font size.
100
workspace.ui.font.preview.mono.size.title=Points
101
102
workspace.language=Language
103
workspace.language.locale=Internationalization
104
workspace.language.locale.desc=Language for application and HTML export.
105
workspace.language.locale.title=Locale
106
107
workspace.typeset=Typesetting
108
workspace.typeset.context=ConTeXt
109
workspace.typeset.context.themes.path=Paths
110
workspace.typeset.context.themes.path.desc=Directory containing theme subdirectories.
111
workspace.typeset.context.themes.path.title=Themes
112
113
# ########################################################################
114
# Menu Bar
115
# ########################################################################
116
117
Main.menu.file=_File
118
Main.menu.edit=_Edit
119
Main.menu.insert=_Insert
120
Main.menu.format=Forma_t
121
Main.menu.definition=_Variable
122
Main.menu.view=Vie_w
123
Main.menu.help=_Help
124
125
# ########################################################################
126
# Detachable Tabs
127
# ########################################################################
128
129
# {0} is the application title; {1} is a unique window ID.
130
Detach.tab.title={0} - {1}
131
132
# ########################################################################
133
# Status Bar
134
# ########################################################################
135
136
Main.status.text.offset=offset
137
Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
138
Main.status.state.default=OK
139
Main.status.export.success=Saved as ''{0}''
140
141
Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
142
143
Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
144
Main.status.error.def.blank=Move the caret to a word before inserting a variable
145
Main.status.error.def.empty=Create a variable before inserting one
146
Main.status.error.def.missing=No variable value found for ''{0}''
147
Main.status.error.r=Error with [{0}...]: {1}
148
Main.status.error.file.missing=Not found: ''{0}''
149
150
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
151
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
152
153
Main.status.error.undo=Cannot undo; beginning of undo history reached
154
Main.status.error.redo=Cannot redo; end of redo history reached
155
156
Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'')
157
Main.status.error.theme.name=Cannot find theme name for ''{0}''
158
159
Main.status.image.request.init=Initializing HTTP request
160
Main.status.image.request.fetch=Requesting content type from ''{0}''
161
Main.status.image.request.success=Determined content type ''{0}''
162
Main.status.image.request.error.media=No media type for ''{0}''
163
Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
164
165
Main.status.font.search.missing=No font name starting with ''{0}'' was found
166
167
Main.status.typeset.create=Creating typesetter
168
Main.status.typeset.xhtml=Export document as XHTML
169
Main.status.typeset.began=Started typesetting ''{0}''
170
Main.status.typeset.failed=Could not generate PDF file
171
Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
172
Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
173
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
174
175
# ########################################################################
176
# Search Bar
177
# ########################################################################
178
179
Main.search.stop.tooltip=Close search bar
180
Main.search.stop.icon=CLOSE
181
Main.search.next.tooltip=Find next match
182
Main.search.next.icon=CHEVRON_DOWN
183
Main.search.prev.tooltip=Find previous match
184
Main.search.prev.icon=CHEVRON_UP
185
Main.search.find.tooltip=Search document for text
186
Main.search.find.icon=SEARCH
187
Main.search.match.none=No matches
188
Main.search.match.some={0} of {1} matches
189
190
# ########################################################################
191
# Definition Pane and its Tree View
192
# ########################################################################
193
194
Definition.menu.add.default=Undefined
195
196
# ########################################################################
197
# Variable Definitions Pane
198
# ########################################################################
199
200
Pane.definition.node.root.title=Variables
201
202
# ########################################################################
203
# HTML Preview Pane
204
# ########################################################################
205
206
Pane.preview.title=Preview
207
208
# ########################################################################
209
# Document Outline Pane
210
# ########################################################################
211
212
Pane.outline.title=Outline
213
214
# ########################################################################
215
# File Manager Pane
216
# ########################################################################
217
218
Pane.files.title=Files
219
220
# ########################################################################
221
# Document Outline Pane
222
# ########################################################################
223
224
Pane.statistics.title=Statistics
225
226
# ########################################################################
227
# Failure messages with respect to YAML files.
228
# ########################################################################
229
230
yaml.error.open=Could not open YAML file (ensure non-empty file).
231
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
232
yaml.error.missing=Empty variable value for key ''{0}''.
233
yaml.error.tree.form=Unassigned variable near ''{0}''.
234
235
# ########################################################################
236
# Text Resource
237
# ########################################################################
238
239
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
240
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
241
242
# ########################################################################
243
# Text Resources
244
# ########################################################################
245
246
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
247
TextResource.saveFailed.title=Save
248
249
# ########################################################################
250
# File Open
251
# ########################################################################
252
253
Dialog.file.choose.open.title=Open File
254
Dialog.file.choose.save.title=Save File
255
Dialog.file.choose.export.title=Export File
256
257
Dialog.file.choose.filter.title.source=Source Files
258
Dialog.file.choose.filter.title.definition=Variable Files
259
Dialog.file.choose.filter.title.xml=XML Files
260
Dialog.file.choose.filter.title.all=All Files
261
262
# ########################################################################
263
# Browse File
264
# ########################################################################
265
266
BrowseFileButton.chooser.title=Open local file
267
BrowseFileButton.chooser.allFilesFilter=All Files
268
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
269
270
# ########################################################################
271
# Browse Directory
272
# ########################################################################
273
274
BrowseDirectoryButton.chooser.title=Open local directory
275
BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
276
277
# ########################################################################
278
# Alert Dialog
279
# ########################################################################
280
281
Alert.file.close.title=Close
282
Alert.file.close.text=Save changes to {0}?
283
284
# ########################################################################
285
# Typesetting Alert Dialog
286
# ########################################################################
287
288
Alert.typesetter.missing.title=Missing Typesetter
289
Alert.typesetter.missing.header=Install typesetter
290
Alert.typesetter.missing.version=for {0} {1} {2}-bit
291
Alert.typesetter.missing.installer.text=Download and install ConTeXt
292
Alert.typesetter.missing.installer.url=https://wiki.contextgarden.net/Installation
293
294
# ########################################################################
295
# Image Dialog
296
# ########################################################################
297
298
Dialog.image.title=Image
299
Dialog.image.chooser.imagesFilter=Images
300
Dialog.image.previewLabel.text=Markdown Preview\:
301
Dialog.image.textLabel.text=Alternate Text\:
302
Dialog.image.titleLabel.text=Title (tooltip)\:
303
Dialog.image.urlLabel.text=Image URL\:
304
305
# ########################################################################
306
# Hyperlink Dialog
307
# ########################################################################
308
309
Dialog.link.title=Link
310
Dialog.link.previewLabel.text=Markdown Preview\:
311
Dialog.link.textLabel.text=Link Text\:
312
Dialog.link.titleLabel.text=Title (tooltip)\:
313
Dialog.link.urlLabel.text=Link URL\:
314
315
# ########################################################################
316
# Themes Dialog
317
# ########################################################################
318
319
Dialog.theme.title=Typesetting theme
320
Dialog.theme.header=Choose a typesetting theme
321
322
# ########################################################################
323
# About Dialog
324
# ########################################################################
325
326
Dialog.about.title=About {0}
327
Dialog.about.header={0}
328
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
329
330
# ########################################################################
331
# Application Actions
332
# ########################################################################
333
334
Action.file.new.description=Create a new file
335
Action.file.new.accelerator=Shortcut+N
336
Action.file.new.icon=FILE_ALT
337
Action.file.new.text=_New
338
339
Action.file.open.description=Open a new file
340
Action.file.open.accelerator=Shortcut+O
341
Action.file.open.text=_Open...
342
Action.file.open.icon=FOLDER_OPEN_ALT
343
344
Action.file.close.description=Close the current document
345
Action.file.close.accelerator=Shortcut+W
346
Action.file.close.text=_Close
347
348
Action.file.close_all.description=Close all open documents
349
Action.file.close_all.accelerator=Ctrl+F4
350
Action.file.close_all.text=Close All
351
352
Action.file.save.description=Save the document
353
Action.file.save.accelerator=Shortcut+S
354
Action.file.save.text=_Save
355
Action.file.save.icon=FLOPPY_ALT
356
357
Action.file.save_as.description=Rename the current document
358
Action.file.save_as.text=Save _As
359
360
Action.file.save_all.description=Save all open documents
361
Action.file.save_all.accelerator=Shortcut+Shift+S
362
Action.file.save_all.text=Save A_ll
363
364
Action.file.export.pdf.description=Typeset the document
365
Action.file.export.pdf.accelerator=Shortcut+P
366
Action.file.export.pdf.text=_PDF
367
Action.file.export.pdf.icon=FILE_PDF_ALT
368
369
Action.file.export.html_svg.description=Export the current document as HTML + SVG
370
Action.file.export.text=_Export As
371
Action.file.export.html_svg.text=HTML and S_VG
372
373
Action.file.export.html_tex.description=Export the current document as HTML + TeX
374
Action.file.export.html_tex.text=HTML and _TeX
375
376
Action.file.export.xhtml_tex.description=Export as XHTML + TeX
377
Action.file.export.xhtml_tex.text=_XHTML and TeX
378
379
Action.file.export.markdown.description=Export the current document as Markdown
380
Action.file.export.markdown.text=Markdown
381
382
Action.file.exit.description=Quit the application
383
Action.file.exit.text=E_xit
384
385
386
Action.edit.undo.description=Undo the previous edit
387
Action.edit.undo.accelerator=Shortcut+Z
388
Action.edit.undo.text=_Undo
389
Action.edit.undo.icon=UNDO
390
391
Action.edit.redo.description=Redo the previous edit
392
Action.edit.redo.accelerator=Shortcut+Y
393
Action.edit.redo.text=_Redo
394
Action.edit.redo.icon=REPEAT
395
396
Action.edit.cut.description=Delete the selected text or line
397
Action.edit.cut.accelerator=Shortcut+X
398
Action.edit.cut.text=Cu_t
399
Action.edit.cut.icon=CUT
400
401
Action.edit.copy.description=Copy the selected text
402
Action.edit.copy.accelerator=Shortcut+C
403
Action.edit.copy.text=_Copy
404
Action.edit.copy.icon=COPY
405
406
Action.edit.paste.description=Paste from the clipboard
407
Action.edit.paste.accelerator=Shortcut+V
408
Action.edit.paste.text=_Paste
409
Action.edit.paste.icon=PASTE
410
411
Action.edit.select_all.description=Highlight the current document text
412
Action.edit.select_all.accelerator=Shortcut+A
413
Action.edit.select_all.text=Select _All
414
415
Action.edit.find.description=Search for text in the document
416
Action.edit.find.accelerator=Shortcut+F
417
Action.edit.find.text=_Find
418
Action.edit.find.icon=SEARCH
419
420
Action.edit.find_next.description=Find next occurrence
421
Action.edit.find_next.accelerator=F3
422
Action.edit.find_next.text=Find _Next
423
424
Action.edit.find_prev.description=Find previous occurrence
425
Action.edit.find_prev.accelerator=Shift+F3
426
Action.edit.find_prev.text=Find _Prev
427
428
Action.edit.preferences.description=Edit user preferences
429
Action.edit.preferences.accelerator=Ctrl+Alt+S
430
Action.edit.preferences.text=_Preferences
431
432
433
Action.format.bold.description=Insert strong text
434
Action.format.bold.accelerator=Shortcut+B
435
Action.format.bold.text=_Bold
436
Action.format.bold.icon=BOLD
437
438
Action.format.italic.description=Insert text emphasis
439
Action.format.italic.accelerator=Shortcut+I
440
Action.format.italic.text=_Italic
441
Action.format.italic.icon=ITALIC
442
443
Action.format.superscript.description=Insert superscript text
444
Action.format.superscript.accelerator=Shortcut+[
445
Action.format.superscript.text=Su_perscript
446
Action.format.superscript.icon=SUPERSCRIPT
447
448
Action.format.subscript.description=Insert subscript text
449
Action.format.subscript.accelerator=Shortcut+]
450
Action.format.subscript.text=Su_bscript
451
Action.format.subscript.icon=SUBSCRIPT
452
453
Action.format.strikethrough.description=Insert struck text
454
Action.format.strikethrough.accelerator=Shortcut+T
455
Action.format.strikethrough.text=Stri_kethrough
456
Action.format.strikethrough.icon=STRIKETHROUGH
457
458
459
Action.insert.blockquote.description=Insert blockquote
460
Action.insert.blockquote.accelerator=Ctrl+Q
461
Action.insert.blockquote.text=_Blockquote
462
Action.insert.blockquote.icon=QUOTE_LEFT
463
464
Action.insert.code.description=Insert inline code
465
Action.insert.code.accelerator=Shortcut+K
466
Action.insert.code.text=Inline _Code
467
Action.insert.code.icon=CODE
468
469
Action.insert.fenced_code_block.description=Insert code block
470
Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
471
Action.insert.fenced_code_block.text=_Fenced Code Block
472
Action.insert.fenced_code_block.prompt.text=Enter code here
473
Action.insert.fenced_code_block.icon=FILE_CODE_ALT
474
475
Action.insert.link.description=Insert hyperlink
476
Action.insert.link.accelerator=Shortcut+L
477
Action.insert.link.text=_Link...
478
Action.insert.link.icon=LINK
479
480
Action.insert.image.description=Insert image
481
Action.insert.image.accelerator=Shortcut+G
482
Action.insert.image.text=_Image...
483
Action.insert.image.icon=PICTURE_ALT
484
485
Action.insert.heading.description=Insert heading level
486
Action.insert.heading.accelerator=Shortcut+
487
Action.insert.heading.icon=HEADER
488
489
Action.insert.heading_1.description=${Action.insert.heading.description} 1
490
Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1
491
Action.insert.heading_1.text=Heading _1
492
Action.insert.heading_1.icon=${Action.insert.heading.icon}
493
494
Action.insert.heading_2.description=${Action.insert.heading.description} 2
495
Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2
496
Action.insert.heading_2.text=Heading _2
497
Action.insert.heading_2.icon=${Action.insert.heading.icon}
498
499
Action.insert.heading_3.description=${Action.insert.heading.description} 3
500
Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3
501
Action.insert.heading_3.text=Heading _3
502
Action.insert.heading_3.icon=${Action.insert.heading.icon}
503
504
Action.insert.unordered_list.description=Insert bulleted list
505
Action.insert.unordered_list.accelerator=Shortcut+U
506
Action.insert.unordered_list.text=_Unordered List
507
Action.insert.unordered_list.icon=LIST_UL
508
509
Action.insert.ordered_list.description=Insert enumerated list
510
Action.insert.ordered_list.accelerator=Shortcut+Shift+O
511
Action.insert.ordered_list.text=_Ordered List
512
Action.insert.ordered_list.icon=LIST_OL
513
514
Action.insert.horizontal_rule.description=Insert horizontal rule
515
Action.insert.horizontal_rule.accelerator=Shortcut+H
516
Action.insert.horizontal_rule.text=_Horizontal Rule
517
Action.insert.horizontal_rule.icon=LIST_OL
518
519
520
Action.definition.create.description=Create a new variable
521
Action.definition.create.text=_Create
522
Action.definition.create.icon=TREE
523
Action.definition.create.tooltip=Add new item (Insert)
524
525
Action.definition.rename.description=Rename the selected variable
526
Action.definition.rename.text=_Rename
527
Action.definition.rename.icon=EDIT
528
Action.definition.rename.tooltip=Rename selected item (F2)
529
530
Action.definition.delete.description=Delete the selected variables
531
Action.definition.delete.text=De_lete
532
Action.definition.delete.icon=TRASH
533
Action.definition.delete.tooltip=Delete selected items (Delete)
534
535
Action.definition.insert.description=Insert a variable
536
Action.definition.insert.accelerator=Ctrl+Space
537
Action.definition.insert.text=_Insert
538
Action.definition.insert.icon=STAR
539
540
541
Action.view.refresh.description=Clear all caches
542
Action.view.refresh.accelerator=F5
543
Action.view.refresh.text=Refresh
544
545
Action.view.preview.description=Open document preview
546
Action.view.preview.accelerator=F6
547
Action.view.preview.text=Preview
548
549
Action.view.outline.description=Open document outline
550
Action.view.outline.accelerator=F7
551
Action.view.outline.text=Outline
552
553
Action.view.statistics.description=Open document word counts
554
Action.view.statistics.accelerator=F8
555
Action.view.statistics.text=Statistics
556
557
Action.view.files.description=Open file manager
558
Action.view.files.accelerator=Ctrl+F8
559
Action.view.files.text=Files
560
561
Action.view.menubar.description=Toggle menu bar
562
Action.view.menubar.accelerator=Ctrl+F9
563
Action.view.menubar.text=Menu bar
564
565
Action.view.toolbar.description=Toggle tool bar
566
Action.view.toolbar.accelerator=Ctrl+Shift+F9
567
Action.view.toolbar.text=Tool bar
568
569
Action.view.statusbar.description=Toggle status bar
570
Action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9
571
Action.view.statusbar.text=Status bar
572
573
Action.view.issues.description=Open document issues
574
Action.view.issues.accelerator=F12
575
Action.view.issues.text=Issues
40
workspace.typeset=Typesetting
41
workspace.typeset.context=ConTeXt
42
workspace.typeset.context.themes.path=Paths
43
workspace.typeset.context.themes.path.desc=Directory containing theme subdirectories.
44
workspace.typeset.context.themes.path.title=Themes
45
workspace.typeset.context.clean=Clean
46
workspace.typeset.context.clean.desc=Delete ancillary files after an unsuccessful export.
47
workspace.typeset.context.clean.title=Purge
48
49
workspace.r=R
50
workspace.r.script=Startup Script
51
workspace.r.script.desc=Script runs prior to executing R statements within the document.
52
workspace.r.dir=Working Directory
53
workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script.
54
workspace.r.dir.title=Directory
55
workspace.r.delimiter.began=Delimiter Prefix
56
workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables.
57
workspace.r.delimiter.began.title=Opening
58
workspace.r.delimiter.ended=Delimiter Suffix
59
workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables.
60
workspace.r.delimiter.ended.title=Closing
61
62
workspace.images=Images
63
workspace.images.dir=Absolute Directory
64
workspace.images.dir.desc=Path to search for local file system images.
65
workspace.images.dir.title=Directory
66
workspace.images.order=Extensions
67
workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
68
workspace.images.order.title=Extensions
69
workspace.images.resize=Resize
70
workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically.
71
workspace.images.resize.title=Resize
72
workspace.images.server=Diagram Server
73
workspace.images.server.desc=Server used to generate diagrams (e.g., kroki.io).
74
workspace.images.server.title=Name
75
76
workspace.definition=Variable
77
workspace.definition.path=File name
78
workspace.definition.path.desc=Absolute path to interpolated string variables.
79
workspace.definition.path.title=Path
80
workspace.definition.delimiter.began=Delimiter Prefix
81
workspace.definition.delimiter.began.desc=Indicates when a variable name is starting.
82
workspace.definition.delimiter.began.title=Opening
83
workspace.definition.delimiter.ended=Delimiter Suffix
84
workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending.
85
workspace.definition.delimiter.ended.title=Closing
86
87
workspace.ui.skin=Skins
88
workspace.ui.skin.selection=Bundled
89
workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light).
90
workspace.ui.skin.selection.title=Name
91
workspace.ui.skin.custom=Custom
92
workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
93
workspace.ui.skin.custom.title=Path
94
95
workspace.ui.font=Fonts
96
workspace.ui.font.editor=Editor Font
97
workspace.ui.font.editor.name=Name
98
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
99
workspace.ui.font.editor.name.title=Family
100
workspace.ui.font.editor.size=Size
101
workspace.ui.font.editor.size.desc=Font size.
102
workspace.ui.font.editor.size.title=Points
103
workspace.ui.font.preview=Preview Font
104
workspace.ui.font.preview.name=Name
105
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
106
workspace.ui.font.preview.name.title=Family
107
workspace.ui.font.preview.size=Size
108
workspace.ui.font.preview.size.desc=Font size.
109
workspace.ui.font.preview.size.title=Points
110
workspace.ui.font.preview.mono.name=Name
111
workspace.ui.font.preview.mono.name.desc=Monospace font name.
112
workspace.ui.font.preview.mono.name.title=Family
113
workspace.ui.font.preview.mono.size=Size
114
workspace.ui.font.preview.mono.size.desc=Monospace font size.
115
workspace.ui.font.preview.mono.size.title=Points
116
117
workspace.language=Language
118
workspace.language.locale=Internationalization
119
workspace.language.locale.desc=Language for application and HTML export.
120
workspace.language.locale.title=Locale
121
122
# ########################################################################
123
# Menu Bar
124
# ########################################################################
125
126
Main.menu.file=_File
127
Main.menu.edit=_Edit
128
Main.menu.insert=_Insert
129
Main.menu.format=Forma_t
130
Main.menu.definition=_Variable
131
Main.menu.view=Vie_w
132
Main.menu.help=_Help
133
134
# ########################################################################
135
# Detachable Tabs
136
# ########################################################################
137
138
# {0} is the application title; {1} is a unique window ID.
139
Detach.tab.title={0} - {1}
140
141
# ########################################################################
142
# Status Bar
143
# ########################################################################
144
145
Main.status.text.offset=offset
146
Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
147
Main.status.state.default=OK
148
Main.status.export.success=Saved as ''{0}''
149
150
Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
151
152
Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
153
Main.status.error.def.blank=Move the caret to a word before inserting a variable
154
Main.status.error.def.empty=Create a variable before inserting one
155
Main.status.error.def.missing=No variable value found for ''{0}''
156
Main.status.error.r=Error with [{0}...]: {1}
157
Main.status.error.file.missing=Not found: ''{0}''
158
Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
159
160
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
161
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
162
163
Main.status.error.undo=Cannot undo; beginning of undo history reached
164
Main.status.error.redo=Cannot redo; end of redo history reached
165
166
Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'')
167
Main.status.error.theme.name=Cannot find theme name for ''{0}''
168
169
Main.status.image.request.init=Initializing HTTP request
170
Main.status.image.request.fetch=Requesting content type from ''{0}''
171
Main.status.image.request.success=Determined content type ''{0}''
172
Main.status.image.request.error.media=No media type for ''{0}''
173
Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
174
175
Main.status.font.search.missing=No font name starting with ''{0}'' was found
176
177
Main.status.export.concat=Concatenating ''{0}''
178
Main.status.export.concat.parent=No parent directory found for ''{0}''
179
Main.status.export.concat.extension=File name must have an extension ''{0}''
180
Main.status.export.concat.io=Could not read from ''{0}''
181
182
Main.status.typeset.create=Creating typesetter
183
Main.status.typeset.xhtml=Export document as XHTML
184
Main.status.typeset.began=Started typesetting ''{0}''
185
Main.status.typeset.failed=Could not generate PDF file
186
Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
187
Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
188
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
189
190
# ########################################################################
191
# Search Bar
192
# ########################################################################
193
194
Main.search.stop.tooltip=Close search bar
195
Main.search.stop.icon=CLOSE
196
Main.search.next.tooltip=Find next match
197
Main.search.next.icon=CHEVRON_DOWN
198
Main.search.prev.tooltip=Find previous match
199
Main.search.prev.icon=CHEVRON_UP
200
Main.search.find.tooltip=Search document for text
201
Main.search.find.icon=SEARCH
202
Main.search.match.none=No matches
203
Main.search.match.some={0} of {1} matches
204
205
# ########################################################################
206
# Definition Pane and its Tree View
207
# ########################################################################
208
209
Definition.menu.add.default=Undefined
210
211
# ########################################################################
212
# Variable Definitions Pane
213
# ########################################################################
214
215
Pane.definition.node.root.title=Variables
216
217
# ########################################################################
218
# HTML Preview Pane
219
# ########################################################################
220
221
Pane.preview.title=Preview
222
223
# ########################################################################
224
# Document Outline Pane
225
# ########################################################################
226
227
Pane.outline.title=Outline
228
229
# ########################################################################
230
# File Manager Pane
231
# ########################################################################
232
233
Pane.files.title=Files
234
235
# ########################################################################
236
# Document Outline Pane
237
# ########################################################################
238
239
Pane.statistics.title=Statistics
240
241
# ########################################################################
242
# Failure messages with respect to YAML files.
243
# ########################################################################
244
245
yaml.error.open=Could not open YAML file (ensure non-empty file).
246
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
247
yaml.error.missing=Empty variable value for key ''{0}''.
248
yaml.error.tree.form=Unassigned variable near ''{0}''.
249
250
# ########################################################################
251
# Text Resource
252
# ########################################################################
253
254
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
255
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
256
257
# ########################################################################
258
# Text Resources
259
# ########################################################################
260
261
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
262
TextResource.saveFailed.title=Save
263
264
# ########################################################################
265
# File Open
266
# ########################################################################
267
268
Dialog.file.choose.open.title=Open File
269
Dialog.file.choose.save.title=Save File
270
Dialog.file.choose.export.title=Export File
271
272
Dialog.file.choose.filter.title.source=Source Files
273
Dialog.file.choose.filter.title.definition=Variable Files
274
Dialog.file.choose.filter.title.xml=XML Files
275
Dialog.file.choose.filter.title.all=All Files
276
277
# ########################################################################
278
# Browse File
279
# ########################################################################
280
281
BrowseFileButton.chooser.title=Open local file
282
BrowseFileButton.chooser.allFilesFilter=All Files
283
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
284
285
# ########################################################################
286
# Browse Directory
287
# ########################################################################
288
289
BrowseDirectoryButton.chooser.title=Open local directory
290
BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
291
292
# ########################################################################
293
# Alert Dialog
294
# ########################################################################
295
296
Alert.file.close.title=Close
297
Alert.file.close.text=Save changes to {0}?
298
299
# ########################################################################
300
# Typesetting Alert Dialog
301
# ########################################################################
302
303
Alert.typesetter.missing.title=Missing Typesetter
304
Alert.typesetter.missing.header=Install typesetter
305
Alert.typesetter.missing.version=for {0} {1} {2}-bit
306
Alert.typesetter.missing.installer.text=Download and install ConTeXt
307
Alert.typesetter.missing.installer.url=https://wiki.contextgarden.net/Installation
308
309
# ########################################################################
310
# Image Dialog
311
# ########################################################################
312
313
Dialog.image.title=Image
314
Dialog.image.chooser.imagesFilter=Images
315
Dialog.image.previewLabel.text=Markdown Preview\:
316
Dialog.image.textLabel.text=Alternate Text\:
317
Dialog.image.titleLabel.text=Title (tooltip)\:
318
Dialog.image.urlLabel.text=Image URL\:
319
320
# ########################################################################
321
# Hyperlink Dialog
322
# ########################################################################
323
324
Dialog.link.title=Link
325
Dialog.link.previewLabel.text=Markdown Preview\:
326
Dialog.link.textLabel.text=Link Text\:
327
Dialog.link.titleLabel.text=Title (tooltip)\:
328
Dialog.link.urlLabel.text=Link URL\:
329
330
# ########################################################################
331
# Themes Dialog
332
# ########################################################################
333
334
Dialog.theme.title=Typesetting theme
335
Dialog.theme.header=Choose a typesetting theme
336
337
# ########################################################################
338
# About Dialog
339
# ########################################################################
340
341
Dialog.about.title=About {0}
342
Dialog.about.header={0}
343
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
344
345
# ########################################################################
346
# Application Actions
347
# ########################################################################
348
349
Action.file.new.description=Create a new file
350
Action.file.new.accelerator=Shortcut+N
351
Action.file.new.icon=FILE_ALT
352
Action.file.new.text=_New
353
354
Action.file.open.description=Open a new file
355
Action.file.open.accelerator=Shortcut+O
356
Action.file.open.text=_Open...
357
Action.file.open.icon=FOLDER_OPEN_ALT
358
359
Action.file.close.description=Close the current document
360
Action.file.close.accelerator=Shortcut+W
361
Action.file.close.text=_Close
362
363
Action.file.close_all.description=Close all open documents
364
Action.file.close_all.accelerator=Ctrl+F4
365
Action.file.close_all.text=Close All
366
367
Action.file.save.description=Save the document
368
Action.file.save.accelerator=Shortcut+S
369
Action.file.save.text=_Save
370
Action.file.save.icon=FLOPPY_ALT
371
372
Action.file.save_as.description=Rename the current document
373
Action.file.save_as.text=Save _As
374
375
Action.file.save_all.description=Save all open documents
376
Action.file.save_all.accelerator=Shortcut+Shift+S
377
Action.file.save_all.text=Save A_ll
378
379
Action.file.export.pdf.description=Typeset the document
380
Action.file.export.pdf.accelerator=Shortcut+P
381
Action.file.export.pdf.text=_PDF
382
Action.file.export.pdf.icon=FILE_PDF_ALT
383
384
Action.file.export.pdf.dir.description=Typeset files in document directory
385
Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P
386
Action.file.export.pdf.dir.text=_Joined PDF
387
Action.file.export.pdf.dir.icon=FILE_PDF_ALT
388
389
Action.file.export.html_svg.description=Export the current document as HTML + SVG
390
Action.file.export.text=_Export As
391
Action.file.export.html_svg.text=HTML and S_VG
392
393
Action.file.export.html_tex.description=Export the current document as HTML + TeX
394
Action.file.export.html_tex.text=HTML and _TeX
395
396
Action.file.export.xhtml_tex.description=Export as XHTML + TeX
397
Action.file.export.xhtml_tex.text=_XHTML and TeX
398
399
Action.file.export.markdown.description=Export the current document as Markdown
400
Action.file.export.markdown.text=Markdown
401
402
Action.file.exit.description=Quit the application
403
Action.file.exit.text=E_xit
404
405
406
Action.edit.undo.description=Undo the previous edit
407
Action.edit.undo.accelerator=Shortcut+Z
408
Action.edit.undo.text=_Undo
409
Action.edit.undo.icon=UNDO
410
411
Action.edit.redo.description=Redo the previous edit
412
Action.edit.redo.accelerator=Shortcut+Y
413
Action.edit.redo.text=_Redo
414
Action.edit.redo.icon=REPEAT
415
416
Action.edit.cut.description=Delete the selected text or line
417
Action.edit.cut.accelerator=Shortcut+X
418
Action.edit.cut.text=Cu_t
419
Action.edit.cut.icon=CUT
420
421
Action.edit.copy.description=Copy the selected text
422
Action.edit.copy.accelerator=Shortcut+C
423
Action.edit.copy.text=_Copy
424
Action.edit.copy.icon=COPY
425
426
Action.edit.paste.description=Paste from the clipboard
427
Action.edit.paste.accelerator=Shortcut+V
428
Action.edit.paste.text=_Paste
429
Action.edit.paste.icon=PASTE
430
431
Action.edit.select_all.description=Highlight the current document text
432
Action.edit.select_all.accelerator=Shortcut+A
433
Action.edit.select_all.text=Select _All
434
435
Action.edit.find.description=Search for text in the document
436
Action.edit.find.accelerator=Shortcut+F
437
Action.edit.find.text=_Find
438
Action.edit.find.icon=SEARCH
439
440
Action.edit.find_next.description=Find next occurrence
441
Action.edit.find_next.accelerator=F3
442
Action.edit.find_next.text=Find _Next
443
444
Action.edit.find_prev.description=Find previous occurrence
445
Action.edit.find_prev.accelerator=Shift+F3
446
Action.edit.find_prev.text=Find _Prev
447
448
Action.edit.preferences.description=Edit user preferences
449
Action.edit.preferences.accelerator=Ctrl+Alt+S
450
Action.edit.preferences.text=_Preferences
451
452
453
Action.format.bold.description=Insert strong text
454
Action.format.bold.accelerator=Shortcut+B
455
Action.format.bold.text=_Bold
456
Action.format.bold.icon=BOLD
457
458
Action.format.italic.description=Insert text emphasis
459
Action.format.italic.accelerator=Shortcut+I
460
Action.format.italic.text=_Italic
461
Action.format.italic.icon=ITALIC
462
463
Action.format.monospace.description=Insert monospace text
464
Action.format.monospace.accelerator=Shortcut+`
465
Action.format.monospace.text=_Monospace
466
467
Action.format.superscript.description=Insert superscript text
468
Action.format.superscript.accelerator=Shortcut+[
469
Action.format.superscript.text=Su_perscript
470
Action.format.superscript.icon=SUPERSCRIPT
471
472
Action.format.subscript.description=Insert subscript text
473
Action.format.subscript.accelerator=Shortcut+]
474
Action.format.subscript.text=Su_bscript
475
Action.format.subscript.icon=SUBSCRIPT
476
477
Action.format.strikethrough.description=Insert struck text
478
Action.format.strikethrough.accelerator=Shortcut+T
479
Action.format.strikethrough.text=Stri_kethrough
480
Action.format.strikethrough.icon=STRIKETHROUGH
481
482
483
Action.insert.blockquote.description=Insert blockquote
484
Action.insert.blockquote.accelerator=Ctrl+Q
485
Action.insert.blockquote.text=_Blockquote
486
Action.insert.blockquote.icon=QUOTE_LEFT
487
488
Action.insert.code.description=Insert inline code
489
Action.insert.code.accelerator=Shortcut+K
490
Action.insert.code.text=Inline _Code
491
Action.insert.code.icon=CODE
492
493
Action.insert.fenced_code_block.description=Insert code block
494
Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
495
Action.insert.fenced_code_block.text=_Fenced Code Block
496
Action.insert.fenced_code_block.prompt.text=Enter code here
497
Action.insert.fenced_code_block.icon=FILE_CODE_ALT
498
499
Action.insert.link.description=Insert hyperlink
500
Action.insert.link.accelerator=Shortcut+L
501
Action.insert.link.text=_Link...
502
Action.insert.link.icon=LINK
503
504
Action.insert.image.description=Insert image
505
Action.insert.image.accelerator=Shortcut+G
506
Action.insert.image.text=_Image...
507
Action.insert.image.icon=PICTURE_ALT
508
509
Action.insert.heading.description=Insert heading level
510
Action.insert.heading.accelerator=Shortcut+
511
Action.insert.heading.icon=HEADER
512
513
Action.insert.heading_1.description=${Action.insert.heading.description} 1
514
Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1
515
Action.insert.heading_1.text=Heading _1
516
Action.insert.heading_1.icon=${Action.insert.heading.icon}
517
518
Action.insert.heading_2.description=${Action.insert.heading.description} 2
519
Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2
520
Action.insert.heading_2.text=Heading _2
521
Action.insert.heading_2.icon=${Action.insert.heading.icon}
522
523
Action.insert.heading_3.description=${Action.insert.heading.description} 3
524
Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3
525
Action.insert.heading_3.text=Heading _3
526
Action.insert.heading_3.icon=${Action.insert.heading.icon}
527
528
Action.insert.unordered_list.description=Insert bulleted list
529
Action.insert.unordered_list.accelerator=Shortcut+U
530
Action.insert.unordered_list.text=_Unordered List
531
Action.insert.unordered_list.icon=LIST_UL
532
533
Action.insert.ordered_list.description=Insert enumerated list
534
Action.insert.ordered_list.accelerator=Shortcut+Shift+O
535
Action.insert.ordered_list.text=_Ordered List
536
Action.insert.ordered_list.icon=LIST_OL
537
538
Action.insert.horizontal_rule.description=Insert horizontal rule
539
Action.insert.horizontal_rule.accelerator=Shortcut+H
540
Action.insert.horizontal_rule.text=_Horizontal Rule
541
Action.insert.horizontal_rule.icon=LIST_OL
542
543
544
Action.definition.create.description=Create a new variable
545
Action.definition.create.text=_Create
546
Action.definition.create.icon=TREE
547
Action.definition.create.tooltip=Add new item (Insert)
548
549
Action.definition.rename.description=Rename the selected variable
550
Action.definition.rename.text=_Rename
551
Action.definition.rename.icon=EDIT
552
Action.definition.rename.tooltip=Rename selected item (F2)
553
554
Action.definition.delete.description=Delete the selected variables
555
Action.definition.delete.text=De_lete
556
Action.definition.delete.icon=TRASH
557
Action.definition.delete.tooltip=Delete selected items (Delete)
558
559
Action.definition.insert.description=Insert a variable
560
Action.definition.insert.accelerator=Ctrl+Space
561
Action.definition.insert.text=_Insert
562
Action.definition.insert.icon=STAR
563
564
565
Action.view.refresh.description=Clear all caches
566
Action.view.refresh.accelerator=F5
567
Action.view.refresh.text=Refresh
568
569
Action.view.preview.description=Open document preview
570
Action.view.preview.accelerator=F6
571
Action.view.preview.text=Preview
572
573
Action.view.outline.description=Open document outline
574
Action.view.outline.accelerator=F7
575
Action.view.outline.text=Outline
576
577
Action.view.statistics.description=Open document word counts
578
Action.view.statistics.accelerator=F8
579
Action.view.statistics.text=Statistics
580
581
Action.view.files.description=Open file manager
582
Action.view.files.accelerator=Ctrl+F8
583
Action.view.files.text=Files
584
585
Action.view.menubar.description=Toggle menu bar
586
Action.view.menubar.accelerator=Ctrl+F9
587
Action.view.menubar.text=Menu bar
588
589
Action.view.toolbar.description=Toggle tool bar
590
Action.view.toolbar.accelerator=Ctrl+Shift+F9
591
Action.view.toolbar.text=Tool bar
592
593
Action.view.statusbar.description=Toggle status bar
594
Action.view.statusbar.accelerator=Ctrl+Shift+Alt+F9
595
Action.view.statusbar.text=Status bar
596
597
Action.view.log.description=Open document issues
598
Action.view.log.accelerator=F12
599
Action.view.log.text=Log
576600
577601
A src/test/java/com/keenwrite/util/AlphanumComparatorTest.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.util;
3
4
import org.junit.jupiter.api.Test;
5
6
import java.util.ArrayList;
7
import java.util.Arrays;
8
import java.util.Collections;
9
10
import static org.junit.jupiter.api.Assertions.assertEquals;
11
12
/**
13
 * Responsible for testing the http://www.davekoelle.com/alphanum.html
14
 * implementation.
15
 */
16
class AlphanumComparatorTest {
17
18
  /**
19
   * Test that a randomly sorted list containing a mix of alphanumeric
20
   * characters ("chunks") will be sorted according to numeric and alphabetic
21
   * order.
22
   */
23
  @Test
24
  public void test_Sort_UnsortedList_SortedAlphanumerically() {
25
    final var expected = Arrays.asList(
26
      "10X Radonius",
27
      "20X Radonius",
28
      "20X Radonius Prime",
29
      "30X Radonius",
30
      "40X Radonius",
31
      "200X Radonius",
32
      "1000X Radonius Maximus",
33
      "Allegia 6R Clasteron",
34
      "Allegia 50 Clasteron",
35
      "Allegia 50B Clasteron",
36
      "Allegia 51 Clasteron",
37
      "Allegia 500 Clasteron",
38
      "Alpha 2",
39
      "Alpha 2A",
40
      "Alpha 2A-900",
41
      "Alpha 2A-8000",
42
      "Alpha 100",
43
      "Alpha 200",
44
      "Callisto Morphamax",
45
      "Callisto Morphamax 500",
46
      "Callisto Morphamax 600",
47
      "Callisto Morphamax 700",
48
      "Callisto Morphamax 5000",
49
      "Callisto Morphamax 6000 SE",
50
      "Callisto Morphamax 6000 SE2",
51
      "Callisto Morphamax 7000",
52
      "Xiph Xlater 5",
53
      "Xiph Xlater 40",
54
      "Xiph Xlater 50",
55
      "Xiph Xlater 58",
56
      "Xiph Xlater 300",
57
      "Xiph Xlater 500",
58
      "Xiph Xlater 2000",
59
      "Xiph Xlater 5000",
60
      "Xiph Xlater 10000"
61
    );
62
    final var actual = new ArrayList<>( expected );
63
64
    Collections.shuffle( actual );
65
    actual.sort( new AlphanumComparator<>() );
66
    assertEquals( expected, actual );
67
  }
68
}
169
D themes
11