| 106 | 106 | Run `./installer -h` to see all command-line options. |
| 107 | 107 | |
| 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 | ||
| 108 | 118 | # Versioning |
| 109 | 119 |
| 36 | 36 | ### Other |
| 37 | 37 | |
| 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: | |
| 39 | 39 | |
| 40 | 40 | ``` bash |
| 41 | java -jar keenwrite.jar | |
| 41 | java --illegal-access=permit -jar build/libs/keenwrite.jar 2> /dev/null | |
| 42 | 42 | ``` |
| 43 | ||
| 44 | The `--illegal-access=permit` is a temporary option until third-party libraries used by the text editor are updated or replaced. | |
| 43 | 45 | |
| 44 | 46 | ## Features |
| 45 | 47 | |
| 46 | 48 | The application offers: |
| 47 | 49 | |
| 48 | 50 | * User-defined interpolated strings |
| 49 | 51 | * Auto-complete variable names based on variable values |
| 52 | * High-quality PDF exports | |
| 50 | 53 | * Real-time spell check |
| 51 | 54 | * Real-time rendering of math using TeX notation |
| ... | ||
| 72 | 75 | |
| 73 | 76 | ## Screenshots |
| 74 | ||
| 75 | Diagrams that include variables: | |
| 76 | ||
| 77 |  | |
| 78 | ||
| 79 |  | |
| 80 | ||
| 81 | Poem with locale settings: | |
| 82 | ||
| 83 |  | |
| 84 | ||
| 85 | TeX equations with detached preview: | |
| 86 | ||
| 87 |  | |
| 88 | ||
| 89 | Document outline opened and docked in bottom-left corner: | |
| 90 | 77 | |
| 91 |  | |
| 78 | See [screenshots](docs/screenshots.md) for visuals. | |
| 92 | 79 | |
| 93 | 80 | ## License |
| 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 | ||
| 1 | 13 |
| 1 | # Variables | |
| 2 | ||
| 3 | Diagrams that include variables: | |
| 4 | ||
| 5 |  | |
| 6 | ||
| 7 |  | |
| 8 | ||
| 9 | # PDF themes | |
| 10 | ||
| 11 | In the background of the following screenshot, the editor shows a novel | |
| 12 | being edited: | |
| 13 | ||
| 14 |  | |
| 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 |  | |
| 34 | ||
| 35 | # Equations | |
| 36 | ||
| 37 | TeX equations with detached preview: | |
| 38 | ||
| 39 |  | |
| 40 | ||
| 41 | # Dockable tabs | |
| 42 | ||
| 43 | Document outline opened and docked in bottom-left corner: | |
| 44 | ||
| 45 |  | |
| 46 | ||
| 1 | 47 |
| 30 | 30 | ## Windows |
| 31 | 31 | |
| 32 | Proceed with a Windows installation of typesetting software as follows: | |
| 32 | Proceed with a Windows installation of the typesetting software as follows: | |
| 33 | 33 | |
| 34 | 34 | 1. Extract the `.zip` file into `C:\Users\%USERNAME%\AppData\Local\context` (the "root" directory) |
| ... | ||
| 104 | 104 | The theme is configured. |
| 105 | 105 | |
| 106 | # Typeset document | |
| 106 | # Typeset single document | |
| 107 | 107 | |
| 108 | 108 | Typeset a document as follows: |
| ... | ||
| 118 | 118 | |
| 119 | 119 | 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. | |
| 120 | 143 | |
| 121 | 144 | # Background |
| 159 | 159 | |
| 160 | 160 | 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 | |
| 162 | 162 | __EOT |
| 163 | 163 |
| 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 | ||
| 345 | 1 |
| 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 | ||
| 1 | 345 |
| 7 | 7 | |
| 8 | 8 | import static com.keenwrite.Bootstrap.*; |
| 9 | import static com.keenwrite.PermissiveCertificate.installTrustManager; | |
| 9 | 10 | import static java.lang.String.format; |
| 10 | 11 | |
| ... | ||
| 24 | 25 | */ |
| 25 | 26 | 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 | } | |
| 28 | 34 | } |
| 29 | 35 | |
| 30 | @SuppressWarnings("RedundantStringFormatCall") | |
| 36 | @SuppressWarnings( "RedundantStringFormatCall" ) | |
| 31 | 37 | private static void showAppInfo() { |
| 32 | 38 | out( format( "%s version %s", APP_TITLE, APP_VERSION ) ); |
| ... | ||
| 56 | 62 | } |
| 57 | 63 | |
| 58 | @SuppressWarnings("SameParameterValue") | |
| 64 | @SuppressWarnings( "SameParameterValue" ) | |
| 59 | 65 | private static Properties loadProperties( final String resource ) |
| 60 | 66 | throws IOException { |
| ... | ||
| 74 | 80 | private static InputStream getResourceAsStream( final String resource ) { |
| 75 | 81 | 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 ) ); | |
| 76 | 97 | } |
| 77 | 98 | } |
| 60 | 60 | @Override |
| 61 | 61 | 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. | |
| 63 | 64 | mWorkspace = new Workspace(); |
| 64 | 65 |
| 173 | 173 | mPreview = new HtmlPreview( workspace ); |
| 174 | 174 | mStatistics = new DocumentStatistics( workspace ); |
| 175 | mActiveTextEditor.set( new MarkdownEditor( workspace ) ); | |
| 175 | 176 | |
| 176 | 177 | open( bin( getRecentFiles() ) ); |
| 177 | 178 | viewPreview(); |
| 178 | 179 | setDividerPositions( calculateDividerPositions() ); |
| 179 | 180 | |
| 180 | 181 | // Once the main scene's window regains focus, update the active definition |
| 181 | 182 | // 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(); | |
| 189 | 189 | |
| 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 | } ) ); | |
| 199 | 198 | |
| 200 | 199 | register( this ); |
| 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 | } | |
| 1 | 74 |
| 210 | 210 | /** |
| 211 | 211 | * Default server name for rendering diagrams. |
| 212 | * <p> | |
| 213 | * TODO: Make this a preference so that local installs are possible. | |
| 214 | 212 | */ |
| 215 | 213 | public static final String DIAGRAM_SERVER_NAME = "kroki.io"; |
| 216 | 214 | |
| 217 | 215 | /** |
| 218 | 216 | * Application action messages properties prefix. |
| 219 | 217 | */ |
| 220 | 218 | 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; | |
| 221 | 224 | |
| 222 | 225 | /** |
| 157 | 157 | |
| 158 | 158 | /** |
| 159 | * Requests making the selected text, or word at caret, monospace. | |
| 160 | */ | |
| 161 | default void monospace() { } | |
| 162 | ||
| 163 | /** | |
| 159 | 164 | * Requests making the selected text, or word at caret, a superscript. |
| 160 | 165 | */ |
| 315 | 315 | |
| 316 | 316 | @Override |
| 317 | public void monospace() { | |
| 318 | enwrap( "`" ); | |
| 319 | } | |
| 320 | ||
| 321 | @Override | |
| 317 | 322 | public void superscript() { |
| 318 | 323 | enwrap( "^" ); |
| 4 | 4 | import com.keenwrite.MainApp; |
| 5 | 5 | |
| 6 | import java.util.List; | |
| 6 | 7 | import java.util.stream.Collectors; |
| 7 | 8 | |
| ... | ||
| 17 | 18 | * exceptions, state problems, parsing errors, and so forth. |
| 18 | 19 | */ |
| 19 | public class StatusEvent implements AppEvent { | |
| 20 | public final class StatusEvent implements AppEvent { | |
| 20 | 21 | private static final String PACKAGE_NAME = MainApp.class.getPackageName(); |
| 21 | 22 | |
| ... | ||
| 40 | 41 | */ |
| 41 | 42 | public StatusEvent( final String message ) { |
| 42 | this( message, null ); | |
| 43 | assert message != null; | |
| 44 | mMessage = message; | |
| 45 | mProblem = null; | |
| 43 | 46 | } |
| 44 | 47 | |
| 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 | ||
| 52 | 52 | public StatusEvent( final String message, final Throwable problem ) { |
| 53 | 53 | assert message != null; |
| 54 | assert problem != null; | |
| 54 | 55 | mMessage = message; |
| 55 | 56 | mProblem = problem; |
| ... | ||
| 78 | 79 | } |
| 79 | 80 | |
| 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 ) ); | |
| 82 | 87 | } |
| 83 | 88 | |
| ... | ||
| 105 | 110 | |
| 106 | 111 | // 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(); | |
| 110 | 115 | } |
| 111 | 116 | |
| 112 | 117 | final var className = problem.getClass().getSimpleName(); |
| 113 | 118 | 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() ); | |
| 115 | 120 | } |
| 116 | 121 | |
| ... | ||
| 133 | 138 | |
| 134 | 139 | /** |
| 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. | |
| 136 | 142 | * |
| 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. | |
| 139 | 144 | */ |
| 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 ); | |
| 142 | 147 | } |
| 143 | 148 | |
| 144 | 149 | /** |
| 145 | * Update the status bar with a pre-parsed message and exception. | |
| 150 | * Notifies listeners of an error. | |
| 146 | 151 | * |
| 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. | |
| 149 | 154 | */ |
| 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 ); | |
| 152 | 157 | } |
| 153 | 158 | |
| 154 | 159 | /** |
| 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. | |
| 156 | 172 | * |
| 157 | 173 | * @param problem The exception with a message to display to the user. |
| 158 | 174 | */ |
| 159 | 175 | public static void clue( final Throwable problem ) { |
| 160 | fireStatusEvent( "", problem ); | |
| 176 | fireStatusEvent( problem ); | |
| 161 | 177 | } |
| 162 | 178 | |
| 163 | 179 | private static void fireStatusEvent( final String message ) { |
| 164 | 180 | new StatusEvent( message ).fire(); |
| 181 | } | |
| 182 | ||
| 183 | private static void fireStatusEvent( final Throwable problem ) { | |
| 184 | new StatusEvent( problem ).fire(); | |
| 165 | 185 | } |
| 166 | 186 | |
| 38 | 38 | */ |
| 39 | 39 | public static Response httpGet( final URL url ) throws Exception { |
| 40 | return new Response(url); | |
| 40 | return new Response( url ); | |
| 41 | 41 | } |
| 42 | 42 | |
| ... | ||
| 74 | 74 | |
| 75 | 75 | clue( "Main.status.image.request.init" ); |
| 76 | final var connection = url.openConnection(); | |
| 77 | 76 | |
| 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 | ||
| 88 | 87 | clue( "Main.status.image.request.fetch", url.getHost() ); |
| 89 | 88 | |
| 90 | final var code = mConn.getResponseCode(); | |
| 89 | final var code = conn.getResponseCode(); | |
| 91 | 90 | |
| 92 | 91 | // Even though there are other "okay" error codes, tell the user when |
| 93 | 92 | // a resource has changed in any unexpected way. |
| 94 | 93 | if( code != HTTP_OK ) { |
| 95 | throw new IOException( url.toString() + " [HTTP " + code + "]" ); | |
| 94 | throw new IOException( url + " [HTTP " + code + "]" ); | |
| 96 | 95 | } |
| 97 | 96 | |
| 97 | mConn = conn; | |
| 98 | 98 | mStream = openBufferedInputStream(); |
| 99 | 99 | } |
| 10 | 10 | import com.dlsc.preferencesfx.util.StorageHandler; |
| 11 | 11 | 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 ); | |
| 354 | 374 | } |
| 355 | 375 |
| 87 | 87 | entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ), |
| 88 | 88 | entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ), |
| 89 | entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ), | |
| 90 | entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ), | |
| 89 | 91 | |
| 90 | 92 | entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ), |
| ... | ||
| 116 | 118 | entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ), |
| 117 | 119 | |
| 120 | entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty(true) ), | |
| 118 | 121 | entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ), |
| 119 | 122 | entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ) |
| ... | ||
| 131 | 134 | private BooleanProperty asBooleanProperty() { |
| 132 | 135 | return new SimpleBooleanProperty(); |
| 136 | } | |
| 137 | ||
| 138 | @SuppressWarnings( "SameParameterValue" ) | |
| 139 | private BooleanProperty asBooleanProperty( final boolean defaultValue ) { | |
| 140 | return new SimpleBooleanProperty( defaultValue ); | |
| 133 | 141 | } |
| 134 | 142 | |
| ... | ||
| 316 | 324 | |
| 317 | 325 | public StringProperty stringProperty( final Key key ) { |
| 326 | assert key != null; | |
| 327 | return valuesProperty( key ); | |
| 328 | } | |
| 329 | ||
| 330 | public BooleanProperty booleanProperty( final Key key ) { | |
| 318 | 331 | assert key != null; |
| 319 | 332 | return valuesProperty( key ); |
| 37 | 37 | public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" ); |
| 38 | 38 | 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" ); | |
| 39 | 41 | |
| 40 | 42 | public static final Key KEY_DEF = key( KEY_ROOT, "definition" ); |
| ... | ||
| 85 | 87 | public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" ); |
| 86 | 88 | 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" ); | |
| 87 | 90 | //@formatter:on |
| 88 | 91 | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 4 | import com.keenwrite.preferences.Workspace; | |
| 4 | 5 | import com.keenwrite.ui.adapters.ReplacedElementAdapter; |
| 5 | 6 | import com.keenwrite.util.BoundedCache; |
| 6 | 7 | import org.w3c.dom.Element; |
| 7 | 8 | import org.xhtmlrenderer.extend.ReplacedElement; |
| 8 | 9 | import org.xhtmlrenderer.extend.ReplacedElementFactory; |
| 9 | 10 | import org.xhtmlrenderer.extend.UserAgentCallback; |
| 10 | 11 | import org.xhtmlrenderer.layout.LayoutContext; |
| 11 | 12 | import org.xhtmlrenderer.render.BlockBox; |
| 13 | import org.xhtmlrenderer.swing.ImageReplacedElement; | |
| 12 | 14 | |
| 15 | import java.awt.event.ComponentEvent; | |
| 16 | import java.awt.event.ComponentListener; | |
| 13 | 17 | import java.util.LinkedHashSet; |
| 14 | 18 | import java.util.Map; |
| 15 | 19 | import java.util.Set; |
| 16 | 20 | |
| 21 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_RESIZE; | |
| 17 | 22 | import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE; |
| 18 | 23 | import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE_SRC; |
| 19 | 24 | import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX; |
| 20 | 25 | import static java.util.Arrays.asList; |
| 21 | 26 | |
| 22 | 27 | /** |
| 23 | 28 | * Responsible for running one or more factories to perform post-processing on |
| 24 | 29 | * the HTML document prior to displaying it. |
| 25 | 30 | */ |
| 26 | public final class ChainedReplacedElementFactory extends ReplacedElementAdapter { | |
| 31 | public final class ChainedReplacedElementFactory | |
| 32 | extends ReplacedElementAdapter implements ComponentListener { | |
| 27 | 33 | /** |
| 28 | 34 | * Retain insertion order so that client classes can control the order that |
| ... | ||
| 37 | 43 | */ |
| 38 | 44 | private final Map<String, ReplacedElement> mCache = new BoundedCache<>( 150 ); |
| 45 | ||
| 46 | private final Workspace mWorkspace; | |
| 39 | 47 | |
| 40 | 48 | 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; | |
| 42 | 54 | mFactories.addAll( asList( factories ) ); |
| 43 | 55 | } |
| ... | ||
| 73 | 85 | |
| 74 | 86 | 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 | } | |
| 76 | 94 | ); |
| 77 | 95 | |
| ... | ||
| 105 | 123 | mCache.clear(); |
| 106 | 124 | } |
| 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 ) { } | |
| 108 | 138 | |
| 139 | @Override | |
| 140 | public void componentHidden( final ComponentEvent e ) { } | |
| 141 | } | |
| 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 | } | |
| 1 | 57 |
| 43 | 43 | |
| 44 | 44 | /** |
| 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 | /** | |
| 57 | 45 | * Used to populate the {@link #HTML_HEAD} with stylesheet file references. |
| 58 | 46 | */ |
| ... | ||
| 87 | 75 | |
| 88 | 76 | 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; | |
| 89 | 85 | |
| 90 | 86 | /** |
| ... | ||
| 100 | 96 | private volatile boolean mLocked; |
| 101 | 97 | private final JButton mScrollLockButton = new JButton(); |
| 102 | ||
| 103 | 98 | private final Workspace mWorkspace; |
| 104 | 99 | |
| ... | ||
| 111 | 106 | public HtmlPreview( final Workspace workspace ) { |
| 112 | 107 | mWorkspace = workspace; |
| 108 | mFactory = new ChainedReplacedElementFactory( | |
| 109 | mWorkspace, | |
| 110 | new SvgReplacedElementFactory(), | |
| 111 | new SwingReplacedElementFactory() | |
| 112 | ); | |
| 113 | 113 | |
| 114 | 114 | // Attempts to prevent a flash of black un-styled content upon load. |
| ... | ||
| 141 | 141 | setCacheHint( SPEED ); |
| 142 | 142 | setContent( wrapper ); |
| 143 | wrapper.addComponentListener( mFactory ); | |
| 143 | 144 | |
| 144 | 145 | final var context = mView.getSharedContext(); |
| 145 | 146 | final var textRenderer = context.getTextRenderer(); |
| 146 | context.setReplacedElementFactory( FACTORY ); | |
| 147 | context.setReplacedElementFactory( mFactory ); | |
| 147 | 148 | textRenderer.setSmoothingThreshold( 0 ); |
| 148 | 149 | |
| ... | ||
| 174 | 175 | */ |
| 175 | 176 | public void refresh() { |
| 176 | FACTORY.clearCache(); | |
| 177 | mFactory.clearCache(); | |
| 177 | 178 | rerender(); |
| 178 | 179 | } |
| 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 | } | |
| 1 | 59 |
| 25 | 25 | import java.text.NumberFormat; |
| 26 | 26 | import java.text.ParseException; |
| 27 | import java.util.HashMap; | |
| 28 | import java.util.Map; | |
| 29 | 27 | |
| 30 | 28 | 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; | |
| 33 | 30 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; |
| 34 | 31 | import static java.nio.charset.StandardCharsets.UTF_8; |
| ... | ||
| 42 | 39 | * Responsible for converting SVG images into rasterized PNG images. |
| 43 | 40 | */ |
| 44 | @SuppressWarnings( "rawtypes" ) | |
| 45 | 41 | 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 | ||
| 83 | 42 | /** |
| 84 | 43 | * <a href="https://issues.apache.org/jira/browse/BATIK-1112">Bug fix</a> |
| 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 | } | |
| 1 | 67 |
| 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 | } | |
| 1 | 46 |
| 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 | } | |
| 1 | 202 |
| 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 | } | |
| 1 | 185 |
| 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 | } | |
| 1 | 37 |
| 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 | } | |
| 1 | 14 |
| 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 | } | |
| 1 | 470 |
| 7 | 7 | |
| 8 | 8 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; |
| 9 | import static com.keenwrite.Messages.get; | |
| 10 | 9 | import static com.keenwrite.events.StatusEvent.clue; |
| 11 | 10 | import static com.keenwrite.io.MediaType.TEXT_XML; |
| ... | ||
| 35 | 34 | public String apply( final String xhtml ) { |
| 36 | 35 | try { |
| 37 | clue( get( "Main.status.typeset.create" ) ); | |
| 36 | clue( "Main.status.typeset.create" ); | |
| 38 | 37 | final var document = TEXT_XML.createTemporaryFile( APP_TITLE_LOWERCASE ); |
| 39 | 38 | final var pathInput = writeString( document, xhtml ); |
| ... | ||
| 47 | 46 | } catch( final IOException | InterruptedException ex ) { |
| 48 | 47 | // Typesetter runtime exceptions will pass up the call stack. |
| 49 | clue( get( "Main.status.typeset.failed" ), ex ); | |
| 48 | clue( "Main.status.typeset.failed", ex ); | |
| 50 | 49 | } |
| 51 | 50 | |
| 20 | 20 | |
| 21 | 21 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; |
| 22 | import static com.keenwrite.Messages.get; | |
| 23 | 22 | import static com.keenwrite.events.StatusEvent.clue; |
| 24 | 23 | import static com.keenwrite.io.HttpFacade.httpGet; |
| ... | ||
| 65 | 64 | @Override |
| 66 | 65 | public String apply( final String html ) { |
| 67 | clue( get( "Main.status.typeset.xhtml" ) ); | |
| 66 | clue( "Main.status.typeset.xhtml" ); | |
| 68 | 67 | |
| 69 | 68 | final var doc = parse( html ); |
| 73 | 73 | extensions.add( ImageLinkExtension.create( context ) ); |
| 74 | 74 | extensions.add( TeXExtension.create( processor, context ) ); |
| 75 | extensions.add( FencedBlockExtension.create( processor ) ); | |
| 75 | extensions.add( FencedBlockExtension.create( processor, context ) ); | |
| 76 | 76 | extensions.add( CaretExtension.create( context ) ); |
| 77 | 77 | extensions.add( DocumentOutlineExtension.create( processor ) ); |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.ExportFormat; |
| 5 | import com.keenwrite.exceptions.MissingFileException; | |
| 6 | 5 | import com.keenwrite.preferences.Workspace; |
| 7 | 6 | import com.keenwrite.processors.ProcessorContext; |
| ... | ||
| 78 | 77 | @NotNull final LinkResolverBasicContext context, |
| 79 | 78 | @NotNull final ResolvedLink link ) { |
| 80 | return node instanceof Image ? forImage( link ) : link; | |
| 79 | return node instanceof Image ? forImage( link, node ) : link; | |
| 81 | 80 | } |
| 82 | 81 | |
| ... | ||
| 92 | 91 | * |
| 93 | 92 | * @param link The link URL to resolve. |
| 93 | * @param node The document node containing the URL. | |
| 94 | 94 | * @return The {@link ResolvedLink} instance used to render the link. |
| 95 | 95 | */ |
| 96 | private ResolvedLink forImage( final ResolvedLink link ) { | |
| 96 | private ResolvedLink forImage( final ResolvedLink link, final Node node ) { | |
| 97 | 97 | var uri = link.getUrl(); |
| 98 | 98 | final var protocol = getProtocol( uri ); |
| ... | ||
| 133 | 133 | } |
| 134 | 134 | |
| 135 | throw new MissingFileException( imageFile + ".*" ); | |
| 135 | clue( "Main.status.error.file.missing.near", | |
| 136 | imageFile + ".*", node.getLineNumber() | |
| 137 | ); | |
| 136 | 138 | } catch( final Exception ex ) { |
| 137 | 139 | clue( ex ); |
| 2 | 2 | package com.keenwrite.processors.markdown.extensions.fences; |
| 3 | 3 | |
| 4 | import com.keenwrite.preferences.Workspace; | |
| 4 | 5 | import com.keenwrite.processors.DefinitionProcessor; |
| 5 | 6 | import com.keenwrite.processors.Processor; |
| 7 | import com.keenwrite.processors.ProcessorContext; | |
| 6 | 8 | import com.keenwrite.processors.markdown.MarkdownProcessor; |
| 7 | 9 | import com.keenwrite.processors.markdown.extensions.HtmlRendererAdapter; |
| ... | ||
| 19 | 21 | import java.util.zip.Deflater; |
| 20 | 22 | |
| 21 | import static com.keenwrite.constants.Constants.DIAGRAM_SERVER_NAME; | |
| 22 | 23 | import static com.keenwrite.events.StatusEvent.clue; |
| 24 | import static com.keenwrite.preferences.WorkspaceKeys.KEY_IMAGES_SERVER; | |
| 23 | 25 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; |
| 24 | 26 | import static com.vladsch.flexmark.html.renderer.LinkType.LINK; |
| ... | ||
| 37 | 39 | |
| 38 | 40 | private final Processor<String> mProcessor; |
| 41 | private final ProcessorContext mContext; | |
| 39 | 42 | |
| 40 | public FencedBlockExtension( final Processor<String> processor ) { | |
| 43 | public FencedBlockExtension( | |
| 44 | final Processor<String> processor, final ProcessorContext context ) { | |
| 41 | 45 | assert processor != null; |
| 46 | assert context != null; | |
| 42 | 47 | mProcessor = processor; |
| 48 | mContext = context; | |
| 43 | 49 | } |
| 44 | 50 | |
| ... | ||
| 62 | 68 | */ |
| 63 | 69 | 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 ); | |
| 66 | 72 | } |
| 67 | 73 | |
| ... | ||
| 83 | 89 | } |
| 84 | 90 | |
| 91 | /** | |
| 92 | * Responsible for generating images from a fenced block that contains a | |
| 93 | * diagram reference. | |
| 94 | */ | |
| 85 | 95 | private class CustomRenderer implements NodeRenderer { |
| 86 | 96 | |
| ... | ||
| 98 | 108 | final var text = mProcessor.apply( content ); |
| 99 | 109 | 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 ); | |
| 103 | 111 | final var link = context.resolveLink( LINK, source, false ); |
| 104 | 112 | |
| ... | ||
| 136 | 144 | private String encode( final String decoded ) { |
| 137 | 145 | 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 ); | |
| 138 | 159 | } |
| 139 | 160 | } |
| 4 | 4 | import com.keenwrite.io.SysFile; |
| 5 | 5 | 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 ); | |
| 345 | 395 | } |
| 346 | 396 | } |
| 22 | 22 | import com.keenwrite.ui.explorer.FilePickerFactory; |
| 23 | 23 | 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 | } | |
| 474 | 581 | } |
| 475 | 582 |
| 58 | 58 | .addSubActions( |
| 59 | 59 | addAction( "file.export.pdf", e -> actions.file‿export‿pdf() ), |
| 60 | addAction( "file.export.pdf.dir", e -> actions.file‿export‿pdf‿dir() ), | |
| 60 | 61 | addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ), |
| 61 | 62 | addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ), |
| ... | ||
| 87 | 88 | addAction( "format.bold", e -> actions.format‿bold() ), |
| 88 | 89 | addAction( "format.italic", e -> actions.format‿italic() ), |
| 90 | addAction( "format.monospace", e -> actions.format‿monospace() ), | |
| 89 | 91 | addAction( "format.superscript", e -> actions.format‿superscript() ), |
| 90 | 92 | addAction( "format.subscript", e -> actions.format‿subscript() ), |
| ... | ||
| 129 | 131 | addAction( "view.statusbar", e -> actions.view‿statusbar() ), |
| 130 | 132 | SEPARATOR_ACTION, |
| 131 | addAction( "view.issues", e -> actions.view‿issues() ) | |
| 133 | addAction( "view.log", e -> actions.view‿log() ) | |
| 132 | 134 | ), |
| 133 | 135 | createMenu( |
| 28 | 28 | @Subscribe |
| 29 | 29 | public void handle( final StatusEvent event ) { |
| 30 | final var m = event.getMessage() + event.getException(); | |
| 30 | final var m = event.toString(); | |
| 31 | 31 | |
| 32 | 32 | // Don't burden the repaint thread if there's no status bar change. |
| 8 | 8 | import javafx.scene.control.ComboBox; |
| 9 | 9 | import javafx.scene.input.KeyCode; |
| 10 | import javafx.stage.Stage; | |
| 10 | 11 | |
| 11 | 12 | import java.io.File; |
| 12 | 13 | import java.io.FileInputStream; |
| 13 | 14 | import java.io.IOException; |
| 14 | 15 | import java.nio.file.Path; |
| 15 | 16 | import java.util.Properties; |
| 16 | 17 | import java.util.TreeMap; |
| 17 | 18 | |
| 18 | 19 | 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; | |
| 19 | 22 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE; |
| 20 | 23 | import static com.keenwrite.events.StatusEvent.clue; |
| 21 | 24 | import static com.keenwrite.util.FileWalker.walk; |
| 22 | 25 | import static java.lang.Math.max; |
| 26 | import static org.codehaus.plexus.util.StringUtils.abbreviate; | |
| 23 | 27 | |
| 24 | 28 | /** |
| ... | ||
| 43 | 47 | mThemes = themes; |
| 44 | 48 | mTheme = theme; |
| 45 | setGraphic( ICON_DIALOG_NODE ); | |
| 49 | initIcon(); | |
| 46 | 50 | setTitle( get( "Dialog.theme.title" ) ); |
| 47 | 51 | setHeaderText( get( "Dialog.theme.header" ) ); |
| ... | ||
| 56 | 60 | } |
| 57 | 61 | } ); |
| 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 | } | |
| 58 | 71 | } |
| 59 | 72 | |
| ... | ||
| 88 | 101 | walk( mThemes.toPath(), "**/theme.properties", ( path ) -> { |
| 89 | 102 | try { |
| 90 | final var themeDisplay = readThemeName( path ); | |
| 103 | final var displayed = readThemeName( path ); | |
| 91 | 104 | final var themeName = path.getParent().toFile().getName(); |
| 92 | choices.put( themeDisplay, themeName ); | |
| 105 | choices.put( abbreviate( displayed, THEME_NAME_LENGTH ), themeName ); | |
| 93 | 106 | |
| 94 | // Used to set the selected item to value from user's settings. | |
| 107 | // Set the selected item to user's settings value. | |
| 95 | 108 | if( themeName.equals( mTheme.get() ) ) { |
| 96 | selection[ 0 ] = themeDisplay; | |
| 109 | selection[ 0 ] = displayed; | |
| 97 | 110 | } |
| 98 | 111 | } catch( final Exception ex ) { |
| 99 | clue( get( "Main.status.error.theme.name", path ) ); | |
| 112 | clue( "Main.status.error.theme.name", path ); | |
| 100 | 113 | } |
| 101 | 114 | } ); |
| 16 | 16 | import java.util.TreeSet; |
| 17 | 17 | |
| 18 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; | |
| 18 | import static com.keenwrite.Messages.get; | |
| 19 | 19 | import static com.keenwrite.constants.Constants.ACTION_PREFIX; |
| 20 | 20 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG; |
| 21 | import static com.keenwrite.Messages.get; | |
| 22 | 21 | import static com.keenwrite.events.Bus.register; |
| 23 | 22 | 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; | |
| 28 | 23 | import static java.time.LocalDateTime.now; |
| 29 | 24 | import static java.time.format.DateTimeFormatter.ofPattern; |
| ... | ||
| 55 | 50 | public LogView() { |
| 56 | 51 | super( INFORMATION ); |
| 57 | setTitle( get( ACTION_PREFIX + "view.issues.text" ) ); | |
| 52 | setTitle( get( ACTION_PREFIX + "view.log.text" ) ); | |
| 58 | 53 | initModality( NONE ); |
| 59 | 54 | initTableView(); |
| ... | ||
| 150 | 145 | |
| 151 | 146 | private void initIcon() { |
| 152 | final var stage = getStage(); | |
| 153 | stage.getIcons().add( ICON_DIALOG ); | |
| 147 | getStage().getIcons().add( ICON_DIALOG ); | |
| 154 | 148 | } |
| 155 | 149 | |
| ... | ||
| 187 | 181 | private StringProperty traceProperty() { |
| 188 | 182 | 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 | } | |
| 202 | 183 | } |
| 203 | 184 | |
| 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 | } | |
| 1 | 134 |
| 38 | 38 | workspace.document.date.title=Timestamp |
| 39 | 39 | |
| 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 | |
| 576 | 600 | |
| 577 | 601 |
| 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 | } | |
| 1 | 69 |
| 1 | 1 |