| 7 | 7 | * text eol=lf |
| 8 | 8 | |
| 9 | ||
| 10 | 9 | # BINARY FILES: |
| 11 | 10 | # Disable line ending normalize on checkin. |
| ... | ||
| 26 | 25 | *.otf binary |
| 27 | 26 | *.ttf binary |
| 27 | *.ods binary | |
| 28 | ||
| 29 | bin/linux-x64.warp-packer binary | |
| 30 | bin/osslsigncode binary | |
| 31 | bin/warp-packer binary | |
| 32 | scripts/rcedit-x64.exe binary | |
| 28 | 33 | |
| 29 | 34 | |
| 13 | 13 | themes/ |
| 14 | 14 | quotes/ |
| 15 | !src/main/java/com/keenwrite/processors/markdown/extensions/quotes | |
| 15 | 16 | tex/ |
| 16 | 17 | spell/ |
| 7 | 7 | Download and install the following software packages: |
| 8 | 8 | |
| 9 | * [JDK 22](https://bell-sw.com/pages/downloads) (Full JDK + JavaFX) | |
| 10 | * [Gradle 8.9](https://gradle.org/releases) | |
| 11 | * [Git 2.45](https://git-scm.com/downloads) | |
| 9 | * [JDK 23](https://bell-sw.com/pages/downloads) (Full JDK + JavaFX) | |
| 10 | * [Gradle 8.10.2](https://gradle.org/releases) | |
| 11 | * [Git 2.46.2](https://git-scm.com/downloads) | |
| 12 | 12 | * [warp v0.4.0-alpha](https://github.com/Reisz/warp/releases/tag/v0.4.0) |
| 13 | 13 |
| 28 | 28 | # file must be in the working directory as specified by setwd. |
| 29 | 29 | # |
| 30 | # @param f The filename to convert. | |
| 31 | # @param decimals Rounded decimal places (default 1). | |
| 30 | # @param f The file name to convert. | |
| 31 | # @param decimals Rounded decimal places (default 2). | |
| 32 | 32 | # @param totals Include total sums (default TRUE). |
| 33 | 33 | # @param align Right-align numbers (default TRUE). |
| 34 | 34 | # ----------------------------------------------------------------------------- |
| 35 | 35 | csv2md <- function( f, decimals = 2, totals = T, align = T, caption = "" ) { |
| 36 | 36 | # Read the CVS data from the file; ensure strings become characters. |
| 37 | df <- read.table( f, sep=',', header=T, stringsAsFactors=F ) | |
| 37 | df <- read.table( f, sep=',', header=T, stringsAsFactors=F, check.names=F ) | |
| 38 | 38 | |
| 39 | 39 | if( totals ) { |
| 1 | #  | |
| 1 | #  | |
| 2 | 2 | |
| 3 | 3 | A free, open-source, cross-platform desktop Markdown editor that can produce beautifully typeset PDFs. |
| ... | ||
| 37 | 37 | Using Java, first follow these one-time setup steps: |
| 38 | 38 | |
| 39 | 1. Download the *Full version* of the Java Runtime Environment, [JRE 22](https://bell-sw.com/pages/downloads). | |
| 39 | 1. Download the *Full version* of the Java Runtime Environment, [JRE 23](https://bell-sw.com/pages/downloads). | |
| 40 | 40 | * JavaFX, which is bundled with BellSoft's *Full version*, is required. |
| 41 | 41 | 1. Install the JRE (include JRE's `bin` directory in the `PATH` environment variable). |
| 1 | MIT License | |
| 2 | ||
| 3 | Copyright (c) 2018 Diego Giagio <diego@giagio.com> | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in all | |
| 13 | copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | SOFTWARE. | |
| 1 | 22 |
| 1 | OpenSSL based Authenticode signing for PE/MSI/Java CAB files. | |
| 2 | ||
| 3 | Copyright (C) 2005-2014 Per Allansson <pallansson@gmail.com> | |
| 4 | Copyright (C) 2018-2022 Michał Trojnara <Michal.Trojnara@stunnel.org> | |
| 5 | ||
| 6 | This program is free software: you can redistribute it and/or modify | |
| 7 | it under the terms of the GNU General Public License as published by | |
| 8 | the Free Software Foundation, either version 3 of the License, or | |
| 9 | (at your option) any later version. | |
| 10 | ||
| 11 | This program is distributed in the hope that it will be useful, | |
| 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| 14 | GNU General Public License for more details. | |
| 15 | ||
| 16 | You should have received a copy of the GNU General Public License | |
| 17 | along with this program. If not, see <http://www.gnu.org/licenses/>. | |
| 18 | ||
| 19 | In addition, as a special exception, the copyright holders give | |
| 20 | permission to link the code of portions of this program with the | |
| 21 | OpenSSL library under certain conditions as described in each | |
| 22 | individual source file, and distribute linked combinations | |
| 23 | including the two. | |
| 24 | You must obey the GNU General Public License in all respects | |
| 25 | for all of the code used other than OpenSSL. If you modify | |
| 26 | file(s) with this exception, you may extend this exception to your | |
| 27 | version of the file(s), but you are not obligated to do so. If you | |
| 28 | do not wish to do so, delete this exception statement from your | |
| 29 | version. If you delete this exception statement from all source | |
| 30 | files in the program, then also delete it here. | |
| 1 | 31 |
| 1 | MIT License | |
| 2 | ||
| 3 | Copyright (c) 2018 Diego Giagio <diego@giagio.com> | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in all | |
| 13 | copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | SOFTWARE. | |
| 1 | 22 |
| 5 | 5 | mavenCentral() |
| 6 | 6 | maven { |
| 7 | url "https://plugins.gradle.org/m2/" | |
| 7 | url = 'https://plugins.gradle.org/m2/' | |
| 8 | 8 | } |
| 9 | 9 | } |
| ... | ||
| 32 | 32 | mavenCentral() |
| 33 | 33 | |
| 34 | maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } | |
| 35 | maven { url 'https://nexus.bedatadriven.com/content/groups/public' } | |
| 34 | maven { url = 'https://oss.sonatype.org/content/repositories/snapshots' } | |
| 35 | maven { url = 'https://nexus.bedatadriven.com/content/groups/public' } | |
| 36 | 36 | |
| 37 | 37 | maven { |
| 38 | url 'https://css4j.github.io/maven' | |
| 38 | url = 'https://css4j.github.io/maven' | |
| 39 | 39 | mavenContent { |
| 40 | 40 | releasesOnly() |
| 41 | 41 | } |
| 42 | 42 | content { |
| 43 | includeGroup 'com.github.css4j' | |
| 44 | includeGroup 'io.sf.graphics' | |
| 45 | includeGroup 'io.sf.carte' | |
| 46 | includeGroup 'io.sf.jclf' | |
| 43 | includeGroupByRegex 'io\\.sf\\..*' | |
| 47 | 44 | } |
| 48 | 45 | } |
| 49 | 46 | } |
| 50 | 47 | |
| 51 | 48 | // Assume a cross-platform überjar unless targetOs is set. |
| 52 | String[] os = ['win', 'mac', 'linux'] | |
| 49 | String[] os = [ 'win', 'mac', 'linux' ] | |
| 53 | 50 | |
| 54 | if (project.hasProperty( 'targetOs' )) { | |
| 55 | if ('windows' == targetOs) { | |
| 56 | os = ['win'] | |
| 57 | } else if ('macos' == targetOs) { | |
| 58 | os = ['mac'] | |
| 51 | if( project.hasProperty( 'targetOs' ) ) { | |
| 52 | if( 'windows' == targetOs ) { | |
| 53 | os = [ 'win' ] | |
| 54 | } else if( 'macos' == targetOs ) { | |
| 55 | os = [ 'mac' ] | |
| 59 | 56 | } else { |
| 60 | os = [targetOs] | |
| 57 | os = [ targetOs ] | |
| 61 | 58 | } |
| 62 | 59 | } |
| ... | ||
| 88 | 85 | javafx { |
| 89 | 86 | version = javaVersion |
| 90 | modules = ['javafx.base', 'javafx.controls', 'javafx.graphics', 'javafx.swing'] | |
| 87 | modules = [ 'javafx.base', 'javafx.controls', 'javafx.graphics', 'javafx.swing' ] | |
| 91 | 88 | configuration = 'compileOnly' |
| 92 | 89 | } |
| 93 | 90 | |
| 94 | 91 | dependencies { |
| 95 | def v_junit = '5.10.3' | |
| 92 | def v_junit = '5.13.4' | |
| 93 | def v_platform = '1.13.4' | |
| 96 | 94 | def v_flexmark = '0.64.8' |
| 97 | def v_jackson = '2.17.2' | |
| 98 | def v_echosvg = '1.2' | |
| 99 | def v_picocli = '4.7.6' | |
| 95 | def v_jackson = '2.19.2' | |
| 96 | def v_echosvg = '2.2' | |
| 97 | def v_picocli = '4.7.7' | |
| 100 | 98 | |
| 101 | 99 | // JavaFX |
| 102 | implementation 'org.controlsfx:controlsfx:11.2.1' | |
| 103 | implementation 'org.fxmisc.richtext:richtextfx:0.11.3' | |
| 104 | implementation 'org.fxmisc.flowless:flowless:0.7.3' | |
| 100 | implementation 'org.controlsfx:controlsfx:11.2.2' | |
| 101 | implementation 'org.fxmisc.richtext:richtextfx:0.11.5' | |
| 102 | implementation 'org.fxmisc.flowless:flowless:0.7.4' | |
| 105 | 103 | implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3' |
| 104 | implementation 'org.openjfx:javafx-media:26-ea+3' | |
| 106 | 105 | implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.17.0' |
| 107 | implementation 'com.panemu:tiwulfx-dock:0.3' | |
| 106 | implementation 'com.panemu:tiwulfx-dock:0.5' | |
| 108 | 107 | |
| 109 | 108 | // Markdown |
| ... | ||
| 116 | 115 | |
| 117 | 116 | // YAML |
| 118 | implementation 'org.yaml:snakeyaml:2.2' | |
| 117 | implementation 'org.yaml:snakeyaml:2.4' | |
| 119 | 118 | implementation "com.fasterxml.jackson.core:jackson-core:${v_jackson}" |
| 120 | 119 | implementation "com.fasterxml.jackson.core:jackson-databind:${v_jackson}" |
| 121 | 120 | implementation "com.fasterxml.jackson.core:jackson-annotations:${v_jackson}" |
| 122 | 121 | implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${v_jackson}" |
| 123 | 122 | |
| 124 | 123 | // HTML parsing and rendering |
| 125 | implementation 'org.jsoup:jsoup:1.18.1' | |
| 126 | implementation 'org.xhtmlrenderer:flying-saucer-core:9.9.0' | |
| 124 | implementation 'org.jsoup:jsoup:1.18.3' | |
| 125 | implementation 'org.xhtmlrenderer:flying-saucer-core:9.11.2' | |
| 127 | 126 | |
| 128 | 127 | // R |
| 129 | implementation 'org.apache.commons:commons-compress:1.26.1' | |
| 130 | implementation 'org.codehaus.plexus:plexus-utils:4.0.1' | |
| 128 | implementation 'org.apache.commons:commons-compress:1.28.0' | |
| 129 | implementation "org.apache.commons:commons-vfs2:2.10.0" | |
| 130 | implementation 'org.codehaus.plexus:plexus-utils:4.0.2' | |
| 131 | 131 | implementation 'org.renjin:renjin-script-engine:3.5-beta76' |
| 132 | 132 | implementation 'org.renjin.cran:rjson:0.2.15-renjin-21' |
| ... | ||
| 151 | 151 | implementation 'jakarta.validation:jakarta.validation-api:3.1.0' |
| 152 | 152 | implementation 'org.greenrobot:eventbus-java:3.3.1' |
| 153 | ||
| 154 | // Logging. | |
| 155 | implementation 'org.slf4j:slf4j-api:2.1.0-alpha1' | |
| 156 | implementation 'org.slf4j:slf4j-nop:2.0.16' | |
| 153 | 157 | |
| 154 | 158 | // Command-line parsing |
| 155 | 159 | implementation "info.picocli:picocli:${v_picocli}" |
| 156 | 160 | annotationProcessor "info.picocli:picocli-codegen:${v_picocli}" |
| 157 | 161 | |
| 158 | 162 | // KeenQuotes, KeenType, KeenSpell, KeenCount. |
| 159 | implementation fileTree( include: ['**/*.jar'], dir: 'libs' ) | |
| 163 | implementation fileTree( include: [ '**/*.jar' ], dir: 'libs' ) | |
| 160 | 164 | |
| 161 | def fx = ['controls', 'graphics', 'fxml', 'swing'] | |
| 165 | def fx = [ 'controls', 'graphics', 'fxml', 'swing' ] | |
| 162 | 166 | |
| 163 | 167 | fx.each { fxitem -> |
| 164 | 168 | os.each { ositem -> |
| 165 | 169 | runtimeOnly "org.openjfx:javafx-${fxitem}:${javafx.version}:${ositem}" |
| 166 | 170 | } |
| 167 | 171 | } |
| 172 | ||
| 173 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${v_junit}" | |
| 174 | testRuntimeOnly "org.junit.platform:junit-platform-engine:${v_platform}" | |
| 175 | testRuntimeOnly "org.junit.platform:junit-platform-launcher:${v_platform}" | |
| 168 | 176 | |
| 177 | testImplementation 'org.junit.jupiter:junit-jupiter:${v_junit}' | |
| 169 | 178 | testImplementation "org.junit.jupiter:junit-jupiter-api:${v_junit}" |
| 170 | 179 | testImplementation "org.junit.jupiter:junit-jupiter-params:${v_junit}" |
| 171 | 180 | testImplementation 'org.testfx:testfx-junit5:4.0.18' |
| 172 | testImplementation 'org.assertj:assertj-core:3.26.3' | |
| 173 | testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' | |
| 181 | testImplementation 'org.assertj:assertj-core:3.27.4' | |
| 174 | 182 | } |
| 175 | 183 | |
| ... | ||
| 251 | 259 | |
| 252 | 260 | contents { |
| 253 | from { ['LICENSE.md', 'README.md'] } | |
| 261 | from { [ 'LICENSE.md', 'README.md' ] } | |
| 254 | 262 | into( 'images' ) { |
| 255 | 263 | from { 'images' } |
| ... | ||
| 268 | 276 | tasks.withType( JavaCompile ).configureEach { |
| 269 | 277 | options.encoding = 'UTF-8' |
| 270 | } | |
| 271 | ||
| 272 | tasks.withType( JavaExec ).configureEach { | |
| 273 | jvmArgs += '--enable-preview' | |
| 274 | } | |
| 275 | ||
| 276 | tasks.withType( Test ).configureEach { | |
| 277 | jvmArgs += '--enable-preview' | |
| 278 | 278 | } |
| 279 | 279 | |
| 1 | token.txt | |
| 1 | host-path.txt | |
| 2 | 2 |
| 6 | 6 | # |
| 7 | 7 | # ######################################################################## |
| 8 | FROM alpine:latest | |
| 8 | 9 | |
| 9 | 10 | LABEL org.opencontainers.image.description Configures a typesetting system. |
| 10 | 11 | |
| 11 | FROM alpine:latest | |
| 12 | 12 | ENV ENV="/etc/profile" |
| 13 | 13 | ENV PROFILE=/etc/profile |
| ... | ||
| 23 | 23 | |
| 24 | 24 | ENV CONTEXT_HOME=$INSTALL_DIR/context |
| 25 | ENV CONTEXT_ARCH=linuxmusl-64 | |
| 25 | 26 | |
| 26 | 27 | # ######################################################################## |
| ... | ||
| 53 | 54 | |
| 54 | 55 | # Typesetting software |
| 55 | ADD "http://lmtx.pragma-ade.nl/install-lmtx/context-linuxmusl.zip" "context.zip" | |
| 56 | ADD "http://lmtx.pragma-ade.nl/install-lmtx/context-$CONTEXT_ARCH.zip" "context.zip" | |
| 56 | 57 | |
| 57 | 58 | # ######################################################################## |
| 58 | 59 | # |
| 59 | 60 | # Install components, modules, configure system, remove unnecessary files |
| 60 | 61 | # |
| 61 | 62 | # ######################################################################## |
| 62 | 63 | WORKDIR $CONTEXT_HOME |
| 63 | 64 | |
| 64 | 65 | RUN \ |
| 66 | apk update && \ | |
| 65 | 67 | apk add -t py3-cssselect && \ |
| 66 | 68 | apk add -t py3-lxml && \ |
| 67 | 69 | apk add -t py3-numpy && \ |
| 68 | 70 | apk --update --no-cache \ |
| 69 | add ca-certificates curl fontconfig inkscape rsync && \ | |
| 71 | add ca-certificates curl fontconfig rsync | |
| 72 | ||
| 73 | RUN apk --update --no-cache add inkscape | |
| 74 | ||
| 75 | RUN \ | |
| 70 | 76 | mkdir -p \ |
| 71 | 77 | "$FONTS_DIR" \ |
| 72 | 78 | "$INSTALL_DIR" \ |
| 73 | 79 | "$TARGET_DIR" \ |
| 74 | 80 | "$SOURCE_DIR" \ |
| 75 | 81 | "$THEMES_DIR" \ |
| 76 | 82 | "$IMAGES_DIR" \ |
| 77 | 83 | "$CACHES_DIR" && \ |
| 78 | 84 | echo "export CONTEXT_HOME=\"$CONTEXT_HOME\"" >> $PROFILE && \ |
| 79 | echo "export PATH=\"\$PATH:\$CONTEXT_HOME/tex/texmf-linuxmusl/bin\"" >> $PROFILE && \ | |
| 85 | echo "export PATH=\"\$PATH:\$CONTEXT_HOME/tex/texmf-$CONTEXT_ARCH/bin\"" >> $PROFILE && \ | |
| 80 | 86 | echo "export OSFONTDIR=\"/usr/share/fonts//\"" >> $PROFILE && \ |
| 81 | 87 | echo "PS1='\\u@typesetter:\\w\\$ '" >> $PROFILE && \ |
| ... | ||
| 110 | 116 | mkdir -p $CONTEXT_HOME/tex/texmf-fonts/tex/context/user && \ |
| 111 | 117 | ln -s $CONTEXT_HOME/tex/texmf-fonts/tex/context/user $HOME/fonts && \ |
| 112 | source $PROFILE && \ | |
| 118 | source $PROFILE \ | |
| 113 | 119 | mtxrun --generate && \ |
| 114 | 120 | find \ |
| 13 | 13 | # Upgrade |
| 14 | 14 | |
| 15 | ## Themes | |
| 16 | ||
| 17 | If changes have been made to the themes, upgrade them as follows: | |
| 18 | ||
| 19 | 1. Change to the themes repository directory. | |
| 20 | ||
| 21 | cd $HOME/dev/java/keenwrite/themes | |
| 22 | ||
| 23 | 1. Tag the themes, such as: | |
| 24 | ||
| 25 | git tag -a 1.11.0 -m "message" | |
| 26 | git push --tags | |
| 27 | ||
| 28 | 1. Ensure the personal access token exists: | |
| 29 | ||
| 30 | test -f tokens/release.pat && echo "exists" | |
| 31 | ||
| 32 | 1. Create the release and release notes via the web. | |
| 33 | ||
| 34 | 1. Run the release script to upload the release akchive: | |
| 35 | ||
| 36 | ./release.sh | |
| 37 | ||
| 38 | 1. Edit `src/main/resources/com/keenwrite/messages.properties`. | |
| 39 | 1. Set `Wizard.typesetter.themes.version` to the version. | |
| 40 | 1. Set `Wizard.typesetter.themes.checksum` to the checksum. | |
| 41 | ||
| 42 | The themes are released. | |
| 43 | ||
| 44 | ## Container | |
| 45 | ||
| 15 | 46 | Upgrade the containerization software (e.g., podman or docker) as follows: |
| 16 | 47 | |
| ... | ||
| 29 | 60 | |
| 30 | 61 | 1. Edit `src/main/resources/com/keenwrite/messages.properties`. |
| 31 | 1. Set `Wizard.typesetter.container.version` to the latest version. | |
| 62 | 1. Set `Wizard.typesetter.container.version` to the new container version. | |
| 32 | 63 | 1. Set `Wizard.typesetter.container.checksum` to the Windows version checksum. |
| 33 | 64 | 1. Set `Wizard.typesetter.container.image.version` to the new image version. |
| 34 | 65 | 1. Save the file. |
| 35 | 66 | |
| 36 | 67 | The containerization software version is changed. |
| 37 | 68 | |
| 38 | 69 | # Publish |
| 39 | 70 | |
| 40 | Publish the changes to the container image as follows: | |
| 71 | Building the container will pull from the container version in the properties | |
| 72 | file. Ensure that a personal access token (`token.txt`) exists, then publish | |
| 73 | the changes to the container image as follows: | |
| 41 | 74 | |
| 42 | 75 | ``` bash |
| 43 | ./manage.sh --delete --build --export --publish | |
| 76 | ./manage.sh --verbose --delete --build --export --publish | |
| 44 | 77 | ``` |
| 45 | 78 | |
| 7 | 7 | # --------------------------------------------------------------------------- |
| 8 | 8 | |
| 9 | source ../scripts/build-template | |
| 10 | ||
| 11 | # Reads the value of a property from a properties file. | |
| 12 | # | |
| 13 | # $1 - The key name to obtain. | |
| 14 | function property { | |
| 15 | grep "^${1}" "${PROPERTIES}" | cut -d'=' -f2 | |
| 16 | } | |
| 17 | ||
| 18 | readonly BUILD_DIR=build | |
| 19 | readonly PROPERTIES="${SCRIPT_DIR}/../src/main/resources/com/keenwrite/messages.properties" | |
| 20 | ||
| 21 | readonly CONTAINER_EXE=podman | |
| 22 | readonly CONTAINER_SHORTNAME=$(property Wizard.typesetter.container.image.name) | |
| 23 | readonly CONTAINER_VERSION=$(property Wizard.typesetter.container.image.version) | |
| 24 | readonly CONTAINER_NETWORK=host | |
| 25 | readonly CONTAINER_FILE="${CONTAINER_SHORTNAME}-${CONTAINER_VERSION}" | |
| 26 | readonly CONTAINER_ARCHIVE_FILE="${CONTAINER_FILE}.tar" | |
| 27 | readonly CONTAINER_ARCHIVE_PATH="${BUILD_DIR}/${CONTAINER_ARCHIVE_FILE}" | |
| 28 | readonly CONTAINER_COMPRESSED_FILE="${CONTAINER_ARCHIVE_FILE}.gz" | |
| 29 | readonly CONTAINER_COMPRESSED_PATH="${BUILD_DIR}/${CONTAINER_ARCHIVE_FILE}.gz" | |
| 30 | readonly CONTAINER_IMAGE_FILE="${BUILD_DIR}/${CONTAINER_FILE}" | |
| 31 | readonly CONTAINER_DIR_SOURCE="/root/source" | |
| 32 | readonly CONTAINER_DIR_TARGET="/root/target" | |
| 33 | readonly CONTAINER_DIR_IMAGES="/root/images" | |
| 34 | readonly CONTAINER_DIR_FONTS="/root/fonts" | |
| 35 | ||
| 36 | ARG_CONTAINER_NAME="${CONTAINER_SHORTNAME}:${CONTAINER_VERSION}" | |
| 37 | ARG_CONTAINER_COMMAND="context --version" | |
| 38 | ARG_MOUNTPOINT_SOURCE="" | |
| 39 | ARG_MOUNTPOINT_TARGET="." | |
| 40 | ARG_MOUNTPOINT_IMAGES="" | |
| 41 | ARG_MOUNTPOINT_FONTS="${HOME}/.fonts" | |
| 42 | ||
| 43 | DEPENDENCIES=( | |
| 44 | "podman,https://podman.io" | |
| 45 | "tar,https://www.gnu.org/software/tar" | |
| 46 | "bzip2,https://gitlab.com/bzip2/bzip2" | |
| 47 | ) | |
| 48 | ||
| 49 | ARGUMENTS+=( | |
| 50 | "b,build,Build container" | |
| 51 | "c,connect,Connect to container" | |
| 52 | "d,delete,Remove all containers" | |
| 53 | "s,source,Set mount point for input document (before typesetting)" | |
| 54 | "t,target,Set mount point for output file (after typesetting)" | |
| 55 | "i,images,Set mount point for image files (to typeset)" | |
| 56 | "f,fonts,Set mount point for font files (during typesetting)" | |
| 57 | "l,load,Load container (${CONTAINER_COMPRESSED_PATH})" | |
| 58 | "p,publish,Publish the container" | |
| 59 | "r,run,Run a command in the container (\"${ARG_CONTAINER_COMMAND}\")" | |
| 60 | "x,export,Save container (${CONTAINER_COMPRESSED_PATH})" | |
| 61 | ) | |
| 62 | ||
| 63 | # --------------------------------------------------------------------------- | |
| 64 | # Manages the container. | |
| 65 | # --------------------------------------------------------------------------- | |
| 66 | execute() { | |
| 67 | $do_delete | |
| 68 | $do_build | |
| 69 | $do_publish | |
| 70 | $do_export | |
| 71 | $do_load | |
| 72 | $do_execute | |
| 73 | $do_connect | |
| 74 | ||
| 75 | return 1 | |
| 76 | } | |
| 77 | ||
| 78 | # --------------------------------------------------------------------------- | |
| 79 | # Deletes all containers. | |
| 80 | # --------------------------------------------------------------------------- | |
| 81 | utile_delete() { | |
| 82 | $log "Deleting all containers" | |
| 83 | ||
| 84 | ${CONTAINER_EXE} rmi --all --force > /dev/null | |
| 85 | ||
| 86 | $log "Containers deleted" | |
| 87 | } | |
| 88 | ||
| 89 | # --------------------------------------------------------------------------- | |
| 90 | # Builds the container file in the current working directory. | |
| 91 | # --------------------------------------------------------------------------- | |
| 92 | utile_build() { | |
| 93 | $log "Building container version ${CONTAINER_VERSION}" | |
| 94 | ||
| 95 | # Show what commands are run while building, but not the commands' output. | |
| 96 | ${CONTAINER_EXE} build \ | |
| 97 | --network="${CONTAINER_NETWORK}" \ | |
| 98 | --squash \ | |
| 99 | -t "${ARG_CONTAINER_NAME}" . | \ | |
| 100 | grep ^STEP | |
| 101 | } | |
| 102 | ||
| 103 | # --------------------------------------------------------------------------- | |
| 104 | # Publishes the container to the repository. | |
| 105 | # --------------------------------------------------------------------------- | |
| 106 | utile_publish() { | |
| 107 | local -r TOKEN_FILE="token.txt" | |
| 108 | ||
| 109 | if [[ -f "${TOKEN_FILE}" ]]; then | |
| 110 | local -r repository=$(cat ${TOKEN_FILE}) | |
| 111 | local -r remote_file="${CONTAINER_SHORTNAME}:${CONTAINER_VERSION}" | |
| 112 | local -r remote_path="${repository}/${remote_file}" | |
| 113 | ||
| 114 | $log "Publishing ${CONTAINER_IMAGE_FILE} to ${remote_path}" | |
| 115 | ||
| 116 | # Path to the repository. | |
| 117 | scp -q "${CONTAINER_IMAGE_FILE}" "${remote_path}" | |
| 118 | else | |
| 119 | error "Create ${TOKEN_FILE} with publish credentials" | |
| 120 | fi | |
| 121 | } | |
| 122 | ||
| 123 | # --------------------------------------------------------------------------- | |
| 124 | # Creates the command-line option for a read-only mountpoint. | |
| 125 | # | |
| 126 | # $1 - The host directory. | |
| 127 | # $2 - The guest (container) directory. | |
| 128 | # $3 - The file system permissions (set to 1 for read-write). | |
| 129 | # --------------------------------------------------------------------------- | |
| 130 | get_mountpoint() { | |
| 131 | local result="" | |
| 132 | local binding="ro" | |
| 133 | ||
| 134 | if [ ! -z "${3+x}" ]; then | |
| 135 | binding="Z" | |
| 136 | fi | |
| 137 | ||
| 138 | if [ ! -z "${1}" ]; then | |
| 139 | result="-v ${1}:${2}:${binding}" | |
| 140 | fi | |
| 141 | ||
| 142 | echo "${result}" | |
| 143 | } | |
| 144 | ||
| 145 | get_mountpoint_source() { | |
| 146 | echo $(get_mountpoint "${ARG_MOUNTPOINT_SOURCE}" "${CONTAINER_DIR_SOURCE}") | |
| 147 | } | |
| 148 | ||
| 149 | get_mountpoint_target() { | |
| 150 | echo $(get_mountpoint "${ARG_MOUNTPOINT_TARGET}" "${CONTAINER_DIR_TARGET}" 1) | |
| 151 | } | |
| 152 | ||
| 153 | get_mountpoint_images() { | |
| 154 | echo $(get_mountpoint "${ARG_MOUNTPOINT_IMAGES}" "${CONTAINER_DIR_IMAGES}") | |
| 155 | } | |
| 156 | ||
| 157 | get_mountpoint_fonts() { | |
| 158 | echo $(get_mountpoint "${ARG_MOUNTPOINT_FONTS}" "${CONTAINER_DIR_FONTS}") | |
| 159 | } | |
| 160 | ||
| 161 | # --------------------------------------------------------------------------- | |
| 162 | # Connects to the container. | |
| 163 | # --------------------------------------------------------------------------- | |
| 164 | utile_connect() { | |
| 165 | $log "Connecting to container" | |
| 166 | ||
| 167 | declare -r mount_source=$(get_mountpoint_source) | |
| 168 | declare -r mount_target=$(get_mountpoint_target) | |
| 169 | declare -r mount_images=$(get_mountpoint_images) | |
| 170 | declare -r mount_fonts=$(get_mountpoint_fonts) | |
| 171 | ||
| 172 | $log "mount_source = '${mount_source}'" | |
| 173 | $log "mount_target = '${mount_target}'" | |
| 174 | $log "mount_images = '${mount_images}'" | |
| 175 | $log "mount_fonts = '${mount_fonts}'" | |
| 176 | ||
| 177 | ${CONTAINER_EXE} run \ | |
| 178 | --network="${CONTAINER_NETWORK}" \ | |
| 179 | --rm \ | |
| 180 | -it \ | |
| 181 | ${mount_source} \ | |
| 182 | ${mount_target} \ | |
| 183 | ${mount_images} \ | |
| 184 | ${mount_fonts} \ | |
| 185 | "${ARG_CONTAINER_NAME}" | |
| 186 | } | |
| 187 | ||
| 188 | # --------------------------------------------------------------------------- | |
| 189 | # Runs a command in the container. | |
| 190 | # | |
| 191 | # Examples: | |
| 192 | # | |
| 193 | # ./manage.sh -r "ls /" | |
| 194 | # ./manage.sh -r "context --version" | |
| 195 | # --------------------------------------------------------------------------- | |
| 196 | utile_execute() { | |
| 197 | $log "Running \"${ARG_CONTAINER_COMMAND}\":" | |
| 198 | ||
| 199 | ${CONTAINER_EXE} run \ | |
| 200 | --network=${CONTAINER_NETWORK} \ | |
| 201 | --rm \ | |
| 202 | -i \ | |
| 203 | -t "${ARG_CONTAINER_NAME}" \ | |
| 204 | /bin/sh --login -c "${ARG_CONTAINER_COMMAND}" | |
| 205 | } | |
| 206 | ||
| 207 | # --------------------------------------------------------------------------- | |
| 208 | # Saves the container to a file. | |
| 209 | # --------------------------------------------------------------------------- | |
| 210 | utile_export() { | |
| 211 | if [[ -f "${CONTAINER_COMPRESSED_PATH}" ]]; then | |
| 212 | warning "${CONTAINER_COMPRESSED_PATH} exists, delete before saving." | |
| 213 | else | |
| 214 | $log "Saving ${CONTAINER_SHORTNAME} image" | |
| 215 | ||
| 216 | mkdir -p "${BUILD_DIR}" | |
| 217 | ||
| 218 | ${CONTAINER_EXE} save \ | |
| 219 | --quiet \ | |
| 220 | -o "${BUILD_DIR}/${CONTAINER_ARCHIVE_FILE}" \ | |
| 221 | "${ARG_CONTAINER_NAME}" | |
| 222 | ||
| 223 | $log "Compressing to ${CONTAINER_COMPRESSED_PATH}" | |
| 224 | gzip "${CONTAINER_ARCHIVE_PATH}" | |
| 225 | ||
| 226 | $log "Renaming to ${CONTAINER_IMAGE_FILE}" | |
| 227 | mv "${CONTAINER_COMPRESSED_PATH}" "${CONTAINER_IMAGE_FILE}" | |
| 228 | ||
| 229 | $log "Saved ${CONTAINER_IMAGE_FILE} image" | |
| 230 | fi | |
| 231 | } | |
| 232 | ||
| 233 | # --------------------------------------------------------------------------- | |
| 234 | # Loads the container from a file. | |
| 235 | # --------------------------------------------------------------------------- | |
| 236 | utile_load() { | |
| 237 | if [[ -f "${CONTAINER_COMPRESSED_PATH}" ]]; then | |
| 238 | $log "Loading ${CONTAINER_SHORTNAME} from ${CONTAINER_COMPRESSED_PATH}" | |
| 239 | ||
| 240 | ${CONTAINER_EXE} load \ | |
| 241 | --quiet \ | |
| 242 | -i "${CONTAINER_COMPRESSED_PATH}" | |
| 243 | ||
| 244 | $log "Loaded ${CONTAINER_SHORTNAME} image" | |
| 245 | else | |
| 246 | warning "Missing ${CONTAINER_COMPRESSED_PATH}; use build followed by save" | |
| 247 | fi | |
| 248 | } | |
| 249 | ||
| 250 | argument() { | |
| 251 | local consume=1 | |
| 252 | ||
| 253 | case "$1" in | |
| 254 | -b|--build) | |
| 255 | do_build=utile_build | |
| 256 | ;; | |
| 257 | -c|--connect) | |
| 258 | do_connect=utile_connect | |
| 259 | ;; | |
| 260 | -d|--delete) | |
| 261 | do_delete=utile_delete | |
| 262 | ;; | |
| 263 | -l|--load) | |
| 264 | do_load=utile_load | |
| 265 | ;; | |
| 266 | -i|--images) | |
| 267 | if [ ! -z "${2+x}" ]; then | |
| 268 | ARG_MOUNTPOINT_IMAGES="$2" | |
| 269 | consume=2 | |
| 270 | fi | |
| 271 | ;; | |
| 272 | -t|--target) | |
| 273 | if [ ! -z "${2+x}" ]; then | |
| 274 | ARG_MOUNTPOINT_TARGET="$2" | |
| 275 | consume=2 | |
| 276 | fi | |
| 277 | ;; | |
| 278 | -p|--publish) | |
| 279 | do_publish=utile_publish | |
| 280 | ;; | |
| 281 | -r|--run) | |
| 282 | do_execute=utile_execute | |
| 283 | ||
| 284 | if [ ! -z "${2+x}" ]; then | |
| 285 | ARG_CONTAINER_COMMAND="$2" | |
| 286 | consume=2 | |
| 287 | fi | |
| 288 | ;; | |
| 289 | -s|--source) | |
| 290 | if [ ! -z "${2+x}" ]; then | |
| 291 | ARG_MOUNTPOINT_SOURCE="$2" | |
| 292 | consume=2 | |
| 293 | fi | |
| 294 | ;; | |
| 295 | -x|--export) | |
| 296 | do_export=utile_export | |
| 297 | ;; | |
| 298 | esac | |
| 299 | ||
| 300 | return ${consume} | |
| 301 | } | |
| 302 | ||
| 303 | do_build=: | |
| 304 | do_connect=: | |
| 305 | do_delete=: | |
| 306 | do_execute=: | |
| 307 | do_load=: | |
| 308 | do_publish=: | |
| 309 | do_export=: | |
| 310 | ||
| 311 | main "$@" | |
| 312 | ||
| 9 | set -euo pipefail # Exit on error, undefined vars, and pipe failures | |
| 10 | ||
| 11 | source ../scripts/build-template | |
| 12 | ||
| 13 | # Reads the value of a property from a properties file. | |
| 14 | # | |
| 15 | # $1 - The key name to obtain. | |
| 16 | function property { | |
| 17 | if [[ ! -f "${PROPERTIES}" ]]; then | |
| 18 | echo "Error: Properties file ${PROPERTIES} not found" >&2 | |
| 19 | exit 1 | |
| 20 | fi | |
| 21 | grep "^${1}" "${PROPERTIES}" | cut -d'=' -f2 | |
| 22 | } | |
| 23 | ||
| 24 | readonly BUILD_DIR=build | |
| 25 | readonly PROPERTIES="${SCRIPT_DIR}/../src/main/resources/com/keenwrite/messages.properties" | |
| 26 | ||
| 27 | readonly CONTAINER_EXE=podman | |
| 28 | readonly CONTAINER_SHORTNAME=$(property Wizard.typesetter.container.image.name) | |
| 29 | readonly CONTAINER_VERSION=$(property Wizard.typesetter.container.image.version) | |
| 30 | readonly CONTAINER_NETWORK=host | |
| 31 | readonly CONTAINER_IMAGE_FILE="${CONTAINER_SHORTNAME}-${CONTAINER_VERSION}" | |
| 32 | readonly CONTAINER_IMAGE_PATH="${BUILD_DIR}/${CONTAINER_IMAGE_FILE}" | |
| 33 | readonly CONTAINER_ARCHIVE_FILE="${CONTAINER_IMAGE_FILE}.tar" | |
| 34 | readonly CONTAINER_ARCHIVE_PATH="${BUILD_DIR}/${CONTAINER_ARCHIVE_FILE}" | |
| 35 | readonly CONTAINER_COMPRESSED_FILE="${CONTAINER_ARCHIVE_FILE}.gz" | |
| 36 | readonly CONTAINER_COMPRESSED_PATH="${BUILD_DIR}/${CONTAINER_COMPRESSED_FILE}" | |
| 37 | readonly CONTAINER_DIR_SOURCE="/root/source" | |
| 38 | readonly CONTAINER_DIR_TARGET="/root/target" | |
| 39 | readonly CONTAINER_DIR_IMAGES="/root/images" | |
| 40 | readonly CONTAINER_DIR_FONTS="/root/fonts" | |
| 41 | ||
| 42 | ARG_CONTAINER_NAME="${CONTAINER_SHORTNAME}:${CONTAINER_VERSION}" | |
| 43 | ARG_CONTAINER_COMMAND="context --version" | |
| 44 | ARG_MOUNTPOINT_SOURCE="" | |
| 45 | ARG_MOUNTPOINT_TARGET="." | |
| 46 | ARG_MOUNTPOINT_IMAGES="" | |
| 47 | ARG_MOUNTPOINT_FONTS="${HOME}/.fonts" | |
| 48 | ||
| 49 | DEPENDENCIES=( | |
| 50 | "podman,https://podman.io" | |
| 51 | "tar,https://www.gnu.org/software/tar" | |
| 52 | "bzip2,https://gitlab.com/bzip2/bzip2" | |
| 53 | ) | |
| 54 | ||
| 55 | ARGUMENTS+=( | |
| 56 | "b,build,Build container" | |
| 57 | "c,connect,Connect to container" | |
| 58 | "d,delete,Remove all containers" | |
| 59 | "s,source,Set mount point for input document (before typesetting)" | |
| 60 | "t,target,Set mount point for output file (after typesetting)" | |
| 61 | "i,images,Set mount point for image files (to typeset)" | |
| 62 | "f,fonts,Set mount point for font files (during typesetting)" | |
| 63 | "l,load,Load container (${CONTAINER_COMPRESSED_PATH})" | |
| 64 | "p,publish,Publish the container" | |
| 65 | "r,run,Run a command in the container (\"${ARG_CONTAINER_COMMAND}\")" | |
| 66 | "x,export,Save container (${CONTAINER_COMPRESSED_PATH})" | |
| 67 | ) | |
| 68 | ||
| 69 | # --------------------------------------------------------------------------- | |
| 70 | # Manages the container. | |
| 71 | # --------------------------------------------------------------------------- | |
| 72 | execute() { | |
| 73 | ${do_delete} | |
| 74 | ${do_build} | |
| 75 | ${do_export} | |
| 76 | ${do_publish} | |
| 77 | ${do_load} | |
| 78 | ${do_execute} | |
| 79 | ${do_connect} | |
| 80 | ||
| 81 | return 1 | |
| 82 | } | |
| 83 | ||
| 84 | # --------------------------------------------------------------------------- | |
| 85 | # Deletes all containers. | |
| 86 | # --------------------------------------------------------------------------- | |
| 87 | utile_delete() { | |
| 88 | [[ -f ${CONTAINER_IMAGE_PATH} ]] && \ | |
| 89 | $log "Deleting ${CONTAINER_IMAGE_PATH}"; \ | |
| 90 | rm -f "${CONTAINER_IMAGE_PATH}" | |
| 91 | ||
| 92 | $log "Deleting all containers" | |
| 93 | ||
| 94 | ${CONTAINER_EXE} rmi --all --force > /dev/null || true | |
| 95 | ||
| 96 | $log "Containers deleted" | |
| 97 | } | |
| 98 | ||
| 99 | # --------------------------------------------------------------------------- | |
| 100 | # Builds the container file in the current working directory. | |
| 101 | # --------------------------------------------------------------------------- | |
| 102 | utile_build() { | |
| 103 | $log "Building container version ${CONTAINER_VERSION}" | |
| 104 | ||
| 105 | mkdir -p "${ARG_MOUNTPOINT_FONTS}" | |
| 106 | ||
| 107 | # Show what commands are run while building, but not the commands' output. | |
| 108 | ${CONTAINER_EXE} build \ | |
| 109 | --network="${CONTAINER_NETWORK}" \ | |
| 110 | --squash \ | |
| 111 | -t "${ARG_CONTAINER_NAME}" . | \ | |
| 112 | grep ^STEP || true | |
| 113 | } | |
| 114 | ||
| 115 | # --------------------------------------------------------------------------- | |
| 116 | # Publishes the container to the repository. | |
| 117 | # --------------------------------------------------------------------------- | |
| 118 | utile_publish() { | |
| 119 | local -r HOST_PATH="host-path.txt" | |
| 120 | ||
| 121 | if [[ -f "${HOST_PATH}" ]]; then | |
| 122 | local -r repository=$(cat "${HOST_PATH}") | |
| 123 | local -r remote_file="${CONTAINER_SHORTNAME}:${CONTAINER_VERSION}" | |
| 124 | local -r remote_path="${repository}/${remote_file}" | |
| 125 | ||
| 126 | $log "Publishing ${CONTAINER_IMAGE_PATH} to ${remote_path}" | |
| 127 | ||
| 128 | # Path to the repository. | |
| 129 | scp -q "${CONTAINER_IMAGE_PATH}" "${remote_path}" | |
| 130 | else | |
| 131 | error "Create ${HOST_PATH} with path on remote host" | |
| 132 | fi | |
| 133 | } | |
| 134 | ||
| 135 | # --------------------------------------------------------------------------- | |
| 136 | # Creates the command-line option for a read-only mountpoint. | |
| 137 | # | |
| 138 | # $1 - The host directory. | |
| 139 | # $2 - The guest (container) directory. | |
| 140 | # $3 - The file system permissions (set to 1 for read-write). | |
| 141 | # --------------------------------------------------------------------------- | |
| 142 | get_mountpoint() { | |
| 143 | local result="" | |
| 144 | local binding="ro" | |
| 145 | ||
| 146 | if [[ -n "${3:-}" ]]; then | |
| 147 | binding="Z" | |
| 148 | fi | |
| 149 | ||
| 150 | if [[ -n "${1:-}" ]]; then | |
| 151 | result="-v ${1}:${2}:${binding}" | |
| 152 | fi | |
| 153 | ||
| 154 | echo "${result}" | |
| 155 | } | |
| 156 | ||
| 157 | get_mountpoint_source() { | |
| 158 | echo "$(get_mountpoint "${ARG_MOUNTPOINT_SOURCE}" "${CONTAINER_DIR_SOURCE}")" | |
| 159 | } | |
| 160 | ||
| 161 | get_mountpoint_target() { | |
| 162 | echo "$(get_mountpoint "${ARG_MOUNTPOINT_TARGET}" "${CONTAINER_DIR_TARGET}" 1)" | |
| 163 | } | |
| 164 | ||
| 165 | get_mountpoint_images() { | |
| 166 | echo "$(get_mountpoint "${ARG_MOUNTPOINT_IMAGES}" "${CONTAINER_DIR_IMAGES}")" | |
| 167 | } | |
| 168 | ||
| 169 | get_mountpoint_fonts() { | |
| 170 | echo "$(get_mountpoint "${ARG_MOUNTPOINT_FONTS}" "${CONTAINER_DIR_FONTS}")" | |
| 171 | } | |
| 172 | ||
| 173 | # --------------------------------------------------------------------------- | |
| 174 | # Connects to the container. | |
| 175 | # --------------------------------------------------------------------------- | |
| 176 | utile_connect() { | |
| 177 | $log "Connecting to container" | |
| 178 | ||
| 179 | local mount_source | |
| 180 | local mount_target | |
| 181 | local mount_images | |
| 182 | local mount_fonts | |
| 183 | ||
| 184 | mount_source=$(get_mountpoint_source) | |
| 185 | mount_target=$(get_mountpoint_target) | |
| 186 | mount_images=$(get_mountpoint_images) | |
| 187 | mount_fonts=$(get_mountpoint_fonts) | |
| 188 | ||
| 189 | $log "mount_source = '${mount_source}'" | |
| 190 | $log "mount_target = '${mount_target}'" | |
| 191 | $log "mount_images = '${mount_images}'" | |
| 192 | $log "mount_fonts = '${mount_fonts}'" | |
| 193 | ||
| 194 | # Use array to properly handle empty mount options | |
| 195 | local mount_args=() | |
| 196 | [[ -n "${mount_source}" ]] && mount_args+=(${mount_source}) | |
| 197 | [[ -n "${mount_target}" ]] && mount_args+=(${mount_target}) | |
| 198 | [[ -n "${mount_images}" ]] && mount_args+=(${mount_images}) | |
| 199 | [[ -n "${mount_fonts}" ]] && mount_args+=(${mount_fonts}) | |
| 200 | ||
| 201 | # Ensure directories exist and log creation | |
| 202 | for mount in "${mount_args[@]}"; do | |
| 203 | # Extract host path from mount string (format: -v /host:/container) | |
| 204 | host_path=$(echo "${mount}" | sed 's/^-v \([^:]*\):.*$/\1/') | |
| 205 | ||
| 206 | [[ ! -d "$host_path" ]] && \ | |
| 207 | $log "Create directory: $host_path" && \ | |
| 208 | mkdir -p "$host_path" | |
| 209 | done | |
| 210 | ||
| 211 | ${CONTAINER_EXE} run \ | |
| 212 | --network="${CONTAINER_NETWORK}" \ | |
| 213 | --rm \ | |
| 214 | -it \ | |
| 215 | "${mount_args[@]}" \ | |
| 216 | "${ARG_CONTAINER_NAME}" | |
| 217 | } | |
| 218 | ||
| 219 | # --------------------------------------------------------------------------- | |
| 220 | # Runs a command in the container. | |
| 221 | # | |
| 222 | # Examples: | |
| 223 | # | |
| 224 | # ./manage.sh -r "ls /" | |
| 225 | # ./manage.sh -r "context --version" | |
| 226 | # --------------------------------------------------------------------------- | |
| 227 | utile_execute() { | |
| 228 | $log "Running \"${ARG_CONTAINER_COMMAND}\":" | |
| 229 | ||
| 230 | ${CONTAINER_EXE} run \ | |
| 231 | --network="${CONTAINER_NETWORK}" \ | |
| 232 | --rm \ | |
| 233 | -it \ | |
| 234 | "${ARG_CONTAINER_NAME}" \ | |
| 235 | /bin/sh --login -c "${ARG_CONTAINER_COMMAND}" | |
| 236 | } | |
| 237 | ||
| 238 | # --------------------------------------------------------------------------- | |
| 239 | # Saves the container to a file. | |
| 240 | # --------------------------------------------------------------------------- | |
| 241 | utile_export() { | |
| 242 | if [[ -f "${CONTAINER_COMPRESSED_PATH}" ]]; then | |
| 243 | warning "${CONTAINER_COMPRESSED_PATH} exists, delete before saving." | |
| 244 | else | |
| 245 | $log "Saving ${CONTAINER_SHORTNAME} image" | |
| 246 | ||
| 247 | mkdir -p "${BUILD_DIR}" | |
| 248 | ||
| 249 | ${CONTAINER_EXE} save \ | |
| 250 | --quiet \ | |
| 251 | -o "${CONTAINER_ARCHIVE_PATH}" \ | |
| 252 | "${ARG_CONTAINER_NAME}" | |
| 253 | ||
| 254 | $log "Compressing to ${CONTAINER_COMPRESSED_PATH}" | |
| 255 | gzip "${CONTAINER_ARCHIVE_PATH}" | |
| 256 | ||
| 257 | $log "Renaming to ${CONTAINER_IMAGE_PATH}" | |
| 258 | mv "${CONTAINER_COMPRESSED_PATH}" "${CONTAINER_IMAGE_PATH}" | |
| 259 | ||
| 260 | $log "Saved ${CONTAINER_IMAGE_PATH} image" | |
| 261 | fi | |
| 262 | } | |
| 263 | ||
| 264 | # --------------------------------------------------------------------------- | |
| 265 | # Loads the container from a file. | |
| 266 | # --------------------------------------------------------------------------- | |
| 267 | utile_load() { | |
| 268 | if [[ -f "${CONTAINER_COMPRESSED_PATH}" ]]; then | |
| 269 | $log "Loading ${CONTAINER_SHORTNAME} from ${CONTAINER_COMPRESSED_PATH}" | |
| 270 | ||
| 271 | ${CONTAINER_EXE} load \ | |
| 272 | --quiet \ | |
| 273 | -i "${CONTAINER_COMPRESSED_PATH}" | |
| 274 | ||
| 275 | $log "Loaded ${CONTAINER_SHORTNAME} image" | |
| 276 | else | |
| 277 | warning "Missing ${CONTAINER_COMPRESSED_PATH}; use build followed by save" | |
| 278 | fi | |
| 279 | } | |
| 280 | ||
| 281 | argument() { | |
| 282 | local consume=1 | |
| 283 | ||
| 284 | case "$1" in | |
| 285 | -b|--build) | |
| 286 | do_build=utile_build | |
| 287 | ;; | |
| 288 | -c|--connect) | |
| 289 | do_connect=utile_connect | |
| 290 | ;; | |
| 291 | -z|--delete) | |
| 292 | do_delete=utile_delete | |
| 293 | ;; | |
| 294 | -l|--load) | |
| 295 | do_load=utile_load | |
| 296 | ;; | |
| 297 | -i|--images) | |
| 298 | if [[ -n "${2:-}" ]]; then | |
| 299 | ARG_MOUNTPOINT_IMAGES="$2" | |
| 300 | consume=2 | |
| 301 | fi | |
| 302 | ;; | |
| 303 | -t|--target) | |
| 304 | if [[ -n "${2:-}" ]]; then | |
| 305 | ARG_MOUNTPOINT_TARGET="$2" | |
| 306 | consume=2 | |
| 307 | fi | |
| 308 | ;; | |
| 309 | -p|--publish) | |
| 310 | do_publish=utile_publish | |
| 311 | ;; | |
| 312 | -r|--run) | |
| 313 | do_execute=utile_execute | |
| 314 | ||
| 315 | if [[ -n "${2:-}" ]]; then | |
| 316 | ARG_CONTAINER_COMMAND="$2" | |
| 317 | consume=2 | |
| 318 | fi | |
| 319 | ;; | |
| 320 | -s|--source) | |
| 321 | if [[ -n "${2:-}" ]]; then | |
| 322 | ARG_MOUNTPOINT_SOURCE="$2" | |
| 323 | consume=2 | |
| 324 | fi | |
| 325 | ;; | |
| 326 | -x|--export) | |
| 327 | do_export=utile_export | |
| 328 | ;; | |
| 329 | esac | |
| 330 | ||
| 331 | return ${consume} | |
| 332 | } | |
| 333 | ||
| 334 | do_build=: | |
| 335 | do_connect=: | |
| 336 | do_delete=: | |
| 337 | do_execute=: | |
| 338 | do_load=: | |
| 339 | do_publish=: | |
| 340 | do_export=: | |
| 341 | ||
| 342 | main "$@" | |
| 313 | 343 |
| 1 | # Quotation marks | |
| 2 | ||
| 3 | When converting straight single quotes into curled single quotes, the | |
| 4 | application offers a variety of entities to use for encoding: | |
| 5 | ||
| 6 | * **regular** -- Do not encode. | |
| 7 | * **modifier** -- Encode as \ʼ, the modifier letter apostrophe. | |
| 8 | * **apos** -- Encode as \', curled when typeset to PDF. | |
| 9 | * **aposhex** -- Encode as \', the apostrophe's numeric value. | |
| 10 | * **quote** -- Encode as \’, the right single quotation mark, which | |
| 11 | is typically curled in HTML and XHTML documents by default. | |
| 12 | * **quotehex** -- Encode \’, the right single quotation mark's numeric | |
| 13 | value. | |
| 14 | ||
| 15 | When typsetting into a PDF document, only the semantically correct value | |
| 16 | of \' will be curled automatically. | |
| 17 | ||
| 18 | # History | |
| 19 | ||
| 20 | Quotation marks trace back to Ancient Greek, later adopted to the diplé (⸖) | |
| 21 | circa 625 BCE, foreshadowing its later curve. By the seventeenth century, | |
| 22 | quotation marks grew common. During the nineteenth century, Western Europe | |
| 23 | turned the convexity of quotation mark pairs outward. | |
| 24 | ||
| 25 | Early mechanical typewriters, circa 1825, lacked many punctuation marks. As | |
| 26 | technology improved, additional keys were added while some keys played dual | |
| 27 | roles (such as I for 1). Straight single and double quotes could be co-opted | |
| 28 | for quotation marks and apostrophes, feet and inches marks, and primes and | |
| 29 | double-primes. There wasn't a pressing need to type curled versions because | |
| 30 | humans excel at understanding from context. | |
| 31 | ||
| 32 | Eventually straight quotes were codified for computers. Unfortunately, the | |
| 33 | apostrophe carried with it the baggage from typewriters. That is, burgeoning | |
| 34 | encoding standards failed to let users capture the nuances of the English | |
| 35 | language; computers forced users to treat the apostrophe as a straight quote. | |
| 36 | Standards bodies suggested using the right single quotation mark for an | |
| 37 | apostrophe instead, shirking off its semantic meaning. Consequently, | |
| 38 | text containing English quotations, especially British English, is now | |
| 39 | riddled with ambiguity. | |
| 40 | ||
| 41 | Consider the sentence: | |
| 42 | ||
| 43 | > Ambiguity lurks in "'cause the horses'". | |
| 44 | ||
| 45 | Does `'cause` mean _because_ or _induce_? The answer determines whether | |
| 46 | an open left single quote is used or an apostrophe, semantically speaking. | |
| 47 | It's amazing how ancient decisions still affect modern systems. | |
| 48 | ||
| 1 | 49 |
| 1 | #!/usr/bin/env bash | |
| 2 | ||
| 3 | readonly FONTS_DIR="/usr/local/share/fonts" | |
| 4 | readonly DOWNLOAD_DIR=$(mktemp -d) | |
| 5 | ||
| 6 | cleanup() { | |
| 7 | if [ -d "${DOWNLOAD_DIR}" ]; then | |
| 8 | rm -rf "${DOWNLOAD_DIR}" | |
| 9 | fi | |
| 10 | } | |
| 11 | ||
| 12 | trap cleanup EXIT | |
| 13 | ||
| 14 | if [ ! -d "${FONTS_DIR}" ]; then | |
| 15 | echo "ERROR: Create ${FONTS_DIR} and ensure write access." | |
| 16 | exit 1 | |
| 17 | fi | |
| 18 | ||
| 19 | while IFS=',' read -r url extension; do | |
| 20 | [[ -n "$url" ]] || continue | |
| 21 | ||
| 22 | filename=$(basename "${url}") | |
| 23 | ||
| 24 | if [ ! -d "${FONTS_DIR}/${extension}" ]; then | |
| 25 | echo "ERROR: Create ${FONTS_DIR}/${extension} and ensure write access." | |
| 26 | exit 1 | |
| 27 | fi | |
| 28 | ||
| 29 | echo "Downloading ${url} to ${DOWNLOAD_DIR}" | |
| 30 | wget --quiet -P "${DOWNLOAD_DIR}" "${url}" | |
| 31 | ||
| 32 | font_dir="${FONTS_DIR}/${extension}" | |
| 33 | ||
| 34 | echo "Extracting ${extension} to ${font_dir}" | |
| 35 | unzip -j -o -d "${font_dir}" "${DOWNLOAD_DIR}/${filename}" "*.${extension}" | |
| 36 | done < urls.csv | |
| 37 | ||
| 1 | 38 |
| 1 | https://fonts.keenwrite.com/download/andada-pro.zip,otf | |
| 2 | https://fonts.keenwrite.com/download/archivo-narrow.zip,otf | |
| 3 | https://fonts.keenwrite.com/download/carlito.zip,ttf | |
| 4 | https://fonts.keenwrite.com/download/courier-prime.zip,ttf | |
| 5 | https://fonts.keenwrite.com/download/inconsolata.zip,ttf | |
| 6 | https://fonts.keenwrite.com/download/libre-baskerville.zip,ttf | |
| 7 | https://fonts.keenwrite.com/download/niconne.zip,ttf | |
| 8 | https://fonts.keenwrite.com/download/nunito.zip,ttf | |
| 9 | https://fonts.keenwrite.com/download/open-sans-emoji.zip,ttf | |
| 10 | https://fonts.keenwrite.com/download/pt-mono.zip,ttf | |
| 11 | https://fonts.keenwrite.com/download/pt-sans.zip,ttf | |
| 12 | https://fonts.keenwrite.com/download/pt-serif.zip,ttf | |
| 13 | https://fonts.keenwrite.com/download/roboto.zip,ttf | |
| 14 | https://fonts.keenwrite.com/download/roboto-mono.zip,ttf | |
| 15 | https://fonts.keenwrite.com/download/source-serif-4.zip,otf | |
| 16 | https://fonts.keenwrite.com/download/underwood.zip,ttf | |
| 17 | ||
| 1 | 18 |
| 53 | 53 | DEPENDENCIES=( |
| 54 | 54 | "gradle,https://gradle.org" |
| 55 | "warp-packer,https://github.com/Reisz/warp/releases" | |
| 56 | "linux-x64.warp-packer,https://github.com/dgiagio/warp/releases" | |
| 57 | "osslsigncode,https://www.winehq.org" | |
| 58 | 55 | "tar,https://www.gnu.org/software/tar" |
| 59 | 56 | "wine,https://www.winehq.org" |
| ... | ||
| 184 | 181 | readonly SCRIPT_SRC="\$(dirname "\${BASH_SOURCE[\${#BASH_SOURCE[@]} - 1]}")" |
| 185 | 182 | |
| 186 | "\${SCRIPT_SRC}/${ARG_JAVA_DIR}/bin/java" ${OPT_JAVA} -jar "\${SCRIPT_SRC}/${FILE_APP_JAR}" "\$@" 2>/dev/null | |
| 183 | "\${SCRIPT_SRC}/${ARG_JAVA_DIR}/bin/java" ${OPT_JAVA} -jar "\${SCRIPT_SRC}/${FILE_APP_JAR}" "\$@" | |
| 187 | 184 | __EOT |
| 188 | 185 | |
| ... | ||
| 200 | 197 | |
| 201 | 198 | set SCRIPT_DIR=%~dp0 |
| 202 | "%SCRIPT_DIR%\\${ARG_JAVA_DIR}\\bin\\java" ${OPT_JAVA} -jar "%SCRIPT_DIR%\\${FILE_APP_JAR}" %* 2>nul | |
| 199 | "%SCRIPT_DIR%\\${ARG_JAVA_DIR}\\bin\\java" ${OPT_JAVA} -jar "%SCRIPT_DIR%\\${FILE_APP_JAR}" %* | |
| 203 | 200 | __EOT |
| 204 | 201 | |
| ... | ||
| 247 | 244 | |
| 248 | 245 | $log "Sign ${FILE_BINARY}" |
| 249 | osslsigncode sign \ | |
| 246 | ${SCRIPT_DIR}/bin/osslsigncode sign \ | |
| 250 | 247 | -pkcs12 "${FILE_CERTIFICATE}" \ |
| 251 | 248 | -askpass \ |
| ... | ||
| 271 | 268 | # --------------------------------------------------------------------------- |
| 272 | 269 | utile_create_launcher() { |
| 273 | packer=warp-packer | |
| 270 | packer=${SCRIPT_DIR}/bin/warp-packer | |
| 274 | 271 | packer_opt_pack="pack" |
| 275 | 272 | packer_opt_input="input-dir" |
| ... | ||
| 288 | 285 | # The warp-packer fork that fixes Windows doesn't support MacOS. |
| 289 | 286 | if [ "${ARG_JAVA_OS}" = "macos" ]; then |
| 290 | packer=linux-x64.warp-packer | |
| 287 | packer=${SCRIPT_DIR}/bin/linux-x64.warp-packer | |
| 291 | 288 | packer_opt_pack="" |
| 292 | 289 | packer_opt_input="input_dir" |
| 1 | 23+38 | |
| 1 | 23.0.1+13 | |
| 2 | 2 |
| 5 | 5 | java \ |
| 6 | 6 | -Dprism.order=sw \ |
| 7 | --enable-preview \ | |
| 8 | 7 | --add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \ |
| 9 | 8 | --add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED \ |
| 18 | 18 | import java.util.concurrent.ExecutorService; |
| 19 | 19 | import java.util.concurrent.Future; |
| 20 | import java.util.concurrent.atomic.AtomicInteger; | |
| 21 | 20 | |
| 22 | import static com.keenwrite.Launcher.terminate; | |
| 23 | 21 | import static com.keenwrite.events.StatusEvent.clue; |
| 24 | 22 | import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; |
| ... | ||
| 41 | 39 | |
| 42 | 40 | public static void run( final Arguments args ) { |
| 43 | final var exitCode = new AtomicInteger(); | |
| 44 | ||
| 45 | 41 | final var future = new CompletableFuture<Path>() { |
| 46 | 42 | @Override |
| 47 | 43 | public boolean complete( final Path path ) { |
| 48 | 44 | return super.complete( path ); |
| 49 | 45 | } |
| 50 | 46 | |
| 51 | 47 | @Override |
| 52 | 48 | public boolean completeExceptionally( final Throwable ex ) { |
| 53 | 49 | clue( ex ); |
| 54 | exitCode.set( 1 ); | |
| 55 | 50 | |
| 56 | 51 | return super.completeExceptionally( ex ); |
| 57 | 52 | } |
| 58 | 53 | }; |
| 59 | 54 | |
| 60 | 55 | file_export( args, future ); |
| 61 | 56 | sExecutor.shutdown(); |
| 62 | 57 | future.join(); |
| 63 | terminate( exitCode.get() ); | |
| 64 | 58 | } |
| 65 | 59 | |
| 5 | 5 | package com.keenwrite; |
| 6 | 6 | |
| 7 | import com.keenwrite.events.StatusEvent; | |
| 7 | 8 | import com.keenwrite.io.MediaType; |
| 8 | 9 | import com.keenwrite.io.MediaTypeExtension; |
| ... | ||
| 36 | 37 | */ |
| 37 | 38 | XHTML_TEX( ".xhtml" ), |
| 39 | ||
| 40 | /** | |
| 41 | * For XHTML exports, encode TeX as SVG. Treat image links relatively. | |
| 42 | */ | |
| 43 | XHTML_TEX_SVG( ".xhtml" ), | |
| 38 | 44 | |
| 39 | 45 | /** |
| ... | ||
| 85 | 91 | throws IllegalArgumentException { |
| 86 | 92 | assert extension != null; |
| 93 | final var mediaType = MediaTypeExtension.fromExtension( extension ); | |
| 87 | 94 | |
| 88 | return valueFrom( MediaTypeExtension.fromExtension( extension ), modifier ); | |
| 95 | return valueFrom( mediaType, modifier ); | |
| 89 | 96 | } |
| 90 | 97 | |
| ... | ||
| 99 | 106 | public static ExportFormat valueFrom( |
| 100 | 107 | final MediaType type, final String modifier ) { |
| 108 | final var svg = "svg".equalsIgnoreCase( modifier.trim() ); | |
| 109 | ||
| 101 | 110 | return switch( type ) { |
| 102 | case TEXT_HTML, TEXT_XHTML -> "svg".equalsIgnoreCase( modifier.trim() ) | |
| 111 | case TEXT_HTML -> svg | |
| 103 | 112 | ? HTML_TEX_SVG |
| 104 | 113 | : HTML_TEX_DELIMITED; |
| 114 | case TEXT_XML, APP_XHTML -> svg | |
| 115 | ? XHTML_TEX_SVG | |
| 116 | : XHTML_TEX; | |
| 105 | 117 | case APP_PDF -> APPLICATION_PDF; |
| 106 | case TEXT_XML -> XHTML_TEX; | |
| 107 | 118 | default -> throw new IllegalArgumentException( format( |
| 108 | 119 | "Unrecognized format type and subtype: '%s' and '%s'", type, modifier |
| 1 | /* Copyright 2020-2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite; | |
| 6 | ||
| 7 | import com.keenwrite.cmdline.HeadlessApp; | |
| 8 | import com.keenwrite.events.HyperlinkOpenEvent; | |
| 9 | import com.keenwrite.events.StatusEvent; | |
| 10 | import com.keenwrite.preferences.Workspace; | |
| 11 | import com.keenwrite.preview.MathRenderer; | |
| 12 | import com.keenwrite.spelling.impl.Lexicon; | |
| 13 | import javafx.application.Application; | |
| 14 | import javafx.event.Event; | |
| 15 | import javafx.event.EventType; | |
| 16 | import javafx.scene.input.KeyCode; | |
| 17 | import javafx.scene.input.KeyEvent; | |
| 18 | import javafx.stage.Stage; | |
| 19 | import org.greenrobot.eventbus.Subscribe; | |
| 20 | ||
| 21 | import java.util.function.BooleanSupplier; | |
| 22 | import java.util.logging.FileHandler; | |
| 23 | import java.util.logging.Level; | |
| 24 | import java.util.logging.Logger; | |
| 25 | ||
| 26 | import static com.keenwrite.Bootstrap.APP_TITLE; | |
| 27 | import static com.keenwrite.constants.GraphicsConstants.LOGOS; | |
| 28 | import static com.keenwrite.events.Bus.register; | |
| 29 | import static com.keenwrite.preferences.AppKeys.*; | |
| 30 | import static com.keenwrite.util.FontLoader.initFonts; | |
| 31 | import static java.util.logging.Logger.getLogger; | |
| 32 | import static javafx.scene.input.KeyCode.F11; | |
| 33 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 34 | import static javafx.scene.input.KeyEvent.KEY_RELEASED; | |
| 35 | import static javafx.stage.WindowEvent.WINDOW_SHOWN; | |
| 36 | ||
| 37 | /** | |
| 38 | * The application allows users to edit plain text files in a markup notation | |
| 39 | * and see a real-time preview of the formatted output. | |
| 40 | */ | |
| 41 | public final class GuiApp extends Application { | |
| 42 | private Workspace mWorkspace; | |
| 43 | ||
| 44 | private final static Logger sLogger = | |
| 45 | getLogger( GuiApp.class.getCanonicalName() ); | |
| 46 | ||
| 47 | /** | |
| 48 | * GUI application entry point. See {@link HeadlessApp} for the entry | |
| 49 | * point to the command-line application. | |
| 50 | * | |
| 51 | * @param args Command-line arguments. | |
| 52 | */ | |
| 53 | public static void run( final String[] args ) { | |
| 54 | setLogLevel( sLogger, Level.FINE ); | |
| 55 | launch( args ); | |
| 56 | } | |
| 57 | ||
| 58 | @SuppressWarnings( "SameParameterValue" ) | |
| 59 | private static void setLogLevel( final Logger logger, final Level level ) { | |
| 60 | final var handlers = logger.getHandlers(); | |
| 61 | ||
| 62 | logger.setLevel( level ); | |
| 63 | ||
| 64 | for( final var h : handlers ) { | |
| 65 | if( h instanceof FileHandler ) { | |
| 66 | h.setLevel( level ); | |
| 67 | } | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Creates an instance of {@link KeyEvent} that represents pressing a key. | |
| 73 | * | |
| 74 | * @param code The key to simulate being pressed down. | |
| 75 | * @param shift Whether shift key modifier shall modify the key code. | |
| 76 | * @return An instance of {@link KeyEvent} that may be used to simulate | |
| 77 | * a key being pressed. | |
| 78 | */ | |
| 79 | public static Event keyDown( final KeyCode code, final boolean shift ) { | |
| 80 | return keyEvent( KEY_PRESSED, code, shift ); | |
| 81 | } | |
| 82 | ||
| 83 | /** | |
| 84 | * Creates an instance of {@link KeyEvent} that represents a key released | |
| 85 | * event without any modifier keys held. | |
| 86 | * | |
| 87 | * @param code The key code representing a key to simulate releasing. | |
| 88 | * @return An instance of {@link KeyEvent}. | |
| 89 | */ | |
| 90 | public static Event keyDown( final KeyCode code ) { | |
| 91 | return keyDown( code, false ); | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Creates an instance of {@link KeyEvent} that represents releasing a key. | |
| 96 | * | |
| 97 | * @param code The key to simulate being released up. | |
| 98 | * @param shift Whether shift key modifier shall modify the key code. | |
| 99 | * @return An instance of {@link KeyEvent} that may be used to simulate | |
| 100 | * a key being released. | |
| 101 | */ | |
| 102 | @SuppressWarnings( "unused" ) | |
| 103 | public static Event keyUp( final KeyCode code, final boolean shift ) { | |
| 104 | return keyEvent( KEY_RELEASED, code, shift ); | |
| 105 | } | |
| 106 | ||
| 107 | private static Event keyEvent( | |
| 108 | final EventType<KeyEvent> type, final KeyCode code, final boolean shift ) { | |
| 109 | return new KeyEvent( | |
| 110 | type, "", "", code, shift, false, false, false | |
| 111 | ); | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * JavaFX entry point. | |
| 116 | * | |
| 117 | * @param stage The primary application stage. | |
| 118 | */ | |
| 119 | @Override | |
| 120 | public void start( final Stage stage ) { | |
| 121 | // Must be instantiated after the UI is initialized (i.e., not in main) | |
| 122 | // because it interacts with GUI properties. | |
| 123 | mWorkspace = new Workspace(); | |
| 124 | ||
| 125 | // The locale was already loaded when the workspace was created. This | |
| 126 | // ensures that when the locale preference changes, a new spellchecker | |
| 127 | // instance will be loaded and applied. | |
| 128 | final var property = mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 129 | property.addListener( ( _, _, _ ) -> readLexicon() ); | |
| 130 | ||
| 131 | initFonts(); | |
| 132 | initState( stage ); | |
| 133 | initStage( stage ); | |
| 134 | initIcons( stage ); | |
| 135 | initScene( stage ); | |
| 136 | ||
| 137 | MathRenderer.bindSize( mWorkspace.doubleProperty( KEY_UI_FONT_MATH_SIZE ) ); | |
| 138 | ||
| 139 | // Load the lexicon and check all the documents after all files are open. | |
| 140 | stage.addEventFilter( WINDOW_SHOWN, _ -> readLexicon() ); | |
| 141 | stage.show(); | |
| 142 | ||
| 143 | register( this ); | |
| 144 | } | |
| 145 | ||
| 146 | private void initState( final Stage stage ) { | |
| 147 | final var enable = createBoundsEnabledSupplier( stage ); | |
| 148 | ||
| 149 | stage.setX( mWorkspace.getDouble( KEY_UI_WINDOW_X ) ); | |
| 150 | stage.setY( mWorkspace.getDouble( KEY_UI_WINDOW_Y ) ); | |
| 151 | stage.setWidth( mWorkspace.getDouble( KEY_UI_WINDOW_W ) ); | |
| 152 | stage.setHeight( mWorkspace.getDouble( KEY_UI_WINDOW_H ) ); | |
| 153 | stage.setMaximized( mWorkspace.getBoolean( KEY_UI_WINDOW_MAX ) ); | |
| 154 | stage.setFullScreen( mWorkspace.getBoolean( KEY_UI_WINDOW_FULL ) ); | |
| 155 | ||
| 156 | mWorkspace.listen( KEY_UI_WINDOW_X, stage.xProperty(), enable ); | |
| 157 | mWorkspace.listen( KEY_UI_WINDOW_Y, stage.yProperty(), enable ); | |
| 158 | mWorkspace.listen( KEY_UI_WINDOW_W, stage.widthProperty(), enable ); | |
| 159 | mWorkspace.listen( KEY_UI_WINDOW_H, stage.heightProperty(), enable ); | |
| 160 | mWorkspace.listen( KEY_UI_WINDOW_MAX, stage.maximizedProperty() ); | |
| 161 | mWorkspace.listen( KEY_UI_WINDOW_FULL, stage.fullScreenProperty() ); | |
| 162 | } | |
| 163 | ||
| 164 | private void initStage( final Stage stage ) { | |
| 165 | stage.setTitle( APP_TITLE ); | |
| 166 | stage.addEventHandler( KEY_PRESSED, event -> { | |
| 167 | if( F11.equals( event.getCode() ) ) { | |
| 168 | stage.setFullScreen( !stage.isFullScreen() ); | |
| 169 | } | |
| 170 | } ); | |
| 171 | } | |
| 172 | ||
| 173 | private void initIcons( final Stage stage ) { | |
| 174 | stage.getIcons().addAll( LOGOS ); | |
| 175 | } | |
| 176 | ||
| 177 | private void initScene( final Stage stage ) { | |
| 178 | final var mainScene = new MainScene( mWorkspace ); | |
| 179 | stage.setScene( mainScene.getScene() ); | |
| 180 | } | |
| 181 | ||
| 182 | /** | |
| 183 | * When a hyperlink website URL is clicked, this method is called to launch | |
| 184 | * the default browser to the event's location. | |
| 185 | * | |
| 186 | * @param event The event called when a hyperlink was clicked. | |
| 187 | */ | |
| 188 | @Subscribe | |
| 189 | public void handle( final HyperlinkOpenEvent event ) { | |
| 190 | getHostServices().showDocument( event.getUri().toString() ); | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * When a status message is shown, write it to the console. | |
| 195 | * | |
| 196 | * @param event The event published when the status changes. | |
| 197 | */ | |
| 198 | @Subscribe | |
| 199 | public void handle( final StatusEvent event ) { | |
| 200 | assert event != null; | |
| 201 | assert sLogger != null; | |
| 202 | ||
| 203 | sLogger.info( event.getMessage() ); | |
| 204 | } | |
| 205 | ||
| 206 | /** | |
| 207 | * This will load the lexicon for the user's preferred locale and fire | |
| 208 | * an event when the all entries in the lexicon have been loaded. | |
| 209 | */ | |
| 210 | private void readLexicon() { | |
| 211 | Lexicon.read( mWorkspace.getLocale() ); | |
| 212 | } | |
| 213 | ||
| 214 | /** | |
| 215 | * When the window is maximized, full screen, or iconified, prevent updating | |
| 216 | * the window bounds. This is used so that if the user exits the application | |
| 217 | * when full screen (or maximized), restarting the application will recall | |
| 218 | * the previous bounds, allowing for continuity of expected behaviour. | |
| 219 | * | |
| 220 | * @param stage The window to check for "normal" status. | |
| 221 | * @return {@code false} when the bounds must not be changed, ergo | |
| 222 | * persisted. | |
| 223 | */ | |
| 224 | private BooleanSupplier createBoundsEnabledSupplier( final Stage stage ) { | |
| 225 | return () -> | |
| 226 | !(stage.isMaximized() || stage.isFullScreen() || stage.isIconified()); | |
| 227 | } | |
| 228 | } | |
| 1 | 229 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 2 | 5 | package com.keenwrite; |
| 3 | 6 | |
| 4 | 7 | import com.keenwrite.cmdline.Arguments; |
| 5 | 8 | import com.keenwrite.cmdline.ColourScheme; |
| 6 | 9 | import com.keenwrite.cmdline.HeadlessApp; |
| 7 | 10 | import picocli.CommandLine; |
| 8 | 11 | |
| 9 | 12 | import java.io.IOException; |
| 10 | 13 | import java.io.InputStream; |
| 14 | import java.io.PrintStream; | |
| 11 | 15 | import java.util.Properties; |
| 12 | 16 | import java.util.function.Consumer; |
| ... | ||
| 21 | 25 | |
| 22 | 26 | /** |
| 23 | * Launches the application using the {@link MainApp} class. | |
| 27 | * This is the main entry point to the application. The {@link Launcher} class | |
| 28 | * is responsible for running the application using either the {@link GuiApp} or | |
| 29 | * {@link HeadlessApp} class, depending on whether running with command-line | |
| 30 | * arguments. | |
| 24 | 31 | * |
| 25 | 32 | * <p> |
| 26 | 33 | * This is required until modules are implemented, which may never happen |
| 27 | 34 | * because the application should be ported away from Java and JavaFX. |
| 28 | 35 | * </p> |
| 29 | 36 | */ |
| 30 | 37 | public final class Launcher implements Consumer<Arguments> { |
| 38 | static { | |
| 39 | // We don't care about the logging provider connection message. | |
| 40 | System.setProperty( "slf4j.internal.verbosity", "WARN" ); | |
| 41 | } | |
| 31 | 42 | |
| 32 | 43 | /** |
| 33 | 44 | * Needed for the GUI. |
| 34 | 45 | */ |
| 35 | 46 | private final String[] mArgs; |
| 47 | ||
| 48 | /** | |
| 49 | * Where to write error messages. | |
| 50 | */ | |
| 51 | private static final PrintStream ERRORS = System.err; | |
| 36 | 52 | |
| 37 | 53 | /** |
| ... | ||
| 87 | 103 | * @param exitCode Code to provide back to the calling shell. |
| 88 | 104 | */ |
| 89 | public static void terminate( final int exitCode ) { | |
| 105 | private static void terminate( final int exitCode ) { | |
| 90 | 106 | System.exit( exitCode ); |
| 91 | 107 | } |
| ... | ||
| 101 | 117 | parser.setUnmatchedArgumentsAllowed( false ); |
| 102 | 118 | |
| 103 | final var exitCode = parser.execute( args ); | |
| 104 | final var parseResult = parser.getParseResult(); | |
| 119 | final var parseResult = parser.parseArgs( args ); | |
| 105 | 120 | |
| 106 | if( parseResult.isUsageHelpRequested() ) { | |
| 107 | terminate( exitCode ); | |
| 108 | } | |
| 109 | else if( parseResult.isVersionHelpRequested() ) { | |
| 121 | if( parseResult.isVersionHelpRequested() ) { | |
| 110 | 122 | showAppInfo(); |
| 111 | terminate( exitCode ); | |
| 112 | 123 | } |
| 124 | ||
| 125 | final var exitCode = parser.execute( args ); | |
| 126 | terminate( exitCode ); | |
| 113 | 127 | } |
| 114 | 128 | |
| ... | ||
| 143 | 157 | if( message != null && message.toLowerCase().contains( "javafx" ) ) { |
| 144 | 158 | message = "Run using a Java Runtime Environment that includes JavaFX."; |
| 145 | out( "ERROR: %s", message ); | |
| 159 | log( "ERROR: %s", message ); | |
| 146 | 160 | } |
| 147 | 161 | else { |
| 148 | error.printStackTrace( System.err ); | |
| 162 | error.printStackTrace( ERRORS ); | |
| 149 | 163 | } |
| 150 | 164 | } |
| 151 | 165 | |
| 152 | 166 | /** |
| 153 | * Suppress writing to standard error, suppresses writing log messages. | |
| 167 | * Suppress writing log messages. | |
| 154 | 168 | */ |
| 155 | 169 | private static void disableLogging() { |
| 156 | 170 | LogManager.getLogManager().reset(); |
| 157 | // TODO: Delete this after JavaFX/GTK 3 no longer barfs useless warnings. | |
| 158 | System.err.close(); | |
| 159 | 171 | } |
| 160 | 172 | |
| 161 | 173 | /** |
| 162 | 174 | * Writes the given placeholder text to standard output with a new line |
| 163 | 175 | * appended. |
| 164 | 176 | * |
| 165 | 177 | * @param message The format string specifier. |
| 166 | 178 | * @param args The arguments to substitute into the format string. |
| 167 | 179 | */ |
| 168 | private static void out( final String message, final Object... args ) { | |
| 169 | System.out.printf( format( "%s%n", message ), args ); | |
| 180 | private static void log( final String message, final Object... args ) { | |
| 181 | ERRORS.printf( format( "%s%n", message ), args ); | |
| 182 | ERRORS.flush(); | |
| 170 | 183 | } |
| 171 | 184 | |
| 172 | 185 | private static void showAppInfo() { |
| 173 | out( "%n%s version %s", APP_TITLE, APP_VERSION ); | |
| 174 | out( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR ); | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * Delegates running the application via the command-line argument parser. | |
| 179 | * This is the main entry point for the application, regardless of whether | |
| 180 | * run from the command-line or as a GUI. | |
| 181 | * | |
| 182 | * @param args Command-line arguments. | |
| 183 | */ | |
| 184 | public static void main( final String[] args ) { | |
| 185 | installTrustManager(); | |
| 186 | parse( args ); | |
| 186 | log( "%s version %s", APP_TITLE, APP_VERSION ); | |
| 187 | log( "Copyright 2016-%s White Magic Software, Ltd.", APP_YEAR ); | |
| 187 | 188 | } |
| 188 | 189 | |
| ... | ||
| 197 | 198 | * Called after the arguments have been parsed. |
| 198 | 199 | * |
| 199 | * @param args The parsed command-line arguments. | |
| 200 | * @param arguments The parsed command-line arguments. | |
| 200 | 201 | */ |
| 201 | 202 | @Override |
| 202 | public void accept( final Arguments args ) { | |
| 203 | assert args != null; | |
| 203 | public void accept( final Arguments arguments ) { | |
| 204 | assert arguments != null; | |
| 204 | 205 | |
| 205 | 206 | try { |
| 206 | 207 | int argCount = mArgs.length; |
| 207 | 208 | |
| 208 | if( args.quiet() ) { | |
| 209 | if( arguments.quiet() ) { | |
| 209 | 210 | argCount--; |
| 210 | 211 | } |
| 211 | 212 | else { |
| 212 | 213 | showAppInfo(); |
| 213 | 214 | } |
| 214 | 215 | |
| 215 | if( args.debug() ) { | |
| 216 | if( arguments.debug() ) { | |
| 216 | 217 | argCount--; |
| 218 | arguments.iterate( null, Launcher::log ); | |
| 217 | 219 | } |
| 218 | 220 | else { |
| 219 | 221 | disableLogging(); |
| 220 | 222 | } |
| 221 | 223 | |
| 222 | 224 | if( argCount <= 0 ) { |
| 223 | 225 | // When no command-line arguments are provided, launch the GUI. |
| 224 | MainApp.main( mArgs ); | |
| 226 | GuiApp.run( mArgs ); | |
| 225 | 227 | } |
| 226 | 228 | else { |
| 227 | 229 | // When command-line arguments are supplied, run in headless mode. |
| 228 | HeadlessApp.main( args ); | |
| 230 | HeadlessApp.run( arguments, ERRORS ); | |
| 229 | 231 | } |
| 230 | 232 | } catch( final Throwable t ) { |
| 231 | 233 | log( t ); |
| 232 | 234 | } |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * Delegates running the application via the command-line argument parser. | |
| 239 | * This is the main entry point for the application, regardless of whether | |
| 240 | * run from the command-line or as a GUI. | |
| 241 | * | |
| 242 | * @param args Command-line arguments. | |
| 243 | */ | |
| 244 | public static void main( final String[] args ) { | |
| 245 | installTrustManager(); | |
| 246 | parse( args ); | |
| 233 | 247 | } |
| 234 | 248 | } |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | package com.keenwrite; | |
| 3 | ||
| 4 | import com.keenwrite.cmdline.HeadlessApp; | |
| 5 | import com.keenwrite.events.HyperlinkOpenEvent; | |
| 6 | import com.keenwrite.preferences.Workspace; | |
| 7 | import com.keenwrite.preview.MathRenderer; | |
| 8 | import com.keenwrite.spelling.impl.Lexicon; | |
| 9 | import javafx.application.Application; | |
| 10 | import javafx.event.Event; | |
| 11 | import javafx.event.EventType; | |
| 12 | import javafx.scene.input.KeyCode; | |
| 13 | import javafx.scene.input.KeyEvent; | |
| 14 | import javafx.stage.Stage; | |
| 15 | import org.greenrobot.eventbus.Subscribe; | |
| 16 | ||
| 17 | import java.io.PrintStream; | |
| 18 | import java.util.function.BooleanSupplier; | |
| 19 | ||
| 20 | import static com.keenwrite.Bootstrap.APP_TITLE; | |
| 21 | import static com.keenwrite.constants.GraphicsConstants.LOGOS; | |
| 22 | import static com.keenwrite.events.Bus.register; | |
| 23 | import static com.keenwrite.preferences.AppKeys.*; | |
| 24 | import static com.keenwrite.util.FontLoader.initFonts; | |
| 25 | import static javafx.scene.input.KeyCode.F11; | |
| 26 | import static javafx.scene.input.KeyEvent.KEY_PRESSED; | |
| 27 | import static javafx.scene.input.KeyEvent.KEY_RELEASED; | |
| 28 | import static javafx.stage.WindowEvent.WINDOW_SHOWN; | |
| 29 | ||
| 30 | /** | |
| 31 | * Application entry point. The application allows users to edit plain text | |
| 32 | * files in a markup notation and see a real-time preview of the formatted | |
| 33 | * output. | |
| 34 | */ | |
| 35 | public final class MainApp extends Application { | |
| 36 | ||
| 37 | private Workspace mWorkspace; | |
| 38 | ||
| 39 | /** | |
| 40 | * TODO: Delete this after JavaFX/GTK 3 no longer barfs useless warnings. | |
| 41 | */ | |
| 42 | @SuppressWarnings( "SameParameterValue" ) | |
| 43 | private static void stderrRedirect( final PrintStream stream ) { | |
| 44 | System.setErr( stream ); | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * GUI application entry point. See {@link HeadlessApp} for the entry | |
| 49 | * point to the command-line application. | |
| 50 | * | |
| 51 | * @param args Command-line arguments. | |
| 52 | */ | |
| 53 | public static void main( final String[] args ) { | |
| 54 | launch( args ); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Creates an instance of {@link KeyEvent} that represents pressing a key. | |
| 59 | * | |
| 60 | * @param code The key to simulate being pressed down. | |
| 61 | * @param shift Whether shift key modifier shall modify the key code. | |
| 62 | * @return An instance of {@link KeyEvent} that may be used to simulate | |
| 63 | * a key being pressed. | |
| 64 | */ | |
| 65 | public static Event keyDown( final KeyCode code, final boolean shift ) { | |
| 66 | return keyEvent( KEY_PRESSED, code, shift ); | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * Creates an instance of {@link KeyEvent} that represents a key released | |
| 71 | * event without any modifier keys held. | |
| 72 | * | |
| 73 | * @param code The key code representing a key to simulate releasing. | |
| 74 | * @return An instance of {@link KeyEvent}. | |
| 75 | */ | |
| 76 | public static Event keyDown( final KeyCode code ) { | |
| 77 | return keyDown( code, false ); | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Creates an instance of {@link KeyEvent} that represents releasing a key. | |
| 82 | * | |
| 83 | * @param code The key to simulate being released up. | |
| 84 | * @param shift Whether shift key modifier shall modify the key code. | |
| 85 | * @return An instance of {@link KeyEvent} that may be used to simulate | |
| 86 | * a key being released. | |
| 87 | */ | |
| 88 | @SuppressWarnings( "unused" ) | |
| 89 | public static Event keyUp( final KeyCode code, final boolean shift ) { | |
| 90 | return keyEvent( KEY_RELEASED, code, shift ); | |
| 91 | } | |
| 92 | ||
| 93 | private static Event keyEvent( | |
| 94 | final EventType<KeyEvent> type, final KeyCode code, final boolean shift ) { | |
| 95 | return new KeyEvent( | |
| 96 | type, "", "", code, shift, false, false, false | |
| 97 | ); | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * JavaFX entry point. | |
| 102 | * | |
| 103 | * @param stage The primary application stage. | |
| 104 | */ | |
| 105 | @Override | |
| 106 | public void start( final Stage stage ) { | |
| 107 | // Must be instantiated after the UI is initialized (i.e., not in main) | |
| 108 | // because it interacts with GUI properties. | |
| 109 | mWorkspace = new Workspace(); | |
| 110 | ||
| 111 | // The locale was already loaded when the workspace was created. This | |
| 112 | // ensures that when the locale preference changes, a new spellchecker | |
| 113 | // instance will be loaded and applied. | |
| 114 | final var property = mWorkspace.localeProperty( KEY_LANGUAGE_LOCALE ); | |
| 115 | property.addListener( ( _, _, _ ) -> readLexicon() ); | |
| 116 | ||
| 117 | initFonts(); | |
| 118 | initState( stage ); | |
| 119 | initStage( stage ); | |
| 120 | initIcons( stage ); | |
| 121 | initScene( stage ); | |
| 122 | ||
| 123 | MathRenderer.bindSize( mWorkspace.doubleProperty( KEY_UI_FONT_MATH_SIZE ) ); | |
| 124 | ||
| 125 | // Load the lexicon and check all the documents after all files are open. | |
| 126 | stage.addEventFilter( WINDOW_SHOWN, _ -> readLexicon() ); | |
| 127 | stage.show(); | |
| 128 | ||
| 129 | stderrRedirect( System.out ); | |
| 130 | ||
| 131 | register( this ); | |
| 132 | } | |
| 133 | ||
| 134 | private void initState( final Stage stage ) { | |
| 135 | final var enable = createBoundsEnabledSupplier( stage ); | |
| 136 | ||
| 137 | stage.setX( mWorkspace.getDouble( KEY_UI_WINDOW_X ) ); | |
| 138 | stage.setY( mWorkspace.getDouble( KEY_UI_WINDOW_Y ) ); | |
| 139 | stage.setWidth( mWorkspace.getDouble( KEY_UI_WINDOW_W ) ); | |
| 140 | stage.setHeight( mWorkspace.getDouble( KEY_UI_WINDOW_H ) ); | |
| 141 | stage.setMaximized( mWorkspace.getBoolean( KEY_UI_WINDOW_MAX ) ); | |
| 142 | stage.setFullScreen( mWorkspace.getBoolean( KEY_UI_WINDOW_FULL ) ); | |
| 143 | ||
| 144 | mWorkspace.listen( KEY_UI_WINDOW_X, stage.xProperty(), enable ); | |
| 145 | mWorkspace.listen( KEY_UI_WINDOW_Y, stage.yProperty(), enable ); | |
| 146 | mWorkspace.listen( KEY_UI_WINDOW_W, stage.widthProperty(), enable ); | |
| 147 | mWorkspace.listen( KEY_UI_WINDOW_H, stage.heightProperty(), enable ); | |
| 148 | mWorkspace.listen( KEY_UI_WINDOW_MAX, stage.maximizedProperty() ); | |
| 149 | mWorkspace.listen( KEY_UI_WINDOW_FULL, stage.fullScreenProperty() ); | |
| 150 | } | |
| 151 | ||
| 152 | private void initStage( final Stage stage ) { | |
| 153 | stage.setTitle( APP_TITLE ); | |
| 154 | stage.addEventHandler( KEY_PRESSED, event -> { | |
| 155 | if( F11.equals( event.getCode() ) ) { | |
| 156 | stage.setFullScreen( !stage.isFullScreen() ); | |
| 157 | } | |
| 158 | } ); | |
| 159 | } | |
| 160 | ||
| 161 | private void initIcons( final Stage stage ) { | |
| 162 | stage.getIcons().addAll( LOGOS ); | |
| 163 | } | |
| 164 | ||
| 165 | private void initScene( final Stage stage ) { | |
| 166 | final var mainScene = new MainScene( mWorkspace ); | |
| 167 | stage.setScene( mainScene.getScene() ); | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * When a hyperlink website URL is clicked, this method is called to launch | |
| 172 | * the default browser to the event's location. | |
| 173 | * | |
| 174 | * @param event The event called when a hyperlink was clicked. | |
| 175 | */ | |
| 176 | @Subscribe | |
| 177 | public void handle( final HyperlinkOpenEvent event ) { | |
| 178 | getHostServices().showDocument( event.getUri().toString() ); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * This will load the lexicon for the user's preferred locale and fire | |
| 183 | * an event when the all entries in the lexicon have been loaded. | |
| 184 | */ | |
| 185 | private void readLexicon() { | |
| 186 | Lexicon.read( mWorkspace.getLocale() ); | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * When the window is maximized, full screen, or iconified, prevent updating | |
| 191 | * the window bounds. This is used so that if the user exits the application | |
| 192 | * when full screen (or maximized), restarting the application will recall | |
| 193 | * the previous bounds, allowing for continuity of expected behaviour. | |
| 194 | * | |
| 195 | * @param stage The window to check for "normal" status. | |
| 196 | * @return {@code false} when the bounds must not be changed, ergo persisted. | |
| 197 | */ | |
| 198 | private BooleanSupplier createBoundsEnabledSupplier( final Stage stage ) { | |
| 199 | return () -> | |
| 200 | !(stage.isMaximized() || stage.isFullScreen() || stage.isIconified()); | |
| 201 | } | |
| 202 | } | |
| 203 | 1 |
| 18 | 18 | import com.keenwrite.preferences.Workspace; |
| 19 | 19 | import com.keenwrite.preview.HtmlPreview; |
| 20 | import com.keenwrite.processors.html.HtmlPreviewProcessor; | |
| 21 | import com.keenwrite.processors.Processor; | |
| 22 | import com.keenwrite.processors.ProcessorContext; | |
| 23 | import com.keenwrite.processors.ProcessorFactory; | |
| 24 | import com.keenwrite.processors.r.Engine; | |
| 25 | import com.keenwrite.processors.r.RBootstrapController; | |
| 26 | import com.keenwrite.service.events.Notifier; | |
| 27 | import com.keenwrite.spelling.api.SpellChecker; | |
| 28 | import com.keenwrite.spelling.impl.PermissiveSpeller; | |
| 29 | import com.keenwrite.spelling.impl.SymSpellSpeller; | |
| 30 | import com.keenwrite.typesetting.installer.TypesetterInstaller; | |
| 31 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 32 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 33 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 34 | import com.keenwrite.ui.spelling.TextEditorSpellChecker; | |
| 35 | import com.keenwrite.util.GenericBuilder; | |
| 36 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 37 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 38 | import javafx.beans.property.*; | |
| 39 | import javafx.collections.ListChangeListener; | |
| 40 | import javafx.concurrent.Task; | |
| 41 | import javafx.event.ActionEvent; | |
| 42 | import javafx.event.Event; | |
| 43 | import javafx.event.EventHandler; | |
| 44 | import javafx.scene.Node; | |
| 45 | import javafx.scene.Scene; | |
| 46 | import javafx.scene.control.SplitPane; | |
| 47 | import javafx.scene.control.Tab; | |
| 48 | import javafx.scene.control.TabPane; | |
| 49 | import javafx.scene.control.Tooltip; | |
| 50 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 51 | import javafx.scene.input.KeyEvent; | |
| 52 | import javafx.stage.Stage; | |
| 53 | import javafx.stage.Window; | |
| 54 | import org.greenrobot.eventbus.Subscribe; | |
| 55 | ||
| 56 | import java.io.File; | |
| 57 | import java.io.FileNotFoundException; | |
| 58 | import java.nio.file.Path; | |
| 59 | import java.util.*; | |
| 60 | import java.util.concurrent.ExecutorService; | |
| 61 | import java.util.concurrent.ScheduledExecutorService; | |
| 62 | import java.util.concurrent.ScheduledFuture; | |
| 63 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 64 | import java.util.concurrent.atomic.AtomicReference; | |
| 65 | import java.util.function.Consumer; | |
| 66 | import java.util.function.Function; | |
| 67 | import java.util.stream.Collectors; | |
| 68 | ||
| 69 | import static com.keenwrite.ExportFormat.NONE; | |
| 70 | import static com.keenwrite.Launcher.terminate; | |
| 71 | import static com.keenwrite.Messages.get; | |
| 72 | import static com.keenwrite.constants.Constants.*; | |
| 73 | import static com.keenwrite.events.Bus.register; | |
| 74 | import static com.keenwrite.events.StatusEvent.clue; | |
| 75 | import static com.keenwrite.io.MediaType.*; | |
| 76 | import static com.keenwrite.io.MediaType.TypeName.TEXT; | |
| 77 | import static com.keenwrite.io.SysFile.toFile; | |
| 78 | import static com.keenwrite.preferences.AppKeys.*; | |
| 79 | import static com.keenwrite.processors.html.IdentityProcessor.IDENTITY; | |
| 80 | import static com.keenwrite.processors.ProcessorContext.Mutator; | |
| 81 | import static com.keenwrite.processors.ProcessorContext.builder; | |
| 82 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 83 | import static java.awt.Desktop.getDesktop; | |
| 84 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 85 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 86 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 87 | import static java.util.stream.Collectors.groupingBy; | |
| 88 | import static javafx.application.Platform.exit; | |
| 89 | import static javafx.application.Platform.runLater; | |
| 90 | import static javafx.scene.control.ButtonType.NO; | |
| 91 | import static javafx.scene.control.ButtonType.YES; | |
| 92 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 93 | import static javafx.scene.input.KeyCode.ENTER; | |
| 94 | import static javafx.scene.input.KeyCode.SPACE; | |
| 95 | import static javafx.scene.input.KeyCombination.ALT_DOWN; | |
| 96 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 97 | import static javafx.util.Duration.millis; | |
| 98 | import static javax.swing.SwingUtilities.invokeLater; | |
| 99 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 100 | ||
| 101 | /** | |
| 102 | * Responsible for wiring together the main application components for a | |
| 103 | * particular {@link Workspace} (project). These include the definition views, | |
| 104 | * text editors, and preview pane along with any corresponding controllers. | |
| 105 | */ | |
| 106 | public final class MainPane extends SplitPane { | |
| 107 | ||
| 108 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 109 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 110 | ||
| 111 | /** | |
| 112 | * Used when opening files to determine how each file should be binned and | |
| 113 | * therefore what tab pane to be opened within. | |
| 114 | */ | |
| 115 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 116 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 117 | ); | |
| 118 | ||
| 119 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 120 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 121 | new AtomicReference<>(); | |
| 122 | ||
| 123 | /** | |
| 124 | * Prevents re-instantiation of processing classes. | |
| 125 | */ | |
| 126 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 127 | new HashMap<>(); | |
| 128 | ||
| 129 | private final Workspace mWorkspace; | |
| 130 | ||
| 131 | /** | |
| 132 | * Groups similar file type tabs together. | |
| 133 | */ | |
| 134 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 135 | ||
| 136 | /** | |
| 137 | * Renders the actively selected plain text editor tab. | |
| 138 | */ | |
| 139 | private final HtmlPreview mPreview; | |
| 140 | ||
| 141 | /** | |
| 142 | * Provides an interactive document outline. | |
| 143 | */ | |
| 144 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 145 | ||
| 146 | /** | |
| 147 | * Changing the active editor fires the value changed event. This allows | |
| 148 | * refreshes to happen when external definitions are modified and need to | |
| 149 | * trigger the processing chain. | |
| 150 | */ | |
| 151 | private final ObjectProperty<TextEditor> mTextEditor = | |
| 152 | new SimpleObjectProperty<>(); | |
| 153 | ||
| 154 | /** | |
| 155 | * Changing the active definition editor fires the value changed event. This | |
| 156 | * allows refreshes to happen when external definitions are modified and need | |
| 157 | * to trigger the processing chain. | |
| 158 | */ | |
| 159 | private final ObjectProperty<TextDefinition> mDefinitionEditor = | |
| 160 | new SimpleObjectProperty<>(); | |
| 161 | ||
| 162 | private final ObjectProperty<SpellChecker> mSpellChecker; | |
| 163 | ||
| 164 | private final TextEditorSpellChecker mEditorSpeller; | |
| 165 | ||
| 166 | /** | |
| 167 | * Called when the definition data is changed. | |
| 168 | */ | |
| 169 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 170 | _ -> { | |
| 171 | process( getTextEditor() ); | |
| 172 | save( getTextDefinition() ); | |
| 173 | }; | |
| 174 | ||
| 175 | /** | |
| 176 | * Tracks the number of detached tab panels opened into their own windows, | |
| 177 | * which allows unique identification of subordinate windows by their title. | |
| 178 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 179 | */ | |
| 180 | private byte mWindowCount; | |
| 181 | ||
| 182 | private final VariableNameInjector mVariableNameInjector; | |
| 183 | ||
| 184 | private final RBootstrapController mRBootstrapController; | |
| 185 | ||
| 186 | private final DocumentStatistics mStatistics; | |
| 187 | ||
| 188 | @SuppressWarnings( { "FieldCanBeLocal", "unused" } ) | |
| 189 | private final TypesetterInstaller mInstallWizard; | |
| 190 | ||
| 191 | /** | |
| 192 | * Adds all content panels to the main user interface. This will load the | |
| 193 | * configuration settings from the workspace to reproduce the settings from | |
| 194 | * a previous session. | |
| 195 | */ | |
| 196 | public MainPane( final Workspace workspace ) { | |
| 197 | mWorkspace = workspace; | |
| 198 | mSpellChecker = createSpellChecker(); | |
| 199 | mEditorSpeller = createTextEditorSpellChecker( mSpellChecker ); | |
| 200 | mPreview = new HtmlPreview( workspace ); | |
| 201 | mStatistics = new DocumentStatistics( workspace ); | |
| 202 | ||
| 203 | mTextEditor.addListener( ( _, o, n ) -> { | |
| 204 | if( o != null ) { | |
| 205 | removeProcessor( o ); | |
| 206 | } | |
| 207 | ||
| 208 | if( n != null ) { | |
| 209 | mPreview.setBaseUri( n.getPath() ); | |
| 210 | updateProcessors( n ); | |
| 211 | process( n ); | |
| 212 | } | |
| 213 | } ); | |
| 214 | ||
| 215 | mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); | |
| 216 | mDefinitionEditor.set( createDefinitionEditor( workspace ) ); | |
| 217 | mVariableNameInjector = new VariableNameInjector( workspace ); | |
| 218 | mRBootstrapController = new RBootstrapController( | |
| 219 | workspace, mDefinitionEditor.get()::getDefinitions | |
| 220 | ); | |
| 221 | ||
| 222 | // If the user modifies the definitions, re-process the variables. | |
| 223 | mDefinitionEditor.addListener( ( _, _, _ ) -> { | |
| 224 | final var textEditor = getTextEditor(); | |
| 225 | ||
| 226 | if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) { | |
| 227 | mRBootstrapController.update(); | |
| 228 | } | |
| 229 | ||
| 230 | process( textEditor ); | |
| 231 | } ); | |
| 232 | ||
| 233 | open( collect( getRecentFiles() ) ); | |
| 234 | viewPreview(); | |
| 235 | setDividerPositions( calculateDividerPositions() ); | |
| 236 | ||
| 237 | // Once the main scene's window regains focus, update the active definition | |
| 238 | // editor to the currently selected tab. | |
| 239 | runLater( () -> getWindow().setOnCloseRequest( event -> { | |
| 240 | // Order matters: Open file names must be persisted before closing all. | |
| 241 | mWorkspace.save(); | |
| 242 | ||
| 243 | if( closeAll() ) { | |
| 244 | exit(); | |
| 245 | terminate( 0 ); | |
| 246 | } | |
| 247 | ||
| 248 | event.consume(); | |
| 249 | } ) ); | |
| 250 | ||
| 251 | register( this ); | |
| 252 | initAutosave( workspace ); | |
| 253 | ||
| 254 | restoreSession(); | |
| 255 | runLater( this::restoreFocus ); | |
| 256 | ||
| 257 | mInstallWizard = new TypesetterInstaller( workspace ); | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Called when spellchecking can be run. This will reload the dictionary | |
| 262 | * into memory once, and then re-use it for all the existing text editors. | |
| 263 | * | |
| 264 | * @param event The event to process, having a populated word-frequency map. | |
| 265 | */ | |
| 266 | @Subscribe | |
| 267 | public void handle( final LexiconLoadedEvent event ) { | |
| 268 | final var lexicon = event.getLexicon(); | |
| 269 | ||
| 270 | try { | |
| 271 | final var checker = SymSpellSpeller.forLexicon( lexicon ); | |
| 272 | mSpellChecker.set( checker ); | |
| 273 | } catch( final Exception ex ) { | |
| 274 | clue( ex ); | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | @Subscribe | |
| 279 | public void handle( final TextEditorFocusEvent event ) { | |
| 280 | mTextEditor.set( event.get() ); | |
| 281 | } | |
| 282 | ||
| 283 | @Subscribe | |
| 284 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 285 | mDefinitionEditor.set( event.get() ); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Typically called when a file name is clicked in the preview panel. | |
| 290 | * | |
| 291 | * @param event The event to process, must contain a valid file reference. | |
| 292 | */ | |
| 293 | @Subscribe | |
| 294 | public void handle( final FileOpenEvent event ) { | |
| 295 | final File eventFile; | |
| 296 | final var eventUri = event.getUri(); | |
| 297 | ||
| 298 | if( eventUri.isAbsolute() ) { | |
| 299 | eventFile = new File( eventUri.getPath() ); | |
| 300 | } | |
| 301 | else { | |
| 302 | final var activeFile = getTextEditor().getFile(); | |
| 303 | final var parent = activeFile.getParentFile(); | |
| 304 | ||
| 305 | if( parent == null ) { | |
| 306 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 307 | return; | |
| 308 | } | |
| 309 | else { | |
| 310 | final var parentPath = parent.getAbsolutePath(); | |
| 311 | eventFile = toFile( Path.of( parentPath, eventUri.getPath() ) ); | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | final var mediaType = MediaTypeExtension.fromFile( eventFile ); | |
| 316 | ||
| 317 | runLater( () -> { | |
| 318 | // Open text files locally. | |
| 319 | if( mediaType.isType( TEXT ) ) { | |
| 320 | open( eventFile ); | |
| 321 | } | |
| 322 | else { | |
| 323 | try { | |
| 324 | // Delegate opening all other file types to the operating system. | |
| 325 | getDesktop().open( eventFile ); | |
| 326 | } catch( final Exception ex ) { | |
| 327 | clue( ex ); | |
| 328 | } | |
| 329 | } | |
| 330 | } ); | |
| 331 | } | |
| 332 | ||
| 333 | @Subscribe | |
| 334 | public void handle( final CaretNavigationEvent event ) { | |
| 335 | runLater( () -> { | |
| 336 | final var textArea = getTextEditor(); | |
| 337 | textArea.moveTo( event.getOffset() ); | |
| 338 | textArea.requestFocus(); | |
| 339 | } ); | |
| 340 | } | |
| 341 | ||
| 342 | @Subscribe | |
| 343 | public void handle( final InsertDefinitionEvent<String> event ) { | |
| 344 | final var leaf = event.getLeaf(); | |
| 345 | final var editor = mTextEditor.get(); | |
| 346 | ||
| 347 | mVariableNameInjector.insert( editor, leaf ); | |
| 348 | } | |
| 349 | ||
| 350 | private void initAutosave( final Workspace workspace ) { | |
| 351 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 352 | ||
| 353 | rate.addListener( | |
| 354 | ( _, _, _ ) -> { | |
| 355 | final var taskRef = mSaveTask.get(); | |
| 356 | ||
| 357 | // Prevent multiple auto-saves from running. | |
| 358 | if( taskRef != null ) { | |
| 359 | taskRef.cancel( false ); | |
| 360 | } | |
| 361 | ||
| 362 | initAutosave( rate ); | |
| 363 | } | |
| 364 | ); | |
| 365 | ||
| 366 | // Start the save listener (avoids duplicating some code). | |
| 367 | initAutosave( rate ); | |
| 368 | } | |
| 369 | ||
| 370 | private void initAutosave( final IntegerProperty rate ) { | |
| 371 | mSaveTask.set( | |
| 372 | mSaver.scheduleAtFixedRate( | |
| 373 | () -> { | |
| 374 | if( getTextEditor().isModified() ) { | |
| 375 | // Ensure the modified indicator is cleared by running on EDT. | |
| 376 | runLater( this::save ); | |
| 377 | } | |
| 378 | }, 0, rate.intValue(), SECONDS | |
| 379 | ) | |
| 380 | ); | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * TODO: Load divider positions from exported settings, see | |
| 385 | * {@link #collect(SetProperty)} comment. | |
| 386 | */ | |
| 387 | private double[] calculateDividerPositions() { | |
| 388 | final var ratio = 100f / getItems().size() / 100; | |
| 389 | final var positions = getDividerPositions(); | |
| 390 | ||
| 391 | for( int i = 0; i < positions.length; i++ ) { | |
| 392 | positions[ i ] = ratio * i; | |
| 393 | } | |
| 394 | ||
| 395 | return positions; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * Opens all the files into the application, provided the paths are unique. | |
| 400 | * This may only be called for any type of files that a user can edit | |
| 401 | * (i.e., update and persist), such as definitions and text files. | |
| 402 | * | |
| 403 | * @param files The list of files to open. | |
| 404 | */ | |
| 405 | public void open( final List<File> files ) { | |
| 406 | files.forEach( this::open ); | |
| 407 | } | |
| 408 | ||
| 409 | /** | |
| 410 | * This opens the given file. Since the preview pane is not a file that | |
| 411 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 412 | * This will exit early if the given file is not a regular file (i.e., a | |
| 413 | * directory). | |
| 414 | * | |
| 415 | * @param inputFile The file to open. | |
| 416 | */ | |
| 417 | private void open( final File inputFile ) { | |
| 418 | // Prevent opening directories (a non-existent "untitled.md" is fine). | |
| 419 | if( !inputFile.isFile() && inputFile.exists() ) { | |
| 420 | return; | |
| 421 | } | |
| 422 | ||
| 423 | final var mediaType = fromFilename( inputFile ); | |
| 424 | ||
| 425 | // Only allow opening text files. | |
| 426 | if( !mediaType.isType( TEXT ) ) { | |
| 427 | return; | |
| 428 | } | |
| 429 | ||
| 430 | final var tab = createTab( inputFile ); | |
| 431 | final var node = tab.getContent(); | |
| 432 | final var tabPane = obtainTabPane( mediaType ); | |
| 433 | ||
| 434 | tab.setTooltip( createTooltip( inputFile ) ); | |
| 435 | tabPane.setFocusTraversable( false ); | |
| 436 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 437 | tabPane.getTabs().add( tab ); | |
| 438 | ||
| 439 | // Attach the tab scene factory for new tab panes. | |
| 440 | if( !getItems().contains( tabPane ) ) { | |
| 441 | addTabPane( | |
| 442 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 443 | ); | |
| 444 | } | |
| 445 | ||
| 446 | if( inputFile.isFile() ) { | |
| 447 | getRecentFiles().add( inputFile.getAbsolutePath() ); | |
| 448 | ||
| 449 | final var dir = inputFile.getParentFile(); | |
| 450 | mWorkspace.fileProperty( KEY_UI_RECENT_DIR ).setValue( dir ); | |
| 451 | } | |
| 452 | } | |
| 453 | ||
| 454 | /** | |
| 455 | * Gives focus to the most recently edited document and attempts to move | |
| 456 | * the caret to the most recently known offset into said document. | |
| 457 | */ | |
| 458 | private void restoreSession() { | |
| 459 | final var workspace = getWorkspace(); | |
| 460 | final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT ); | |
| 461 | final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET ); | |
| 462 | ||
| 463 | for( final var pane : mTabPanes ) { | |
| 464 | for( final var tab : pane.getTabs() ) { | |
| 465 | final var tooltip = tab.getTooltip(); | |
| 466 | ||
| 467 | if( tooltip != null ) { | |
| 468 | final var tabName = tooltip.getText(); | |
| 469 | final var fileName = file.get().toString(); | |
| 470 | ||
| 471 | if( tabName.equalsIgnoreCase( fileName ) ) { | |
| 472 | final var node = tab.getContent(); | |
| 473 | ||
| 474 | pane.getSelectionModel().select( tab ); | |
| 475 | node.requestFocus(); | |
| 476 | ||
| 477 | if( node instanceof TextEditor editor ) { | |
| 478 | runLater( () -> editor.moveTo( offset.getValue() ) ); | |
| 479 | } | |
| 480 | ||
| 481 | break; | |
| 482 | } | |
| 483 | } | |
| 484 | } | |
| 485 | } | |
| 486 | } | |
| 487 | ||
| 488 | /** | |
| 489 | * Sets the focus to the middle pane, which contains the text editor tabs. | |
| 490 | */ | |
| 491 | private void restoreFocus() { | |
| 492 | // Work around a bug where focusing directly on the middle pane results | |
| 493 | // in the R engine not loading variables properly. | |
| 494 | mTabPanes.get( 0 ).requestFocus(); | |
| 495 | ||
| 496 | // This is the only line that should be required. | |
| 497 | mTabPanes.get( 1 ).requestFocus(); | |
| 498 | } | |
| 499 | ||
| 500 | /** | |
| 501 | * Opens a new text editor document using a document file name that doesn't | |
| 502 | * clash with an existing document. | |
| 503 | */ | |
| 504 | public void newTextEditor() { | |
| 505 | final String key = "file.default.document."; | |
| 506 | final String prefix = Constants.get( String.format( "%s%s", key, "prefix" ) ); | |
| 507 | final String suffix = Constants.get( String.format( "%s%s", key, "suffix" ) ); | |
| 508 | ||
| 509 | File file = new File( String.format( "%s.%s", prefix, suffix ) ); | |
| 510 | int i = 0; | |
| 511 | ||
| 512 | while( file.exists() && i++ < 100 ) { | |
| 513 | file = new File( String.format( "%s-%s.%s", prefix, i, suffix ) ); | |
| 514 | } | |
| 515 | ||
| 516 | open( file ); | |
| 517 | } | |
| 518 | ||
| 519 | /** | |
| 520 | * Opens a new definition editor document using the default definition | |
| 521 | * file name. | |
| 522 | */ | |
| 523 | @SuppressWarnings( "unused" ) | |
| 524 | public void newDefinitionEditor() { | |
| 525 | open( DEFINITION_DEFAULT ); | |
| 526 | } | |
| 527 | ||
| 528 | /** | |
| 529 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 530 | * that they save themselves. | |
| 531 | */ | |
| 532 | public void saveAll() { | |
| 533 | iterateEditors( this::save ); | |
| 534 | } | |
| 535 | ||
| 536 | /** | |
| 537 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 538 | * checking if modified first because if the user swaps external media from | |
| 539 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 540 | * the user: save always re-saves. Also, it's less code. | |
| 541 | */ | |
| 542 | public void save() { | |
| 543 | save( getTextEditor() ); | |
| 544 | } | |
| 545 | ||
| 546 | /** | |
| 547 | * Saves the active {@link TextEditor} under a new name. | |
| 548 | * | |
| 549 | * @param files The new active editor {@link File} reference, must contain | |
| 550 | * at least one element. | |
| 551 | */ | |
| 552 | public void saveAs( final List<File> files ) { | |
| 553 | assert files != null; | |
| 554 | assert !files.isEmpty(); | |
| 555 | final var editor = getTextEditor(); | |
| 556 | final var tab = getTab( editor ); | |
| 557 | final var file = files.getFirst(); | |
| 558 | ||
| 559 | // If the file type has changed, refresh the processors. | |
| 560 | final var mediaType = fromFilename( file ); | |
| 561 | final var typeChanged = !editor.isMediaType( mediaType ); | |
| 562 | ||
| 563 | if( typeChanged ) { | |
| 564 | removeProcessor( editor ); | |
| 565 | } | |
| 566 | ||
| 567 | editor.rename( file ); | |
| 568 | tab.ifPresent( t -> { | |
| 569 | t.setText( editor.getFilename() ); | |
| 570 | t.setTooltip( createTooltip( file ) ); | |
| 571 | } ); | |
| 572 | ||
| 573 | if( typeChanged ) { | |
| 574 | updateProcessors( editor ); | |
| 575 | process( editor ); | |
| 576 | } | |
| 577 | ||
| 578 | save(); | |
| 579 | } | |
| 580 | ||
| 581 | /** | |
| 582 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 583 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 584 | * | |
| 585 | * @param resource The resource to export. | |
| 586 | */ | |
| 587 | private void save( final TextResource resource ) { | |
| 588 | try { | |
| 589 | resource.save(); | |
| 590 | } catch( final Exception ex ) { | |
| 591 | clue( ex ); | |
| 592 | sNotifier.alert( | |
| 593 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 594 | ); | |
| 595 | } | |
| 596 | } | |
| 597 | ||
| 598 | /** | |
| 599 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 600 | * | |
| 601 | * @return {@code true} when all editors, modified or otherwise, were | |
| 602 | * permitted to close; {@code false} when one or more editors were modified | |
| 603 | * and the user requested no closing. | |
| 604 | */ | |
| 605 | public boolean closeAll() { | |
| 606 | var closable = true; | |
| 607 | ||
| 608 | for( final var tabPane : mTabPanes ) { | |
| 609 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 610 | ||
| 611 | while( tabIterator.hasNext() ) { | |
| 612 | final var tab = tabIterator.next(); | |
| 613 | final var resource = tab.getContent(); | |
| 614 | ||
| 615 | // The definition panes auto-save, so being specific here prevents | |
| 616 | // closing the definitions in the situation where the user wants to | |
| 617 | // continue editing (i.e., possibly save unsaved work). | |
| 618 | if( !(resource instanceof TextEditor) ) { | |
| 619 | continue; | |
| 620 | } | |
| 621 | ||
| 622 | if( canClose( (TextEditor) resource ) ) { | |
| 623 | tabIterator.remove(); | |
| 624 | close( tab ); | |
| 625 | } | |
| 626 | else { | |
| 627 | closable = false; | |
| 628 | } | |
| 629 | } | |
| 630 | } | |
| 631 | ||
| 632 | return closable; | |
| 633 | } | |
| 634 | ||
| 635 | /** | |
| 636 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 637 | * event. | |
| 638 | * | |
| 639 | * @param tab The {@link Tab} that was closed. | |
| 640 | */ | |
| 641 | private void close( final Tab tab ) { | |
| 642 | assert tab != null; | |
| 643 | ||
| 644 | final var handler = tab.getOnClosed(); | |
| 645 | ||
| 646 | if( handler != null ) { | |
| 647 | handler.handle( new ActionEvent() ); | |
| 648 | } | |
| 649 | } | |
| 650 | ||
| 651 | /** | |
| 652 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 653 | */ | |
| 654 | public void close() { | |
| 655 | final var editor = getTextEditor(); | |
| 656 | ||
| 657 | if( canClose( editor ) ) { | |
| 658 | close( editor ); | |
| 659 | removeProcessor( editor ); | |
| 660 | } | |
| 661 | } | |
| 662 | ||
| 663 | /** | |
| 664 | * Closes the given {@link TextResource}. This must not be called from within | |
| 665 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 666 | * concurrent modification exception be thrown. | |
| 667 | * | |
| 668 | * @param resource The {@link TextResource} to close, without confirming with | |
| 669 | * the user. | |
| 670 | */ | |
| 671 | private void close( final TextResource resource ) { | |
| 672 | getTab( resource ).ifPresent( | |
| 673 | tab -> { | |
| 674 | close( tab ); | |
| 675 | tab.getTabPane().getTabs().remove( tab ); | |
| 676 | } | |
| 677 | ); | |
| 678 | } | |
| 679 | ||
| 680 | /** | |
| 681 | * Answers whether the given {@link TextResource} may be closed. | |
| 682 | * | |
| 683 | * @param editor The {@link TextResource} to try closing. | |
| 684 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 685 | * the user has requested to keep the editor open. | |
| 686 | */ | |
| 687 | private boolean canClose( final TextResource editor ) { | |
| 688 | final var editorTab = getTab( editor ); | |
| 689 | final var canClose = new AtomicBoolean( true ); | |
| 690 | ||
| 691 | if( editor.isModified() ) { | |
| 692 | final var filename = new StringBuilder(); | |
| 693 | editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); | |
| 694 | ||
| 695 | final var message = sNotifier.createNotification( | |
| 696 | Messages.get( "Alert.file.close.title" ), | |
| 697 | Messages.get( "Alert.file.close.text" ), | |
| 698 | filename.toString() | |
| 699 | ); | |
| 700 | ||
| 701 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 702 | ||
| 703 | dialog.showAndWait().ifPresent( | |
| 704 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 705 | ); | |
| 706 | } | |
| 707 | ||
| 708 | return canClose.get(); | |
| 709 | } | |
| 710 | ||
| 711 | private void iterateEditors( final Consumer<TextEditor> consumer ) { | |
| 712 | mTabPanes.forEach( | |
| 713 | tp -> tp.getTabs().forEach( tab -> { | |
| 714 | final var node = tab.getContent(); | |
| 715 | ||
| 716 | if( node instanceof final TextEditor editor ) { | |
| 717 | consumer.accept( editor ); | |
| 718 | } | |
| 719 | } ) | |
| 720 | ); | |
| 721 | } | |
| 722 | ||
| 723 | /** | |
| 724 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 725 | */ | |
| 726 | public void viewPreview() { | |
| 727 | addTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 728 | } | |
| 729 | ||
| 730 | /** | |
| 731 | * Adds the document outline tab to its own, singular tab pane. | |
| 732 | */ | |
| 733 | public void viewOutline() { | |
| 734 | addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 735 | } | |
| 736 | ||
| 737 | public void viewStatistics() { | |
| 738 | addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 739 | } | |
| 740 | ||
| 741 | public void viewFiles() { | |
| 742 | try { | |
| 743 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 744 | final var fileManager = factory.createModeless(); | |
| 745 | addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 746 | } catch( final Exception ex ) { | |
| 747 | clue( ex ); | |
| 748 | } | |
| 749 | } | |
| 750 | ||
| 751 | public void viewRefresh() { | |
| 752 | mPreview.refresh(); | |
| 753 | Engine.clear(); | |
| 754 | mRBootstrapController.update(); | |
| 755 | } | |
| 756 | ||
| 757 | private void addTab( | |
| 758 | final Node node, final MediaType mediaType, final String key ) { | |
| 759 | final var tabPane = obtainTabPane( mediaType ); | |
| 760 | ||
| 761 | for( final var tab : tabPane.getTabs() ) { | |
| 762 | if( tab.getContent() == node ) { | |
| 763 | return; | |
| 764 | } | |
| 765 | } | |
| 766 | ||
| 767 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 768 | addTabPane( tabPane ); | |
| 769 | } | |
| 770 | ||
| 771 | /** | |
| 772 | * Returns the tab that contains the given {@link TextEditor}. | |
| 773 | * | |
| 774 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 775 | * @return The first tab having content that matches the given tab. | |
| 776 | */ | |
| 777 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 778 | return mTabPanes.stream() | |
| 779 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 780 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 781 | .findFirst(); | |
| 782 | } | |
| 783 | ||
| 784 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 785 | final var editor = new DefinitionEditor( file, createTreeTransformer() ); | |
| 786 | ||
| 787 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 788 | ||
| 789 | return editor; | |
| 790 | } | |
| 791 | ||
| 792 | /** | |
| 793 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 794 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 795 | * Upon changing, the variables are interpolated and the active text editor | |
| 796 | * is refreshed. | |
| 797 | * | |
| 798 | * @param workspace Has the most recently edited definitions file name. | |
| 799 | * @return A newly configured property that represents the active | |
| 800 | * {@link DefinitionEditor}, never {@code null}. | |
| 801 | */ | |
| 802 | private TextDefinition createDefinitionEditor( | |
| 803 | final Workspace workspace ) { | |
| 804 | final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION ); | |
| 805 | final var filename = fileProperty.get(); | |
| 806 | final SetProperty<String> recent = workspace.setsProperty( | |
| 807 | KEY_UI_RECENT_OPEN_PATH | |
| 808 | ); | |
| 809 | ||
| 810 | // Open the most recently used YAML definition file. | |
| 811 | for( final var recentFile : recent.get() ) { | |
| 812 | if( recentFile.endsWith( filename.toString() ) ) { | |
| 813 | return createDefinitionEditor( new File( recentFile ) ); | |
| 814 | } | |
| 815 | } | |
| 816 | ||
| 817 | return createDefaultDefinitionEditor(); | |
| 818 | } | |
| 819 | ||
| 820 | private TextDefinition createDefaultDefinitionEditor() { | |
| 821 | final var transformer = createTreeTransformer(); | |
| 822 | return new DefinitionEditor( transformer ); | |
| 823 | } | |
| 824 | ||
| 825 | private TreeTransformer createTreeTransformer() { | |
| 826 | return new YamlTreeTransformer(); | |
| 827 | } | |
| 828 | ||
| 829 | private Tab createTab( final String filename, final Node node ) { | |
| 830 | return new DetachableTab( filename, node ); | |
| 831 | } | |
| 832 | ||
| 833 | private Tab createTab( final File file ) { | |
| 834 | final var r = createTextResource( file ); | |
| 835 | final var filename = r.getFilename(); | |
| 836 | final var tab = createTab( filename, r.getNode() ); | |
| 837 | ||
| 838 | r.modifiedProperty().addListener( | |
| 839 | ( _, _, n ) -> tab.setText( filename + (n ? "*" : "") ) | |
| 840 | ); | |
| 841 | ||
| 842 | // This is called when either the tab is closed by the user clicking on | |
| 843 | // the tab's close icon or when closing (all) from the file menu. | |
| 844 | tab.setOnClosed( | |
| 845 | _ -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 846 | ); | |
| 847 | ||
| 848 | // When closing a tab, give focus to the newly revealed tab. | |
| 849 | tab.selectedProperty().addListener( ( _, _, n ) -> { | |
| 850 | if( n != null && n ) { | |
| 851 | final var pane = tab.getTabPane(); | |
| 852 | ||
| 853 | if( pane != null ) { | |
| 854 | pane.requestFocus(); | |
| 855 | } | |
| 856 | } | |
| 857 | } ); | |
| 858 | ||
| 859 | tab.tabPaneProperty().addListener( ( _, _, nPane ) -> { | |
| 860 | if( nPane != null ) { | |
| 861 | nPane.focusedProperty().addListener( ( _, _, n ) -> { | |
| 862 | if( n != null && n ) { | |
| 863 | final var selected = nPane.getSelectionModel().getSelectedItem(); | |
| 864 | final var node = selected.getContent(); | |
| 865 | node.requestFocus(); | |
| 866 | } | |
| 867 | } ); | |
| 868 | } | |
| 869 | } ); | |
| 870 | ||
| 871 | return tab; | |
| 872 | } | |
| 873 | ||
| 874 | /** | |
| 875 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 876 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 877 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 878 | * be replaced by such a class. | |
| 879 | * <p> | |
| 880 | * When binning the files, this makes sure that at least one file exists | |
| 881 | * for every type. If the user has opted to close a particular type (such | |
| 882 | * as the definition pane), the view will suppressed elsewhere. | |
| 883 | * </p> | |
| 884 | * <p> | |
| 885 | * The order that the binned files are returned will be reflected in the | |
| 886 | * order that the corresponding panes are rendered in the UI. | |
| 887 | * </p> | |
| 888 | * | |
| 889 | * @param paths The file paths to bin according to their type. | |
| 890 | * @return An in-order list of files, first by structured definition files, | |
| 891 | * then by plain text documents. | |
| 892 | */ | |
| 893 | private List<File> collect( final SetProperty<String> paths ) { | |
| 894 | // Treat all files destined for the text editor as plain text documents | |
| 895 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 896 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 897 | final Function<MediaType, MediaType> bin = | |
| 898 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 899 | ||
| 900 | // Create two groups: YAML files and plain text files. The order that | |
| 901 | // the elements are listed in the enumeration for media types determines | |
| 902 | // what files are loaded first. Variable definitions come before all other | |
| 903 | // plain text documents. | |
| 904 | final var bins = paths | |
| 905 | .stream() | |
| 906 | .collect( | |
| 907 | groupingBy( | |
| 908 | path -> bin.apply( fromFilename( path ) ), | |
| 909 | () -> new TreeMap<>( Enum::compareTo ), | |
| 910 | Collectors.toList() | |
| 911 | ) | |
| 912 | ); | |
| 913 | ||
| 914 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 915 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 916 | ||
| 917 | final var result = new LinkedList<File>(); | |
| 918 | ||
| 919 | // Ensure that the same types are listed together (keep insertion order). | |
| 920 | bins.forEach( ( _, files ) -> result.addAll( | |
| 921 | files.stream().map( File::new ).toList() ) | |
| 922 | ); | |
| 923 | ||
| 924 | return result; | |
| 925 | } | |
| 926 | ||
| 927 | /** | |
| 928 | * Force the active editor to update, which will cause the processor | |
| 929 | * to re-evaluate the interpolated definition map thereby updating the | |
| 930 | * preview pane. | |
| 931 | * | |
| 932 | * @param editor Contains the source document to update in the preview pane. | |
| 933 | */ | |
| 934 | private void process( final TextEditor editor ) { | |
| 935 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 936 | // text editor immediately for caret movement. The preview will have a | |
| 937 | // slight delay when catching up to the caret position. | |
| 938 | final var task = new Task<Void>() { | |
| 939 | @Override | |
| 940 | public Void call() { | |
| 941 | try { | |
| 942 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 943 | p.apply( editor == null ? "" : editor.getText() ); | |
| 944 | } catch( final Exception ex ) { | |
| 945 | clue( ex ); | |
| 946 | } | |
| 947 | ||
| 948 | return null; | |
| 949 | } | |
| 950 | }; | |
| 951 | ||
| 952 | // TODO: Each time the editor successfully runs the processor, the task is | |
| 953 | // considered successful. Due to the rapid-fire nature of processing | |
| 954 | // (e.g., keyboard navigation, fast typing), it isn't necessary to | |
| 955 | // scroll each time. | |
| 956 | // The algorithm: | |
| 957 | // 1. Peek at the oldest time. | |
| 958 | // 2. If the difference between the oldest time and current time exceeds | |
| 959 | // 250 milliseconds, then invoke the scrolling. | |
| 960 | // 3. Insert the current time into the circular queue. | |
| 961 | task.setOnSucceeded( | |
| 962 | _ -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 963 | ); | |
| 964 | ||
| 965 | // Prevents multiple process requests from executing simultaneously (due | |
| 966 | // to having a restricted queue size). | |
| 967 | sExecutor.execute( task ); | |
| 968 | } | |
| 969 | ||
| 970 | /** | |
| 971 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 972 | * events. The tab pane is associated with a given media type so that | |
| 973 | * similar files can be grouped together. | |
| 974 | * | |
| 975 | * @param mediaType The media type to associate with the tab pane. | |
| 976 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 977 | */ | |
| 978 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 979 | for( final var pane : mTabPanes ) { | |
| 980 | for( final var tab : pane.getTabs() ) { | |
| 981 | final var node = tab.getContent(); | |
| 982 | ||
| 983 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 984 | return pane; | |
| 985 | } | |
| 986 | } | |
| 987 | } | |
| 988 | ||
| 989 | final var pane = createTabPane(); | |
| 990 | mTabPanes.add( pane ); | |
| 991 | return pane; | |
| 992 | } | |
| 993 | ||
| 994 | /** | |
| 995 | * Creates an initialized {@link TabPane} instance. | |
| 996 | * | |
| 997 | * @return A new {@link TabPane} with all listeners configured. | |
| 998 | */ | |
| 999 | private TabPane createTabPane() { | |
| 1000 | final var tabPane = new DetachableTabPane(); | |
| 1001 | ||
| 1002 | initStageOwnerFactory( tabPane ); | |
| 1003 | initTabListener( tabPane ); | |
| 1004 | ||
| 1005 | return tabPane; | |
| 1006 | } | |
| 1007 | ||
| 1008 | /** | |
| 1009 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 1010 | * the stage owner factory must be given its parent window, which will | |
| 1011 | * own the child window. The parent window is the {@link MainPane}'s | |
| 1012 | * {@link Scene}'s {@link Window} instance. | |
| 1013 | * | |
| 1014 | * <p> | |
| 1015 | * This will derives the new title from the main window title, incrementing | |
| 1016 | * the window count to help uniquely identify the child windows. | |
| 1017 | * </p> | |
| 1018 | * | |
| 1019 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 1020 | */ | |
| 1021 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 1022 | tabPane.setStageOwnerFactory( stage -> { | |
| 1023 | final var title = get( | |
| 1024 | "Detach.tab.title", | |
| 1025 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 1026 | ); | |
| 1027 | stage.setTitle( title ); | |
| 1028 | ||
| 1029 | return getScene().getWindow(); | |
| 1030 | } ); | |
| 1031 | } | |
| 1032 | ||
| 1033 | /** | |
| 1034 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 1035 | * it is added to the given {@link DetachableTabPane} instance. | |
| 1036 | * <p> | |
| 1037 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 1038 | * is initialized to perform synchronized scrolling between the editor and | |
| 1039 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 1040 | * tabs is given focus. | |
| 1041 | * </p> | |
| 1042 | * <p> | |
| 1043 | * Note that multiple tabs can be added simultaneously. | |
| 1044 | * </p> | |
| 1045 | * | |
| 1046 | * @param tabPane A new {@link TabPane} to configure. | |
| 1047 | */ | |
| 1048 | private void initTabListener( final TabPane tabPane ) { | |
| 1049 | tabPane.getTabs().addListener( | |
| 1050 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 1051 | while( listener.next() ) { | |
| 1052 | if( listener.wasAdded() ) { | |
| 1053 | final var tabs = listener.getAddedSubList(); | |
| 1054 | ||
| 1055 | tabs.forEach( tab -> { | |
| 1056 | final var node = tab.getContent(); | |
| 1057 | ||
| 1058 | if( node instanceof TextEditor ) { | |
| 1059 | initScrollEventListener( tab ); | |
| 1060 | } | |
| 1061 | } ); | |
| 1062 | ||
| 1063 | // Select and give focus to the last tab opened. | |
| 1064 | final var index = tabs.size() - 1; | |
| 1065 | if( index >= 0 ) { | |
| 1066 | final var tab = tabs.get( index ); | |
| 1067 | tabPane.getSelectionModel().select( tab ); | |
| 1068 | tab.getContent().requestFocus(); | |
| 1069 | } | |
| 1070 | } | |
| 1071 | } | |
| 1072 | } | |
| 1073 | ); | |
| 1074 | } | |
| 1075 | ||
| 1076 | /** | |
| 1077 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 1078 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 1079 | * | |
| 1080 | * @param tab The container for an instance of {@link TextEditor}. | |
| 1081 | */ | |
| 1082 | private void initScrollEventListener( final Tab tab ) { | |
| 1083 | final var editor = (TextEditor) tab.getContent(); | |
| 1084 | final var scrollPane = editor.getScrollPane(); | |
| 1085 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 1086 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 1087 | ||
| 1088 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 1089 | } | |
| 1090 | ||
| 1091 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 1092 | final var items = getItems(); | |
| 1093 | ||
| 1094 | if( !items.contains( tabPane ) ) { | |
| 1095 | items.add( index, tabPane ); | |
| 1096 | } | |
| 1097 | } | |
| 1098 | ||
| 1099 | private void addTabPane( final TabPane tabPane ) { | |
| 1100 | addTabPane( getItems().size(), tabPane ); | |
| 1101 | } | |
| 1102 | ||
| 1103 | private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { | |
| 1104 | final var w = getWorkspace(); | |
| 1105 | ||
| 1106 | return builder() | |
| 1107 | .with( Mutator::setDefinitions, this::getDefinitions ) | |
| 1108 | .with( Mutator::setLocale, w::getLocale ) | |
| 1109 | .with( Mutator::setMetadata, w::getMetadata ) | |
| 1110 | .with( Mutator::setThemeDir, w::getThemesPath ) | |
| 1111 | .with( Mutator::setCacheDir, | |
| 1112 | () -> w.getFile( KEY_CACHE_DIR ) ) | |
| 1113 | .with( Mutator::setImageDir, | |
| 1114 | () -> w.getFile( KEY_IMAGE_DIR ) ) | |
| 1115 | .with( Mutator::setImageOrder, | |
| 1116 | () -> w.getString( KEY_IMAGE_ORDER ) ) | |
| 1117 | .with( Mutator::setImageServer, | |
| 1118 | () -> w.getString( KEY_IMAGE_SERVER ) ) | |
| 1119 | .with( Mutator::setCaret, | |
| 1120 | () -> getTextEditor().getCaret() ) | |
| 1121 | .with( Mutator::setSigilBegan, | |
| 1122 | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | |
| 1123 | .with( Mutator::setSigilEnded, | |
| 1124 | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | |
| 1125 | .with( Mutator::setRScript, | |
| 1126 | () -> w.getString( KEY_R_SCRIPT ) ) | |
| 1127 | .with( Mutator::setRWorkingDir, | |
| 1128 | () -> w.getFile( KEY_R_DIR ).toPath() ) | |
| 1129 | .with( Mutator::setFontDir, | |
| 1130 | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) | |
| 1131 | .with( Mutator::setModesEnabled, | |
| 1132 | () -> w.getString( KEY_TYPESET_MODES_ENABLED ) ) | |
| 1133 | .with( Mutator::setCurlQuotes, | |
| 1134 | () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 1135 | .with( Mutator::setAutoRemove, | |
| 1136 | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | |
| 1137 | } | |
| 1138 | ||
| 1139 | public ProcessorContext createProcessorContext() { | |
| 1140 | return createProcessorContextBuilder( NONE ).build(); | |
| 1141 | } | |
| 1142 | ||
| 1143 | private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder( | |
| 1144 | final ExportFormat format ) { | |
| 1145 | final var textEditor = getTextEditor(); | |
| 1146 | final var sourcePath = textEditor.getPath(); | |
| 1147 | ||
| 1148 | return processorContextBuilder() | |
| 1149 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1150 | .with( Mutator::setExportFormat, format ); | |
| 1151 | } | |
| 1152 | ||
| 1153 | /** | |
| 1154 | * @param targetPath Used when exporting to a PDF file (binary). | |
| 1155 | * @param format Used when processors export to a new text format. | |
| 1156 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1157 | * {@link Processor}. | |
| 1158 | */ | |
| 1159 | public ProcessorContext createProcessorContext( | |
| 1160 | final Path targetPath, final ExportFormat format ) { | |
| 1161 | assert targetPath != null; | |
| 1162 | assert format != null; | |
| 1163 | ||
| 1164 | return createProcessorContextBuilder( format ) | |
| 1165 | .with( Mutator::setTargetPath, targetPath ) | |
| 1166 | .build(); | |
| 1167 | } | |
| 1168 | ||
| 1169 | /** | |
| 1170 | * @param sourcePath Used by {@link ProcessorFactory} to determine | |
| 1171 | * {@link Processor} type to create based on file type. | |
| 1172 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1173 | * {@link Processor}. | |
| 1174 | */ | |
| 1175 | private ProcessorContext createProcessorContext( final Path sourcePath ) { | |
| 1176 | return processorContextBuilder() | |
| 1177 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1178 | .with( Mutator::setExportFormat, NONE ) | |
| 1179 | .build(); | |
| 1180 | } | |
| 1181 | ||
| 1182 | private TextResource createTextResource( final File file ) { | |
| 1183 | if( fromFilename( file ) == TEXT_YAML ) { | |
| 1184 | final var editor = createDefinitionEditor( file ); | |
| 1185 | mDefinitionEditor.set( editor ); | |
| 1186 | return editor; | |
| 1187 | } | |
| 1188 | else { | |
| 1189 | final var editor = createMarkdownEditor( file ); | |
| 1190 | mTextEditor.set( editor ); | |
| 1191 | return editor; | |
| 1192 | } | |
| 1193 | } | |
| 1194 | ||
| 1195 | /** | |
| 1196 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 1197 | * caret change events and text change events. Text change events must | |
| 1198 | * take priority over caret change events because it's possible to change | |
| 1199 | * the text without moving the caret (e.g., delete selected text). | |
| 1200 | * | |
| 1201 | * @param inputFile The file containing contents for the text editor. | |
| 1202 | * @return A non-null text editor. | |
| 1203 | */ | |
| 1204 | private MarkdownEditor createMarkdownEditor( final File inputFile ) { | |
| 1205 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 1206 | ||
| 1207 | // Listener for editor modifications or caret position changes. | |
| 1208 | editor.addDirtyListener( ( _, _, n ) -> { | |
| 1209 | if( n ) { | |
| 1210 | // Reset the status bar after changing the text. | |
| 1211 | clue(); | |
| 1212 | ||
| 1213 | // Processing the text may update the status bar. | |
| 1214 | process( editor ); | |
| 1215 | ||
| 1216 | // Update the caret position in the status bar. | |
| 1217 | CaretMovedEvent.fire( editor.getCaret() ); | |
| 1218 | } | |
| 1219 | } ); | |
| 1220 | ||
| 1221 | editor.addEventListener( | |
| 1222 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1223 | ); | |
| 1224 | ||
| 1225 | editor.addEventListener( | |
| 1226 | keyPressed( ENTER, ALT_DOWN ), _ -> mEditorSpeller.autofix( editor ) | |
| 1227 | ); | |
| 1228 | ||
| 1229 | final var textArea = editor.getTextArea(); | |
| 1230 | ||
| 1231 | // Spell check when the paragraph changes. | |
| 1232 | textArea | |
| 1233 | .plainTextChanges() | |
| 1234 | .filter( p -> !p.isIdentity() ) | |
| 1235 | .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); | |
| 1236 | ||
| 1237 | // Store the caret position to restore it after restarting the application. | |
| 1238 | textArea.caretPositionProperty().addListener( | |
| 1239 | ( _, _, n ) -> | |
| 1240 | getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) | |
| 1241 | ); | |
| 1242 | ||
| 1243 | // Check the entire document after the spellchecker is initialized (with | |
| 1244 | // a valid lexicon) so that only the current paragraph need be scanned | |
| 1245 | // while editing. (Technically, only the most recently modified word must | |
| 1246 | // be scanned.) | |
| 1247 | mSpellChecker.addListener( | |
| 1248 | ( _, _, _ ) -> runLater( | |
| 1249 | () -> iterateEditors( mEditorSpeller::checkDocument ) | |
| 1250 | ) | |
| 1251 | ); | |
| 1252 | ||
| 1253 | // Check the entire document after it has been loaded. | |
| 1254 | mEditorSpeller.checkDocument( editor ); | |
| 1255 | ||
| 1256 | return editor; | |
| 1257 | } | |
| 1258 | ||
| 1259 | /** | |
| 1260 | * Creates a processor for an editor, provided one doesn't already exist. | |
| 1261 | * | |
| 1262 | * @param editor The editor that potentially requires an associated processor. | |
| 1263 | */ | |
| 1264 | private void updateProcessors( final TextEditor editor ) { | |
| 1265 | final var path = editor.getFile().toPath(); | |
| 1266 | ||
| 1267 | mProcessors.computeIfAbsent( | |
| 1268 | editor, _ -> createProcessors( | |
| 1269 | createProcessorContext( path ), | |
| 1270 | createHtmlPreviewProcessor() | |
| 1271 | ) | |
| 1272 | ); | |
| 1273 | } | |
| 1274 | ||
| 1275 | /** | |
| 1276 | * Removes a processor for an editor. This is required because a file may | |
| 1277 | * change type while editing (e.g., from plain Markdown to R Markdown). | |
| 1278 | * In the case that an editor's type changes, its associated processor must | |
| 1279 | * be changed accordingly. | |
| 1280 | * | |
| 1281 | * @param editor The editor that potentially requires an associated processor. | |
| 1282 | */ | |
| 1283 | private void removeProcessor( final TextEditor editor ) { | |
| 1284 | mProcessors.remove( editor ); | |
| 1285 | } | |
| 1286 | ||
| 1287 | /** | |
| 1288 | * Creates a {@link Processor} capable of rendering an HTML document onto | |
| 1289 | * a GUI widget. | |
| 1290 | * | |
| 1291 | * @return The {@link Processor} for rendering an HTML document. | |
| 1292 | */ | |
| 1293 | private Processor<String> createHtmlPreviewProcessor() { | |
| 1294 | return new HtmlPreviewProcessor( getPreview() ); | |
| 20 | import com.keenwrite.processors.Processor; | |
| 21 | import com.keenwrite.processors.ProcessorContext; | |
| 22 | import com.keenwrite.processors.ProcessorFactory; | |
| 23 | import com.keenwrite.processors.html.HtmlPreviewProcessor; | |
| 24 | import com.keenwrite.processors.r.Engine; | |
| 25 | import com.keenwrite.processors.r.RBootstrapController; | |
| 26 | import com.keenwrite.service.events.Notifier; | |
| 27 | import com.keenwrite.spelling.api.SpellChecker; | |
| 28 | import com.keenwrite.spelling.impl.PermissiveSpeller; | |
| 29 | import com.keenwrite.spelling.impl.SymSpellSpeller; | |
| 30 | import com.keenwrite.typesetting.installer.TypesetterInstaller; | |
| 31 | import com.keenwrite.ui.explorer.FilePickerFactory; | |
| 32 | import com.keenwrite.ui.heuristics.DocumentStatistics; | |
| 33 | import com.keenwrite.ui.outline.DocumentOutline; | |
| 34 | import com.keenwrite.ui.spelling.TextEditorSpellChecker; | |
| 35 | import com.keenwrite.util.GenericBuilder; | |
| 36 | import com.panemu.tiwulfx.control.dock.DetachableTab; | |
| 37 | import com.panemu.tiwulfx.control.dock.DetachableTabPane; | |
| 38 | import javafx.beans.property.*; | |
| 39 | import javafx.collections.ListChangeListener; | |
| 40 | import javafx.concurrent.Task; | |
| 41 | import javafx.event.ActionEvent; | |
| 42 | import javafx.event.Event; | |
| 43 | import javafx.event.EventHandler; | |
| 44 | import javafx.scene.Node; | |
| 45 | import javafx.scene.Scene; | |
| 46 | import javafx.scene.control.SplitPane; | |
| 47 | import javafx.scene.control.Tab; | |
| 48 | import javafx.scene.control.TabPane; | |
| 49 | import javafx.scene.control.Tooltip; | |
| 50 | import javafx.scene.control.TreeItem.TreeModificationEvent; | |
| 51 | import javafx.scene.input.KeyEvent; | |
| 52 | import javafx.stage.Stage; | |
| 53 | import javafx.stage.Window; | |
| 54 | import org.greenrobot.eventbus.Subscribe; | |
| 55 | ||
| 56 | import java.io.File; | |
| 57 | import java.io.FileNotFoundException; | |
| 58 | import java.nio.file.Path; | |
| 59 | import java.util.*; | |
| 60 | import java.util.concurrent.ExecutorService; | |
| 61 | import java.util.concurrent.ScheduledExecutorService; | |
| 62 | import java.util.concurrent.ScheduledFuture; | |
| 63 | import java.util.concurrent.atomic.AtomicBoolean; | |
| 64 | import java.util.concurrent.atomic.AtomicReference; | |
| 65 | import java.util.function.Consumer; | |
| 66 | import java.util.function.Function; | |
| 67 | import java.util.stream.Collectors; | |
| 68 | ||
| 69 | import static com.keenwrite.ExportFormat.NONE; | |
| 70 | import static com.keenwrite.Messages.get; | |
| 71 | import static com.keenwrite.constants.Constants.*; | |
| 72 | import static com.keenwrite.events.Bus.register; | |
| 73 | import static com.keenwrite.events.StatusEvent.clue; | |
| 74 | import static com.keenwrite.io.MediaType.*; | |
| 75 | import static com.keenwrite.io.MediaType.TypeName.TEXT; | |
| 76 | import static com.keenwrite.io.SysFile.toFile; | |
| 77 | import static com.keenwrite.preferences.AppKeys.*; | |
| 78 | import static com.keenwrite.processors.ProcessorContext.Mutator; | |
| 79 | import static com.keenwrite.processors.ProcessorContext.builder; | |
| 80 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; | |
| 81 | import static com.keenwrite.processors.html.IdentityProcessor.IDENTITY; | |
| 82 | import static java.awt.Desktop.getDesktop; | |
| 83 | import static java.lang.String.*; | |
| 84 | import static java.util.concurrent.Executors.newFixedThreadPool; | |
| 85 | import static java.util.concurrent.Executors.newScheduledThreadPool; | |
| 86 | import static java.util.concurrent.TimeUnit.SECONDS; | |
| 87 | import static java.util.stream.Collectors.groupingBy; | |
| 88 | import static javafx.application.Platform.exit; | |
| 89 | import static javafx.application.Platform.runLater; | |
| 90 | import static javafx.scene.control.ButtonType.NO; | |
| 91 | import static javafx.scene.control.ButtonType.YES; | |
| 92 | import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS; | |
| 93 | import static javafx.scene.input.KeyCode.ENTER; | |
| 94 | import static javafx.scene.input.KeyCode.SPACE; | |
| 95 | import static javafx.scene.input.KeyCombination.ALT_DOWN; | |
| 96 | import static javafx.scene.input.KeyCombination.CONTROL_DOWN; | |
| 97 | import static javafx.util.Duration.millis; | |
| 98 | import static javax.swing.SwingUtilities.invokeLater; | |
| 99 | import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed; | |
| 100 | ||
| 101 | /** | |
| 102 | * Responsible for wiring together the main application components for a | |
| 103 | * particular {@link Workspace} (project). These include the definition views, | |
| 104 | * text editors, and preview pane along with any corresponding controllers. | |
| 105 | */ | |
| 106 | public final class MainPane extends SplitPane { | |
| 107 | ||
| 108 | private static final ExecutorService sExecutor = newFixedThreadPool( 1 ); | |
| 109 | private static final Notifier sNotifier = Services.load( Notifier.class ); | |
| 110 | ||
| 111 | /** | |
| 112 | * Used when opening files to determine how each file should be binned and | |
| 113 | * therefore what tab pane to be opened within. | |
| 114 | */ | |
| 115 | private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of( | |
| 116 | TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED | |
| 117 | ); | |
| 118 | ||
| 119 | private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 ); | |
| 120 | private final AtomicReference<ScheduledFuture<?>> mSaveTask = | |
| 121 | new AtomicReference<>(); | |
| 122 | ||
| 123 | /** | |
| 124 | * Prevents re-instantiation of processing classes. | |
| 125 | */ | |
| 126 | private final Map<TextResource, Processor<String>> mProcessors = | |
| 127 | new HashMap<>(); | |
| 128 | ||
| 129 | private final Workspace mWorkspace; | |
| 130 | ||
| 131 | /** | |
| 132 | * Groups similar file type tabs together. | |
| 133 | */ | |
| 134 | private final List<TabPane> mTabPanes = new ArrayList<>(); | |
| 135 | ||
| 136 | /** | |
| 137 | * Renders the actively selected plain text editor tab. | |
| 138 | */ | |
| 139 | private final HtmlPreview mPreview; | |
| 140 | ||
| 141 | /** | |
| 142 | * Provides an interactive document outline. | |
| 143 | */ | |
| 144 | private final DocumentOutline mOutline = new DocumentOutline(); | |
| 145 | ||
| 146 | /** | |
| 147 | * Changing the active editor fires the value changed event. This allows | |
| 148 | * refreshes to happen when external definitions are modified and need to | |
| 149 | * trigger the processing chain. | |
| 150 | */ | |
| 151 | private final ObjectProperty<TextEditor> mTextEditor = | |
| 152 | new SimpleObjectProperty<>(); | |
| 153 | ||
| 154 | /** | |
| 155 | * Changing the active definition editor fires the value changed event. This | |
| 156 | * allows refreshes to happen when external definitions are modified and need | |
| 157 | * to trigger the processing chain. | |
| 158 | */ | |
| 159 | private final ObjectProperty<TextDefinition> mDefinitionEditor = | |
| 160 | new SimpleObjectProperty<>(); | |
| 161 | ||
| 162 | private final ObjectProperty<SpellChecker> mSpellChecker; | |
| 163 | ||
| 164 | private final TextEditorSpellChecker mEditorSpeller; | |
| 165 | ||
| 166 | /** | |
| 167 | * Called when the definition data is changed. | |
| 168 | */ | |
| 169 | private final EventHandler<TreeModificationEvent<Event>> mTreeHandler = | |
| 170 | _ -> { | |
| 171 | process( getTextEditor() ); | |
| 172 | save( getTextDefinition() ); | |
| 173 | }; | |
| 174 | ||
| 175 | /** | |
| 176 | * Tracks the number of detached tab panels opened into their own windows, | |
| 177 | * which allows unique identification of subordinate windows by their title. | |
| 178 | * It is doubtful more than 128 windows, much less 256, will be created. | |
| 179 | */ | |
| 180 | private byte mWindowCount; | |
| 181 | ||
| 182 | private final VariableNameInjector mVariableNameInjector; | |
| 183 | ||
| 184 | private final RBootstrapController mRBootstrapController; | |
| 185 | ||
| 186 | private final DocumentStatistics mStatistics; | |
| 187 | ||
| 188 | @SuppressWarnings( { "FieldCanBeLocal", "unused" } ) | |
| 189 | private final TypesetterInstaller mInstallWizard; | |
| 190 | ||
| 191 | /** | |
| 192 | * Adds all content panels to the main user interface. This will load the | |
| 193 | * configuration settings from the workspace to reproduce the settings from | |
| 194 | * a previous session. | |
| 195 | */ | |
| 196 | public MainPane( final Workspace workspace ) { | |
| 197 | mWorkspace = workspace; | |
| 198 | mSpellChecker = createSpellChecker(); | |
| 199 | mEditorSpeller = createTextEditorSpellChecker( mSpellChecker ); | |
| 200 | mPreview = new HtmlPreview( workspace ); | |
| 201 | mStatistics = new DocumentStatistics( workspace ); | |
| 202 | ||
| 203 | mTextEditor.addListener( ( _, o, n ) -> { | |
| 204 | if( o != null ) { | |
| 205 | removeProcessor( o ); | |
| 206 | } | |
| 207 | ||
| 208 | if( n != null ) { | |
| 209 | mPreview.setBaseUri( n.getPath() ); | |
| 210 | updateProcessors( n ); | |
| 211 | process( n ); | |
| 212 | } | |
| 213 | } ); | |
| 214 | ||
| 215 | mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) ); | |
| 216 | mDefinitionEditor.set( createDefinitionEditor( workspace ) ); | |
| 217 | mVariableNameInjector = new VariableNameInjector( workspace ); | |
| 218 | mRBootstrapController = new RBootstrapController( | |
| 219 | workspace, mDefinitionEditor.get()::getDefinitions | |
| 220 | ); | |
| 221 | ||
| 222 | // If the user modifies the definitions, re-process the variables. | |
| 223 | mDefinitionEditor.addListener( ( _, _, _ ) -> { | |
| 224 | final var textEditor = getTextEditor(); | |
| 225 | ||
| 226 | if( textEditor.isMediaType( TEXT_R_MARKDOWN ) ) { | |
| 227 | mRBootstrapController.update(); | |
| 228 | } | |
| 229 | ||
| 230 | process( textEditor ); | |
| 231 | } ); | |
| 232 | ||
| 233 | open( collect( getRecentFiles() ) ); | |
| 234 | viewPreview(); | |
| 235 | setDividerPositions( calculateDividerPositions() ); | |
| 236 | ||
| 237 | // Once the main scene's window regains focus, update the active definition | |
| 238 | // editor to the currently selected tab. | |
| 239 | runLater( () -> getWindow().setOnCloseRequest( event -> { | |
| 240 | // Order matters: Open file names must be persisted before closing all. | |
| 241 | mWorkspace.save(); | |
| 242 | ||
| 243 | if( closeAll() ) { | |
| 244 | exit(); | |
| 245 | } | |
| 246 | ||
| 247 | event.consume(); | |
| 248 | } ) ); | |
| 249 | ||
| 250 | register( this ); | |
| 251 | initAutosave( workspace ); | |
| 252 | ||
| 253 | restoreSession(); | |
| 254 | runLater( this::restoreFocus ); | |
| 255 | ||
| 256 | mInstallWizard = new TypesetterInstaller( workspace ); | |
| 257 | } | |
| 258 | ||
| 259 | /** | |
| 260 | * Called when spellchecking can be run. This will reload the dictionary | |
| 261 | * into memory once, and then re-use it for all the existing text editors. | |
| 262 | * | |
| 263 | * @param event The event to process, having a populated word-frequency map. | |
| 264 | */ | |
| 265 | @Subscribe | |
| 266 | public void handle( final LexiconLoadedEvent event ) { | |
| 267 | final var lexicon = event.getLexicon(); | |
| 268 | ||
| 269 | try { | |
| 270 | final var checker = SymSpellSpeller.forLexicon( lexicon ); | |
| 271 | mSpellChecker.set( checker ); | |
| 272 | } catch( final Exception ex ) { | |
| 273 | clue( ex ); | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | @Subscribe | |
| 278 | public void handle( final TextEditorFocusEvent event ) { | |
| 279 | mTextEditor.set( event.get() ); | |
| 280 | } | |
| 281 | ||
| 282 | @Subscribe | |
| 283 | public void handle( final TextDefinitionFocusEvent event ) { | |
| 284 | mDefinitionEditor.set( event.get() ); | |
| 285 | } | |
| 286 | ||
| 287 | /** | |
| 288 | * Typically called when a file name is clicked in the preview panel. | |
| 289 | * | |
| 290 | * @param event The event to process, must contain a valid file reference. | |
| 291 | */ | |
| 292 | @Subscribe | |
| 293 | public void handle( final FileOpenEvent event ) { | |
| 294 | final File eventFile; | |
| 295 | final var eventUri = event.getUri(); | |
| 296 | ||
| 297 | if( eventUri.isAbsolute() ) { | |
| 298 | eventFile = new File( eventUri.getPath() ); | |
| 299 | } | |
| 300 | else { | |
| 301 | final var activeFile = getTextEditor().getFile(); | |
| 302 | final var parent = activeFile.getParentFile(); | |
| 303 | ||
| 304 | if( parent == null ) { | |
| 305 | clue( new FileNotFoundException( eventUri.getPath() ) ); | |
| 306 | return; | |
| 307 | } | |
| 308 | else { | |
| 309 | final var parentPath = parent.getAbsolutePath(); | |
| 310 | eventFile = toFile( Path.of( parentPath, eventUri.getPath() ) ); | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | final var mediaType = MediaTypeExtension.fromFile( eventFile ); | |
| 315 | ||
| 316 | runLater( () -> { | |
| 317 | // Open text files locally. | |
| 318 | if( mediaType.isType( TEXT ) ) { | |
| 319 | open( eventFile ); | |
| 320 | } | |
| 321 | else { | |
| 322 | try { | |
| 323 | // Delegate opening all other file types to the operating system. | |
| 324 | getDesktop().open( eventFile ); | |
| 325 | } catch( final Exception ex ) { | |
| 326 | clue( ex ); | |
| 327 | } | |
| 328 | } | |
| 329 | } ); | |
| 330 | } | |
| 331 | ||
| 332 | @Subscribe | |
| 333 | public void handle( final CaretNavigationEvent event ) { | |
| 334 | runLater( () -> { | |
| 335 | final var textArea = getTextEditor(); | |
| 336 | textArea.moveTo( event.getOffset() ); | |
| 337 | textArea.requestFocus(); | |
| 338 | } ); | |
| 339 | } | |
| 340 | ||
| 341 | @Subscribe | |
| 342 | public void handle( final InsertDefinitionEvent<String> event ) { | |
| 343 | final var leaf = event.getLeaf(); | |
| 344 | final var editor = mTextEditor.get(); | |
| 345 | ||
| 346 | mVariableNameInjector.insert( editor, leaf ); | |
| 347 | } | |
| 348 | ||
| 349 | private void initAutosave( final Workspace workspace ) { | |
| 350 | final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE ); | |
| 351 | ||
| 352 | rate.addListener( | |
| 353 | ( _, _, _ ) -> { | |
| 354 | final var taskRef = mSaveTask.get(); | |
| 355 | ||
| 356 | // Prevent multiple auto-saves from running. | |
| 357 | if( taskRef != null ) { | |
| 358 | taskRef.cancel( false ); | |
| 359 | } | |
| 360 | ||
| 361 | initAutosave( rate ); | |
| 362 | } | |
| 363 | ); | |
| 364 | ||
| 365 | // Start the save listener (avoids duplicating some code). | |
| 366 | initAutosave( rate ); | |
| 367 | } | |
| 368 | ||
| 369 | private void initAutosave( final IntegerProperty rate ) { | |
| 370 | mSaveTask.set( | |
| 371 | mSaver.scheduleAtFixedRate( | |
| 372 | () -> { | |
| 373 | if( getTextEditor().isModified() ) { | |
| 374 | // Ensure the modified indicator is cleared by running on EDT. | |
| 375 | runLater( this::save ); | |
| 376 | } | |
| 377 | }, 0, rate.intValue(), SECONDS | |
| 378 | ) | |
| 379 | ); | |
| 380 | } | |
| 381 | ||
| 382 | /** | |
| 383 | * TODO: Load divider positions from exported settings, see | |
| 384 | * {@link #collect(SetProperty)} comment. | |
| 385 | */ | |
| 386 | private double[] calculateDividerPositions() { | |
| 387 | final var ratio = 100f / getItems().size() / 100; | |
| 388 | final var positions = getDividerPositions(); | |
| 389 | ||
| 390 | for( int i = 0; i < positions.length; i++ ) { | |
| 391 | positions[ i ] = ratio * i; | |
| 392 | } | |
| 393 | ||
| 394 | return positions; | |
| 395 | } | |
| 396 | ||
| 397 | /** | |
| 398 | * Opens all the files into the application, provided the paths are unique. | |
| 399 | * This may only be called for any type of files that a user can edit | |
| 400 | * (i.e., update and persist), such as definitions and text files. | |
| 401 | * | |
| 402 | * @param files The list of files to open. | |
| 403 | */ | |
| 404 | public void open( final List<File> files ) { | |
| 405 | files.forEach( this::open ); | |
| 406 | } | |
| 407 | ||
| 408 | /** | |
| 409 | * This opens the given file. Since the preview pane is not a file that | |
| 410 | * can be opened, it is safe to add a listener to the detachable pane. | |
| 411 | * This will exit early if the given file is not a regular file (i.e., a | |
| 412 | * directory). | |
| 413 | * | |
| 414 | * @param inputFile The file to open. | |
| 415 | */ | |
| 416 | private void open( final File inputFile ) { | |
| 417 | // Prevent opening directories (a non-existent "untitled.md" is fine). | |
| 418 | if( !inputFile.isFile() && inputFile.exists() ) { | |
| 419 | return; | |
| 420 | } | |
| 421 | ||
| 422 | final var mediaType = fromFilename( inputFile ); | |
| 423 | ||
| 424 | // Only allow opening text files. | |
| 425 | if( !mediaType.isType( TEXT ) ) { | |
| 426 | return; | |
| 427 | } | |
| 428 | ||
| 429 | final var tab = createTab( inputFile ); | |
| 430 | final var node = tab.getContent(); | |
| 431 | final var tabPane = obtainTabPane( mediaType ); | |
| 432 | ||
| 433 | tab.setTooltip( createTooltip( inputFile ) ); | |
| 434 | tabPane.setFocusTraversable( false ); | |
| 435 | tabPane.setTabClosingPolicy( ALL_TABS ); | |
| 436 | tabPane.getTabs().add( tab ); | |
| 437 | ||
| 438 | // Attach the tab scene factory for new tab panes. | |
| 439 | if( !getItems().contains( tabPane ) ) { | |
| 440 | addTabPane( | |
| 441 | node instanceof TextDefinition ? 0 : getItems().size(), tabPane | |
| 442 | ); | |
| 443 | } | |
| 444 | ||
| 445 | if( inputFile.isFile() ) { | |
| 446 | getRecentFiles().add( inputFile.getAbsolutePath() ); | |
| 447 | ||
| 448 | final var dir = inputFile.getParentFile(); | |
| 449 | mWorkspace.fileProperty( KEY_UI_RECENT_DIR ).setValue( dir ); | |
| 450 | } | |
| 451 | } | |
| 452 | ||
| 453 | /** | |
| 454 | * Gives focus to the most recently edited document and attempts to move | |
| 455 | * the caret to the most recently known offset into said document. | |
| 456 | */ | |
| 457 | private void restoreSession() { | |
| 458 | final var workspace = getWorkspace(); | |
| 459 | final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT ); | |
| 460 | final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET ); | |
| 461 | ||
| 462 | for( final var pane : mTabPanes ) { | |
| 463 | for( final var tab : pane.getTabs() ) { | |
| 464 | final var tooltip = tab.getTooltip(); | |
| 465 | ||
| 466 | if( tooltip != null ) { | |
| 467 | final var tabName = tooltip.getText(); | |
| 468 | final var fileName = file.get().toString(); | |
| 469 | ||
| 470 | if( tabName.equalsIgnoreCase( fileName ) ) { | |
| 471 | final var node = tab.getContent(); | |
| 472 | ||
| 473 | pane.getSelectionModel().select( tab ); | |
| 474 | node.requestFocus(); | |
| 475 | ||
| 476 | if( node instanceof TextEditor editor ) { | |
| 477 | runLater( () -> editor.moveTo( offset.getValue() ) ); | |
| 478 | } | |
| 479 | ||
| 480 | break; | |
| 481 | } | |
| 482 | } | |
| 483 | } | |
| 484 | } | |
| 485 | } | |
| 486 | ||
| 487 | /** | |
| 488 | * Sets the focus to the middle pane, which contains the text editor tabs. | |
| 489 | */ | |
| 490 | private void restoreFocus() { | |
| 491 | // Work around a bug where focusing directly on the middle pane results | |
| 492 | // in the R engine not loading variables properly. | |
| 493 | mTabPanes.get( 0 ).requestFocus(); | |
| 494 | ||
| 495 | // This is the only line that should be required. | |
| 496 | mTabPanes.get( 1 ).requestFocus(); | |
| 497 | } | |
| 498 | ||
| 499 | /** | |
| 500 | * Opens a new text editor document using a document file name that doesn't | |
| 501 | * clash with an existing document. | |
| 502 | */ | |
| 503 | public void newTextEditor() { | |
| 504 | final String key = "file.default.document."; | |
| 505 | final String prefix = Constants.get( format( "%s%s", key, "prefix" ) ); | |
| 506 | final String suffix = Constants.get( format( "%s%s", key, "suffix" ) ); | |
| 507 | ||
| 508 | File file = new File( format( "%s.%s", prefix, suffix ) ); | |
| 509 | int i = 0; | |
| 510 | ||
| 511 | while( file.exists() && i++ < 100 ) { | |
| 512 | file = new File( format( "%s-%s.%s", prefix, i, suffix ) ); | |
| 513 | } | |
| 514 | ||
| 515 | open( file ); | |
| 516 | } | |
| 517 | ||
| 518 | /** | |
| 519 | * Opens a new definition editor document using the default definition | |
| 520 | * file name. | |
| 521 | */ | |
| 522 | @SuppressWarnings( "unused" ) | |
| 523 | public void newDefinitionEditor() { | |
| 524 | open( DEFINITION_DEFAULT ); | |
| 525 | } | |
| 526 | ||
| 527 | /** | |
| 528 | * Iterates over all tab panes to find all {@link TextEditor}s and request | |
| 529 | * that they save themselves. | |
| 530 | */ | |
| 531 | public void saveAll() { | |
| 532 | iterateEditors( this::save ); | |
| 533 | } | |
| 534 | ||
| 535 | /** | |
| 536 | * Requests that the active {@link TextEditor} saves itself. Don't bother | |
| 537 | * checking if modified first because if the user swaps external media from | |
| 538 | * an external source (e.g., USB thumb drive), save should not second-guess | |
| 539 | * the user: save always re-saves. Also, it's less code. | |
| 540 | */ | |
| 541 | public void save() { | |
| 542 | save( getTextEditor() ); | |
| 543 | } | |
| 544 | ||
| 545 | /** | |
| 546 | * Saves the active {@link TextEditor} under a new name. | |
| 547 | * | |
| 548 | * @param files The new active editor {@link File} reference, must contain | |
| 549 | * at least one element. | |
| 550 | */ | |
| 551 | public void saveAs( final List<File> files ) { | |
| 552 | assert files != null; | |
| 553 | assert !files.isEmpty(); | |
| 554 | final var editor = getTextEditor(); | |
| 555 | final var tab = getTab( editor ); | |
| 556 | final var file = files.getFirst(); | |
| 557 | ||
| 558 | // If the file type has changed, refresh the processors. | |
| 559 | final var mediaType = fromFilename( file ); | |
| 560 | final var typeChanged = !editor.isMediaType( mediaType ); | |
| 561 | ||
| 562 | if( typeChanged ) { | |
| 563 | removeProcessor( editor ); | |
| 564 | } | |
| 565 | ||
| 566 | editor.rename( file ); | |
| 567 | tab.ifPresent( t -> { | |
| 568 | t.setText( editor.getFilename() ); | |
| 569 | t.setTooltip( createTooltip( file ) ); | |
| 570 | } ); | |
| 571 | ||
| 572 | if( typeChanged ) { | |
| 573 | updateProcessors( editor ); | |
| 574 | process( editor ); | |
| 575 | } | |
| 576 | ||
| 577 | save(); | |
| 578 | } | |
| 579 | ||
| 580 | /** | |
| 581 | * Saves the given {@link TextResource} to a file. This is typically used | |
| 582 | * to save either an instance of {@link TextEditor} or {@link TextDefinition}. | |
| 583 | * | |
| 584 | * @param resource The resource to export. | |
| 585 | */ | |
| 586 | private void save( final TextResource resource ) { | |
| 587 | try { | |
| 588 | resource.save(); | |
| 589 | } catch( final Exception ex ) { | |
| 590 | clue( ex ); | |
| 591 | sNotifier.alert( | |
| 592 | getWindow(), resource.getPath(), "TextResource.saveFailed", ex | |
| 593 | ); | |
| 594 | } | |
| 595 | } | |
| 596 | ||
| 597 | /** | |
| 598 | * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open. | |
| 599 | * | |
| 600 | * @return {@code true} when all editors, modified or otherwise, were | |
| 601 | * permitted to close; {@code false} when one or more editors were modified | |
| 602 | * and the user requested no closing. | |
| 603 | */ | |
| 604 | public boolean closeAll() { | |
| 605 | var closable = true; | |
| 606 | ||
| 607 | for( final var tabPane : mTabPanes ) { | |
| 608 | final var tabIterator = tabPane.getTabs().iterator(); | |
| 609 | ||
| 610 | while( tabIterator.hasNext() ) { | |
| 611 | final var tab = tabIterator.next(); | |
| 612 | final var resource = tab.getContent(); | |
| 613 | ||
| 614 | // The definition panes auto-save, so being specific here prevents | |
| 615 | // closing the definitions in the situation where the user wants to | |
| 616 | // continue editing (i.e., possibly save unsaved work). | |
| 617 | if( !(resource instanceof TextEditor) ) { | |
| 618 | continue; | |
| 619 | } | |
| 620 | ||
| 621 | if( canClose( (TextEditor) resource ) ) { | |
| 622 | tabIterator.remove(); | |
| 623 | close( tab ); | |
| 624 | } | |
| 625 | else { | |
| 626 | closable = false; | |
| 627 | } | |
| 628 | } | |
| 629 | } | |
| 630 | ||
| 631 | return closable; | |
| 632 | } | |
| 633 | ||
| 634 | /** | |
| 635 | * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close | |
| 636 | * event. | |
| 637 | * | |
| 638 | * @param tab The {@link Tab} that was closed. | |
| 639 | */ | |
| 640 | private void close( final Tab tab ) { | |
| 641 | assert tab != null; | |
| 642 | ||
| 643 | final var handler = tab.getOnClosed(); | |
| 644 | ||
| 645 | if( handler != null ) { | |
| 646 | handler.handle( new ActionEvent() ); | |
| 647 | } | |
| 648 | } | |
| 649 | ||
| 650 | /** | |
| 651 | * Closes the active tab; delegates to {@link #canClose(TextResource)}. | |
| 652 | */ | |
| 653 | public void close() { | |
| 654 | final var editor = getTextEditor(); | |
| 655 | ||
| 656 | if( canClose( editor ) ) { | |
| 657 | close( editor ); | |
| 658 | removeProcessor( editor ); | |
| 659 | } | |
| 660 | } | |
| 661 | ||
| 662 | /** | |
| 663 | * Closes the given {@link TextResource}. This must not be called from within | |
| 664 | * a loop that iterates over the tab panes using {@code forEach}, lest a | |
| 665 | * concurrent modification exception be thrown. | |
| 666 | * | |
| 667 | * @param resource The {@link TextResource} to close, without confirming with | |
| 668 | * the user. | |
| 669 | */ | |
| 670 | private void close( final TextResource resource ) { | |
| 671 | getTab( resource ).ifPresent( | |
| 672 | tab -> { | |
| 673 | close( tab ); | |
| 674 | tab.getTabPane().getTabs().remove( tab ); | |
| 675 | } | |
| 676 | ); | |
| 677 | } | |
| 678 | ||
| 679 | /** | |
| 680 | * Answers whether the given {@link TextResource} may be closed. | |
| 681 | * | |
| 682 | * @param editor The {@link TextResource} to try closing. | |
| 683 | * @return {@code true} when the editor may be closed; {@code false} when | |
| 684 | * the user has requested to keep the editor open. | |
| 685 | */ | |
| 686 | private boolean canClose( final TextResource editor ) { | |
| 687 | final var editorTab = getTab( editor ); | |
| 688 | final var canClose = new AtomicBoolean( true ); | |
| 689 | ||
| 690 | if( editor.isModified() ) { | |
| 691 | final var filename = new StringBuilder(); | |
| 692 | editorTab.ifPresent( tab -> filename.append( tab.getText() ) ); | |
| 693 | ||
| 694 | final var message = sNotifier.createNotification( | |
| 695 | Messages.get( "Alert.file.close.title" ), | |
| 696 | Messages.get( "Alert.file.close.text" ), | |
| 697 | filename.toString() | |
| 698 | ); | |
| 699 | ||
| 700 | final var dialog = sNotifier.createConfirmation( getWindow(), message ); | |
| 701 | ||
| 702 | dialog.showAndWait().ifPresent( | |
| 703 | save -> canClose.set( save == YES ? editor.save() : save == NO ) | |
| 704 | ); | |
| 705 | } | |
| 706 | ||
| 707 | return canClose.get(); | |
| 708 | } | |
| 709 | ||
| 710 | private void iterateEditors( final Consumer<TextEditor> consumer ) { | |
| 711 | mTabPanes.forEach( | |
| 712 | tp -> tp.getTabs().forEach( tab -> { | |
| 713 | final var node = tab.getContent(); | |
| 714 | ||
| 715 | if( node instanceof final TextEditor editor ) { | |
| 716 | consumer.accept( editor ); | |
| 717 | } | |
| 718 | } ) | |
| 719 | ); | |
| 720 | } | |
| 721 | ||
| 722 | /** | |
| 723 | * Adds the HTML preview tab to its own, singular tab pane. | |
| 724 | */ | |
| 725 | public void viewPreview() { | |
| 726 | addTab( mPreview, TEXT_HTML, "Pane.preview.title" ); | |
| 727 | } | |
| 728 | ||
| 729 | /** | |
| 730 | * Adds the document outline tab to its own, singular tab pane. | |
| 731 | */ | |
| 732 | public void viewOutline() { | |
| 733 | addTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" ); | |
| 734 | } | |
| 735 | ||
| 736 | public void viewStatistics() { | |
| 737 | addTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" ); | |
| 738 | } | |
| 739 | ||
| 740 | public void viewFiles() { | |
| 741 | try { | |
| 742 | final var factory = new FilePickerFactory( getWorkspace() ); | |
| 743 | final var fileManager = factory.createModeless(); | |
| 744 | addTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" ); | |
| 745 | } catch( final Exception ex ) { | |
| 746 | clue( ex ); | |
| 747 | } | |
| 748 | } | |
| 749 | ||
| 750 | public void viewRefresh() { | |
| 751 | mPreview.refresh(); | |
| 752 | Engine.clear(); | |
| 753 | mRBootstrapController.update(); | |
| 754 | } | |
| 755 | ||
| 756 | private void addTab( | |
| 757 | final Node node, final MediaType mediaType, final String key ) { | |
| 758 | final var tabPane = obtainTabPane( mediaType ); | |
| 759 | ||
| 760 | for( final var tab : tabPane.getTabs() ) { | |
| 761 | if( tab.getContent() == node ) { | |
| 762 | return; | |
| 763 | } | |
| 764 | } | |
| 765 | ||
| 766 | tabPane.getTabs().add( createTab( get( key ), node ) ); | |
| 767 | addTabPane( tabPane ); | |
| 768 | } | |
| 769 | ||
| 770 | /** | |
| 771 | * Returns the tab that contains the given {@link TextEditor}. | |
| 772 | * | |
| 773 | * @param editor The {@link TextEditor} instance to find amongst the tabs. | |
| 774 | * @return The first tab having content that matches the given tab. | |
| 775 | */ | |
| 776 | private Optional<Tab> getTab( final TextResource editor ) { | |
| 777 | return mTabPanes.stream() | |
| 778 | .flatMap( pane -> pane.getTabs().stream() ) | |
| 779 | .filter( tab -> editor.equals( tab.getContent() ) ) | |
| 780 | .findFirst(); | |
| 781 | } | |
| 782 | ||
| 783 | private TextDefinition createDefinitionEditor( final File file ) { | |
| 784 | final var editor = new DefinitionEditor( file, createTreeTransformer() ); | |
| 785 | ||
| 786 | editor.addTreeChangeHandler( mTreeHandler ); | |
| 787 | ||
| 788 | return editor; | |
| 789 | } | |
| 790 | ||
| 791 | /** | |
| 792 | * Creates a new {@link DefinitionEditor} wrapped in a listener that | |
| 793 | * is used to detect when the active {@link DefinitionEditor} has changed. | |
| 794 | * Upon changing, the variables are interpolated and the active text editor | |
| 795 | * is refreshed. | |
| 796 | * | |
| 797 | * @param workspace Has the most recently edited definitions file name. | |
| 798 | * @return A newly configured property that represents the active | |
| 799 | * {@link DefinitionEditor}, never {@code null}. | |
| 800 | */ | |
| 801 | private TextDefinition createDefinitionEditor( | |
| 802 | final Workspace workspace ) { | |
| 803 | final var fileProperty = workspace.fileProperty( KEY_UI_RECENT_DEFINITION ); | |
| 804 | final var filename = fileProperty.get(); | |
| 805 | final SetProperty<String> recent = workspace.setsProperty( | |
| 806 | KEY_UI_RECENT_OPEN_PATH | |
| 807 | ); | |
| 808 | ||
| 809 | // Open the most recently used YAML definition file. | |
| 810 | for( final var recentFile : recent.get() ) { | |
| 811 | if( recentFile.endsWith( filename.toString() ) ) { | |
| 812 | return createDefinitionEditor( new File( recentFile ) ); | |
| 813 | } | |
| 814 | } | |
| 815 | ||
| 816 | return createDefaultDefinitionEditor(); | |
| 817 | } | |
| 818 | ||
| 819 | private TextDefinition createDefaultDefinitionEditor() { | |
| 820 | final var transformer = createTreeTransformer(); | |
| 821 | return new DefinitionEditor( transformer ); | |
| 822 | } | |
| 823 | ||
| 824 | private TreeTransformer createTreeTransformer() { | |
| 825 | return new YamlTreeTransformer(); | |
| 826 | } | |
| 827 | ||
| 828 | private Tab createTab( final String filename, final Node node ) { | |
| 829 | return new DetachableTab( filename, node ); | |
| 830 | } | |
| 831 | ||
| 832 | private Tab createTab( final File file ) { | |
| 833 | final var r = createTextResource( file ); | |
| 834 | final var filename = r.getFilename(); | |
| 835 | final var tab = createTab( filename, r.getNode() ); | |
| 836 | ||
| 837 | r.modifiedProperty().addListener( | |
| 838 | ( _, _, n ) -> tab.setText( filename + (n ? "*" : "") ) | |
| 839 | ); | |
| 840 | ||
| 841 | // This is called when either the tab is closed by the user clicking on | |
| 842 | // the tab's close icon or when closing (all) from the file menu. | |
| 843 | tab.setOnClosed( | |
| 844 | _ -> getRecentFiles().remove( file.getAbsolutePath() ) | |
| 845 | ); | |
| 846 | ||
| 847 | // When closing a tab, give focus to the newly revealed tab. | |
| 848 | tab.selectedProperty().addListener( ( _, _, n ) -> { | |
| 849 | if( n != null && n ) { | |
| 850 | final var pane = tab.getTabPane(); | |
| 851 | ||
| 852 | if( pane != null ) { | |
| 853 | pane.requestFocus(); | |
| 854 | } | |
| 855 | } | |
| 856 | } ); | |
| 857 | ||
| 858 | tab.tabPaneProperty().addListener( ( _, _, nPane ) -> { | |
| 859 | if( nPane != null ) { | |
| 860 | nPane.focusedProperty().addListener( ( _, _, n ) -> { | |
| 861 | if( n != null && n ) { | |
| 862 | final var model = nPane.getSelectionModel(); | |
| 863 | ||
| 864 | if( model != null ) { | |
| 865 | final var selected = model.getSelectedItem(); | |
| 866 | ||
| 867 | if( selected != null ) { | |
| 868 | final var node = selected.getContent(); | |
| 869 | node.requestFocus(); | |
| 870 | } | |
| 871 | } | |
| 872 | } | |
| 873 | } ); | |
| 874 | } | |
| 875 | } ); | |
| 876 | ||
| 877 | return tab; | |
| 878 | } | |
| 879 | ||
| 880 | /** | |
| 881 | * Creates bins for the different {@link MediaType}s, which eventually are | |
| 882 | * added to the UI as separate tab panes. If ever a general-purpose scene | |
| 883 | * exporter is developed to serialize a scene to an FXML file, this could | |
| 884 | * be replaced by such a class. | |
| 885 | * <p> | |
| 886 | * When binning the files, this makes sure that at least one file exists | |
| 887 | * for every type. If the user has opted to close a particular type (such | |
| 888 | * as the definition pane), the view will suppressed elsewhere. | |
| 889 | * </p> | |
| 890 | * <p> | |
| 891 | * The order that the binned files are returned will be reflected in the | |
| 892 | * order that the corresponding panes are rendered in the UI. | |
| 893 | * </p> | |
| 894 | * | |
| 895 | * @param paths The file paths to bin according to their type. | |
| 896 | * @return An in-order list of files, first by structured definition files, | |
| 897 | * then by plain text documents. | |
| 898 | */ | |
| 899 | private List<File> collect( final SetProperty<String> paths ) { | |
| 900 | // Treat all files destined for the text editor as plain text documents | |
| 901 | // so that they are added to the same pane. Grouping by TEXT_PLAIN is a | |
| 902 | // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed. | |
| 903 | final Function<MediaType, MediaType> bin = | |
| 904 | m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m; | |
| 905 | ||
| 906 | // Create two groups: YAML files and plain text files. The order that | |
| 907 | // the elements are listed in the enumeration for media types determines | |
| 908 | // what files are loaded first. Variable definitions come before all other | |
| 909 | // plain text documents. | |
| 910 | final var bins = paths | |
| 911 | .stream() | |
| 912 | .collect( | |
| 913 | groupingBy( | |
| 914 | path -> bin.apply( fromFilename( path ) ), | |
| 915 | () -> new TreeMap<>( Enum::compareTo ), | |
| 916 | Collectors.toList() | |
| 917 | ) | |
| 918 | ); | |
| 919 | ||
| 920 | bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) ); | |
| 921 | bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) ); | |
| 922 | ||
| 923 | final var result = new LinkedList<File>(); | |
| 924 | ||
| 925 | // Ensure that the same types are listed together (keep insertion order). | |
| 926 | bins.forEach( ( _, files ) -> result.addAll( | |
| 927 | files.stream().map( File::new ).toList() ) | |
| 928 | ); | |
| 929 | ||
| 930 | return result; | |
| 931 | } | |
| 932 | ||
| 933 | /** | |
| 934 | * Force the active editor to update, which will cause the processor | |
| 935 | * to re-evaluate the interpolated definition map thereby updating the | |
| 936 | * preview pane. | |
| 937 | * | |
| 938 | * @param editor Contains the source document to update in the preview pane. | |
| 939 | */ | |
| 940 | private void process( final TextEditor editor ) { | |
| 941 | // Ensure processing does not run on the JavaFX thread, which frees the | |
| 942 | // text editor immediately for caret movement. The preview will have a | |
| 943 | // slight delay when catching up to the caret position. | |
| 944 | final var task = new Task<Void>() { | |
| 945 | @Override | |
| 946 | public Void call() { | |
| 947 | try { | |
| 948 | final var p = mProcessors.getOrDefault( editor, IDENTITY ); | |
| 949 | p.apply( editor == null ? "" : editor.getText() ); | |
| 950 | } catch( final Exception ex ) { | |
| 951 | clue( ex ); | |
| 952 | } | |
| 953 | ||
| 954 | return null; | |
| 955 | } | |
| 956 | }; | |
| 957 | ||
| 958 | // TODO: Each time the editor successfully runs the processor, the task is | |
| 959 | // considered successful. Due to the rapid-fire nature of processing | |
| 960 | // (e.g., keyboard navigation, fast typing), it isn't necessary to | |
| 961 | // scroll each time. | |
| 962 | // The algorithm: | |
| 963 | // 1. Peek at the oldest time. | |
| 964 | // 2. If the difference between the oldest time and current time exceeds | |
| 965 | // 250 milliseconds, then invoke the scrolling. | |
| 966 | // 3. Insert the current time into the circular queue. | |
| 967 | task.setOnSucceeded( | |
| 968 | _ -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) ) | |
| 969 | ); | |
| 970 | ||
| 971 | // Prevents multiple process requests from executing simultaneously (due | |
| 972 | // to having a restricted queue size). | |
| 973 | sExecutor.execute( task ); | |
| 974 | } | |
| 975 | ||
| 976 | /** | |
| 977 | * Lazily creates a {@link TabPane} configured to listen for tab select | |
| 978 | * events. The tab pane is associated with a given media type so that | |
| 979 | * similar files can be grouped together. | |
| 980 | * | |
| 981 | * @param mediaType The media type to associate with the tab pane. | |
| 982 | * @return An instance of {@link TabPane} that will handle tab docking. | |
| 983 | */ | |
| 984 | private TabPane obtainTabPane( final MediaType mediaType ) { | |
| 985 | for( final var pane : mTabPanes ) { | |
| 986 | for( final var tab : pane.getTabs() ) { | |
| 987 | final var node = tab.getContent(); | |
| 988 | ||
| 989 | if( node instanceof TextResource r && r.supports( mediaType ) ) { | |
| 990 | return pane; | |
| 991 | } | |
| 992 | } | |
| 993 | } | |
| 994 | ||
| 995 | final var pane = createTabPane(); | |
| 996 | mTabPanes.add( pane ); | |
| 997 | return pane; | |
| 998 | } | |
| 999 | ||
| 1000 | /** | |
| 1001 | * Creates an initialized {@link TabPane} instance. | |
| 1002 | * | |
| 1003 | * @return A new {@link TabPane} with all listeners configured. | |
| 1004 | */ | |
| 1005 | private TabPane createTabPane() { | |
| 1006 | final var tabPane = new DetachableTabPane(); | |
| 1007 | ||
| 1008 | initStageOwnerFactory( tabPane ); | |
| 1009 | initTabListener( tabPane ); | |
| 1010 | ||
| 1011 | return tabPane; | |
| 1012 | } | |
| 1013 | ||
| 1014 | /** | |
| 1015 | * When any {@link DetachableTabPane} is detached from the main window, | |
| 1016 | * the stage owner factory must be given its parent window, which will | |
| 1017 | * own the child window. The parent window is the {@link MainPane}'s | |
| 1018 | * {@link Scene}'s {@link Window} instance. | |
| 1019 | * | |
| 1020 | * <p> | |
| 1021 | * This will derives the new title from the main window title, incrementing | |
| 1022 | * the window count to help uniquely identify the child windows. | |
| 1023 | * </p> | |
| 1024 | * | |
| 1025 | * @param tabPane A new {@link DetachableTabPane} to configure. | |
| 1026 | */ | |
| 1027 | private void initStageOwnerFactory( final DetachableTabPane tabPane ) { | |
| 1028 | tabPane.setStageOwnerFactory( stage -> { | |
| 1029 | final var title = get( | |
| 1030 | "Detach.tab.title", | |
| 1031 | ((Stage) getWindow()).getTitle(), ++mWindowCount | |
| 1032 | ); | |
| 1033 | stage.setTitle( title ); | |
| 1034 | ||
| 1035 | return getScene().getWindow(); | |
| 1036 | } ); | |
| 1037 | } | |
| 1038 | ||
| 1039 | /** | |
| 1040 | * Responsible for configuring the content of each {@link DetachableTab} when | |
| 1041 | * it is added to the given {@link DetachableTabPane} instance. | |
| 1042 | * <p> | |
| 1043 | * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler} | |
| 1044 | * is initialized to perform synchronized scrolling between the editor and | |
| 1045 | * its preview window. Additionally, the last tab in the tab pane's list of | |
| 1046 | * tabs is given focus. | |
| 1047 | * </p> | |
| 1048 | * <p> | |
| 1049 | * Note that multiple tabs can be added simultaneously. | |
| 1050 | * </p> | |
| 1051 | * | |
| 1052 | * @param tabPane A new {@link TabPane} to configure. | |
| 1053 | */ | |
| 1054 | private void initTabListener( final TabPane tabPane ) { | |
| 1055 | tabPane.getTabs().addListener( | |
| 1056 | ( final ListChangeListener.Change<? extends Tab> listener ) -> { | |
| 1057 | while( listener.next() ) { | |
| 1058 | if( listener.wasAdded() ) { | |
| 1059 | final var tabs = listener.getAddedSubList(); | |
| 1060 | ||
| 1061 | tabs.forEach( tab -> { | |
| 1062 | final var node = tab.getContent(); | |
| 1063 | ||
| 1064 | if( node instanceof TextEditor ) { | |
| 1065 | initScrollEventListener( tab ); | |
| 1066 | } | |
| 1067 | } ); | |
| 1068 | ||
| 1069 | // Select and give focus to the last tab opened. | |
| 1070 | final var index = tabs.size() - 1; | |
| 1071 | if( index >= 0 ) { | |
| 1072 | final var tab = tabs.get( index ); | |
| 1073 | tabPane.getSelectionModel().select( tab ); | |
| 1074 | tab.getContent().requestFocus(); | |
| 1075 | } | |
| 1076 | } | |
| 1077 | } | |
| 1078 | } | |
| 1079 | ); | |
| 1080 | } | |
| 1081 | ||
| 1082 | /** | |
| 1083 | * Synchronizes scrollbar positions between the given {@link Tab} that | |
| 1084 | * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane. | |
| 1085 | * | |
| 1086 | * @param tab The container for an instance of {@link TextEditor}. | |
| 1087 | */ | |
| 1088 | private void initScrollEventListener( final Tab tab ) { | |
| 1089 | final var editor = (TextEditor) tab.getContent(); | |
| 1090 | final var scrollPane = editor.getScrollPane(); | |
| 1091 | final var scrollBar = mPreview.getVerticalScrollBar(); | |
| 1092 | final var handler = new ScrollEventHandler( scrollPane, scrollBar ); | |
| 1093 | ||
| 1094 | handler.enabledProperty().bind( tab.selectedProperty() ); | |
| 1095 | } | |
| 1096 | ||
| 1097 | private void addTabPane( final int index, final TabPane tabPane ) { | |
| 1098 | final var items = getItems(); | |
| 1099 | ||
| 1100 | if( !items.contains( tabPane ) ) { | |
| 1101 | items.add( index, tabPane ); | |
| 1102 | } | |
| 1103 | } | |
| 1104 | ||
| 1105 | private void addTabPane( final TabPane tabPane ) { | |
| 1106 | addTabPane( getItems().size(), tabPane ); | |
| 1107 | } | |
| 1108 | ||
| 1109 | private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() { | |
| 1110 | final var w = getWorkspace(); | |
| 1111 | ||
| 1112 | return builder() | |
| 1113 | .with( Mutator::setDefinitions, this::getDefinitions ) | |
| 1114 | .with( Mutator::setLocale, w::getLocale ) | |
| 1115 | .with( Mutator::setMetadata, w::getMetadata ) | |
| 1116 | .with( Mutator::setThemeDir, w::getThemesPath ) | |
| 1117 | .with( Mutator::setCacheDir, | |
| 1118 | () -> w.getFile( KEY_CACHE_DIR ) ) | |
| 1119 | .with( Mutator::setImageDir, | |
| 1120 | () -> w.getFile( KEY_IMAGE_DIR ) ) | |
| 1121 | .with( Mutator::setImageOrder, | |
| 1122 | () -> w.getString( KEY_IMAGE_ORDER ) ) | |
| 1123 | .with( Mutator::setImageServer, | |
| 1124 | () -> w.getString( KEY_IMAGE_SERVER ) ) | |
| 1125 | .with( Mutator::setCaret, | |
| 1126 | () -> getTextEditor().getCaret() ) | |
| 1127 | .with( Mutator::setSigilBegan, | |
| 1128 | () -> w.getString( KEY_DEF_DELIM_BEGAN ) ) | |
| 1129 | .with( Mutator::setSigilEnded, | |
| 1130 | () -> w.getString( KEY_DEF_DELIM_ENDED ) ) | |
| 1131 | .with( Mutator::setRScript, | |
| 1132 | () -> w.getString( KEY_R_SCRIPT ) ) | |
| 1133 | .with( Mutator::setRWorkingDir, | |
| 1134 | () -> w.getFile( KEY_R_DIR ).toPath() ) | |
| 1135 | .with( Mutator::setFontDir, | |
| 1136 | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) | |
| 1137 | .with( Mutator::setModesEnabled, | |
| 1138 | () -> w.getString( KEY_TYPESET_MODES_ENABLED ) ) | |
| 1139 | .with( Mutator::setCurlQuotes, | |
| 1140 | () -> w.listProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ).get() ) | |
| 1141 | .with( Mutator::setAutoRemove, | |
| 1142 | () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) ); | |
| 1143 | } | |
| 1144 | ||
| 1145 | public ProcessorContext createProcessorContext() { | |
| 1146 | return createProcessorContextBuilder( NONE ).build(); | |
| 1147 | } | |
| 1148 | ||
| 1149 | private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder( | |
| 1150 | final ExportFormat format ) { | |
| 1151 | final var textEditor = getTextEditor(); | |
| 1152 | final var sourcePath = textEditor.getPath(); | |
| 1153 | ||
| 1154 | return processorContextBuilder() | |
| 1155 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1156 | .with( Mutator::setExportFormat, format ); | |
| 1157 | } | |
| 1158 | ||
| 1159 | /** | |
| 1160 | * @param targetPath Used when exporting to a PDF file (binary). | |
| 1161 | * @param format Used when processors export to a new text format. | |
| 1162 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1163 | * {@link Processor}. | |
| 1164 | */ | |
| 1165 | public ProcessorContext createProcessorContext( | |
| 1166 | final Path targetPath, final ExportFormat format ) { | |
| 1167 | assert targetPath != null; | |
| 1168 | assert format != null; | |
| 1169 | ||
| 1170 | return createProcessorContextBuilder( format ) | |
| 1171 | .with( Mutator::setTargetPath, targetPath ) | |
| 1172 | .build(); | |
| 1173 | } | |
| 1174 | ||
| 1175 | /** | |
| 1176 | * @param sourcePath Used by {@link ProcessorFactory} to determine | |
| 1177 | * {@link Processor} type to create based on file type. | |
| 1178 | * @return A new {@link ProcessorContext} to use when creating an instance of | |
| 1179 | * {@link Processor}. | |
| 1180 | */ | |
| 1181 | private ProcessorContext createProcessorContext( final Path sourcePath ) { | |
| 1182 | return processorContextBuilder() | |
| 1183 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1184 | .with( Mutator::setExportFormat, NONE ) | |
| 1185 | .build(); | |
| 1186 | } | |
| 1187 | ||
| 1188 | private TextResource createTextResource( final File file ) { | |
| 1189 | if( fromFilename( file ) == TEXT_YAML ) { | |
| 1190 | final var editor = createDefinitionEditor( file ); | |
| 1191 | mDefinitionEditor.set( editor ); | |
| 1192 | return editor; | |
| 1193 | } | |
| 1194 | else { | |
| 1195 | final var editor = createMarkdownEditor( file ); | |
| 1196 | mTextEditor.set( editor ); | |
| 1197 | return editor; | |
| 1198 | } | |
| 1199 | } | |
| 1200 | ||
| 1201 | /** | |
| 1202 | * Creates an instance of {@link MarkdownEditor} that listens for both | |
| 1203 | * caret change events and text change events. Text change events must | |
| 1204 | * take priority over caret change events because it's possible to change | |
| 1205 | * the text without moving the caret (e.g., delete selected text). | |
| 1206 | * | |
| 1207 | * @param inputFile The file containing contents for the text editor. | |
| 1208 | * @return A non-null text editor. | |
| 1209 | */ | |
| 1210 | private MarkdownEditor createMarkdownEditor( final File inputFile ) { | |
| 1211 | final var editor = new MarkdownEditor( inputFile, getWorkspace() ); | |
| 1212 | ||
| 1213 | // Listener for editor modifications or caret position changes. | |
| 1214 | editor.addDirtyListener( ( _, _, n ) -> { | |
| 1215 | if( n ) { | |
| 1216 | // Reset the status bar after changing the text. | |
| 1217 | clue(); | |
| 1218 | ||
| 1219 | // Processing the text may update the status bar. | |
| 1220 | process( editor ); | |
| 1221 | ||
| 1222 | // Update the caret position in the status bar. | |
| 1223 | CaretMovedEvent.fire( editor.getCaret() ); | |
| 1224 | } | |
| 1225 | } ); | |
| 1226 | ||
| 1227 | editor.addEventListener( | |
| 1228 | keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert | |
| 1229 | ); | |
| 1230 | ||
| 1231 | editor.addEventListener( | |
| 1232 | keyPressed( ENTER, ALT_DOWN ), _ -> mEditorSpeller.autofix( editor ) | |
| 1233 | ); | |
| 1234 | ||
| 1235 | final var textArea = editor.getTextArea(); | |
| 1236 | ||
| 1237 | // Spell check when the paragraph changes. | |
| 1238 | textArea | |
| 1239 | .plainTextChanges() | |
| 1240 | .filter( p -> !p.isIdentity() ) | |
| 1241 | .subscribe( change -> mEditorSpeller.checkParagraph( textArea, change ) ); | |
| 1242 | ||
| 1243 | // Store the caret position to restore it after restarting the application. | |
| 1244 | textArea.caretPositionProperty().addListener( | |
| 1245 | ( _, _, n ) -> | |
| 1246 | getWorkspace().integerProperty( KEY_UI_RECENT_OFFSET ).setValue( n ) | |
| 1247 | ); | |
| 1248 | ||
| 1249 | // Check the entire document after the spellchecker is initialized (with | |
| 1250 | // a valid lexicon) so that only the current paragraph need be scanned | |
| 1251 | // while editing. (Technically, only the most recently modified word must | |
| 1252 | // be scanned.) | |
| 1253 | mSpellChecker.addListener( | |
| 1254 | ( _, _, _ ) -> runLater( | |
| 1255 | () -> iterateEditors( mEditorSpeller::checkDocument ) | |
| 1256 | ) | |
| 1257 | ); | |
| 1258 | ||
| 1259 | // Check the entire document after it has been loaded. | |
| 1260 | mEditorSpeller.checkDocument( editor ); | |
| 1261 | ||
| 1262 | return editor; | |
| 1263 | } | |
| 1264 | ||
| 1265 | /** | |
| 1266 | * Creates a processor for an editor, provided one doesn't already exist. | |
| 1267 | * | |
| 1268 | * @param editor The editor that potentially requires an associated processor. | |
| 1269 | */ | |
| 1270 | private void updateProcessors( final TextEditor editor ) { | |
| 1271 | final var path = editor.getFile().toPath(); | |
| 1272 | ||
| 1273 | mProcessors.computeIfAbsent( | |
| 1274 | editor, _ -> { | |
| 1275 | final var context = createProcessorContext( path ); | |
| 1276 | final var preview = createHtmlPreviewProcessor( context ); | |
| 1277 | ||
| 1278 | return createProcessors( | |
| 1279 | context, | |
| 1280 | preview | |
| 1281 | ); | |
| 1282 | } | |
| 1283 | ); | |
| 1284 | } | |
| 1285 | ||
| 1286 | /** | |
| 1287 | * Removes a processor for an editor. This is required because a file may | |
| 1288 | * change type while editing (e.g., from plain Markdown to R Markdown). | |
| 1289 | * In the case that an editor's type changes, its associated processor must | |
| 1290 | * be changed accordingly. | |
| 1291 | * | |
| 1292 | * @param editor The editor that potentially requires an associated processor. | |
| 1293 | */ | |
| 1294 | private void removeProcessor( final TextEditor editor ) { | |
| 1295 | mProcessors.remove( editor ); | |
| 1296 | } | |
| 1297 | ||
| 1298 | /** | |
| 1299 | * Creates a {@link Processor} capable of rendering an HTML document onto | |
| 1300 | * a GUI widget. | |
| 1301 | * | |
| 1302 | * @return The {@link Processor} for rendering an HTML document. | |
| 1303 | */ | |
| 1304 | private Processor<String> createHtmlPreviewProcessor( | |
| 1305 | final ProcessorContext context | |
| 1306 | ) { | |
| 1307 | return new HtmlPreviewProcessor( context, getPreview() ); | |
| 1295 | 1308 | } |
| 1296 | 1309 |
| 66 | 66 | |
| 67 | 67 | /** |
| 68 | * Called by the {@link MainApp} to get a handle on the {@link Scene} | |
| 68 | * Called by the {@link GuiApp} to get a handle on the {@link Scene} | |
| 69 | 69 | * created by an instance of {@link MainScene}. |
| 70 | 70 | * |
| ... | ||
| 88 | 88 | final var node = mStatusBar; |
| 89 | 89 | node.setVisible( !node.isVisible() ); |
| 90 | } | |
| 91 | ||
| 92 | MenuBar getMenuBar() { | |
| 93 | return mMenuBar; | |
| 94 | 90 | } |
| 95 | 91 | |
| 96 | 92 | public StatusBar getStatusBar() {return mStatusBar;} |
| 97 | 93 | |
| 98 | 94 | private void initStylesheets( final Scene scene, final Workspace workspace ) { |
| 99 | final var internal = workspace.skinProperty( KEY_UI_SKIN_SELECTION ); | |
| 95 | final var internal = workspace.listProperty( KEY_UI_SKIN_SELECTION ); | |
| 100 | 96 | final var external = workspace.fileProperty( KEY_UI_SKIN_CUSTOM ); |
| 101 | 97 | final var inSkin = internal.get(); |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.cmdline; | |
| 6 | ||
| 7 | import com.fasterxml.jackson.databind.JsonNode; | |
| 8 | import com.fasterxml.jackson.databind.ObjectMapper; | |
| 9 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; | |
| 10 | import com.keenwrite.ExportFormat; | |
| 11 | import com.keenwrite.processors.ProcessorContext; | |
| 12 | import com.keenwrite.processors.ProcessorContext.Mutator; | |
| 13 | import picocli.CommandLine; | |
| 14 | ||
| 15 | import java.io.File; | |
| 16 | import java.io.IOException; | |
| 17 | import java.nio.file.Files; | |
| 18 | import java.nio.file.Path; | |
| 19 | import java.util.HashMap; | |
| 20 | import java.util.Locale; | |
| 21 | import java.util.Map; | |
| 22 | import java.util.Map.Entry; | |
| 23 | import java.util.concurrent.Callable; | |
| 24 | import java.util.function.Consumer; | |
| 25 | ||
| 26 | import static com.keenwrite.constants.Constants.DIAGRAM_SERVER_NAME; | |
| 27 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 28 | ||
| 29 | /** | |
| 30 | * Responsible for mapping command-line arguments to keys that are used by | |
| 31 | * the application. | |
| 32 | */ | |
| 33 | @CommandLine.Command( | |
| 34 | name = "KeenWrite", | |
| 35 | mixinStandardHelpOptions = true, | |
| 36 | description = "Plain text editor for editing with variables" | |
| 37 | ) | |
| 38 | @SuppressWarnings( "unused" ) | |
| 39 | public final class Arguments implements Callable<Integer> { | |
| 40 | @CommandLine.Option( | |
| 41 | names = { "--all" }, | |
| 42 | description = | |
| 43 | "Concatenate files before processing (${DEFAULT-VALUE})", | |
| 44 | defaultValue = "false" | |
| 45 | ) | |
| 46 | private boolean mConcatenate; | |
| 47 | ||
| 48 | @CommandLine.Option( | |
| 49 | names = { "--keep-files" }, | |
| 50 | description = | |
| 51 | "Retain temporary build files (${DEFAULT-VALUE})", | |
| 52 | defaultValue = "false" | |
| 53 | ) | |
| 54 | private boolean mKeepFiles; | |
| 55 | ||
| 56 | @CommandLine.Option( | |
| 57 | names = { "-c", "--chapters" }, | |
| 58 | description = | |
| 59 | "Export chapter ranges, no spaces (e.g., -3,5-9,15-)", | |
| 60 | paramLabel = "String" | |
| 61 | ) | |
| 62 | private String mChapters; | |
| 63 | ||
| 64 | @CommandLine.Option( | |
| 65 | names = { "--curl-quotes" }, | |
| 66 | description = | |
| 67 | "Replace straight quotes with curly quotes (${DEFAULT-VALUE})", | |
| 68 | defaultValue = "true" | |
| 69 | ) | |
| 70 | private boolean mCurlQuotes; | |
| 71 | ||
| 72 | @CommandLine.Option( | |
| 73 | names = { "-d", "--debug" }, | |
| 74 | description = | |
| 75 | "Enable logging to the console (${DEFAULT-VALUE})", | |
| 76 | paramLabel = "Boolean", | |
| 77 | defaultValue = "false" | |
| 78 | ) | |
| 79 | private boolean mDebug; | |
| 80 | ||
| 81 | @CommandLine.Option( | |
| 82 | names = { "-i", "--input" }, | |
| 83 | description = | |
| 84 | "Source document file path", | |
| 85 | paramLabel = "PATH", | |
| 86 | defaultValue = "stdin", | |
| 87 | required = true | |
| 88 | ) | |
| 89 | private Path mSourcePath; | |
| 90 | ||
| 91 | @CommandLine.Option( | |
| 92 | names = { "--font-dir" }, | |
| 93 | description = | |
| 94 | "Directory to specify additional fonts", | |
| 95 | paramLabel = "String" | |
| 96 | ) | |
| 97 | private File mFontDir; | |
| 98 | ||
| 99 | @CommandLine.Option( | |
| 100 | names = { "--mode" }, | |
| 101 | description = | |
| 102 | "Enable one or more modes when typesetting", | |
| 103 | paramLabel = "String" | |
| 104 | ) | |
| 105 | private String mTypesetMode; | |
| 106 | ||
| 107 | @CommandLine.Option( | |
| 108 | names = { "--format-subtype" }, | |
| 109 | description = | |
| 110 | "Export TeX subtype for HTML formats: svg, delimited", | |
| 111 | paramLabel = "String", | |
| 112 | defaultValue = "svg" | |
| 113 | ) | |
| 114 | private String mFormatSubtype; | |
| 115 | ||
| 116 | @CommandLine.Option( | |
| 117 | names = { "--cache-dir" }, | |
| 118 | description = | |
| 119 | "Directory to store remote resources", | |
| 120 | paramLabel = "DIR" | |
| 121 | ) | |
| 122 | private File mCachesDir; | |
| 123 | ||
| 124 | @CommandLine.Option( | |
| 125 | names = { "--image-dir" }, | |
| 126 | description = | |
| 127 | "Directory containing images", | |
| 128 | paramLabel = "DIR" | |
| 129 | ) | |
| 130 | private File mImagesDir; | |
| 131 | ||
| 132 | @CommandLine.Option( | |
| 133 | names = { "--image-order" }, | |
| 134 | description = | |
| 135 | "Comma-separated image order (${DEFAULT-VALUE})", | |
| 136 | paramLabel = "String", | |
| 137 | defaultValue = "svg,pdf,png,jpg,tiff" | |
| 138 | ) | |
| 139 | private String mImageOrder; | |
| 140 | ||
| 141 | @CommandLine.Option( | |
| 142 | names = { "--image-server" }, | |
| 143 | description = | |
| 144 | "SVG diagram rendering service (${DEFAULT-VALUE})", | |
| 145 | paramLabel = "String", | |
| 146 | defaultValue = DIAGRAM_SERVER_NAME | |
| 147 | ) | |
| 148 | private String mImageServer; | |
| 149 | ||
| 150 | @CommandLine.Option( | |
| 151 | names = { "--locale" }, | |
| 152 | description = | |
| 153 | "Set localization (${DEFAULT-VALUE})", | |
| 154 | paramLabel = "String", | |
| 155 | defaultValue = "en" | |
| 156 | ) | |
| 157 | private String mLocale; | |
| 158 | ||
| 159 | @CommandLine.Option( | |
| 160 | names = { "-m", "--metadata" }, | |
| 161 | description = | |
| 162 | "Map metadata keys to values, variable names allowed", | |
| 163 | paramLabel = "key=value" | |
| 164 | ) | |
| 165 | private Map<String, String> mMetadata; | |
| 166 | ||
| 167 | @CommandLine.Option( | |
| 168 | names = { "-o", "--output" }, | |
| 169 | description = | |
| 170 | "Destination document file path", | |
| 171 | paramLabel = "PATH", | |
| 172 | defaultValue = "stdout", | |
| 173 | required = true | |
| 174 | ) | |
| 175 | private Path mTargetPath; | |
| 176 | ||
| 177 | @CommandLine.Option( | |
| 178 | names = { "-q", "--quiet" }, | |
| 179 | description = | |
| 180 | "Suppress all status messages (${DEFAULT-VALUE})", | |
| 181 | defaultValue = "false" | |
| 182 | ) | |
| 183 | private boolean mQuiet; | |
| 184 | ||
| 185 | @CommandLine.Option( | |
| 186 | names = { "--r-dir" }, | |
| 187 | description = | |
| 188 | "R working directory", | |
| 189 | paramLabel = "DIR" | |
| 190 | ) | |
| 191 | private Path mRWorkingDir; | |
| 192 | ||
| 193 | @CommandLine.Option( | |
| 194 | names = { "--r-script" }, | |
| 195 | description = | |
| 196 | "R bootstrap script file path", | |
| 197 | paramLabel = "PATH" | |
| 198 | ) | |
| 199 | private Path mRScriptPath; | |
| 200 | ||
| 201 | @CommandLine.Option( | |
| 202 | names = { "-s", "--set" }, | |
| 203 | description = | |
| 204 | "Set (or override) a document variable value", | |
| 205 | paramLabel = "key=value" | |
| 206 | ) | |
| 207 | private Map<String, String> mOverrides; | |
| 208 | ||
| 209 | @CommandLine.Option( | |
| 210 | names = { "--sigil-opening" }, | |
| 211 | description = | |
| 212 | "Starting sigil for variable names (${DEFAULT-VALUE})", | |
| 213 | paramLabel = "String", | |
| 214 | defaultValue = "{{" | |
| 215 | ) | |
| 216 | private String mSigilBegan; | |
| 217 | ||
| 218 | @CommandLine.Option( | |
| 219 | names = { "--sigil-closing" }, | |
| 220 | description = | |
| 221 | "Ending sigil for variable names (${DEFAULT-VALUE})", | |
| 222 | paramLabel = "String", | |
| 223 | defaultValue = "}}" | |
| 224 | ) | |
| 225 | private String mSigilEnded; | |
| 226 | ||
| 227 | @CommandLine.Option( | |
| 228 | names = { "--theme-dir" }, | |
| 229 | description = | |
| 230 | "Theme directory", | |
| 231 | paramLabel = "DIR" | |
| 232 | ) | |
| 233 | private Path mThemesDir; | |
| 234 | ||
| 235 | @CommandLine.Option( | |
| 236 | names = { "-v", "--variables" }, | |
| 237 | description = | |
| 238 | "Variables file path", | |
| 239 | paramLabel = "PATH" | |
| 240 | ) | |
| 241 | private Path mPathVariables; | |
| 242 | ||
| 243 | private final Consumer<Arguments> mLauncher; | |
| 244 | ||
| 245 | public Arguments( final Consumer<Arguments> launcher ) { | |
| 246 | mLauncher = launcher; | |
| 247 | } | |
| 248 | ||
| 249 | public ProcessorContext createProcessorContext() | |
| 250 | throws IOException { | |
| 251 | final var definitions = parse( mPathVariables ); | |
| 252 | final var format = ExportFormat.valueFrom( mTargetPath, mFormatSubtype ); | |
| 253 | final var locale = lookupLocale( mLocale ); | |
| 254 | final var rScript = read( mRScriptPath ); | |
| 255 | ||
| 256 | return ProcessorContext | |
| 257 | .builder() | |
| 258 | .with( Mutator::setSourcePath, mSourcePath ) | |
| 259 | .with( Mutator::setTargetPath, mTargetPath ) | |
| 260 | .with( Mutator::setThemeDir, () -> mThemesDir ) | |
| 261 | .with( Mutator::setCacheDir, () -> mCachesDir ) | |
| 262 | .with( Mutator::setImageDir, () -> mImagesDir ) | |
| 263 | .with( Mutator::setImageServer, () -> mImageServer ) | |
| 264 | .with( Mutator::setImageOrder, () -> mImageOrder ) | |
| 265 | .with( Mutator::setFontDir, () -> mFontDir ) | |
| 266 | .with( Mutator::setModesEnabled, () -> mTypesetMode ) | |
| 267 | .with( Mutator::setExportFormat, format ) | |
| 268 | .with( Mutator::setDefinitions, () -> definitions ) | |
| 269 | .with( Mutator::setMetadata, () -> mMetadata ) | |
| 270 | .with( Mutator::setOverrides, () -> mOverrides ) | |
| 271 | .with( Mutator::setLocale, () -> locale ) | |
| 272 | .with( Mutator::setConcatenate, () -> mConcatenate ) | |
| 273 | .with( Mutator::setChapters, () -> mChapters ) | |
| 274 | .with( Mutator::setSigilBegan, () -> mSigilBegan ) | |
| 275 | .with( Mutator::setSigilEnded, () -> mSigilEnded ) | |
| 276 | .with( Mutator::setRScript, () -> rScript ) | |
| 277 | .with( Mutator::setRWorkingDir, () -> mRWorkingDir ) | |
| 278 | .with( Mutator::setCurlQuotes, () -> mCurlQuotes ) | |
| 279 | .with( Mutator::setAutoRemove, () -> !mKeepFiles ) | |
| 280 | .build(); | |
| 281 | } | |
| 282 | ||
| 283 | public boolean quiet() { | |
| 284 | return mQuiet; | |
| 285 | } | |
| 286 | ||
| 287 | public boolean debug() { | |
| 288 | return mDebug; | |
| 289 | } | |
| 290 | ||
| 291 | /** | |
| 292 | * Launches the main application window. This is called when not running | |
| 293 | * in headless mode. | |
| 294 | * | |
| 295 | * @return {@code 0} | |
| 296 | * @throws Exception The application encountered an unrecoverable error. | |
| 297 | */ | |
| 298 | @Override | |
| 299 | public Integer call() throws Exception { | |
| 300 | mLauncher.accept( this ); | |
| 301 | return 0; | |
| 302 | } | |
| 303 | ||
| 304 | private static String read( final Path path ) throws IOException { | |
| 305 | return path == null ? "" : Files.readString( path, UTF_8 ); | |
| 306 | } | |
| 307 | ||
| 308 | /** | |
| 309 | * Parses the given YAML document into a map of key-value pairs. | |
| 310 | * | |
| 311 | * @param vars Variable definition file to read, may be {@code null} if no | |
| 312 | * variables are specified. | |
| 313 | * @return A non-interpolated variable map, or an empty map. | |
| 314 | * @throws IOException Could not read the variable definition file | |
| 315 | */ | |
| 316 | private static Map<String, String> parse( final Path vars ) | |
| 317 | throws IOException { | |
| 318 | final var map = new HashMap<String, String>(); | |
| 319 | ||
| 320 | if( vars != null ) { | |
| 321 | final var yaml = read( vars ); | |
| 322 | final var factory = new YAMLFactory(); | |
| 323 | final var json = new ObjectMapper( factory ).readTree( yaml ); | |
| 324 | ||
| 325 | parse( json, "", map ); | |
| 326 | } | |
| 327 | ||
| 328 | return map; | |
| 329 | } | |
| 330 | ||
| 331 | private static void parse( | |
| 332 | final JsonNode json, final String parent, final Map<String, String> map ) { | |
| 333 | assert json != null; | |
| 334 | assert parent != null; | |
| 335 | assert map != null; | |
| 336 | ||
| 337 | json.fields().forEachRemaining( node -> parse( node, parent, map ) ); | |
| 338 | } | |
| 339 | ||
| 340 | private static void parse( | |
| 341 | final Entry<String, JsonNode> node, | |
| 342 | final String parent, | |
| 343 | final Map<String, String> map ) { | |
| 344 | assert node != null; | |
| 345 | assert parent != null; | |
| 346 | assert map != null; | |
| 347 | ||
| 348 | final var jsonNode = node.getValue(); | |
| 349 | final var keyName = String.format( "%s.%s", parent, node.getKey() ); | |
| 350 | ||
| 351 | if( jsonNode.isValueNode() ) { | |
| 1 | /* Copyright 2023-2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.cmdline; | |
| 6 | ||
| 7 | import com.fasterxml.jackson.databind.JsonNode; | |
| 8 | import com.fasterxml.jackson.databind.ObjectMapper; | |
| 9 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; | |
| 10 | import com.keenwrite.ExportFormat; | |
| 11 | import com.keenwrite.processors.ProcessorContext; | |
| 12 | import com.keenwrite.processors.ProcessorContext.Mutator; | |
| 13 | import picocli.CommandLine; | |
| 14 | import picocli.CommandLine.ParseResult; | |
| 15 | ||
| 16 | import java.io.File; | |
| 17 | import java.io.IOException; | |
| 18 | import java.nio.file.Files; | |
| 19 | import java.nio.file.Path; | |
| 20 | import java.util.HashMap; | |
| 21 | import java.util.Locale; | |
| 22 | import java.util.Map; | |
| 23 | import java.util.Map.Entry; | |
| 24 | import java.util.concurrent.Callable; | |
| 25 | import java.util.function.Consumer; | |
| 26 | ||
| 27 | import static com.keenwrite.constants.Constants.DIAGRAM_SERVER_NAME; | |
| 28 | import static java.lang.String.format; | |
| 29 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 30 | import static java.nio.file.Files.*; | |
| 31 | ||
| 32 | /** | |
| 33 | * Responsible for mapping command-line arguments to keys that are used by | |
| 34 | * the application. | |
| 35 | */ | |
| 36 | @CommandLine.Command( | |
| 37 | name = "KeenWrite", | |
| 38 | mixinStandardHelpOptions = true, | |
| 39 | description = "Plain text editor for editing with variables" | |
| 40 | ) | |
| 41 | @SuppressWarnings( "unused" ) | |
| 42 | public final class Arguments implements Callable<Integer> { | |
| 43 | @CommandLine.Spec | |
| 44 | CommandLine.Model.CommandSpec mSpecifications; | |
| 45 | ||
| 46 | @CommandLine.Option( | |
| 47 | names = { "--all" }, | |
| 48 | description = | |
| 49 | "Concatenate files before processing (${DEFAULT-VALUE})", | |
| 50 | defaultValue = "false" | |
| 51 | ) | |
| 52 | private boolean mConcatenate; | |
| 53 | ||
| 54 | @CommandLine.Option( | |
| 55 | names = { "--keep-files" }, | |
| 56 | description = | |
| 57 | "Retain temporary build files (${DEFAULT-VALUE})", | |
| 58 | defaultValue = "false" | |
| 59 | ) | |
| 60 | private boolean mKeepFiles; | |
| 61 | ||
| 62 | @CommandLine.Option( | |
| 63 | names = { "-c", "--chapters" }, | |
| 64 | description = | |
| 65 | "Export chapter ranges, no spaces (e.g., -3,5-9,15-)", | |
| 66 | paramLabel = "String" | |
| 67 | ) | |
| 68 | private String mChapters; | |
| 69 | ||
| 70 | @CommandLine.Option( | |
| 71 | names = { "--curl-quotes" }, | |
| 72 | description = | |
| 73 | "Encode quotation marks (see docs)", | |
| 74 | paramLabel = "String", | |
| 75 | defaultValue = "regular" | |
| 76 | ) | |
| 77 | private String mCurlQuotes; | |
| 78 | ||
| 79 | @CommandLine.Option( | |
| 80 | names = { "-d", "--debug" }, | |
| 81 | description = | |
| 82 | "Enable logging to the console (${DEFAULT-VALUE})", | |
| 83 | paramLabel = "Boolean", | |
| 84 | defaultValue = "false" | |
| 85 | ) | |
| 86 | private boolean mDebug; | |
| 87 | ||
| 88 | @CommandLine.Option( | |
| 89 | names = { "-i", "--input" }, | |
| 90 | description = | |
| 91 | "Source document path", | |
| 92 | paramLabel = "PATH", | |
| 93 | defaultValue = "stdin", | |
| 94 | required = true | |
| 95 | ) | |
| 96 | private Path mSourcePath; | |
| 97 | ||
| 98 | @CommandLine.Option( | |
| 99 | names = { "--html-head" }, | |
| 100 | description = | |
| 101 | "Document fragment to append to HTML head element", | |
| 102 | paramLabel = "PATH", | |
| 103 | defaultValue = "", | |
| 104 | required = true | |
| 105 | ) | |
| 106 | private Path mHtmlHeadPath; | |
| 107 | ||
| 108 | @CommandLine.Option( | |
| 109 | names = { "--html-foot" }, | |
| 110 | description = | |
| 111 | "Document fragment to append to HTML body element", | |
| 112 | paramLabel = "PATH", | |
| 113 | defaultValue = "", | |
| 114 | required = true | |
| 115 | ) | |
| 116 | private Path mHtmlFootPath; | |
| 117 | ||
| 118 | @CommandLine.Option( | |
| 119 | names = { "--font-dir" }, | |
| 120 | description = | |
| 121 | "Directory to specify additional fonts", | |
| 122 | paramLabel = "String" | |
| 123 | ) | |
| 124 | private File mFontDir; | |
| 125 | ||
| 126 | @CommandLine.Option( | |
| 127 | names = { "--mode" }, | |
| 128 | description = | |
| 129 | "Enable one or more modes when typesetting", | |
| 130 | paramLabel = "String" | |
| 131 | ) | |
| 132 | private String mTypesetMode; | |
| 133 | ||
| 134 | @CommandLine.Option( | |
| 135 | names = { "--format-subtype" }, | |
| 136 | description = | |
| 137 | "Export TeX subtype for HTML formats: svg, delimited", | |
| 138 | paramLabel = "String", | |
| 139 | defaultValue = "svg" | |
| 140 | ) | |
| 141 | private String mFormatSubtype; | |
| 142 | ||
| 143 | @CommandLine.Option( | |
| 144 | names = { "--cache-dir" }, | |
| 145 | description = | |
| 146 | "Directory to store remote resources", | |
| 147 | paramLabel = "DIR" | |
| 148 | ) | |
| 149 | private File mCachesDir; | |
| 150 | ||
| 151 | @CommandLine.Option( | |
| 152 | names = { "--image-dir" }, | |
| 153 | description = | |
| 154 | "Directory containing images", | |
| 155 | paramLabel = "DIR" | |
| 156 | ) | |
| 157 | private File mImagesDir; | |
| 158 | ||
| 159 | @CommandLine.Option( | |
| 160 | names = { "--image-order" }, | |
| 161 | description = | |
| 162 | "Comma-separated image order (${DEFAULT-VALUE})", | |
| 163 | paramLabel = "String", | |
| 164 | defaultValue = "svg,pdf,png,jpg,tiff" | |
| 165 | ) | |
| 166 | private String mImageOrder; | |
| 167 | ||
| 168 | @CommandLine.Option( | |
| 169 | names = { "--image-server" }, | |
| 170 | description = | |
| 171 | "SVG diagram rendering service (${DEFAULT-VALUE})", | |
| 172 | paramLabel = "String", | |
| 173 | defaultValue = DIAGRAM_SERVER_NAME | |
| 174 | ) | |
| 175 | private String mImageServer; | |
| 176 | ||
| 177 | @CommandLine.Option( | |
| 178 | names = { "--locale" }, | |
| 179 | description = | |
| 180 | "Set localization (${DEFAULT-VALUE})", | |
| 181 | paramLabel = "String", | |
| 182 | defaultValue = "en" | |
| 183 | ) | |
| 184 | private String mLocale; | |
| 185 | ||
| 186 | @CommandLine.Option( | |
| 187 | names = { "-m", "--metadata" }, | |
| 188 | description = | |
| 189 | "Map metadata keys to values, variable names allowed", | |
| 190 | paramLabel = "key=value" | |
| 191 | ) | |
| 192 | private Map<String, String> mMetadata; | |
| 193 | ||
| 194 | @CommandLine.Option( | |
| 195 | names = { "-o", "--output" }, | |
| 196 | description = | |
| 197 | "Destination document path", | |
| 198 | paramLabel = "PATH", | |
| 199 | defaultValue = "stdout", | |
| 200 | required = true | |
| 201 | ) | |
| 202 | private Path mTargetPath; | |
| 203 | ||
| 204 | @CommandLine.Option( | |
| 205 | names = { "-q", "--quiet" }, | |
| 206 | description = | |
| 207 | "Suppress all status messages (${DEFAULT-VALUE})", | |
| 208 | defaultValue = "false" | |
| 209 | ) | |
| 210 | private boolean mQuiet; | |
| 211 | ||
| 212 | @CommandLine.Option( | |
| 213 | names = { "--r-dir" }, | |
| 214 | description = | |
| 215 | "R working directory", | |
| 216 | paramLabel = "DIR" | |
| 217 | ) | |
| 218 | private Path mRWorkingDir; | |
| 219 | ||
| 220 | @CommandLine.Option( | |
| 221 | names = { "--r-script" }, | |
| 222 | description = | |
| 223 | "R bootstrap script path", | |
| 224 | paramLabel = "PATH" | |
| 225 | ) | |
| 226 | private Path mRScriptPath; | |
| 227 | ||
| 228 | @CommandLine.Option( | |
| 229 | names = { "-s", "--set" }, | |
| 230 | description = | |
| 231 | "Set (or override) a document variable value", | |
| 232 | paramLabel = "key=value" | |
| 233 | ) | |
| 234 | private Map<String, String> mOverrides; | |
| 235 | ||
| 236 | @CommandLine.Option( | |
| 237 | names = { "--sigil-opening" }, | |
| 238 | description = | |
| 239 | "Starting sigil for variable names (${DEFAULT-VALUE})", | |
| 240 | paramLabel = "String", | |
| 241 | defaultValue = "{{" | |
| 242 | ) | |
| 243 | private String mSigilBegan; | |
| 244 | ||
| 245 | @CommandLine.Option( | |
| 246 | names = { "--sigil-closing" }, | |
| 247 | description = | |
| 248 | "Ending sigil for variable names (${DEFAULT-VALUE})", | |
| 249 | paramLabel = "String", | |
| 250 | defaultValue = "}}" | |
| 251 | ) | |
| 252 | private String mSigilEnded; | |
| 253 | ||
| 254 | @CommandLine.Option( | |
| 255 | names = { "--theme-dir" }, | |
| 256 | description = | |
| 257 | "Theme directory", | |
| 258 | paramLabel = "DIR" | |
| 259 | ) | |
| 260 | private Path mThemesDir; | |
| 261 | ||
| 262 | @CommandLine.Option( | |
| 263 | names = { "-v", "--variables" }, | |
| 264 | description = | |
| 265 | "Variables path", | |
| 266 | paramLabel = "PATH" | |
| 267 | ) | |
| 268 | private Path mPathVariables; | |
| 269 | ||
| 270 | private final Consumer<Arguments> mLauncher; | |
| 271 | ||
| 272 | public Arguments( final Consumer<Arguments> launcher ) { | |
| 273 | mLauncher = launcher; | |
| 274 | } | |
| 275 | ||
| 276 | public ProcessorContext createProcessorContext() | |
| 277 | throws IOException { | |
| 278 | final var definitions = parse( mPathVariables ); | |
| 279 | final var format = ExportFormat.valueFrom( mTargetPath, mFormatSubtype ); | |
| 280 | final var locale = lookupLocale( mLocale ); | |
| 281 | final var rScript = read( mRScriptPath ); | |
| 282 | final var htmlHead = read( mHtmlHeadPath ); | |
| 283 | final var htmlFoot = read( mHtmlFootPath ); | |
| 284 | ||
| 285 | return ProcessorContext | |
| 286 | .builder() | |
| 287 | .with( Mutator::setSourcePath, mSourcePath ) | |
| 288 | .with( Mutator::setTargetPath, mTargetPath ) | |
| 289 | .with( Mutator::setHtmlHead, htmlHead ) | |
| 290 | .with( Mutator::setHtmlFoot, htmlFoot ) | |
| 291 | .with( Mutator::setThemeDir, () -> mThemesDir ) | |
| 292 | .with( Mutator::setCacheDir, () -> mCachesDir ) | |
| 293 | .with( Mutator::setImageDir, () -> mImagesDir ) | |
| 294 | .with( Mutator::setImageServer, () -> mImageServer ) | |
| 295 | .with( Mutator::setImageOrder, () -> mImageOrder ) | |
| 296 | .with( Mutator::setFontDir, () -> mFontDir ) | |
| 297 | .with( Mutator::setModesEnabled, () -> mTypesetMode ) | |
| 298 | .with( Mutator::setExportFormat, format ) | |
| 299 | .with( Mutator::setDefinitions, () -> definitions ) | |
| 300 | .with( Mutator::setMetadata, () -> mMetadata ) | |
| 301 | .with( Mutator::setOverrides, () -> mOverrides ) | |
| 302 | .with( Mutator::setLocale, () -> locale ) | |
| 303 | .with( Mutator::setConcatenate, () -> mConcatenate ) | |
| 304 | .with( Mutator::setChapters, () -> mChapters ) | |
| 305 | .with( Mutator::setSigilBegan, () -> mSigilBegan ) | |
| 306 | .with( Mutator::setSigilEnded, () -> mSigilEnded ) | |
| 307 | .with( Mutator::setRScript, () -> rScript ) | |
| 308 | .with( Mutator::setRWorkingDir, () -> mRWorkingDir ) | |
| 309 | .with( Mutator::setCurlQuotes, () -> mCurlQuotes ) | |
| 310 | .with( Mutator::setAutoRemove, () -> !mKeepFiles ) | |
| 311 | .build(); | |
| 312 | } | |
| 313 | ||
| 314 | public boolean quiet() { | |
| 315 | return mQuiet; | |
| 316 | } | |
| 317 | ||
| 318 | public boolean debug() { | |
| 319 | return mDebug; | |
| 320 | } | |
| 321 | ||
| 322 | /** | |
| 323 | * Launches the main application window. This is called when not running | |
| 324 | * in headless mode. | |
| 325 | * | |
| 326 | * @return {@code 0} | |
| 327 | * @throws Exception The application encountered an unrecoverable error. | |
| 328 | */ | |
| 329 | @Override | |
| 330 | public Integer call() throws Exception { | |
| 331 | mLauncher.accept( this ); | |
| 332 | return 0; | |
| 333 | } | |
| 334 | ||
| 335 | public void iterate( | |
| 336 | final ParseResult parseResult, | |
| 337 | final Consumer<String> consumer | |
| 338 | ) { | |
| 339 | final var options = mSpecifications.options(); | |
| 340 | ||
| 341 | for( final var opt : options ) { | |
| 342 | consumer.accept( format( "%s=%s", opt.longestName(), opt.getValue() ) ); | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | private static String read( final Path path ) throws IOException { | |
| 347 | return path == null | |
| 348 | ? "" | |
| 349 | : canRead( path ) | |
| 350 | ? readString( path, UTF_8 ) | |
| 351 | : ""; | |
| 352 | } | |
| 353 | ||
| 354 | private static boolean canRead( final Path path ) { | |
| 355 | return exists( path ) && isRegularFile( path ) && isReadable( path ); | |
| 356 | } | |
| 357 | ||
| 358 | /** | |
| 359 | * Parses the given YAML document into a map of key-value pairs. | |
| 360 | * | |
| 361 | * @param vars Variable definition file to read, may be {@code null} if no | |
| 362 | * variables are specified. | |
| 363 | * @return A non-interpolated variable map, or an empty map. | |
| 364 | * @throws IOException Could not read the variable definition file | |
| 365 | */ | |
| 366 | private static Map<String, String> parse( final Path vars ) | |
| 367 | throws IOException { | |
| 368 | final var map = new HashMap<String, String>(); | |
| 369 | ||
| 370 | if( vars != null ) { | |
| 371 | final var yaml = read( vars ); | |
| 372 | final var factory = new YAMLFactory(); | |
| 373 | final var json = new ObjectMapper( factory ).readTree( yaml ); | |
| 374 | ||
| 375 | parse( json, "", map ); | |
| 376 | } | |
| 377 | ||
| 378 | return map; | |
| 379 | } | |
| 380 | ||
| 381 | private static void parse( | |
| 382 | final JsonNode json, final String parent, final Map<String, String> map ) { | |
| 383 | assert json != null; | |
| 384 | assert parent != null; | |
| 385 | assert map != null; | |
| 386 | ||
| 387 | final var fields = json.properties().iterator(); | |
| 388 | ||
| 389 | fields.forEachRemaining( node -> parse( node, parent, map ) ); | |
| 390 | } | |
| 391 | ||
| 392 | private static void parse( | |
| 393 | final Entry<String, JsonNode> node, | |
| 394 | final String parent, | |
| 395 | final Map<String, String> map ) { | |
| 396 | assert node != null; | |
| 397 | assert parent != null; | |
| 398 | assert map != null; | |
| 399 | ||
| 400 | final var jsonNode = node.getValue(); | |
| 401 | final var keyName = format( "%s.%s", parent, node.getKey() ); | |
| 402 | ||
| 403 | if( jsonNode.isNull() ) { | |
| 404 | map.put( keyName.substring( 1 ), "" ); | |
| 405 | } | |
| 406 | else if( jsonNode.isValueNode() ) { | |
| 352 | 407 | // Trim the leading period, which is always present. |
| 353 | 408 | map.put( keyName.substring( 1 ), node.getValue().asText() ); |
| 1 | /* Copyright 2020-2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 1 | 5 | package com.keenwrite.cmdline; |
| 2 | 6 | |
| 3 | 7 | import com.keenwrite.AppCommands; |
| 4 | 8 | import com.keenwrite.events.StatusEvent; |
| 5 | 9 | import org.greenrobot.eventbus.Subscribe; |
| 10 | ||
| 11 | import java.io.PrintStream; | |
| 6 | 12 | |
| 7 | 13 | import static com.keenwrite.events.Bus.register; |
| 8 | 14 | import static java.lang.String.format; |
| 9 | 15 | |
| 10 | 16 | /** |
| 11 | 17 | * Responsible for running the application in headless mode. |
| 12 | 18 | */ |
| 13 | 19 | public class HeadlessApp { |
| 14 | ||
| 15 | 20 | /** |
| 16 | 21 | * Contains directives that control text file processing. |
| 17 | 22 | */ |
| 18 | 23 | private final Arguments mArgs; |
| 24 | ||
| 25 | /** | |
| 26 | * Where to write error messages. | |
| 27 | */ | |
| 28 | private final PrintStream mErrStream; | |
| 19 | 29 | |
| 20 | 30 | /** |
| 21 | 31 | * Creates a new command-line version of the application. |
| 22 | 32 | * |
| 23 | * @param args The post-processed command-line arguments. | |
| 33 | * @param args The post-processed command-line arguments. | |
| 34 | * @param errStream Where to write error messages. | |
| 24 | 35 | */ |
| 25 | public HeadlessApp( final Arguments args ) { | |
| 36 | public HeadlessApp( final Arguments args, final PrintStream errStream ) { | |
| 26 | 37 | assert args != null; |
| 27 | 38 | |
| 28 | 39 | mArgs = args; |
| 40 | mErrStream = errStream; | |
| 29 | 41 | |
| 30 | 42 | register( this ); |
| 31 | AppCommands.run( mArgs ); | |
| 32 | 43 | } |
| 33 | 44 | |
| ... | ||
| 49 | 60 | final var msg = format( "%s%s", event, problem ); |
| 50 | 61 | |
| 51 | System.out.println( msg ); | |
| 62 | mErrStream.println( msg ); | |
| 52 | 63 | } |
| 64 | } | |
| 65 | ||
| 66 | private void run() { | |
| 67 | AppCommands.run( mArgs ); | |
| 53 | 68 | } |
| 54 | 69 | |
| 55 | 70 | /** |
| 56 | 71 | * Entry point for running the application in headless mode. |
| 57 | 72 | * |
| 58 | * @param args The parsed command-line arguments. | |
| 73 | * @param args The parsed command-line arguments. | |
| 74 | * @param errStream Where to write error messages. | |
| 59 | 75 | */ |
| 60 | @SuppressWarnings( "ConfusingMainMethod" ) | |
| 61 | public static void main( final Arguments args ) { | |
| 62 | new HeadlessApp( args ); | |
| 76 | public static void run( | |
| 77 | final Arguments args, | |
| 78 | final PrintStream errStream ) { | |
| 79 | final var app = new HeadlessApp( args, errStream ); | |
| 80 | app.run(); | |
| 63 | 81 | } |
| 64 | 82 | } |
| 244 | 244 | |
| 245 | 245 | /** |
| 246 | * The default apostrophe to use when exporting. | |
| 247 | */ | |
| 248 | public static final String APOS_DEFAULT = "apos"; | |
| 249 | ||
| 250 | /** | |
| 246 | 251 | * Prevent instantiation. |
| 247 | 252 | */ |
| 248 | private Constants() { | |
| 249 | } | |
| 253 | private Constants() {} | |
| 250 | 254 | |
| 251 | 255 | /** |
| 19 | 19 | import static com.keenwrite.dom.DocumentParser.sDomImplementation; |
| 20 | 20 | import static com.keenwrite.processors.text.TextReplacementFactory.replace; |
| 21 | import static java.util.Map.*; | |
| 21 | import static java.util.Map.entry; | |
| 22 | import static java.util.Map.ofEntries; | |
| 22 | 23 | |
| 23 | 24 | /** |
| ... | ||
| 48 | 49 | final var parent = node.parentNode(); |
| 49 | 50 | final var name = parent == null ? "root" : parent.nodeName(); |
| 50 | ||
| 51 | if( !("pre".equalsIgnoreCase( name ) || | |
| 51 | final var codeBlock = | |
| 52 | "pre".equalsIgnoreCase( name ) || | |
| 52 | 53 | "code".equalsIgnoreCase( name ) || |
| 53 | 54 | "kbd".equalsIgnoreCase( name ) || |
| 54 | 55 | "var".equalsIgnoreCase( name ) || |
| 55 | "tt".equalsIgnoreCase( name )) ) { | |
| 56 | // Calling getWholeText() will return newlines, which must be kept | |
| 56 | "tex".equalsIgnoreCase( name ) || | |
| 57 | "tt".equalsIgnoreCase( name ); | |
| 58 | ||
| 59 | if( !codeBlock ) { | |
| 60 | // Obtaining the whole text will return newlines, which must be kept | |
| 57 | 61 | // to ensure that preformatted text maintains its formatting. |
| 58 | 62 | textNode.text( replace( textNode.getWholeText(), LIGATURES ) ); |
| 59 | 63 | } |
| 60 | 64 | } |
| 61 | 65 | } |
| 62 | 66 | |
| 63 | 67 | @Override |
| 64 | public void tail( final @NotNull Node node, final int depth ) { } | |
| 68 | public void tail( final @NotNull Node node, final int depth ) { | |
| 69 | } | |
| 65 | 70 | }; |
| 66 | 71 | |
| 5 | 5 | package com.keenwrite.dom; |
| 6 | 6 | |
| 7 | import org.w3c.dom.*; | |
| 8 | import org.xml.sax.InputSource; | |
| 9 | import org.xml.sax.SAXException; | |
| 10 | ||
| 11 | import javax.xml.parsers.DocumentBuilder; | |
| 12 | import javax.xml.parsers.DocumentBuilderFactory; | |
| 13 | import javax.xml.transform.Transformer; | |
| 14 | import javax.xml.transform.TransformerException; | |
| 15 | import javax.xml.transform.TransformerFactory; | |
| 16 | import javax.xml.transform.dom.DOMSource; | |
| 17 | import javax.xml.transform.stream.StreamResult; | |
| 18 | import javax.xml.xpath.XPath; | |
| 19 | import javax.xml.xpath.XPathExpression; | |
| 20 | import javax.xml.xpath.XPathExpressionException; | |
| 21 | import javax.xml.xpath.XPathFactory; | |
| 22 | import java.io.*; | |
| 23 | import java.nio.file.Path; | |
| 24 | import java.util.HashMap; | |
| 25 | import java.util.Map; | |
| 26 | import java.util.function.Consumer; | |
| 27 | ||
| 28 | import static com.keenwrite.events.StatusEvent.clue; | |
| 29 | import static com.keenwrite.io.SysFile.toFile; | |
| 30 | import static java.nio.charset.StandardCharsets.UTF_16; | |
| 31 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 32 | import static java.nio.file.Files.write; | |
| 33 | import static javax.xml.transform.OutputKeys.*; | |
| 34 | import static javax.xml.xpath.XPathConstants.NODESET; | |
| 35 | ||
| 36 | /** | |
| 37 | * Responsible for initializing an XML parser. | |
| 38 | */ | |
| 39 | public class DocumentParser { | |
| 40 | private static final String LOAD_EXTERNAL_DTD = | |
| 41 | "http://apache.org/xml/features/nonvalidating/load-external-dtd"; | |
| 42 | private static final String INDENT_AMOUNT = | |
| 43 | "{http://xml.apache.org/xslt}indent-amount"; | |
| 44 | ||
| 45 | private static final ByteArrayOutputStream sWriter = | |
| 46 | new ByteArrayOutputStream( 65536 ); | |
| 47 | private static final OutputStreamWriter sOutput = | |
| 48 | new OutputStreamWriter( sWriter, UTF_8 ); | |
| 49 | ||
| 50 | /** | |
| 51 | * Caches {@link XPathExpression}s to avoid re-compiling. | |
| 52 | */ | |
| 53 | private static final Map<String, XPathExpression> sXpaths = new HashMap<>(); | |
| 54 | ||
| 55 | private static final DocumentBuilderFactory sDocumentFactory; | |
| 56 | private static DocumentBuilder sDocumentBuilder; | |
| 57 | private static Transformer sTransformer; | |
| 58 | private static final XPath sXpath = XPathFactory.newInstance().newXPath(); | |
| 59 | ||
| 60 | public static final DOMImplementation sDomImplementation; | |
| 61 | ||
| 62 | static { | |
| 63 | sDocumentFactory = DocumentBuilderFactory.newInstance(); | |
| 64 | ||
| 65 | sDocumentFactory.setValidating( false ); | |
| 66 | sDocumentFactory.setAttribute( LOAD_EXTERNAL_DTD, false ); | |
| 67 | sDocumentFactory.setNamespaceAware( true ); | |
| 68 | sDocumentFactory.setIgnoringComments( true ); | |
| 69 | sDocumentFactory.setIgnoringElementContentWhitespace( true ); | |
| 70 | ||
| 71 | DOMImplementation domImplementation; | |
| 72 | ||
| 73 | try { | |
| 74 | sDocumentBuilder = sDocumentFactory.newDocumentBuilder(); | |
| 75 | domImplementation = sDocumentBuilder.getDOMImplementation(); | |
| 76 | sTransformer = TransformerFactory.newInstance().newTransformer(); | |
| 77 | ||
| 78 | // Ensure Unicode characters (emojis) are encoded correctly. | |
| 79 | sTransformer.setOutputProperty( ENCODING, UTF_16.toString() ); | |
| 80 | sTransformer.setOutputProperty( OMIT_XML_DECLARATION, "yes" ); | |
| 81 | sTransformer.setOutputProperty( METHOD, "xml" ); | |
| 82 | sTransformer.setOutputProperty( INDENT, "no" ); | |
| 83 | sTransformer.setOutputProperty( INDENT_AMOUNT, "2" ); | |
| 84 | } catch( final Exception ex ) { | |
| 85 | clue( ex ); | |
| 86 | domImplementation = sDocumentBuilder.getDOMImplementation(); | |
| 87 | } | |
| 88 | ||
| 89 | sDomImplementation = domImplementation; | |
| 90 | } | |
| 91 | ||
| 92 | public static Document newDocument() { | |
| 93 | return sDocumentBuilder.newDocument(); | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Creates a new document object model based on the given XML document | |
| 98 | * string. This will return an empty document if the document could not | |
| 99 | * be parsed. | |
| 100 | * | |
| 101 | * @param xml The document text to convert into a DOM. | |
| 102 | * @return The DOM that represents the given XML data. | |
| 103 | */ | |
| 104 | public static Document parse( final String xml ) { | |
| 105 | assert xml != null; | |
| 106 | ||
| 107 | final var input = new InputSource(); | |
| 108 | ||
| 109 | try( final var reader = new StringReader( xml ) ) { | |
| 110 | input.setEncoding( UTF_8.toString() ); | |
| 111 | input.setCharacterStream( reader ); | |
| 112 | ||
| 113 | return sDocumentBuilder.parse( input ); | |
| 114 | } catch( final Throwable t ) { | |
| 115 | clue( t ); | |
| 116 | ||
| 117 | return sDocumentBuilder.newDocument(); | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * Parses the given file contents into a document object model. | |
| 123 | * | |
| 124 | * @param doc The source XML document to parse. | |
| 125 | * @return The file as a document object model. | |
| 126 | * @throws IOException Could not open the document. | |
| 127 | * @throws SAXException Could not read the XML file content. | |
| 128 | */ | |
| 129 | public static Document parse( final File doc ) | |
| 130 | throws IOException, SAXException { | |
| 131 | assert doc != null; | |
| 132 | ||
| 133 | try( final var in = new FileInputStream( doc ) ) { | |
| 134 | return parse( in ); | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Parses the given file contents into a document object model. Callers | |
| 140 | * must close the stream. | |
| 141 | * | |
| 142 | * @param doc The source XML document to parse. | |
| 143 | * @return The {@link InputStream} converted to a document object model. | |
| 144 | * @throws IOException Could not open the document. | |
| 145 | * @throws SAXException Could not read the XML file content. | |
| 146 | */ | |
| 147 | public static Document parse( final InputStream doc ) | |
| 148 | throws IOException, SAXException { | |
| 149 | assert doc != null; | |
| 150 | ||
| 151 | return sDocumentBuilder.parse( doc ); | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Allows an operation to be applied for every node in the document that | |
| 156 | * matches a given tag name pattern. | |
| 157 | * | |
| 158 | * @param document Document to traverse. | |
| 159 | * @param xpath Document elements to find via {@link XPath} expression. | |
| 160 | * @param consumer The consumer to call for each matching document node. | |
| 161 | */ | |
| 162 | public static void visit( | |
| 163 | final Document document, | |
| 164 | final CharSequence xpath, | |
| 165 | final Consumer<Node> consumer ) { | |
| 166 | assert document != null; | |
| 167 | assert consumer != null; | |
| 168 | ||
| 169 | try { | |
| 170 | final var expr = compile( xpath ); | |
| 171 | final var nodeSet = expr.evaluate( document, NODESET ); | |
| 172 | ||
| 173 | if( nodeSet instanceof NodeList nodes ) { | |
| 174 | for( int i = 0, len = nodes.getLength(); i < len; i++ ) { | |
| 175 | consumer.accept( nodes.item( i ) ); | |
| 176 | } | |
| 177 | } | |
| 178 | } catch( final Exception ex ) { | |
| 179 | clue( ex ); | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | public static Node createMeta( | |
| 184 | final Document document, final Map.Entry<String, String> entry ) { | |
| 185 | assert document != null; | |
| 186 | assert entry != null; | |
| 187 | ||
| 188 | final var node = document.createElement( "meta" ); | |
| 189 | ||
| 190 | node.setAttribute( "name", entry.getKey() ); | |
| 191 | node.setAttribute( "content", entry.getValue() ); | |
| 192 | ||
| 193 | return node; | |
| 194 | } | |
| 195 | ||
| 196 | public static Node createEncoding( | |
| 197 | final Document document, final String encoding | |
| 198 | ) { | |
| 199 | assert document != null; | |
| 200 | assert encoding != null; | |
| 201 | ||
| 202 | final var node = document.createElement( "meta" ); | |
| 203 | ||
| 204 | node.setAttribute( "charset", encoding ); | |
| 205 | ||
| 206 | return node; | |
| 207 | } | |
| 208 | ||
| 209 | public static Node createElement( | |
| 210 | final Document doc, final String nodeName, final String nodeValue ) { | |
| 211 | assert doc != null; | |
| 212 | assert nodeName != null; | |
| 213 | assert !nodeName.isBlank(); | |
| 214 | ||
| 215 | final var node = doc.createElement( nodeName ); | |
| 216 | ||
| 217 | if( nodeValue != null ) { | |
| 218 | node.setTextContent( nodeValue ); | |
| 219 | } | |
| 220 | ||
| 221 | return node; | |
| 222 | } | |
| 223 | ||
| 224 | public static String toString( final Document xhtml ) { | |
| 225 | assert xhtml != null; | |
| 226 | ||
| 227 | try( final var writer = new StringWriter() ) { | |
| 228 | final var result = new StreamResult( writer ); | |
| 229 | ||
| 230 | transform( xhtml, result ); | |
| 231 | ||
| 232 | return writer.toString(); | |
| 233 | } catch( final Exception ex ) { | |
| 234 | clue( ex ); | |
| 235 | return ""; | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | public static String transform( final Element root ) | |
| 240 | throws IOException, TransformerException { | |
| 241 | assert root != null; | |
| 242 | ||
| 243 | try( final var writer = new StringWriter() ) { | |
| 244 | transform( root.getOwnerDocument(), new StreamResult( writer ) ); | |
| 245 | ||
| 246 | return writer.toString(); | |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 250 | /** | |
| 251 | * Remove whitespace, comments, and XML/DOCTYPE declarations to make | |
| 252 | * processing work with ConTeXt. | |
| 253 | * | |
| 254 | * @param path The SVG file to process. | |
| 255 | * @throws Exception The file could not be processed. | |
| 256 | */ | |
| 257 | public static void sanitize( final Path path ) throws Exception { | |
| 258 | assert path != null; | |
| 259 | ||
| 260 | // Preprocessing the SVG image is a single-threaded operation, no matter | |
| 261 | // how many SVG images are in the document to typeset. | |
| 262 | sWriter.reset(); | |
| 263 | ||
| 264 | final var target = new StreamResult( sOutput ); | |
| 265 | final var source = sDocumentBuilder.parse( toFile( path ) ); | |
| 266 | ||
| 267 | transform( source, target ); | |
| 268 | write( path, sWriter.toByteArray() ); | |
| 269 | } | |
| 270 | ||
| 271 | /** | |
| 272 | * Converts a string into an {@link XPathExpression}, which may be used to | |
| 273 | * extract elements from a {@link Document} object model. | |
| 274 | * | |
| 275 | * @param cs The string to convert to an {@link XPathExpression}. | |
| 276 | * @return {@code null} if there was an error compiling the xpath. | |
| 277 | */ | |
| 278 | public static XPathExpression compile( final CharSequence cs ) { | |
| 279 | assert cs != null; | |
| 280 | ||
| 281 | final var xpath = cs.toString(); | |
| 282 | ||
| 283 | return sXpaths.computeIfAbsent( xpath, k -> { | |
| 284 | try { | |
| 285 | return sXpath.compile( xpath ); | |
| 286 | } catch( final XPathExpressionException ex ) { | |
| 287 | clue( ex ); | |
| 288 | return null; | |
| 289 | } | |
| 290 | } ); | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * Streams an instance of {@link Document} as a plain text XML document. | |
| 295 | * | |
| 296 | * @param src The source document to transform. | |
| 297 | * @param dst The destination location to write the transformed version. | |
| 298 | * @throws TransformerException Could not transform the document. | |
| 299 | */ | |
| 300 | private static void transform( final Document src, final StreamResult dst ) | |
| 301 | throws TransformerException { | |
| 302 | sTransformer.transform( new DOMSource( src ), dst ); | |
| 303 | } | |
| 304 | ||
| 305 | /** | |
| 306 | * Use the {@code static} constants and methods, not an instance, at least | |
| 307 | * until an iterable sub-interface is written. | |
| 308 | */ | |
| 309 | private DocumentParser() {} | |
| 7 | import com.keenwrite.util.Strings; | |
| 8 | import org.w3c.dom.*; | |
| 9 | import org.xml.sax.InputSource; | |
| 10 | import org.xml.sax.SAXException; | |
| 11 | ||
| 12 | import javax.xml.parsers.DocumentBuilder; | |
| 13 | import javax.xml.parsers.DocumentBuilderFactory; | |
| 14 | import javax.xml.transform.Transformer; | |
| 15 | import javax.xml.transform.TransformerException; | |
| 16 | import javax.xml.transform.TransformerFactory; | |
| 17 | import javax.xml.transform.dom.DOMSource; | |
| 18 | import javax.xml.transform.stream.StreamResult; | |
| 19 | import javax.xml.xpath.XPath; | |
| 20 | import javax.xml.xpath.XPathExpression; | |
| 21 | import javax.xml.xpath.XPathExpressionException; | |
| 22 | import javax.xml.xpath.XPathFactory; | |
| 23 | import java.io.*; | |
| 24 | import java.nio.file.Path; | |
| 25 | import java.util.HashMap; | |
| 26 | import java.util.Locale; | |
| 27 | import java.util.Map; | |
| 28 | import java.util.function.Consumer; | |
| 29 | ||
| 30 | import static com.keenwrite.events.StatusEvent.clue; | |
| 31 | import static com.keenwrite.io.SysFile.toFile; | |
| 32 | import static java.nio.charset.StandardCharsets.UTF_16; | |
| 33 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 34 | import static java.nio.file.Files.write; | |
| 35 | import static javax.xml.transform.OutputKeys.*; | |
| 36 | import static javax.xml.xpath.XPathConstants.NODE; | |
| 37 | import static javax.xml.xpath.XPathConstants.NODESET; | |
| 38 | ||
| 39 | /** | |
| 40 | * Responsible for initializing an XML parser. | |
| 41 | */ | |
| 42 | public class DocumentParser { | |
| 43 | private static final String LOAD_EXTERNAL_DTD = | |
| 44 | "http://apache.org/xml/features/nonvalidating/load-external-dtd"; | |
| 45 | private static final String INDENT_AMOUNT = | |
| 46 | "{http://xml.apache.org/xslt}indent-amount"; | |
| 47 | private static final String NAMESPACE = "http://www.w3.org/1999/xhtml"; | |
| 48 | ||
| 49 | private static final XPath XPATH = XPathFactory.newInstance().newXPath(); | |
| 50 | ||
| 51 | private static final ByteArrayOutputStream sWriter = | |
| 52 | new ByteArrayOutputStream( 65536 ); | |
| 53 | private static final OutputStreamWriter sOutput = | |
| 54 | new OutputStreamWriter( sWriter, UTF_8 ); | |
| 55 | ||
| 56 | /** | |
| 57 | * Caches {@link XPathExpression}s to avoid re-compiling. | |
| 58 | */ | |
| 59 | private static final Map<String, XPathExpression> sXpaths = new HashMap<>(); | |
| 60 | ||
| 61 | private static final DocumentBuilderFactory sDocumentFactory; | |
| 62 | private static DocumentBuilder sDocumentBuilder; | |
| 63 | private static Transformer sTransformer; | |
| 64 | private static final XPath sXpath = XPathFactory.newInstance().newXPath(); | |
| 65 | ||
| 66 | public static final DOMImplementation sDomImplementation; | |
| 67 | ||
| 68 | static { | |
| 69 | sDocumentFactory = DocumentBuilderFactory.newInstance(); | |
| 70 | ||
| 71 | sDocumentFactory.setValidating( false ); | |
| 72 | sDocumentFactory.setAttribute( LOAD_EXTERNAL_DTD, false ); | |
| 73 | sDocumentFactory.setNamespaceAware( true ); | |
| 74 | sDocumentFactory.setIgnoringComments( true ); | |
| 75 | sDocumentFactory.setIgnoringElementContentWhitespace( true ); | |
| 76 | ||
| 77 | DOMImplementation domImplementation; | |
| 78 | ||
| 79 | try { | |
| 80 | sDocumentBuilder = sDocumentFactory.newDocumentBuilder(); | |
| 81 | domImplementation = sDocumentBuilder.getDOMImplementation(); | |
| 82 | sTransformer = TransformerFactory.newInstance().newTransformer(); | |
| 83 | ||
| 84 | // Ensure Unicode characters (emojis) are encoded correctly. | |
| 85 | sTransformer.setOutputProperty( ENCODING, UTF_16.toString() ); | |
| 86 | sTransformer.setOutputProperty( OMIT_XML_DECLARATION, "yes" ); | |
| 87 | sTransformer.setOutputProperty( METHOD, "xml" ); | |
| 88 | sTransformer.setOutputProperty( INDENT, "no" ); | |
| 89 | sTransformer.setOutputProperty( INDENT_AMOUNT, "2" ); | |
| 90 | } | |
| 91 | catch( final Exception ex ) { | |
| 92 | clue( ex ); | |
| 93 | domImplementation = sDocumentBuilder.getDOMImplementation(); | |
| 94 | } | |
| 95 | ||
| 96 | sDomImplementation = domImplementation; | |
| 97 | } | |
| 98 | ||
| 99 | public static Document newDocument() { | |
| 100 | return sDocumentBuilder.newDocument(); | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Creates a new document object model based on the given XML document | |
| 105 | * string. This will return an empty document if the document could not | |
| 106 | * be parsed. | |
| 107 | * | |
| 108 | * @param xml The document text to convert into a DOM. | |
| 109 | * @return The DOM that represents the given XML data. | |
| 110 | */ | |
| 111 | public static Document parse( final String xml ) { | |
| 112 | assert xml != null; | |
| 113 | ||
| 114 | if( !xml.isBlank() ) { | |
| 115 | try( final var reader = new StringReader( xml ) ) { | |
| 116 | final var input = new InputSource(); | |
| 117 | ||
| 118 | input.setEncoding( UTF_8.toString() ); | |
| 119 | input.setCharacterStream( reader ); | |
| 120 | ||
| 121 | return sDocumentBuilder.parse( input ); | |
| 122 | } | |
| 123 | catch( final Throwable t ) { | |
| 124 | clue( t ); | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | return sDocumentBuilder.newDocument(); | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * Creates a well-formed XHTML document from a standard HTML document. | |
| 133 | * | |
| 134 | * @param source The HTML source document to transform. | |
| 135 | * @param metadata The metadata contained within the head element. | |
| 136 | * @param locale The localization information for the lang attribute. | |
| 137 | * @return The well-formed XHTML document. | |
| 138 | */ | |
| 139 | public static Document create( | |
| 140 | final Document source, | |
| 141 | final Map<String, String> metadata, | |
| 142 | final Locale locale, | |
| 143 | final String pageTitle | |
| 144 | ) throws XPathExpressionException { | |
| 145 | final var target = createXhtmlDocument(); | |
| 146 | final var html = target.getDocumentElement(); | |
| 147 | final var sourceHead = evaluate( "//head", source ); | |
| 148 | final var head = target.importNode( sourceHead, true ); | |
| 149 | ||
| 150 | html.setAttribute( "lang", locale.getLanguage() ); | |
| 151 | ||
| 152 | final var encoding = createEncoding( target, "UTF-8" ); | |
| 153 | head.appendChild( encoding ); | |
| 154 | ||
| 155 | for( final var entry : metadata.entrySet() ) { | |
| 156 | final var node = createMeta( target, entry ); | |
| 157 | head.appendChild( node ); | |
| 158 | } | |
| 159 | ||
| 160 | final var titleText = Strings.sanitize( pageTitle ); | |
| 161 | ||
| 162 | // Empty titles result in <title/>, which some browsers cannot parse. | |
| 163 | if( !titleText.isBlank() ) { | |
| 164 | final var title = createElement( target, "title", titleText ); | |
| 165 | head.appendChild( title ); | |
| 166 | } | |
| 167 | ||
| 168 | html.appendChild( head ); | |
| 169 | ||
| 170 | final var body = createElement( target, "body", null ); | |
| 171 | final var sourceBody = source.getElementsByTagName( "body" ).item( 0 ); | |
| 172 | final var children = sourceBody.getChildNodes(); | |
| 173 | final var count = children.getLength(); | |
| 174 | ||
| 175 | for( var i = 0; i < count; i++ ) { | |
| 176 | body.appendChild( importNode( target, children.item( i ) ) ); | |
| 177 | } | |
| 178 | ||
| 179 | html.appendChild( body ); | |
| 180 | ||
| 181 | return target; | |
| 182 | } | |
| 183 | ||
| 184 | public static Node evaluate( final String xpath, final Document doc ) throws XPathExpressionException { | |
| 185 | return (Node) XPATH.evaluate( xpath, doc, NODE ); | |
| 186 | } | |
| 187 | ||
| 188 | /** | |
| 189 | * Parses the given file contents into a document object model. | |
| 190 | * | |
| 191 | * @param doc The source XML document to parse. | |
| 192 | * @return The file as a document object model. | |
| 193 | * @throws IOException Could not open the document. | |
| 194 | * @throws SAXException Could not read the XML file content. | |
| 195 | */ | |
| 196 | public static Document parse( final File doc ) | |
| 197 | throws IOException, SAXException { | |
| 198 | assert doc != null; | |
| 199 | ||
| 200 | try( final var in = new FileInputStream( doc ) ) { | |
| 201 | return parse( in ); | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | /** | |
| 206 | * Parses the given file contents into a document object model. Callers | |
| 207 | * must close the stream. | |
| 208 | * | |
| 209 | * @param doc The source XML document to parse. | |
| 210 | * @return The {@link InputStream} converted to a document object model. | |
| 211 | * @throws IOException Could not open the document. | |
| 212 | * @throws SAXException Could not read the XML file content. | |
| 213 | */ | |
| 214 | public static Document parse( final InputStream doc ) | |
| 215 | throws IOException, SAXException { | |
| 216 | assert doc != null; | |
| 217 | ||
| 218 | return sDocumentBuilder.parse( doc ); | |
| 219 | } | |
| 220 | ||
| 221 | /** | |
| 222 | * Allows an operation to be applied for every node in the document that | |
| 223 | * matches a given tag name pattern. | |
| 224 | * | |
| 225 | * @param document Document to traverse. | |
| 226 | * @param xpath Document elements to find via {@link XPath} expression. | |
| 227 | * @param consumer The consumer to call for each matching document node. | |
| 228 | */ | |
| 229 | public static void visit( | |
| 230 | final Document document, | |
| 231 | final CharSequence xpath, | |
| 232 | final Consumer<Node> consumer ) { | |
| 233 | assert document != null; | |
| 234 | assert consumer != null; | |
| 235 | ||
| 236 | try { | |
| 237 | final var expr = compile( xpath ); | |
| 238 | final var nodeSet = expr.evaluate( document, NODESET ); | |
| 239 | ||
| 240 | if( nodeSet instanceof NodeList nodes ) { | |
| 241 | for( int i = 0, len = nodes.getLength(); i < len; i++ ) { | |
| 242 | consumer.accept( nodes.item( i ) ); | |
| 243 | } | |
| 244 | } | |
| 245 | } | |
| 246 | catch( final Exception ex ) { | |
| 247 | clue( ex ); | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | public static Node createMeta( | |
| 252 | final Document document, final Map.Entry<String, String> entry ) { | |
| 253 | assert document != null; | |
| 254 | assert entry != null; | |
| 255 | ||
| 256 | final var node = createElement( document, "meta", null ); | |
| 257 | ||
| 258 | node.setAttribute( "name", entry.getKey() ); | |
| 259 | node.setAttribute( "content", entry.getValue() ); | |
| 260 | ||
| 261 | return node; | |
| 262 | } | |
| 263 | ||
| 264 | public static Node createEncoding( | |
| 265 | final Document document, final String encoding | |
| 266 | ) { | |
| 267 | assert document != null; | |
| 268 | assert encoding != null; | |
| 269 | ||
| 270 | final var node = createElement( document, "meta", null ); | |
| 271 | ||
| 272 | node.setAttribute( "http-equiv", "Content-Type" ); | |
| 273 | node.setAttribute( "content", "text/html; charset=" + encoding ); | |
| 274 | ||
| 275 | return node; | |
| 276 | } | |
| 277 | ||
| 278 | public static Element createElement( | |
| 279 | final Document document, final String nodeName, final String nodeValue | |
| 280 | ) { | |
| 281 | assert document != null; | |
| 282 | assert nodeName != null; | |
| 283 | assert !nodeName.isBlank(); | |
| 284 | ||
| 285 | final var node = document.createElement( nodeName ); | |
| 286 | ||
| 287 | if( nodeValue != null ) { | |
| 288 | node.setTextContent( nodeValue ); | |
| 289 | } | |
| 290 | ||
| 291 | return node; | |
| 292 | } | |
| 293 | ||
| 294 | public static String toString( final Node xhtml ) { | |
| 295 | assert xhtml != null; | |
| 296 | ||
| 297 | String result = ""; | |
| 298 | ||
| 299 | try( final var writer = new StringWriter() ) { | |
| 300 | final var stream = new StreamResult( writer ); | |
| 301 | ||
| 302 | transform( xhtml, stream ); | |
| 303 | ||
| 304 | result = writer.toString(); | |
| 305 | } | |
| 306 | catch( final Exception ex ) { | |
| 307 | clue( ex ); | |
| 308 | } | |
| 309 | ||
| 310 | return result; | |
| 311 | } | |
| 312 | ||
| 313 | public static String transform( final Element root ) | |
| 314 | throws IOException, TransformerException { | |
| 315 | assert root != null; | |
| 316 | ||
| 317 | try( final var writer = new StringWriter() ) { | |
| 318 | transform( root.getOwnerDocument(), new StreamResult( writer ) ); | |
| 319 | ||
| 320 | return writer.toString(); | |
| 321 | } | |
| 322 | } | |
| 323 | ||
| 324 | /** | |
| 325 | * Remove whitespace, comments, and XML/DOCTYPE declarations to make | |
| 326 | * processing work with ConTeXt. | |
| 327 | * | |
| 328 | * @param path The SVG file to process. | |
| 329 | * @throws Exception The file could not be processed. | |
| 330 | */ | |
| 331 | public static void sanitize( final Path path ) throws Exception { | |
| 332 | assert path != null; | |
| 333 | ||
| 334 | // Preprocessing the SVG image is a single-threaded operation, no matter | |
| 335 | // how many SVG images are in the document to typeset. | |
| 336 | sWriter.reset(); | |
| 337 | ||
| 338 | final var target = new StreamResult( sOutput ); | |
| 339 | final var source = sDocumentBuilder.parse( toFile( path ) ); | |
| 340 | ||
| 341 | transform( source, target ); | |
| 342 | write( path, sWriter.toByteArray() ); | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Converts a string into an {@link XPathExpression}, which may be used to | |
| 347 | * extract elements from a {@link Document} object model. | |
| 348 | * | |
| 349 | * @param cs The string to convert to an {@link XPathExpression}. | |
| 350 | * @return {@code null} if there was an error compiling the xpath. | |
| 351 | */ | |
| 352 | public static XPathExpression compile( final CharSequence cs ) { | |
| 353 | assert cs != null; | |
| 354 | ||
| 355 | final var xpath = cs.toString(); | |
| 356 | ||
| 357 | return sXpaths.computeIfAbsent( | |
| 358 | xpath, _ -> { | |
| 359 | try { | |
| 360 | return sXpath.compile( xpath ); | |
| 361 | } | |
| 362 | catch( final XPathExpressionException ex ) { | |
| 363 | clue( ex ); | |
| 364 | return null; | |
| 365 | } | |
| 366 | } | |
| 367 | ); | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * Merges a source document into a target document. This avoids adding an | |
| 372 | * empty XML namespace attribute to elements. | |
| 373 | * | |
| 374 | * @param target The document to envelop the source document. | |
| 375 | * @param source The source document to embed. | |
| 376 | * @return The target document with the source document included. | |
| 377 | */ | |
| 378 | private static Node importNode( final Document target, final Node source ) { | |
| 379 | assert target != null; | |
| 380 | assert source != null; | |
| 381 | ||
| 382 | Node result; | |
| 383 | final var nodeType = source.getNodeType(); | |
| 384 | ||
| 385 | if( nodeType == Node.ELEMENT_NODE ) { | |
| 386 | final var element = createElement( target, source.getNodeName(), null ); | |
| 387 | final var attrs = source.getAttributes(); | |
| 388 | ||
| 389 | if( attrs != null ) { | |
| 390 | final var attrLength = attrs.getLength(); | |
| 391 | ||
| 392 | for( var i = 0; i < attrLength; i++ ) { | |
| 393 | final var attr = attrs.item( i ); | |
| 394 | element.setAttribute( attr.getNodeName(), attr.getNodeValue() ); | |
| 395 | } | |
| 396 | } | |
| 397 | ||
| 398 | final var children = source.getChildNodes(); | |
| 399 | final var childLength = children.getLength(); | |
| 400 | ||
| 401 | for( var i = 0; i < childLength; i++ ) { | |
| 402 | element.appendChild( importNode( target, children.item( i ) ) ); | |
| 403 | } | |
| 404 | ||
| 405 | result = element; | |
| 406 | } | |
| 407 | else if( nodeType == Node.TEXT_NODE ) { | |
| 408 | result = target.createTextNode( source.getNodeValue() ); | |
| 409 | } | |
| 410 | else { | |
| 411 | result = target.importNode( source, true ); | |
| 412 | } | |
| 413 | ||
| 414 | return result; | |
| 415 | } | |
| 416 | ||
| 417 | private static Document createXhtmlDocument() { | |
| 418 | return sDomImplementation.createDocument( | |
| 419 | NAMESPACE, | |
| 420 | "html", | |
| 421 | sDomImplementation.createDocumentType( | |
| 422 | "html", "-//W3C//DTD XHTML 1.0 Strict//EN", | |
| 423 | "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" | |
| 424 | ) | |
| 425 | ); | |
| 426 | } | |
| 427 | ||
| 428 | /** | |
| 429 | * Streams an instance of {@link Document} as a plain text XML document. | |
| 430 | * | |
| 431 | * @param src The source document to transform. | |
| 432 | * @param dst The destination location to write the transformed version. | |
| 433 | * @throws TransformerException Could not transform the document. | |
| 434 | */ | |
| 435 | private static void transform( final Node src, final StreamResult dst ) | |
| 436 | throws TransformerException { | |
| 437 | sTransformer.transform( new DOMSource( src ), dst ); | |
| 438 | } | |
| 439 | ||
| 440 | /** | |
| 441 | * Use the {@code static} constants and methods, not an instance, at least | |
| 442 | * until an iterable sub-interface is written. | |
| 443 | */ | |
| 444 | private DocumentParser() { | |
| 445 | } | |
| 310 | 446 | } |
| 311 | 447 |
| 121 | 121 | */ |
| 122 | 122 | private void transform( final JsonNode node, final TreeItem<String> item ) { |
| 123 | node.fields().forEachRemaining( leaf -> transform( leaf, item ) ); | |
| 123 | final var fields = node.properties().iterator(); | |
| 124 | fields.forEachRemaining( leaf -> transform( leaf, item ) ); | |
| 124 | 125 | } |
| 125 | 126 |
| 37 | 37 | import java.util.regex.Pattern; |
| 38 | 38 | |
| 39 | import static com.keenwrite.MainApp.keyDown; | |
| 39 | import static com.keenwrite.GuiApp.keyDown; | |
| 40 | 40 | import static com.keenwrite.constants.Constants.*; |
| 41 | 41 | import static com.keenwrite.events.StatusEvent.clue; |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 2 | 5 | package com.keenwrite.events; |
| 3 | 6 | |
| 4 | 7 | import org.greenrobot.eventbus.EventBus; |
| 8 | ||
| 9 | import static org.greenrobot.eventbus.EventBus.builder; | |
| 5 | 10 | |
| 6 | 11 | /** |
| 7 | 12 | * Responsible for delegating interactions to the event bus library. This |
| 8 | 13 | * class decouples the rest of the application from a particular event bus |
| 9 | 14 | * implementation. |
| 10 | 15 | */ |
| 11 | 16 | public class Bus { |
| 12 | private static final EventBus sEventBus = EventBus | |
| 13 | .builder().logNoSubscriberMessages( false ).installDefaultEventBus(); | |
| 17 | private static final EventBus sEventBus = builder() | |
| 18 | .logNoSubscriberMessages( false ) | |
| 19 | .installDefaultEventBus(); | |
| 14 | 20 | |
| 15 | 21 | public static <Subscriber> void register( final Subscriber subscriber ) { |
| 10 | 10 | import static com.keenwrite.constants.Constants.NEWLINE; |
| 11 | 11 | import static com.keenwrite.constants.Constants.STATUS_BAR_OK; |
| 12 | import static com.keenwrite.util.Strings.sanitize; | |
| 12 | 13 | import static java.lang.String.format; |
| 13 | 14 | import static java.lang.String.join; |
| ... | ||
| 56 | 57 | */ |
| 57 | 58 | public StatusEvent( final String message, final Throwable problem ) { |
| 58 | mMessage = message == null ? "" : message; | |
| 59 | mMessage = sanitize( message ); | |
| 59 | 60 | mProblem = problem; |
| 60 | 61 | } |
| ... | ||
| 86 | 87 | final var message = mMessage == null ? "UNKNOWN" : mMessage; |
| 87 | 88 | |
| 88 | return format( "%s%s%s", | |
| 89 | message, | |
| 90 | message.isBlank() ? "" : " ", | |
| 91 | mProblem == null ? "" : toEnglish( mProblem ) ); | |
| 89 | return format( | |
| 90 | "%s%s%s", | |
| 91 | message, | |
| 92 | message.isBlank() ? "" : " ", | |
| 93 | mProblem == null ? "" : toEnglish( mProblem ) | |
| 94 | ); | |
| 92 | 95 | } |
| 93 | 96 | |
| 32 | 32 | APP_PDF( APPLICATION, "pdf" ), |
| 33 | 33 | APP_ZIP( APPLICATION, "zip" ), |
| 34 | APP_XHTML( APPLICATION, "xhtml+xml" ), | |
| 34 | 35 | |
| 35 | 36 | /* |
| ... | ||
| 109 | 110 | TEXT_PROPERTIES( TEXT, "x-java-properties" ), |
| 110 | 111 | TEXT_HTML( TEXT, "html" ), |
| 111 | TEXT_XHTML( TEXT, "xhtml+xml" ), | |
| 112 | 112 | TEXT_XML( TEXT, "xml" ), |
| 113 | 113 | |
| 35 | 35 | MEDIA_IMAGE_GIF( IMAGE_GIF ), |
| 36 | 36 | MEDIA_IMAGE_JPEG( IMAGE_JPEG, |
| 37 | of( "jpg", "jpe", "jpeg", "jfif", "pjpeg", "pjp" ) ), | |
| 37 | of( "jpg", "jpe", "jpeg", "jfif", "pjpeg", "pjp" ) ), | |
| 38 | 38 | MEDIA_IMAGE_PNG( IMAGE_PNG ), |
| 39 | 39 | MEDIA_IMAGE_PSD( IMAGE_PHOTOSHOP, of( "psd" ) ), |
| ... | ||
| 52 | 52 | MEDIA_TEXT_R_MARKDOWN( TEXT_R_MARKDOWN, of( "Rmd" ) ), |
| 53 | 53 | MEDIA_TEXT_PROPERTIES( TEXT_PROPERTIES, of( "properties" ) ), |
| 54 | MEDIA_TEXT_XHTML( TEXT_XHTML, of( "htm", "html", "xhtml" ) ), | |
| 54 | MEDIA_TEXT_HTML( TEXT_HTML, of( "htm", "html" ) ), | |
| 55 | MEDIA_APP_XHTML( APP_XHTML, of( "xhtml" ) ), | |
| 55 | 56 | MEDIA_TEXT_XML( TEXT_XML ), |
| 56 | 57 | MEDIA_TEXT_YAML( TEXT_YAML, of( "yaml", "yml" ) ), |
| ... | ||
| 153 | 154 | */ |
| 154 | 155 | public String getExtension() { |
| 155 | return mExtensions.get( 0 ); | |
| 156 | return mExtensions.getFirst(); | |
| 156 | 157 | } |
| 157 | 158 | |
| 1 | package com.keenwrite.io; | |
| 2 | ||
| 3 | import java.io.File; | |
| 4 | import java.nio.file.Path; | |
| 5 | import java.util.ArrayList; | |
| 6 | import java.util.List; | |
| 7 | import java.util.Optional; | |
| 8 | ||
| 9 | import static com.keenwrite.util.Strings.sanitize; | |
| 10 | import static java.lang.System.getenv; | |
| 11 | import static java.nio.file.Files.isExecutable; | |
| 12 | import static java.util.Arrays.asList; | |
| 13 | ||
| 14 | /** | |
| 15 | * Responsible for finding the fully qualified PATH to an executable file. | |
| 16 | */ | |
| 17 | public class PathScanner { | |
| 18 | /** | |
| 19 | * For finding executable programs. These are used in an O( n^2 ) search, | |
| 20 | * so don't add more entries than necessary. | |
| 21 | */ | |
| 22 | private static final String[] EXECUTABLE_EXTENSIONS = { | |
| 23 | "", ".exe", ".bat", ".cmd", ".com", ".msc", ".msi", | |
| 24 | }; | |
| 25 | ||
| 26 | public static List<String> scan() { | |
| 27 | final var path = sanitize( getenv( "PATH" ) ); | |
| 28 | final var directories = path.split( File.pathSeparator ); | |
| 29 | ||
| 30 | return new ArrayList<>( asList( directories ) ); | |
| 31 | } | |
| 32 | ||
| 33 | public static Optional<Path> scanExtensions( | |
| 34 | final String directory, | |
| 35 | final String executable | |
| 36 | ) { | |
| 37 | for( final var ext : EXECUTABLE_EXTENSIONS ) { | |
| 38 | final var dir = Path.of( directory ); | |
| 39 | final var path = dir.resolve( executable + ext ); | |
| 40 | ||
| 41 | if( isExecutable( path ) ) { | |
| 42 | return Optional.of( path ); | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | return Optional.empty(); | |
| 47 | } | |
| 48 | } | |
| 1 | 49 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 2 | 5 | package com.keenwrite.io; |
| 3 | 6 | |
| ... | ||
| 10 | 13 | import java.security.MessageDigest; |
| 11 | 14 | import java.security.NoSuchAlgorithmException; |
| 12 | import java.util.ArrayList; | |
| 15 | import java.util.List; | |
| 13 | 16 | import java.util.Optional; |
| 14 | 17 | import java.util.function.Function; |
| 15 | 18 | import java.util.function.Predicate; |
| 16 | 19 | |
| 17 | 20 | import static com.keenwrite.constants.Constants.USER_DIRECTORY; |
| 18 | 21 | import static com.keenwrite.events.StatusEvent.clue; |
| 22 | import static com.keenwrite.io.PathScanner.scan; | |
| 23 | import static com.keenwrite.io.PathScanner.scanExtensions; | |
| 19 | 24 | import static com.keenwrite.io.WindowsRegistry.pathsWindows; |
| 20 | 25 | import static com.keenwrite.util.DataTypeConverter.toHex; |
| 21 | 26 | import static com.keenwrite.util.SystemUtils.IS_OS_WINDOWS; |
| 22 | 27 | import static java.lang.System.getenv; |
| 23 | import static java.nio.file.Files.isExecutable; | |
| 24 | 28 | import static java.util.regex.Pattern.quote; |
| 25 | 29 | |
| 26 | 30 | /** |
| 27 | 31 | * Responsible for file-related functionality. |
| 28 | 32 | */ |
| 29 | 33 | public final class SysFile extends java.io.File { |
| 30 | /** | |
| 31 | * For finding executable programs. These are used in an O( n^2 ) search, | |
| 32 | * so don't add more entries than necessary. | |
| 33 | */ | |
| 34 | private static final String[] EXTENSIONS = new String[] | |
| 35 | { "", ".exe", ".bat", ".cmd", ".msi", ".com" }; | |
| 36 | ||
| 37 | 34 | private static final String WHERE_COMMAND = |
| 38 | 35 | IS_OS_WINDOWS ? "where" : "which"; |
| ... | ||
| 71 | 68 | */ |
| 72 | 69 | public boolean canRun() { |
| 73 | return locate().isPresent(); | |
| 70 | final var path = locate(); | |
| 71 | ||
| 72 | path.ifPresentOrElse( | |
| 73 | _ -> clue( "Wizard.container.executable.run.found", path ), | |
| 74 | () -> clue( "Wizard.container.executable.run.missing", path ) | |
| 75 | ); | |
| 76 | ||
| 77 | return path.isPresent(); | |
| 74 | 78 | } |
| 75 | 79 | |
| ... | ||
| 90 | 94 | */ |
| 91 | 95 | public Optional<Path> locate() { |
| 92 | final var dirList = new ArrayList<String>(); | |
| 93 | final var paths = pathsSane(); | |
| 94 | int began = 0; | |
| 95 | int ended; | |
| 96 | ||
| 97 | while( (ended = paths.indexOf( pathSeparatorChar, began )) != -1 ) { | |
| 98 | final var dir = paths.substring( began, ended ); | |
| 99 | began = ended + 1; | |
| 100 | ||
| 101 | dirList.add( dir ); | |
| 102 | } | |
| 96 | final var dirs = scan(); | |
| 97 | clue( dirs ); | |
| 103 | 98 | |
| 104 | final var dirs = dirList.toArray( new String[]{} ); | |
| 105 | 99 | var path = locate( dirs, "Wizard.container.executable.path" ); |
| 106 | 100 | |
| ... | ||
| 123 | 117 | } |
| 124 | 118 | |
| 125 | private Optional<Path> locate( final String[] dirs, final String msg ) { | |
| 119 | private Optional<Path> locate( final List<String> dirs, final String msg ) { | |
| 126 | 120 | final var exe = getName(); |
| 127 | 121 | |
| 128 | 122 | for( final var dir : dirs ) { |
| 129 | Path p; | |
| 130 | ||
| 131 | 123 | try { |
| 132 | p = Path.of( dir ).resolve( exe ); | |
| 124 | return scanExtensions( dir, exe ); | |
| 133 | 125 | } catch( final Exception ex ) { |
| 134 | 126 | clue( ex ); |
| 135 | continue; | |
| 136 | } | |
| 137 | ||
| 138 | for( final var extension : EXTENSIONS ) { | |
| 139 | final var filename = Path.of( p + extension ); | |
| 140 | ||
| 141 | if( isExecutable( filename ) ) { | |
| 142 | return Optional.of( filename ); | |
| 143 | } | |
| 144 | 127 | } |
| 145 | 128 | } |
| ... | ||
| 152 | 135 | final Function<String, String> map, final String msg ) { |
| 153 | 136 | final var paths = paths( map ).split( quote( pathSeparator ) ); |
| 154 | ||
| 155 | return locate( paths, msg ); | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Runs {@code where} or {@code which} to determine the fully qualified path | |
| 160 | * to an executable. | |
| 161 | * | |
| 162 | * @return The path to the executable for this file, if found. | |
| 163 | * @throws IOException Could not determine the location of the command. | |
| 164 | */ | |
| 165 | public Optional<Path> where() throws IOException { | |
| 166 | // The "where" command on Windows will automatically add the extension. | |
| 167 | final var args = new String[]{ WHERE_COMMAND, getName() }; | |
| 168 | final var output = run( _ -> true, args ); | |
| 169 | final var result = output.lines().findFirst(); | |
| 170 | 137 | |
| 171 | return result.map( Path::of ); | |
| 138 | return locate( List.of( paths ), msg ); | |
| 172 | 139 | } |
| 173 | 140 | |
| ... | ||
| 222 | 189 | return toHex( digest.digest() ); |
| 223 | 190 | } |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * Runs {@code where} or {@code which} to determine the fully qualified path | |
| 195 | * to an executable. | |
| 196 | * | |
| 197 | * @return The path to the executable for this file, if found. | |
| 198 | * @throws IOException Could not determine the location of the command. | |
| 199 | */ | |
| 200 | public Optional<Path> where() throws IOException { | |
| 201 | // The "where" command on Windows will automatically add the extension. | |
| 202 | final var args = new String[]{ WHERE_COMMAND, getName() }; | |
| 203 | final var output = run( _ -> true, args ); | |
| 204 | final var result = output.lines().findFirst(); | |
| 205 | ||
| 206 | return result.map( Path::of ); | |
| 224 | 207 | } |
| 225 | 208 | |
| ... | ||
| 284 | 267 | ? path |
| 285 | 268 | : USER_DIRECTORY.toPath(); |
| 269 | } | |
| 270 | ||
| 271 | public static Path normalize( final File file ) { | |
| 272 | return file == null | |
| 273 | ? USER_DIRECTORY.toPath() | |
| 274 | : normalize( file.toPath() ); | |
| 286 | 275 | } |
| 287 | 276 | |
| 1 | package com.keenwrite.preferences; | |
| 2 | ||
| 3 | import javafx.beans.property.SimpleObjectProperty; | |
| 4 | import javafx.collections.ObservableList; | |
| 5 | ||
| 6 | import java.util.LinkedHashSet; | |
| 7 | import java.util.Set; | |
| 8 | ||
| 9 | import static com.keenwrite.constants.Constants.APOS_DEFAULT; | |
| 10 | import static com.keenwrite.preferences.Workspace.listProperty; | |
| 11 | ||
| 12 | /** | |
| 13 | * Maintains a list of apostrophe encodings the user may select. | |
| 14 | */ | |
| 15 | public final class AposProperty extends SimpleObjectProperty<String> { | |
| 16 | /** | |
| 17 | * Ordered set of available apostrophe encodings. | |
| 18 | */ | |
| 19 | private static final Set<String> sProperties = new LinkedHashSet<>(); | |
| 20 | ||
| 21 | static { | |
| 22 | sProperties.add( "regular" ); | |
| 23 | sProperties.add( "modifier" ); | |
| 24 | sProperties.add( APOS_DEFAULT ); | |
| 25 | sProperties.add( "aposhex" ); | |
| 26 | sProperties.add( "quote" ); | |
| 27 | sProperties.add( "quotehex" ); | |
| 28 | } | |
| 29 | ||
| 30 | public AposProperty( final String property ) { | |
| 31 | super( property ); | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Returns the list of available apostrophe types to use when encoding. | |
| 36 | * | |
| 37 | * @return A selection of apostrophes. | |
| 38 | */ | |
| 39 | public static ObservableList<String> aposListProperty() { | |
| 40 | assert !sProperties.isEmpty(); | |
| 41 | ||
| 42 | return listProperty( sProperties ); | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * Ensures that the given property name is in the property list. | |
| 47 | * | |
| 48 | * @param property Property to validate. | |
| 49 | * @return The given property was found, otherwise the default property. | |
| 50 | */ | |
| 51 | private static String sanitize( final String property ) { | |
| 52 | assert property != null; | |
| 53 | ||
| 54 | return sProperties.contains( property ) ? property : APOS_DEFAULT; | |
| 55 | } | |
| 56 | } | |
| 1 | 57 |
| 24 | 24 | import static com.keenwrite.constants.Constants.USER_DIRECTORY; |
| 25 | 25 | import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG; |
| 26 | import static com.keenwrite.preferences.AppKeys.*; | |
| 27 | import static com.keenwrite.preferences.LocaleProperty.localeListProperty; | |
| 28 | import static com.keenwrite.preferences.SkinProperty.skinListProperty; | |
| 29 | import static com.keenwrite.preferences.TableField.ofListType; | |
| 30 | import static javafx.scene.control.ButtonType.CANCEL; | |
| 31 | import static javafx.scene.control.ButtonType.OK; | |
| 32 | ||
| 33 | /** | |
| 34 | * Provides the ability for users to configure their preferences. This links | |
| 35 | * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC. | |
| 36 | */ | |
| 37 | @SuppressWarnings( "SameParameterValue" ) | |
| 38 | public final class PreferencesController { | |
| 39 | ||
| 40 | private final Workspace mWorkspace; | |
| 41 | private final PreferencesFx mPreferencesFx; | |
| 42 | ||
| 43 | public PreferencesController( final Workspace workspace ) { | |
| 44 | mWorkspace = workspace; | |
| 45 | ||
| 46 | // Order matters: set the workspace before creating the dialog. | |
| 47 | mPreferencesFx = createPreferencesFx(); | |
| 48 | ||
| 49 | initKeyEventHandler( mPreferencesFx ); | |
| 50 | initSaveEventHandler( mPreferencesFx ); | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Display the user preferences settings dialog (non-modal). | |
| 55 | */ | |
| 56 | public void show() { | |
| 57 | mPreferencesFx.show( false ); | |
| 58 | } | |
| 59 | ||
| 60 | private StringField createFontNameField( | |
| 61 | final StringProperty fontName, final DoubleProperty fontSize ) { | |
| 62 | final var control = new SimpleFontControl( "Change" ); | |
| 63 | ||
| 64 | control.fontSizeProperty().addListener( ( _, _, n ) -> { | |
| 65 | if( n != null ) { | |
| 66 | fontSize.set( n.doubleValue() ); | |
| 67 | } | |
| 68 | } ); | |
| 69 | ||
| 70 | return ofStringType( fontName ).render( control ); | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Convenience method to create a helper class for the user interface. This | |
| 75 | * establishes a key-value pair for the view. | |
| 76 | * | |
| 77 | * @param persist A reference to the values that will be persisted. | |
| 78 | * @param <K> The type of key, usually a string. | |
| 79 | * @param <V> The type of value, usually a string. | |
| 80 | * @return UI data model container that may update the persistent state. | |
| 81 | */ | |
| 82 | private <K, V> TableField<Entry<K, V>> createTableField( | |
| 83 | final ListProperty<Entry<K, V>> persist ) { | |
| 84 | return ofListType( persist ).render( new SimpleTableControl<>() ); | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * Creates the preferences dialog using {@link SkeletonStorageHandler} and | |
| 89 | * numerous {@link Category} objects. | |
| 90 | * | |
| 91 | * @return A component for editing preferences. | |
| 92 | * @throws RuntimeException Could not construct the {@link PreferencesFx} | |
| 93 | * object (e.g., illegal access permissions, | |
| 94 | * unmapped XML resource). | |
| 95 | */ | |
| 96 | private PreferencesFx createPreferencesFx() { | |
| 97 | return PreferencesFx.of( createStorageHandler(), createCategories() ) | |
| 98 | .instantPersistent( false ) | |
| 99 | .dialogIcon( ICON_DIALOG ); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Override the {@link PreferencesFx} storage handler to perform no actions. | |
| 104 | * Persistence is accomplished using the {@link XmlStore}. | |
| 105 | * | |
| 106 | * @return A no-op {@link StorageHandler} implementation. | |
| 107 | */ | |
| 108 | private StorageHandler createStorageHandler() { | |
| 109 | return new SkeletonStorageHandler(); | |
| 110 | } | |
| 111 | ||
| 112 | private Category[] createCategories() { | |
| 113 | return new Category[]{ | |
| 114 | Category.of( | |
| 115 | get( KEY_DOC ), | |
| 116 | Group.of( | |
| 117 | get( KEY_DOC_META ), | |
| 118 | Setting.of( label( KEY_DOC_META ) ), | |
| 119 | Setting.of( title( KEY_DOC_META ), | |
| 120 | createTableField( listEntryProperty( KEY_DOC_META ) ), | |
| 121 | listEntryProperty( KEY_DOC_META ) ) | |
| 122 | ) | |
| 123 | ), | |
| 124 | Category.of( | |
| 125 | get( KEY_TYPESET ), | |
| 126 | Group.of( | |
| 127 | get( KEY_TYPESET_CONTEXT ), | |
| 128 | Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ), | |
| 129 | Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ), | |
| 130 | directoryProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), | |
| 131 | true ), | |
| 132 | Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ), | |
| 133 | Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ), | |
| 134 | booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) ) | |
| 135 | ), | |
| 136 | Group.of( | |
| 137 | get( KEY_TYPESET_CONTEXT_FONTS ), | |
| 138 | Setting.of( label( KEY_TYPESET_CONTEXT_FONTS_DIR ) ), | |
| 139 | Setting.of( title( KEY_TYPESET_CONTEXT_FONTS_DIR ), | |
| 140 | directoryProperty( KEY_TYPESET_CONTEXT_FONTS_DIR ), | |
| 141 | true ) | |
| 142 | ), | |
| 143 | Group.of( | |
| 144 | get( KEY_TYPESET_TYPOGRAPHY ), | |
| 145 | Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ), | |
| 146 | Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ), | |
| 147 | booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 148 | ), | |
| 149 | Group.of( | |
| 150 | get( KEY_TYPESET_MODES ), | |
| 151 | Setting.of( label( KEY_TYPESET_MODES_ENABLED ) ), | |
| 152 | Setting.of( title( KEY_TYPESET_MODES_ENABLED ), | |
| 153 | stringProperty( KEY_TYPESET_MODES_ENABLED ) ) | |
| 154 | ) | |
| 155 | ), | |
| 156 | Category.of( | |
| 157 | get( KEY_EDITOR ), | |
| 158 | Group.of( | |
| 159 | get( KEY_EDITOR_AUTOSAVE ), | |
| 160 | Setting.of( label( KEY_EDITOR_AUTOSAVE ) ), | |
| 161 | Setting.of( title( KEY_EDITOR_AUTOSAVE ), | |
| 162 | integerProperty( KEY_EDITOR_AUTOSAVE ) ) | |
| 163 | ) | |
| 164 | ), | |
| 165 | Category.of( | |
| 166 | get( KEY_R ), | |
| 167 | Group.of( | |
| 168 | get( KEY_R_DIR ), | |
| 169 | Setting.of( label( KEY_R_DIR ) ), | |
| 170 | Setting.of( title( KEY_R_DIR ), | |
| 171 | directoryProperty( KEY_R_DIR ), | |
| 172 | true ) | |
| 173 | ), | |
| 174 | Group.of( | |
| 175 | get( KEY_R_SCRIPT ), | |
| 176 | Setting.of( label( KEY_R_SCRIPT ) ), | |
| 177 | createMultilineSetting( "Script", KEY_R_SCRIPT ) | |
| 178 | ), | |
| 179 | Group.of( | |
| 180 | get( KEY_R_DELIM_BEGAN ), | |
| 181 | Setting.of( label( KEY_R_DELIM_BEGAN ) ), | |
| 182 | Setting.of( title( KEY_R_DELIM_BEGAN ), | |
| 183 | stringProperty( KEY_R_DELIM_BEGAN ) ) | |
| 184 | ), | |
| 185 | Group.of( | |
| 186 | get( KEY_R_DELIM_ENDED ), | |
| 187 | Setting.of( label( KEY_R_DELIM_ENDED ) ), | |
| 188 | Setting.of( title( KEY_R_DELIM_ENDED ), | |
| 189 | stringProperty( KEY_R_DELIM_ENDED ) ) | |
| 190 | ) | |
| 191 | ), | |
| 192 | Category.of( | |
| 193 | get( KEY_IMAGE ), | |
| 194 | Group.of( | |
| 195 | get( KEY_IMAGE_DIR ), | |
| 196 | Setting.of( label( KEY_IMAGE_DIR ) ), | |
| 197 | Setting.of( title( KEY_IMAGE_DIR ), | |
| 198 | directoryProperty( KEY_IMAGE_DIR ), | |
| 199 | true ), | |
| 200 | Setting.of( label( KEY_CACHE_DIR ) ), | |
| 201 | Setting.of( title( KEY_CACHE_DIR ), | |
| 202 | directoryProperty( KEY_CACHE_DIR ), | |
| 203 | true ) | |
| 204 | ), | |
| 205 | Group.of( | |
| 206 | get( KEY_IMAGE_ORDER ), | |
| 207 | Setting.of( label( KEY_IMAGE_ORDER ) ), | |
| 208 | Setting.of( title( KEY_IMAGE_ORDER ), | |
| 209 | stringProperty( KEY_IMAGE_ORDER ) ) | |
| 210 | ), | |
| 211 | Group.of( | |
| 212 | get( KEY_IMAGE_RESIZE ), | |
| 213 | Setting.of( label( KEY_IMAGE_RESIZE ) ), | |
| 214 | Setting.of( title( KEY_IMAGE_RESIZE ), | |
| 215 | booleanProperty( KEY_IMAGE_RESIZE ) ) | |
| 216 | ), | |
| 217 | Group.of( | |
| 218 | get( KEY_IMAGE_SERVER ), | |
| 219 | Setting.of( label( KEY_IMAGE_SERVER ) ), | |
| 220 | Setting.of( title( KEY_IMAGE_SERVER ), | |
| 221 | stringProperty( KEY_IMAGE_SERVER ) ) | |
| 222 | ) | |
| 223 | ), | |
| 224 | Category.of( | |
| 225 | get( KEY_DEF ), | |
| 226 | Group.of( | |
| 227 | get( KEY_DEF_PATH ), | |
| 228 | Setting.of( label( KEY_DEF_PATH ) ), | |
| 229 | Setting.of( title( KEY_DEF_PATH ), | |
| 230 | fileProperty( KEY_DEF_PATH ), false ) | |
| 231 | ), | |
| 232 | Group.of( | |
| 233 | get( KEY_DEF_DELIM_BEGAN ), | |
| 234 | Setting.of( label( KEY_DEF_DELIM_BEGAN ) ), | |
| 235 | Setting.of( title( KEY_DEF_DELIM_BEGAN ), | |
| 236 | stringProperty( KEY_DEF_DELIM_BEGAN ) ) | |
| 237 | ), | |
| 238 | Group.of( | |
| 239 | get( KEY_DEF_DELIM_ENDED ), | |
| 240 | Setting.of( label( KEY_DEF_DELIM_ENDED ) ), | |
| 241 | Setting.of( title( KEY_DEF_DELIM_ENDED ), | |
| 242 | stringProperty( KEY_DEF_DELIM_ENDED ) ) | |
| 243 | ) | |
| 244 | ), | |
| 245 | Category.of( | |
| 246 | get( KEY_UI_FONT ), | |
| 247 | Group.of( | |
| 248 | get( KEY_UI_FONT_EDITOR ), | |
| 249 | Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 250 | Setting.of( title( KEY_UI_FONT_EDITOR_NAME ), | |
| 251 | createFontNameField( | |
| 252 | stringProperty( KEY_UI_FONT_EDITOR_NAME ), | |
| 253 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 254 | stringProperty( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 255 | Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 256 | Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ), | |
| 257 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ) | |
| 258 | ), | |
| 259 | Group.of( | |
| 260 | get( KEY_UI_FONT_PREVIEW ), | |
| 261 | Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 262 | Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ), | |
| 263 | createFontNameField( | |
| 264 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ), | |
| 265 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 266 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 267 | Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 268 | Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 269 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 270 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 271 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 272 | createFontNameField( | |
| 273 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 274 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 275 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 276 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 277 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ), | |
| 278 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ) | |
| 279 | ), | |
| 280 | Group.of( | |
| 281 | get( KEY_UI_FONT_MATH ), | |
| 282 | Setting.of( title( KEY_UI_FONT_MATH_SIZE ), | |
| 283 | doubleProperty( KEY_UI_FONT_MATH_SIZE ) ) | |
| 284 | ) | |
| 285 | ), | |
| 286 | Category.of( | |
| 287 | get( KEY_UI_SKIN ), | |
| 288 | Group.of( | |
| 289 | get( KEY_UI_SKIN_SELECTION ), | |
| 290 | Setting.of( label( KEY_UI_SKIN_SELECTION ) ), | |
| 291 | Setting.of( title( KEY_UI_SKIN_SELECTION ), | |
| 292 | skinListProperty(), | |
| 293 | skinProperty( KEY_UI_SKIN_SELECTION ) ) | |
| 294 | ), | |
| 295 | Group.of( | |
| 296 | get( KEY_UI_SKIN_CUSTOM ), | |
| 297 | Setting.of( label( KEY_UI_SKIN_CUSTOM ) ), | |
| 298 | Setting.of( title( KEY_UI_SKIN_CUSTOM ), | |
| 299 | fileProperty( KEY_UI_SKIN_CUSTOM ), false ) | |
| 300 | ) | |
| 301 | ), | |
| 302 | Category.of( | |
| 303 | get( KEY_UI_PREVIEW ), | |
| 304 | Group.of( | |
| 305 | get( KEY_UI_PREVIEW_STYLESHEET ), | |
| 306 | Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ), | |
| 307 | Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ), | |
| 308 | fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false ) | |
| 309 | ) | |
| 310 | ), | |
| 311 | Category.of( | |
| 312 | get( KEY_LANGUAGE ), | |
| 313 | Group.of( | |
| 314 | get( KEY_LANGUAGE_LOCALE ), | |
| 315 | Setting.of( label( KEY_LANGUAGE_LOCALE ) ), | |
| 316 | Setting.of( title( KEY_LANGUAGE_LOCALE ), | |
| 317 | localeListProperty(), | |
| 318 | localeProperty( KEY_LANGUAGE_LOCALE ) ) | |
| 319 | ) | |
| 320 | ) | |
| 321 | }; | |
| 322 | } | |
| 323 | ||
| 324 | @SuppressWarnings( "unchecked" ) | |
| 325 | private Setting<StringField, StringProperty> createMultilineSetting( | |
| 326 | final String description, final Key property ) { | |
| 327 | final Setting<StringField, StringProperty> setting = | |
| 328 | Setting.of( description, stringProperty( property ) ); | |
| 329 | final var field = setting.getElement(); | |
| 330 | field.multiline( true ); | |
| 331 | ||
| 332 | return setting; | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * Map ENTER and ESCAPE keys to OK and CANCEL buttons, respectively. | |
| 337 | */ | |
| 338 | private void initKeyEventHandler( final PreferencesFx preferences ) { | |
| 339 | final var view = preferences.getView(); | |
| 340 | final var nodes = view.getChildrenUnmodifiable(); | |
| 341 | final var master = (MasterDetailPane) nodes.getFirst(); | |
| 342 | final var detail = (NavigationView) master.getDetailNode(); | |
| 343 | final var pane = (DialogPane) view.getParent(); | |
| 344 | ||
| 345 | detail.setOnKeyReleased( key -> { | |
| 346 | switch( key.getCode() ) { | |
| 347 | case ENTER -> ((Button) pane.lookupButton( OK )).fire(); | |
| 348 | case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); | |
| 349 | default -> {} | |
| 350 | } | |
| 351 | } ); | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * Called when the user clicks the APPLY or OK buttons in the dialog. | |
| 356 | * | |
| 357 | * @param preferences Preferences widget. | |
| 358 | */ | |
| 359 | private void initSaveEventHandler( final PreferencesFx preferences ) { | |
| 360 | preferences.addEventHandler( | |
| 361 | EVENT_PREFERENCES_SAVED, _ -> mWorkspace.save() | |
| 362 | ); | |
| 363 | } | |
| 364 | ||
| 365 | /** | |
| 366 | * Creates a label for the given key after interpolating its value. | |
| 367 | * | |
| 368 | * @param key The key to find in the resource bundle. | |
| 369 | * @return The value of the key as a label. | |
| 370 | */ | |
| 371 | private Node label( final Key key ) { | |
| 372 | return label( key, (String[]) null ); | |
| 373 | } | |
| 374 | ||
| 375 | private Node label( final Key key, final String... values ) { | |
| 376 | return new Label( get( String.format( "%s%s", key.toString(), ".desc" ), (Object[]) values ) ); | |
| 377 | } | |
| 378 | ||
| 379 | private String title( final Key key ) { | |
| 380 | return get( String.format( "%s%s", key.toString(), ".title" ) ); | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * Screens out non-existent directories to avoid throwing an exception caused | |
| 385 | * by | |
| 386 | * <a href="https://github.com/dlsc-software-consulting-gmbh/PreferencesFX/issues/441"> | |
| 387 | * PreferencesFX issue #441 | |
| 388 | * </a>. | |
| 389 | * | |
| 390 | * @param key Preference to pre-screen before creating a {@link FileProperty}. | |
| 391 | * @return The preferred value or the user's home directory if the directory | |
| 392 | * does not exist. | |
| 393 | */ | |
| 394 | private ObjectProperty<File> directoryProperty( final Key key ) { | |
| 395 | final var property = mWorkspace.fileProperty( key ); | |
| 396 | final var file = property.get(); | |
| 397 | ||
| 398 | if( !file.exists() ) { | |
| 399 | property.set( USER_DIRECTORY ); | |
| 400 | } | |
| 401 | ||
| 402 | return property; | |
| 403 | } | |
| 404 | ||
| 405 | private ObjectProperty<File> fileProperty( final Key key ) { | |
| 406 | return mWorkspace.fileProperty( key ); | |
| 407 | } | |
| 408 | ||
| 409 | private StringProperty stringProperty( final Key key ) { | |
| 410 | return mWorkspace.stringProperty( key ); | |
| 411 | } | |
| 412 | ||
| 413 | private BooleanProperty booleanProperty( final Key key ) { | |
| 414 | return mWorkspace.booleanProperty( key ); | |
| 415 | } | |
| 416 | ||
| 417 | private IntegerProperty integerProperty( final Key key ) { | |
| 418 | return mWorkspace.integerProperty( key ); | |
| 419 | } | |
| 420 | ||
| 421 | private DoubleProperty doubleProperty( final Key key ) { | |
| 422 | return mWorkspace.doubleProperty( key ); | |
| 423 | } | |
| 424 | ||
| 425 | private ObjectProperty<String> skinProperty( final Key key ) { | |
| 426 | return mWorkspace.skinProperty( key ); | |
| 26 | import static com.keenwrite.preferences.AposProperty.aposListProperty; | |
| 27 | import static com.keenwrite.preferences.AppKeys.*; | |
| 28 | import static com.keenwrite.preferences.LocaleProperty.localeListProperty; | |
| 29 | import static com.keenwrite.preferences.SkinProperty.skinListProperty; | |
| 30 | import static com.keenwrite.preferences.TableField.ofListType; | |
| 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 | // Order matters: set the workspace before creating the dialog. | |
| 48 | mPreferencesFx = createPreferencesFx(); | |
| 49 | ||
| 50 | initKeyEventHandler( mPreferencesFx ); | |
| 51 | initSaveEventHandler( mPreferencesFx ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Display the user preferences settings dialog (non-modal). | |
| 56 | */ | |
| 57 | public void show() { | |
| 58 | mPreferencesFx.show( false ); | |
| 59 | } | |
| 60 | ||
| 61 | private StringField createFontNameField( | |
| 62 | final StringProperty fontName, final DoubleProperty fontSize ) { | |
| 63 | final var control = new SimpleFontControl( "Change" ); | |
| 64 | ||
| 65 | control.fontSizeProperty().addListener( ( _, _, n ) -> { | |
| 66 | if( n != null ) { | |
| 67 | fontSize.set( n.doubleValue() ); | |
| 68 | } | |
| 69 | } ); | |
| 70 | ||
| 71 | return ofStringType( fontName ).render( control ); | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Convenience method to create a helper class for the user interface. This | |
| 76 | * establishes a key-value pair for the view. | |
| 77 | * | |
| 78 | * @param persist A reference to the values that will be persisted. | |
| 79 | * @param <K> The type of key, usually a string. | |
| 80 | * @param <V> The type of value, usually a string. | |
| 81 | * @return UI data model container that may update the persistent state. | |
| 82 | */ | |
| 83 | private <K, V> TableField<Entry<K, V>> createTableField( | |
| 84 | final ListProperty<Entry<K, V>> persist ) { | |
| 85 | return ofListType( persist ).render( new SimpleTableControl<>() ); | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Creates the preferences dialog using {@link SkeletonStorageHandler} and | |
| 90 | * numerous {@link Category} objects. | |
| 91 | * | |
| 92 | * @return A component for editing preferences. | |
| 93 | * @throws RuntimeException Could not construct the {@link PreferencesFx} | |
| 94 | * object (e.g., illegal access permissions, | |
| 95 | * unmapped XML resource). | |
| 96 | */ | |
| 97 | private PreferencesFx createPreferencesFx() { | |
| 98 | return PreferencesFx.of( createStorageHandler(), createCategories() ) | |
| 99 | .instantPersistent( false ) | |
| 100 | .dialogIcon( ICON_DIALOG ); | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Override the {@link PreferencesFx} storage handler to perform no actions. | |
| 105 | * Persistence is accomplished using the {@link XmlStore}. | |
| 106 | * | |
| 107 | * @return A no-op {@link StorageHandler} implementation. | |
| 108 | */ | |
| 109 | private StorageHandler createStorageHandler() { | |
| 110 | return new SkeletonStorageHandler(); | |
| 111 | } | |
| 112 | ||
| 113 | private Category[] createCategories() { | |
| 114 | return new Category[]{ | |
| 115 | Category.of( | |
| 116 | get( KEY_DOC ), | |
| 117 | Group.of( | |
| 118 | get( KEY_DOC_META ), | |
| 119 | Setting.of( label( KEY_DOC_META ) ), | |
| 120 | Setting.of( title( KEY_DOC_META ), | |
| 121 | createTableField( listEntryProperty( KEY_DOC_META ) ), | |
| 122 | listEntryProperty( KEY_DOC_META ) ) | |
| 123 | ) | |
| 124 | ), | |
| 125 | Category.of( | |
| 126 | get( KEY_TYPESET ), | |
| 127 | Group.of( | |
| 128 | get( KEY_TYPESET_CONTEXT ), | |
| 129 | Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ), | |
| 130 | Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ), | |
| 131 | directoryProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), | |
| 132 | true ), | |
| 133 | Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ), | |
| 134 | Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ), | |
| 135 | booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) ) | |
| 136 | ), | |
| 137 | Group.of( | |
| 138 | get( KEY_TYPESET_CONTEXT_FONTS ), | |
| 139 | Setting.of( label( KEY_TYPESET_CONTEXT_FONTS_DIR ) ), | |
| 140 | Setting.of( title( KEY_TYPESET_CONTEXT_FONTS_DIR ), | |
| 141 | directoryProperty( KEY_TYPESET_CONTEXT_FONTS_DIR ), | |
| 142 | true ) | |
| 143 | ), | |
| 144 | Group.of( | |
| 145 | get( KEY_TYPESET_TYPOGRAPHY ), | |
| 146 | Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ), | |
| 147 | Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ), | |
| 148 | aposListProperty(), | |
| 149 | listProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ) | |
| 150 | ), | |
| 151 | Group.of( | |
| 152 | get( KEY_TYPESET_MODES ), | |
| 153 | Setting.of( label( KEY_TYPESET_MODES_ENABLED ) ), | |
| 154 | Setting.of( title( KEY_TYPESET_MODES_ENABLED ), | |
| 155 | stringProperty( KEY_TYPESET_MODES_ENABLED ) ) | |
| 156 | ) | |
| 157 | ), | |
| 158 | Category.of( | |
| 159 | get( KEY_EDITOR ), | |
| 160 | Group.of( | |
| 161 | get( KEY_EDITOR_AUTOSAVE ), | |
| 162 | Setting.of( label( KEY_EDITOR_AUTOSAVE ) ), | |
| 163 | Setting.of( title( KEY_EDITOR_AUTOSAVE ), | |
| 164 | integerProperty( KEY_EDITOR_AUTOSAVE ) ) | |
| 165 | ) | |
| 166 | ), | |
| 167 | Category.of( | |
| 168 | get( KEY_R ), | |
| 169 | Group.of( | |
| 170 | get( KEY_R_DIR ), | |
| 171 | Setting.of( label( KEY_R_DIR ) ), | |
| 172 | Setting.of( title( KEY_R_DIR ), | |
| 173 | directoryProperty( KEY_R_DIR ), | |
| 174 | true ) | |
| 175 | ), | |
| 176 | Group.of( | |
| 177 | get( KEY_R_SCRIPT ), | |
| 178 | Setting.of( label( KEY_R_SCRIPT ) ), | |
| 179 | createMultilineSetting( "Script", KEY_R_SCRIPT ) | |
| 180 | ), | |
| 181 | Group.of( | |
| 182 | get( KEY_R_DELIM_BEGAN ), | |
| 183 | Setting.of( label( KEY_R_DELIM_BEGAN ) ), | |
| 184 | Setting.of( title( KEY_R_DELIM_BEGAN ), | |
| 185 | stringProperty( KEY_R_DELIM_BEGAN ) ) | |
| 186 | ), | |
| 187 | Group.of( | |
| 188 | get( KEY_R_DELIM_ENDED ), | |
| 189 | Setting.of( label( KEY_R_DELIM_ENDED ) ), | |
| 190 | Setting.of( title( KEY_R_DELIM_ENDED ), | |
| 191 | stringProperty( KEY_R_DELIM_ENDED ) ) | |
| 192 | ) | |
| 193 | ), | |
| 194 | Category.of( | |
| 195 | get( KEY_IMAGE ), | |
| 196 | Group.of( | |
| 197 | get( KEY_IMAGE_DIR ), | |
| 198 | Setting.of( label( KEY_IMAGE_DIR ) ), | |
| 199 | Setting.of( title( KEY_IMAGE_DIR ), | |
| 200 | directoryProperty( KEY_IMAGE_DIR ), | |
| 201 | true ), | |
| 202 | Setting.of( label( KEY_CACHE_DIR ) ), | |
| 203 | Setting.of( title( KEY_CACHE_DIR ), | |
| 204 | directoryProperty( KEY_CACHE_DIR ), | |
| 205 | true ) | |
| 206 | ), | |
| 207 | Group.of( | |
| 208 | get( KEY_IMAGE_ORDER ), | |
| 209 | Setting.of( label( KEY_IMAGE_ORDER ) ), | |
| 210 | Setting.of( title( KEY_IMAGE_ORDER ), | |
| 211 | stringProperty( KEY_IMAGE_ORDER ) ) | |
| 212 | ), | |
| 213 | Group.of( | |
| 214 | get( KEY_IMAGE_RESIZE ), | |
| 215 | Setting.of( label( KEY_IMAGE_RESIZE ) ), | |
| 216 | Setting.of( title( KEY_IMAGE_RESIZE ), | |
| 217 | booleanProperty( KEY_IMAGE_RESIZE ) ) | |
| 218 | ), | |
| 219 | Group.of( | |
| 220 | get( KEY_IMAGE_SERVER ), | |
| 221 | Setting.of( label( KEY_IMAGE_SERVER ) ), | |
| 222 | Setting.of( title( KEY_IMAGE_SERVER ), | |
| 223 | stringProperty( KEY_IMAGE_SERVER ) ) | |
| 224 | ) | |
| 225 | ), | |
| 226 | Category.of( | |
| 227 | get( KEY_DEF ), | |
| 228 | Group.of( | |
| 229 | get( KEY_DEF_PATH ), | |
| 230 | Setting.of( label( KEY_DEF_PATH ) ), | |
| 231 | Setting.of( title( KEY_DEF_PATH ), | |
| 232 | fileProperty( KEY_DEF_PATH ), false ) | |
| 233 | ), | |
| 234 | Group.of( | |
| 235 | get( KEY_DEF_DELIM_BEGAN ), | |
| 236 | Setting.of( label( KEY_DEF_DELIM_BEGAN ) ), | |
| 237 | Setting.of( title( KEY_DEF_DELIM_BEGAN ), | |
| 238 | stringProperty( KEY_DEF_DELIM_BEGAN ) ) | |
| 239 | ), | |
| 240 | Group.of( | |
| 241 | get( KEY_DEF_DELIM_ENDED ), | |
| 242 | Setting.of( label( KEY_DEF_DELIM_ENDED ) ), | |
| 243 | Setting.of( title( KEY_DEF_DELIM_ENDED ), | |
| 244 | stringProperty( KEY_DEF_DELIM_ENDED ) ) | |
| 245 | ) | |
| 246 | ), | |
| 247 | Category.of( | |
| 248 | get( KEY_UI_FONT ), | |
| 249 | Group.of( | |
| 250 | get( KEY_UI_FONT_EDITOR ), | |
| 251 | Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 252 | Setting.of( title( KEY_UI_FONT_EDITOR_NAME ), | |
| 253 | createFontNameField( | |
| 254 | stringProperty( KEY_UI_FONT_EDITOR_NAME ), | |
| 255 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 256 | stringProperty( KEY_UI_FONT_EDITOR_NAME ) ), | |
| 257 | Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ), | |
| 258 | Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ), | |
| 259 | doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ) | |
| 260 | ), | |
| 261 | Group.of( | |
| 262 | get( KEY_UI_FONT_PREVIEW ), | |
| 263 | Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 264 | Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ), | |
| 265 | createFontNameField( | |
| 266 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ), | |
| 267 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 268 | stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ), | |
| 269 | Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 270 | Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ), | |
| 271 | doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ), | |
| 272 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 273 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 274 | createFontNameField( | |
| 275 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ), | |
| 276 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 277 | stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ), | |
| 278 | Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ), | |
| 279 | Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ), | |
| 280 | doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ) | |
| 281 | ), | |
| 282 | Group.of( | |
| 283 | get( KEY_UI_FONT_MATH ), | |
| 284 | Setting.of( title( KEY_UI_FONT_MATH_SIZE ), | |
| 285 | doubleProperty( KEY_UI_FONT_MATH_SIZE ) ) | |
| 286 | ) | |
| 287 | ), | |
| 288 | Category.of( | |
| 289 | get( KEY_UI_SKIN ), | |
| 290 | Group.of( | |
| 291 | get( KEY_UI_SKIN_SELECTION ), | |
| 292 | Setting.of( label( KEY_UI_SKIN_SELECTION ) ), | |
| 293 | Setting.of( title( KEY_UI_SKIN_SELECTION ), | |
| 294 | skinListProperty(), | |
| 295 | listProperty( KEY_UI_SKIN_SELECTION ) ) | |
| 296 | ), | |
| 297 | Group.of( | |
| 298 | get( KEY_UI_SKIN_CUSTOM ), | |
| 299 | Setting.of( label( KEY_UI_SKIN_CUSTOM ) ), | |
| 300 | Setting.of( title( KEY_UI_SKIN_CUSTOM ), | |
| 301 | fileProperty( KEY_UI_SKIN_CUSTOM ), false ) | |
| 302 | ) | |
| 303 | ), | |
| 304 | Category.of( | |
| 305 | get( KEY_UI_PREVIEW ), | |
| 306 | Group.of( | |
| 307 | get( KEY_UI_PREVIEW_STYLESHEET ), | |
| 308 | Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ), | |
| 309 | Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ), | |
| 310 | fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false ) | |
| 311 | ) | |
| 312 | ), | |
| 313 | Category.of( | |
| 314 | get( KEY_LANGUAGE ), | |
| 315 | Group.of( | |
| 316 | get( KEY_LANGUAGE_LOCALE ), | |
| 317 | Setting.of( label( KEY_LANGUAGE_LOCALE ) ), | |
| 318 | Setting.of( title( KEY_LANGUAGE_LOCALE ), | |
| 319 | localeListProperty(), | |
| 320 | localeProperty( KEY_LANGUAGE_LOCALE ) ) | |
| 321 | ) | |
| 322 | ) | |
| 323 | }; | |
| 324 | } | |
| 325 | ||
| 326 | @SuppressWarnings( "unchecked" ) | |
| 327 | private Setting<StringField, StringProperty> createMultilineSetting( | |
| 328 | final String description, final Key property ) { | |
| 329 | final Setting<StringField, StringProperty> setting = | |
| 330 | Setting.of( description, stringProperty( property ) ); | |
| 331 | final var field = setting.getElement(); | |
| 332 | field.multiline( true ); | |
| 333 | ||
| 334 | return setting; | |
| 335 | } | |
| 336 | ||
| 337 | /** | |
| 338 | * Map ENTER and ESCAPE keys to OK and CANCEL buttons, respectively. | |
| 339 | */ | |
| 340 | private void initKeyEventHandler( final PreferencesFx preferences ) { | |
| 341 | final var view = preferences.getView(); | |
| 342 | final var nodes = view.getChildrenUnmodifiable(); | |
| 343 | final var master = (MasterDetailPane) nodes.getFirst(); | |
| 344 | final var detail = (NavigationView) master.getDetailNode(); | |
| 345 | final var pane = (DialogPane) view.getParent(); | |
| 346 | ||
| 347 | detail.setOnKeyReleased( key -> { | |
| 348 | switch( key.getCode() ) { | |
| 349 | case ENTER -> ((Button) pane.lookupButton( OK )).fire(); | |
| 350 | case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire(); | |
| 351 | default -> {} | |
| 352 | } | |
| 353 | } ); | |
| 354 | } | |
| 355 | ||
| 356 | /** | |
| 357 | * Called when the user clicks the APPLY or OK buttons in the dialog. | |
| 358 | * | |
| 359 | * @param preferences Preferences widget. | |
| 360 | */ | |
| 361 | private void initSaveEventHandler( final PreferencesFx preferences ) { | |
| 362 | preferences.addEventHandler( | |
| 363 | EVENT_PREFERENCES_SAVED, _ -> mWorkspace.save() | |
| 364 | ); | |
| 365 | } | |
| 366 | ||
| 367 | /** | |
| 368 | * Creates a label for the given key after interpolating its value. | |
| 369 | * | |
| 370 | * @param key The key to find in the resource bundle. | |
| 371 | * @return The value of the key as a label. | |
| 372 | */ | |
| 373 | private Node label( final Key key ) { | |
| 374 | return label( key, (String[]) null ); | |
| 375 | } | |
| 376 | ||
| 377 | private Node label( final Key key, final String... values ) { | |
| 378 | return new Label( get( String.format( "%s%s", key.toString(), ".desc" ), (Object[]) values ) ); | |
| 379 | } | |
| 380 | ||
| 381 | private String title( final Key key ) { | |
| 382 | return get( String.format( "%s%s", key.toString(), ".title" ) ); | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Screens out non-existent directories to avoid throwing an exception caused | |
| 387 | * by | |
| 388 | * <a href="https://github.com/dlsc-software-consulting-gmbh/PreferencesFX/issues/441"> | |
| 389 | * PreferencesFX issue #441 | |
| 390 | * </a>. | |
| 391 | * | |
| 392 | * @param key Preference to pre-screen before creating a {@link FileProperty}. | |
| 393 | * @return The preferred value or the user's home directory if the directory | |
| 394 | * does not exist. | |
| 395 | */ | |
| 396 | private ObjectProperty<File> directoryProperty( final Key key ) { | |
| 397 | final var property = mWorkspace.fileProperty( key ); | |
| 398 | final var file = property.get(); | |
| 399 | ||
| 400 | if( !file.exists() ) { | |
| 401 | property.set( USER_DIRECTORY ); | |
| 402 | } | |
| 403 | ||
| 404 | return property; | |
| 405 | } | |
| 406 | ||
| 407 | private ObjectProperty<File> fileProperty( final Key key ) { | |
| 408 | return mWorkspace.fileProperty( key ); | |
| 409 | } | |
| 410 | ||
| 411 | private StringProperty stringProperty( final Key key ) { | |
| 412 | return mWorkspace.stringProperty( key ); | |
| 413 | } | |
| 414 | ||
| 415 | private BooleanProperty booleanProperty( final Key key ) { | |
| 416 | return mWorkspace.booleanProperty( key ); | |
| 417 | } | |
| 418 | ||
| 419 | private IntegerProperty integerProperty( final Key key ) { | |
| 420 | return mWorkspace.integerProperty( key ); | |
| 421 | } | |
| 422 | ||
| 423 | private DoubleProperty doubleProperty( final Key key ) { | |
| 424 | return mWorkspace.doubleProperty( key ); | |
| 425 | } | |
| 426 | ||
| 427 | private ObjectProperty<String> listProperty( final Key key ) { | |
| 428 | return mWorkspace.listProperty( key ); | |
| 427 | 429 | } |
| 428 | 430 |
| 19 | 19 | * Ordered set of available skins. |
| 20 | 20 | */ |
| 21 | private static final Set<String> sSkins = new LinkedHashSet<>(); | |
| 21 | private static final Set<String> sProperties = new LinkedHashSet<>(); | |
| 22 | 22 | |
| 23 | 23 | static { |
| 24 | sSkins.add( "Count Darcula" ); | |
| 25 | sSkins.add( "Haunted Grey" ); | |
| 26 | sSkins.add( "Modena Dark" ); | |
| 27 | sSkins.add( "Monokai" ); | |
| 28 | sSkins.add( SKIN_DEFAULT ); | |
| 29 | sSkins.add( "Silver Cavern" ); | |
| 30 | sSkins.add( "Solarized Dark" ); | |
| 31 | sSkins.add( "Vampire Byte" ); | |
| 24 | sProperties.add( "Count Darcula" ); | |
| 25 | sProperties.add( "Haunted Grey" ); | |
| 26 | sProperties.add( "Modena Dark" ); | |
| 27 | sProperties.add( "Monokai" ); | |
| 28 | sProperties.add( SKIN_DEFAULT ); | |
| 29 | sProperties.add( "Silver Cavern" ); | |
| 30 | sProperties.add( "Solarized Dark" ); | |
| 31 | sProperties.add( "Vampire Byte" ); | |
| 32 | } | |
| 33 | ||
| 34 | public SkinProperty( final String skin ) { | |
| 35 | super( skin ); | |
| 32 | 36 | } |
| 33 | 37 | |
| 34 | 38 | /** |
| 35 | 39 | * Returns the list of available skin names to change the UI fonts and |
| 36 | 40 | * colours. |
| 37 | 41 | * |
| 38 | 42 | * @return A selection of skins. |
| 39 | 43 | */ |
| 40 | 44 | public static ObservableList<String> skinListProperty() { |
| 41 | assert !sSkins.isEmpty(); | |
| 45 | assert !sProperties.isEmpty(); | |
| 42 | 46 | |
| 43 | return listProperty( sSkins ); | |
| 47 | return listProperty( sProperties ); | |
| 44 | 48 | } |
| 45 | 49 | |
| 46 | 50 | /** |
| 47 | 51 | * Returns the given skin name as a sanitized file name, which must map |
| 48 | 52 | * to a stylesheet file bundled with the application. This does not include |
| 49 | 53 | * the path to the stylesheet. If the given name is not known, the file |
| 50 | 54 | * name for {@link Constants#SKIN_DEFAULT} is returned. The extension must |
| 51 | 55 | * be added separately. |
| 52 | 56 | * |
| 53 | * @param skin The name to convert to a file name. | |
| 54 | * @return The given name converted lower case, spaces replaced with | |
| 57 | * @param property The property name to convert to a file name. | |
| 58 | * @return The given property name converted lower case, spaces replaced with | |
| 55 | 59 | * underscores, without the ".css" extension appended. |
| 56 | 60 | */ |
| 57 | public static String toFilename( final String skin ) { | |
| 58 | assert skin != null; | |
| 61 | public static String toFilename( final String property ) { | |
| 62 | assert property != null; | |
| 59 | 63 | |
| 60 | return sanitize( skin ).toLowerCase().replace( ' ', '_' ); | |
| 64 | return sanitize( property ).toLowerCase().replace( ' ', '_' ); | |
| 61 | 65 | } |
| 62 | 66 | |
| 63 | 67 | /** |
| 64 | * Ensures that the given name is in the list of known skins. | |
| 68 | * Ensures that the given property name is in the property list. | |
| 65 | 69 | * |
| 66 | * @param skin Validate this name's existence. | |
| 67 | * @return The given name, if valid, otherwise the default skin. | |
| 70 | * @param property Property to validate. | |
| 71 | * @return The given property was found, otherwise the default property. | |
| 68 | 72 | */ |
| 69 | private static String sanitize( final String skin ) { | |
| 70 | assert skin != null; | |
| 71 | ||
| 72 | return sSkins.contains( skin ) ? skin : SKIN_DEFAULT; | |
| 73 | } | |
| 73 | private static String sanitize( final String property ) { | |
| 74 | assert property != null; | |
| 74 | 75 | |
| 75 | public SkinProperty( final String skin ) { | |
| 76 | super( skin ); | |
| 76 | return sProperties.contains( property ) ? property : SKIN_DEFAULT; | |
| 77 | 77 | } |
| 78 | 78 | } |
| 138 | 138 | entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ), |
| 139 | 139 | entry( KEY_TYPESET_CONTEXT_CHAPTERS, asStringProperty( "" ) ), |
| 140 | entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) ), | |
| 141 | entry( KEY_TYPESET_MODES_ENABLED, asStringProperty( "" ) ) | |
| 142 | //@formatter:on | |
| 143 | ); | |
| 144 | ||
| 145 | /** | |
| 146 | * Sets of configuration values, all the same type (e.g., file names), | |
| 147 | * where the key name doesn't change per set. | |
| 148 | */ | |
| 149 | private final Map<Key, SetProperty<?>> mSets = Map.ofEntries( | |
| 150 | entry( | |
| 151 | KEY_UI_RECENT_OPEN_PATH, | |
| 152 | createSetProperty( new HashSet<String>() ) | |
| 153 | ) | |
| 154 | ); | |
| 155 | ||
| 156 | /** | |
| 157 | * Lists of configuration values, such as key-value pairs where both the | |
| 158 | * key name and the value must be preserved per list. | |
| 159 | */ | |
| 160 | private final Map<Key, ListProperty<?>> mLists = Map.ofEntries( | |
| 161 | entry( | |
| 162 | KEY_DOC_META, | |
| 163 | createListProperty( new LinkedList<Entry<String, String>>() ) | |
| 164 | ) | |
| 165 | ); | |
| 166 | ||
| 167 | /** | |
| 168 | * Helps instantiate {@link Property} instances for XML configuration items. | |
| 169 | */ | |
| 170 | private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | |
| 171 | Map.of( | |
| 172 | LocaleProperty.class, LocaleProperty::parseLocale, | |
| 173 | SimpleBooleanProperty.class, Boolean::parseBoolean, | |
| 174 | SimpleIntegerProperty.class, Integer::parseInt, | |
| 175 | SimpleDoubleProperty.class, Double::parseDouble, | |
| 176 | SimpleFloatProperty.class, Float::parseFloat, | |
| 177 | SimpleStringProperty.class, String::new, | |
| 178 | SimpleObjectProperty.class, String::new, | |
| 179 | SkinProperty.class, String::new, | |
| 180 | FileProperty.class, File::new | |
| 181 | ); | |
| 182 | ||
| 183 | /** | |
| 184 | * The asymmetry with respect to {@link #UNMARSHALL} is because most objects | |
| 185 | * can simply call {@link Object#toString()} to convert the value to a string. | |
| 186 | */ | |
| 187 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 188 | Map.of( | |
| 189 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 190 | ); | |
| 191 | ||
| 192 | /** | |
| 193 | * Converts the given {@link Property} value to a string. | |
| 194 | * | |
| 195 | * @param property The {@link Property} to convert. | |
| 196 | * @return A string representation of the given property, or the empty | |
| 197 | * string if no conversion was possible. | |
| 198 | */ | |
| 199 | private static String marshall( final Property<?> property ) { | |
| 200 | final var v = property.getValue(); | |
| 201 | ||
| 202 | return v == null | |
| 203 | ? "" | |
| 204 | : MARSHALL | |
| 205 | .getOrDefault( property.getClass(), _ -> property.getValue() ) | |
| 206 | .apply( v.toString() ) | |
| 207 | .toString(); | |
| 208 | } | |
| 209 | ||
| 210 | private static Object unmarshall( | |
| 211 | final Property<?> property, final Object configValue ) { | |
| 212 | final var v = configValue.toString(); | |
| 213 | ||
| 214 | return UNMARSHALL | |
| 215 | .getOrDefault( property.getClass(), _ -> property.getValue() ) | |
| 216 | .apply( v ); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Creates an instance of {@link ObservableList} that is based on a | |
| 221 | * modifiable observable array list for the given items. | |
| 222 | * | |
| 223 | * @param items The items to wrap in an observable list. | |
| 224 | * @param <E> The type of items to add to the list. | |
| 225 | * @return An observable property that can have its contents modified. | |
| 226 | */ | |
| 227 | public static <E> ObservableList<E> listProperty( final Set<E> items ) { | |
| 228 | return new SimpleListProperty<>( observableArrayList( items ) ); | |
| 229 | } | |
| 230 | ||
| 231 | private static <E> SetProperty<E> createSetProperty( final Set<E> set ) { | |
| 232 | return new SimpleSetProperty<>( observableSet( set ) ); | |
| 233 | } | |
| 234 | ||
| 235 | private static <E> ListProperty<E> createListProperty( final List<E> list ) { | |
| 236 | return new SimpleListProperty<>( observableArrayList( list ) ); | |
| 237 | } | |
| 238 | ||
| 239 | private static StringProperty asStringProperty( final String value ) { | |
| 240 | return new SimpleStringProperty( value ); | |
| 241 | } | |
| 242 | ||
| 243 | private static BooleanProperty asBooleanProperty() { | |
| 244 | return new SimpleBooleanProperty(); | |
| 245 | } | |
| 246 | ||
| 247 | /** | |
| 248 | * @param value Default value. | |
| 249 | */ | |
| 250 | @SuppressWarnings( "SameParameterValue" ) | |
| 251 | private static BooleanProperty asBooleanProperty( final boolean value ) { | |
| 252 | return new SimpleBooleanProperty( value ); | |
| 253 | } | |
| 254 | ||
| 255 | /** | |
| 256 | * @param value Default value. | |
| 257 | */ | |
| 258 | @SuppressWarnings( "SameParameterValue" ) | |
| 259 | private static IntegerProperty asIntegerProperty( final int value ) { | |
| 260 | return new SimpleIntegerProperty( value ); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * @param value Default value. | |
| 265 | */ | |
| 266 | private static DoubleProperty asDoubleProperty( final double value ) { | |
| 267 | return new SimpleDoubleProperty( value ); | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * @param value Default value. | |
| 272 | */ | |
| 273 | private static FileProperty asFileProperty( final File value ) { | |
| 274 | return new FileProperty( value ); | |
| 275 | } | |
| 276 | ||
| 277 | /** | |
| 278 | * @param value Default value. | |
| 279 | */ | |
| 280 | @SuppressWarnings( "SameParameterValue" ) | |
| 281 | private static LocaleProperty asLocaleProperty( final Locale value ) { | |
| 282 | return new LocaleProperty( value ); | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * @param value Default value. | |
| 287 | */ | |
| 288 | @SuppressWarnings( "SameParameterValue" ) | |
| 289 | private static SkinProperty asSkinProperty( final String value ) { | |
| 290 | return new SkinProperty( value ); | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * Creates a new {@link Workspace} that will attempt to load the users' | |
| 295 | * preferences. If the configuration file cannot be loaded, the workspace | |
| 296 | * settings returns default values. | |
| 297 | */ | |
| 298 | public Workspace() { | |
| 299 | load(); | |
| 300 | } | |
| 301 | ||
| 302 | /** | |
| 303 | * Attempts to load the app's configuration file. | |
| 304 | */ | |
| 305 | private void load() { | |
| 306 | final var store = createXmlStore(); | |
| 307 | store.load( FILE_PREFERENCES ); | |
| 308 | ||
| 309 | mValues.keySet().forEach( key -> { | |
| 310 | try { | |
| 311 | final var storeValue = store.getValue( key ); | |
| 312 | final var property = valuesProperty( key ); | |
| 313 | final var unmarshalled = unmarshall( property, storeValue ); | |
| 314 | ||
| 315 | property.setValue( unmarshalled ); | |
| 316 | } catch( final NoSuchElementException ex ) { | |
| 317 | // When no configuration (item), use the default value. | |
| 318 | clue( ex ); | |
| 319 | } | |
| 320 | } ); | |
| 321 | ||
| 322 | mSets.keySet().forEach( key -> { | |
| 323 | final var set = store.getSet( key ); | |
| 324 | final SetProperty<String> property = setsProperty( key ); | |
| 325 | ||
| 326 | property.setValue( observableSet( set ) ); | |
| 327 | } ); | |
| 328 | ||
| 329 | mLists.keySet().forEach( key -> { | |
| 330 | final var map = store.getMap( key ); | |
| 331 | final ListProperty<Entry<String, String>> property = listsProperty( key ); | |
| 332 | final var list = map | |
| 333 | .entrySet() | |
| 334 | .stream() | |
| 335 | .toList(); | |
| 336 | ||
| 337 | property.setValue( observableArrayList( list ) ); | |
| 338 | } ); | |
| 339 | ||
| 340 | WorkspaceLoadedEvent.fire( this ); | |
| 341 | } | |
| 342 | ||
| 343 | /** | |
| 344 | * Saves the current workspace. | |
| 345 | */ | |
| 346 | public void save() { | |
| 347 | final var store = createXmlStore(); | |
| 348 | ||
| 349 | try { | |
| 350 | // Update the string values to include the application version. | |
| 351 | valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | |
| 352 | ||
| 353 | mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) ); | |
| 354 | mSets.forEach( store::setSet ); | |
| 355 | mLists.forEach( store::setMap ); | |
| 356 | ||
| 357 | store.save( FILE_PREFERENCES ); | |
| 358 | } catch( final Exception ex ) { | |
| 359 | clue( ex ); | |
| 360 | } | |
| 361 | } | |
| 362 | ||
| 363 | /** | |
| 364 | * Returns a value that represents a setting in the application that the user | |
| 365 | * may configure, either directly or indirectly. | |
| 366 | * | |
| 367 | * @param key The reference to the users' preference stored in deference | |
| 368 | * of app reëntrance. | |
| 369 | * @return An observable property to be persisted. | |
| 370 | */ | |
| 371 | @SuppressWarnings( "unchecked" ) | |
| 372 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 373 | assert key != null; | |
| 374 | return (U) mValues.get( key ); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Returns a set of values that represent a setting in the application that | |
| 379 | * the user may configure, either directly or indirectly. The property | |
| 380 | * returned is backed by a {@link Set}. | |
| 381 | * | |
| 382 | * @param key The {@link Key} associated with a preference value. | |
| 383 | * @return An observable property to be persisted. | |
| 384 | */ | |
| 385 | @SuppressWarnings( "unchecked" ) | |
| 386 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 387 | assert key != null; | |
| 388 | return (SetProperty<T>) mSets.get( key ); | |
| 389 | } | |
| 390 | ||
| 391 | /** | |
| 392 | * Returns a list of values that represent a setting in the application that | |
| 393 | * the user may configure, either directly or indirectly. The property | |
| 394 | * returned is backed by a mutable {@link List}. | |
| 395 | * | |
| 396 | * @param key The {@link Key} associated with a preference value. | |
| 397 | * @return An observable property to be persisted. | |
| 398 | */ | |
| 399 | @SuppressWarnings( "unchecked" ) | |
| 400 | public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) { | |
| 401 | assert key != null; | |
| 402 | return (ListProperty<Entry<K, V>>) mLists.get( key ); | |
| 403 | } | |
| 404 | ||
| 405 | /** | |
| 406 | * Returns the {@link String} {@link Property} associated with the given | |
| 407 | * {@link Key} from the internal list of preference values. The caller | |
| 408 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 409 | * {@link Property}. | |
| 410 | * | |
| 411 | * @param key The {@link Key} associated with a preference value. | |
| 412 | * @return The value associated with the given {@link Key}. | |
| 413 | */ | |
| 414 | public StringProperty stringProperty( final Key key ) { | |
| 415 | assert key != null; | |
| 416 | return valuesProperty( key ); | |
| 417 | } | |
| 418 | ||
| 419 | /** | |
| 420 | * Returns the {@link Boolean} {@link Property} associated with the given | |
| 421 | * {@link Key} from the internal list of preference values. The caller | |
| 422 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 423 | * {@link Property}. | |
| 424 | * | |
| 425 | * @param key The {@link Key} associated with a preference value. | |
| 426 | * @return The value associated with the given {@link Key}. | |
| 427 | */ | |
| 428 | public BooleanProperty booleanProperty( final Key key ) { | |
| 429 | assert key != null; | |
| 430 | return valuesProperty( key ); | |
| 431 | } | |
| 432 | ||
| 433 | /** | |
| 434 | * Returns the {@link Integer} {@link Property} associated with the given | |
| 435 | * {@link Key} from the internal list of preference values. The caller | |
| 436 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 437 | * {@link Property}. | |
| 438 | * | |
| 439 | * @param key The {@link Key} associated with a preference value. | |
| 440 | * @return The value associated with the given {@link Key}. | |
| 441 | */ | |
| 442 | public IntegerProperty integerProperty( final Key key ) { | |
| 443 | assert key != null; | |
| 444 | return valuesProperty( key ); | |
| 445 | } | |
| 446 | ||
| 447 | /** | |
| 448 | * Returns the {@link Double} {@link Property} associated with the given | |
| 449 | * {@link Key} from the internal list of preference values. The caller | |
| 450 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 451 | * {@link Property}. | |
| 452 | * | |
| 453 | * @param key The {@link Key} associated with a preference value. | |
| 454 | * @return The value associated with the given {@link Key}. | |
| 455 | */ | |
| 456 | public DoubleProperty doubleProperty( final Key key ) { | |
| 457 | assert key != null; | |
| 458 | return valuesProperty( key ); | |
| 459 | } | |
| 460 | ||
| 461 | /** | |
| 462 | * Returns the {@link File} {@link Property} associated with the given | |
| 463 | * {@link Key} from the internal list of preference values. The caller | |
| 464 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 465 | * {@link Property}. | |
| 466 | * | |
| 467 | * @param key The {@link Key} associated with a preference value. | |
| 468 | * @return The value associated with the given {@link Key}. | |
| 469 | */ | |
| 470 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 471 | assert key != null; | |
| 472 | return valuesProperty( key ); | |
| 473 | } | |
| 474 | ||
| 475 | /** | |
| 476 | * Returns the {@link Locale} {@link Property} associated with the given | |
| 477 | * {@link Key} from the internal list of preference values. The caller | |
| 478 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 479 | * {@link Property}. | |
| 480 | * | |
| 481 | * @param key The {@link Key} associated with a preference value. | |
| 482 | * @return The value associated with the given {@link Key}. | |
| 483 | */ | |
| 484 | public LocaleProperty localeProperty( final Key key ) { | |
| 485 | assert key != null; | |
| 486 | return valuesProperty( key ); | |
| 487 | } | |
| 488 | ||
| 489 | public ObjectProperty<String> skinProperty( final Key key ) { | |
| 140 | entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asAposProperty( APOS_DEFAULT ) ), | |
| 141 | entry( KEY_TYPESET_MODES_ENABLED, asStringProperty( "" ) ) | |
| 142 | //@formatter:on | |
| 143 | ); | |
| 144 | ||
| 145 | /** | |
| 146 | * Sets of configuration values, all the same type (e.g., file names), | |
| 147 | * where the key name doesn't change per set. | |
| 148 | */ | |
| 149 | private final Map<Key, SetProperty<?>> mSets = Map.ofEntries( | |
| 150 | entry( | |
| 151 | KEY_UI_RECENT_OPEN_PATH, | |
| 152 | createSetProperty( new HashSet<String>() ) | |
| 153 | ) | |
| 154 | ); | |
| 155 | ||
| 156 | /** | |
| 157 | * Lists of configuration values, such as key-value pairs where both the | |
| 158 | * key name and the value must be preserved per list. | |
| 159 | */ | |
| 160 | private final Map<Key, ListProperty<?>> mLists = Map.ofEntries( | |
| 161 | entry( | |
| 162 | KEY_DOC_META, | |
| 163 | createListProperty( new LinkedList<Entry<String, String>>() ) | |
| 164 | ) | |
| 165 | ); | |
| 166 | ||
| 167 | /** | |
| 168 | * Helps instantiate {@link Property} instances for XML configuration items. | |
| 169 | */ | |
| 170 | private static final Map<Class<?>, Function<String, Object>> UNMARSHALL = | |
| 171 | Map.of( | |
| 172 | LocaleProperty.class, LocaleProperty::parseLocale, | |
| 173 | SimpleBooleanProperty.class, Boolean::parseBoolean, | |
| 174 | SimpleIntegerProperty.class, Integer::parseInt, | |
| 175 | SimpleDoubleProperty.class, Double::parseDouble, | |
| 176 | SimpleFloatProperty.class, Float::parseFloat, | |
| 177 | SimpleStringProperty.class, String::new, | |
| 178 | SimpleObjectProperty.class, String::new, | |
| 179 | SkinProperty.class, String::new, | |
| 180 | FileProperty.class, File::new | |
| 181 | ); | |
| 182 | ||
| 183 | /** | |
| 184 | * The asymmetry with respect to {@link #UNMARSHALL} is because most objects | |
| 185 | * can simply call {@link Object#toString()} to convert the value to a string. | |
| 186 | */ | |
| 187 | private static final Map<Class<?>, Function<String, Object>> MARSHALL = | |
| 188 | Map.of( | |
| 189 | LocaleProperty.class, LocaleProperty::toLanguageTag | |
| 190 | ); | |
| 191 | ||
| 192 | /** | |
| 193 | * Converts the given {@link Property} value to a string. | |
| 194 | * | |
| 195 | * @param property The {@link Property} to convert. | |
| 196 | * @return A string representation of the given property, or the empty | |
| 197 | * string if no conversion was possible. | |
| 198 | */ | |
| 199 | private static String marshall( final Property<?> property ) { | |
| 200 | final var v = property.getValue(); | |
| 201 | ||
| 202 | return v == null | |
| 203 | ? "" | |
| 204 | : MARSHALL | |
| 205 | .getOrDefault( property.getClass(), _ -> property.getValue() ) | |
| 206 | .apply( v.toString() ) | |
| 207 | .toString(); | |
| 208 | } | |
| 209 | ||
| 210 | private static Object unmarshall( | |
| 211 | final Property<?> property, final Object configValue ) { | |
| 212 | final var v = configValue.toString(); | |
| 213 | ||
| 214 | return UNMARSHALL | |
| 215 | .getOrDefault( property.getClass(), _ -> property.getValue() ) | |
| 216 | .apply( v ); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Creates an instance of {@link ObservableList} that is based on a | |
| 221 | * modifiable observable array list for the given items. | |
| 222 | * | |
| 223 | * @param items The items to wrap in an observable list. | |
| 224 | * @param <E> The type of items to add to the list. | |
| 225 | * @return An observable property that can have its contents modified. | |
| 226 | */ | |
| 227 | public static <E> ObservableList<E> listProperty( final Set<E> items ) { | |
| 228 | return new SimpleListProperty<>( observableArrayList( items ) ); | |
| 229 | } | |
| 230 | ||
| 231 | private static <E> SetProperty<E> createSetProperty( final Set<E> set ) { | |
| 232 | return new SimpleSetProperty<>( observableSet( set ) ); | |
| 233 | } | |
| 234 | ||
| 235 | private static <E> ListProperty<E> createListProperty( final List<E> list ) { | |
| 236 | return new SimpleListProperty<>( observableArrayList( list ) ); | |
| 237 | } | |
| 238 | ||
| 239 | private static StringProperty asStringProperty( final String value ) { | |
| 240 | return new SimpleStringProperty( value ); | |
| 241 | } | |
| 242 | ||
| 243 | private static BooleanProperty asBooleanProperty() { | |
| 244 | return new SimpleBooleanProperty(); | |
| 245 | } | |
| 246 | ||
| 247 | /** | |
| 248 | * @param value Default value. | |
| 249 | */ | |
| 250 | @SuppressWarnings( "SameParameterValue" ) | |
| 251 | private static BooleanProperty asBooleanProperty( final boolean value ) { | |
| 252 | return new SimpleBooleanProperty( value ); | |
| 253 | } | |
| 254 | ||
| 255 | /** | |
| 256 | * @param value Default value. | |
| 257 | */ | |
| 258 | @SuppressWarnings( "SameParameterValue" ) | |
| 259 | private static IntegerProperty asIntegerProperty( final int value ) { | |
| 260 | return new SimpleIntegerProperty( value ); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * @param value Default value. | |
| 265 | */ | |
| 266 | private static DoubleProperty asDoubleProperty( final double value ) { | |
| 267 | return new SimpleDoubleProperty( value ); | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * @param value Default value. | |
| 272 | */ | |
| 273 | private static FileProperty asFileProperty( final File value ) { | |
| 274 | return new FileProperty( value ); | |
| 275 | } | |
| 276 | ||
| 277 | /** | |
| 278 | * @param value Default value. | |
| 279 | */ | |
| 280 | @SuppressWarnings( "SameParameterValue" ) | |
| 281 | private static LocaleProperty asLocaleProperty( final Locale value ) { | |
| 282 | return new LocaleProperty( value ); | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * @param value Default value. | |
| 287 | */ | |
| 288 | @SuppressWarnings( "SameParameterValue" ) | |
| 289 | private static SkinProperty asSkinProperty( final String value ) { | |
| 290 | return new SkinProperty( value ); | |
| 291 | } | |
| 292 | ||
| 293 | /** | |
| 294 | * @param value Default value. | |
| 295 | */ | |
| 296 | @SuppressWarnings( "SameParameterValue" ) | |
| 297 | private static AposProperty asAposProperty( final String value ) { | |
| 298 | return new AposProperty( value ); | |
| 299 | } | |
| 300 | ||
| 301 | /** | |
| 302 | * Creates a new {@link Workspace} that will attempt to load the users' | |
| 303 | * preferences. If the configuration file cannot be loaded, the workspace | |
| 304 | * settings returns default values. | |
| 305 | */ | |
| 306 | public Workspace() { | |
| 307 | load(); | |
| 308 | } | |
| 309 | ||
| 310 | /** | |
| 311 | * Attempts to load the app's configuration file. | |
| 312 | */ | |
| 313 | private void load() { | |
| 314 | final var store = createXmlStore(); | |
| 315 | store.load( FILE_PREFERENCES ); | |
| 316 | ||
| 317 | mValues.keySet().forEach( key -> { | |
| 318 | try { | |
| 319 | final var storeValue = store.getValue( key ); | |
| 320 | final var property = valuesProperty( key ); | |
| 321 | final var unmarshalled = unmarshall( property, storeValue ); | |
| 322 | ||
| 323 | property.setValue( unmarshalled ); | |
| 324 | } catch( final NoSuchElementException ex ) { | |
| 325 | // When no configuration (item), use the default value. | |
| 326 | clue( ex ); | |
| 327 | } | |
| 328 | } ); | |
| 329 | ||
| 330 | mSets.keySet().forEach( key -> { | |
| 331 | final var set = store.getSet( key ); | |
| 332 | final SetProperty<String> property = setsProperty( key ); | |
| 333 | ||
| 334 | property.setValue( observableSet( set ) ); | |
| 335 | } ); | |
| 336 | ||
| 337 | mLists.keySet().forEach( key -> { | |
| 338 | final var map = store.getMap( key ); | |
| 339 | final ListProperty<Entry<String, String>> property = listsProperty( key ); | |
| 340 | final var list = map | |
| 341 | .entrySet() | |
| 342 | .stream() | |
| 343 | .toList(); | |
| 344 | ||
| 345 | property.setValue( observableArrayList( list ) ); | |
| 346 | } ); | |
| 347 | ||
| 348 | WorkspaceLoadedEvent.fire( this ); | |
| 349 | } | |
| 350 | ||
| 351 | /** | |
| 352 | * Saves the current workspace. | |
| 353 | */ | |
| 354 | public void save() { | |
| 355 | final var store = createXmlStore(); | |
| 356 | ||
| 357 | try { | |
| 358 | // Update the string values to include the application version. | |
| 359 | valuesProperty( KEY_META_VERSION ).setValue( getVersion() ); | |
| 360 | ||
| 361 | mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) ); | |
| 362 | mSets.forEach( store::setSet ); | |
| 363 | mLists.forEach( store::setMap ); | |
| 364 | ||
| 365 | store.save( FILE_PREFERENCES ); | |
| 366 | } catch( final Exception ex ) { | |
| 367 | clue( ex ); | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | /** | |
| 372 | * Returns a value that represents a setting in the application that the user | |
| 373 | * may configure, either directly or indirectly. | |
| 374 | * | |
| 375 | * @param key The reference to the users' preference stored in deference | |
| 376 | * of app reëntrance. | |
| 377 | * @return An observable property to be persisted. | |
| 378 | */ | |
| 379 | @SuppressWarnings( "unchecked" ) | |
| 380 | public <T, U extends Property<T>> U valuesProperty( final Key key ) { | |
| 381 | assert key != null; | |
| 382 | return (U) mValues.get( key ); | |
| 383 | } | |
| 384 | ||
| 385 | /** | |
| 386 | * Returns a set of values that represent a setting in the application that | |
| 387 | * the user may configure, either directly or indirectly. The property | |
| 388 | * returned is backed by a {@link Set}. | |
| 389 | * | |
| 390 | * @param key The {@link Key} associated with a preference value. | |
| 391 | * @return An observable property to be persisted. | |
| 392 | */ | |
| 393 | @SuppressWarnings( "unchecked" ) | |
| 394 | public <T> SetProperty<T> setsProperty( final Key key ) { | |
| 395 | assert key != null; | |
| 396 | return (SetProperty<T>) mSets.get( key ); | |
| 397 | } | |
| 398 | ||
| 399 | /** | |
| 400 | * Returns a list of values that represent a setting in the application that | |
| 401 | * the user may configure, either directly or indirectly. The property | |
| 402 | * returned is backed by a mutable {@link List}. | |
| 403 | * | |
| 404 | * @param key The {@link Key} associated with a preference value. | |
| 405 | * @return An observable property to be persisted. | |
| 406 | */ | |
| 407 | @SuppressWarnings( "unchecked" ) | |
| 408 | public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) { | |
| 409 | assert key != null; | |
| 410 | return (ListProperty<Entry<K, V>>) mLists.get( key ); | |
| 411 | } | |
| 412 | ||
| 413 | /** | |
| 414 | * Returns the {@link String} {@link Property} associated with the given | |
| 415 | * {@link Key} from the internal list of preference values. The caller | |
| 416 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 417 | * {@link Property}. | |
| 418 | * | |
| 419 | * @param key The {@link Key} associated with a preference value. | |
| 420 | * @return The value associated with the given {@link Key}. | |
| 421 | */ | |
| 422 | public StringProperty stringProperty( final Key key ) { | |
| 423 | assert key != null; | |
| 424 | return valuesProperty( key ); | |
| 425 | } | |
| 426 | ||
| 427 | /** | |
| 428 | * Returns the {@link Boolean} {@link Property} associated with the given | |
| 429 | * {@link Key} from the internal list of preference values. The caller | |
| 430 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 431 | * {@link Property}. | |
| 432 | * | |
| 433 | * @param key The {@link Key} associated with a preference value. | |
| 434 | * @return The value associated with the given {@link Key}. | |
| 435 | */ | |
| 436 | public BooleanProperty booleanProperty( final Key key ) { | |
| 437 | assert key != null; | |
| 438 | return valuesProperty( key ); | |
| 439 | } | |
| 440 | ||
| 441 | /** | |
| 442 | * Returns the {@link Integer} {@link Property} associated with the given | |
| 443 | * {@link Key} from the internal list of preference values. The caller | |
| 444 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 445 | * {@link Property}. | |
| 446 | * | |
| 447 | * @param key The {@link Key} associated with a preference value. | |
| 448 | * @return The value associated with the given {@link Key}. | |
| 449 | */ | |
| 450 | public IntegerProperty integerProperty( final Key key ) { | |
| 451 | assert key != null; | |
| 452 | return valuesProperty( key ); | |
| 453 | } | |
| 454 | ||
| 455 | /** | |
| 456 | * Returns the {@link Double} {@link Property} associated with the given | |
| 457 | * {@link Key} from the internal list of preference values. The caller | |
| 458 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 459 | * {@link Property}. | |
| 460 | * | |
| 461 | * @param key The {@link Key} associated with a preference value. | |
| 462 | * @return The value associated with the given {@link Key}. | |
| 463 | */ | |
| 464 | public DoubleProperty doubleProperty( final Key key ) { | |
| 465 | assert key != null; | |
| 466 | return valuesProperty( key ); | |
| 467 | } | |
| 468 | ||
| 469 | /** | |
| 470 | * Returns the {@link File} {@link Property} associated with the given | |
| 471 | * {@link Key} from the internal list of preference values. The caller | |
| 472 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 473 | * {@link Property}. | |
| 474 | * | |
| 475 | * @param key The {@link Key} associated with a preference value. | |
| 476 | * @return The value associated with the given {@link Key}. | |
| 477 | */ | |
| 478 | public ObjectProperty<File> fileProperty( final Key key ) { | |
| 479 | assert key != null; | |
| 480 | return valuesProperty( key ); | |
| 481 | } | |
| 482 | ||
| 483 | /** | |
| 484 | * Returns the {@link Locale} {@link Property} associated with the given | |
| 485 | * {@link Key} from the internal list of preference values. The caller | |
| 486 | * must be sure that the given {@link Key} is associated with a {@link File} | |
| 487 | * {@link Property}. | |
| 488 | * | |
| 489 | * @param key The {@link Key} associated with a preference value. | |
| 490 | * @return The value associated with the given {@link Key}. | |
| 491 | */ | |
| 492 | public LocaleProperty localeProperty( final Key key ) { | |
| 493 | assert key != null; | |
| 494 | return valuesProperty( key ); | |
| 495 | } | |
| 496 | ||
| 497 | public ObjectProperty<String> listProperty( final Key key ) { | |
| 490 | 498 | assert key != null; |
| 491 | 499 | return valuesProperty( key ); |
| 158 | 158 | public void render( final String html ) { |
| 159 | 159 | final var jsoupDoc = DocumentConverter.parse( decorate( html ) ); |
| 160 | ||
| 161 | // Ensure the title (metadata) doesn't appear in the preview panel. | |
| 162 | jsoupDoc.select( "title" ).remove(); | |
| 163 | ||
| 160 | 164 | final var doc = CONVERTER.fromJsoup( jsoupDoc ); |
| 161 | 165 | final var uri = getBaseUri(); |
| ... | ||
| 357 | 361 | |
| 358 | 362 | @Override |
| 359 | public void componentMoved( final ComponentEvent e ) { } | |
| 363 | public void componentMoved( final ComponentEvent e ) {} | |
| 360 | 364 | |
| 361 | 365 | @Override |
| 362 | public void componentShown( final ComponentEvent e ) { } | |
| 366 | public void componentShown( final ComponentEvent e ) {} | |
| 363 | 367 | |
| 364 | 368 | @Override |
| 365 | public void componentHidden( final ComponentEvent e ) { } | |
| 369 | public void componentHidden( final ComponentEvent e ) {} | |
| 366 | 370 | |
| 367 | 371 | private static String toStylesheetString( final URL url ) { |
| 30 | 30 | import static io.sf.carte.echosvg.bridge.UnitProcessor.createContext; |
| 31 | 31 | import static io.sf.carte.echosvg.bridge.UnitProcessor.svgHorizontalLengthToUserSpace; |
| 32 | import static io.sf.carte.echosvg.transcoder.SVGAbstractTranscoder.KEY_HEIGHT; | |
| 33 | import static io.sf.carte.echosvg.transcoder.SVGAbstractTranscoder.KEY_WIDTH; | |
| 34 | import static io.sf.carte.echosvg.transcoder.TranscodingHints.Key; | |
| 35 | import static io.sf.carte.echosvg.transcoder.image.ImageTranscoder.KEY_PIXEL_UNIT_TO_MILLIMETER; | |
| 36 | import static io.sf.carte.echosvg.util.SVGConstants.SVG_HEIGHT_ATTRIBUTE; | |
| 37 | import static io.sf.carte.echosvg.util.SVGConstants.SVG_WIDTH_ATTRIBUTE; | |
| 38 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; | |
| 39 | import static java.text.NumberFormat.getIntegerInstance; | |
| 40 | ||
| 41 | /** | |
| 42 | * Responsible for converting SVG images into rasterized PNG images. | |
| 43 | */ | |
| 44 | public final class SvgRasterizer { | |
| 45 | ||
| 46 | /** | |
| 47 | * Prevent rudely barfing stack traces to the console. | |
| 48 | */ | |
| 49 | private static final class SvgErrorHandler implements ErrorHandler { | |
| 50 | @Override | |
| 51 | public void error( final TranscoderException ex ) { | |
| 52 | clue( ex ); | |
| 53 | } | |
| 54 | ||
| 55 | @Override | |
| 56 | public void fatalError( final TranscoderException ex ) { | |
| 57 | clue( ex ); | |
| 58 | } | |
| 59 | ||
| 60 | @Override | |
| 61 | public void warning( final TranscoderException ex ) { | |
| 62 | clue( ex ); | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | private static final UserAgent USER_AGENT = new UserAgentAdapter(); | |
| 67 | private static final BridgeContext BRIDGE_CONTEXT = new BridgeContext( | |
| 68 | USER_AGENT, new DocumentLoader( USER_AGENT ) | |
| 69 | ); | |
| 70 | private static final ErrorHandler sErrorHandler = new SvgErrorHandler(); | |
| 71 | ||
| 72 | private static final SAXSVGDocumentFactory FACTORY_DOM = | |
| 73 | new SAXSVGDocumentFactory(); | |
| 74 | ||
| 75 | private static final NumberFormat INT_FORMAT = getIntegerInstance(); | |
| 76 | ||
| 77 | public static final BufferedImage BROKEN_IMAGE_PLACEHOLDER; | |
| 78 | ||
| 79 | /** | |
| 80 | * A FontAwesome camera icon, cleft asunder. | |
| 81 | */ | |
| 82 | public static final String BROKEN_IMAGE_SVG = | |
| 83 | "<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www" + | |
| 84 | ".w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c" + | |
| 85 | ".332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path " + | |
| 86 | "d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531" + | |
| 87 | ".03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2" + | |
| 88 | ".511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-" + | |
| 89 | ".367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1" + | |
| 90 | ".699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 " + | |
| 91 | "2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094" + | |
| 92 | ".0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5" + | |
| 93 | ".0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-" + | |
| 94 | ".195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4" + | |
| 95 | ".191406-3.652344.207031-.015625.414063-.015625.617187-.007812l" + | |
| 96 | ".933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2" + | |
| 97 | ".820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3" + | |
| 98 | ".429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3" + | |
| 99 | ".429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 " + | |
| 100 | ".140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281" + | |
| 101 | ".808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2" + | |
| 102 | ".472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4" + | |
| 103 | ".285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1" + | |
| 104 | ".9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-" + | |
| 105 | ".667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1" + | |
| 106 | ".253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 " + | |
| 107 | "0'/></g></svg>"; | |
| 108 | ||
| 109 | static { | |
| 110 | // The width and height cannot be embedded in the SVG above because the | |
| 111 | // path element values are relative to the viewBox dimensions. | |
| 112 | final int w = 75; | |
| 113 | final int h = 75; | |
| 114 | BufferedImage image; | |
| 115 | ||
| 116 | try { | |
| 117 | image = rasterizeImage( BROKEN_IMAGE_SVG, w ); | |
| 118 | } catch( final Exception ex ) { | |
| 119 | image = new BufferedImage( w, h, TYPE_INT_RGB ); | |
| 120 | final var graphics = (Graphics2D) image.getGraphics(); | |
| 121 | graphics.setRenderingHints( RENDERING_HINTS ); | |
| 122 | ||
| 123 | // Fall back to a (\) symbol. | |
| 124 | graphics.setColor( new Color( 204, 204, 204 ) ); | |
| 125 | graphics.fillRect( 0, 0, w, h ); | |
| 126 | graphics.setColor( new Color( 255, 204, 204 ) ); | |
| 127 | graphics.setStroke( new BasicStroke( 4 ) ); | |
| 128 | graphics.drawOval( w / 4, h / 4, w / 2, h / 2 ); | |
| 129 | graphics.drawLine( w / 4 + (int) (w / 4 / Math.PI), | |
| 130 | h / 4 + (int) (w / 4 / Math.PI), | |
| 131 | w / 2 + w / 4 - (int) (w / 4 / Math.PI), | |
| 132 | h / 2 + h / 4 - (int) (w / 4 / Math.PI) ); | |
| 133 | } | |
| 134 | ||
| 135 | BROKEN_IMAGE_PLACEHOLDER = image; | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Responsible for creating a new {@link ImageRenderer} implementation that | |
| 140 | * can render a DOM as an SVG image. | |
| 141 | */ | |
| 142 | private static class BufferedImageTranscoder extends ImageTranscoder { | |
| 143 | private BufferedImage mImage; | |
| 144 | ||
| 145 | /** | |
| 146 | * Prevent barfing a stack trace when the transcoder encounters problems | |
| 147 | * parsing SVG contents. | |
| 148 | */ | |
| 149 | @Override | |
| 150 | protected UserAgent createUserAgent() { | |
| 151 | return new SVGAbstractTranscoderUserAgent() { | |
| 152 | @Override | |
| 153 | public void displayError( final Exception ex ) { | |
| 154 | clue( ex ); | |
| 155 | } | |
| 156 | }; | |
| 157 | } | |
| 158 | ||
| 159 | @Override | |
| 160 | public BufferedImage createImage( final int w, final int h ) { | |
| 161 | return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB ); | |
| 162 | } | |
| 163 | ||
| 164 | @Override | |
| 165 | public void writeImage( | |
| 166 | final BufferedImage image, final TranscoderOutput output ) { | |
| 167 | mImage = image; | |
| 168 | } | |
| 169 | ||
| 170 | public BufferedImage getImage() { | |
| 171 | return mImage; | |
| 172 | } | |
| 173 | ||
| 174 | @Override | |
| 175 | protected ImageRenderer createRenderer() { | |
| 176 | final ImageRenderer renderer = super.createRenderer(); | |
| 177 | final RenderingHints hints = renderer.getRenderingHints(); | |
| 178 | hints.putAll( RENDERING_HINTS ); | |
| 179 | renderer.setRenderingHints( hints ); | |
| 180 | ||
| 181 | return renderer; | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | /** | |
| 186 | * Rasterizes the given SVG input stream into an image. | |
| 187 | * | |
| 188 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 189 | * @return The given input stream converted to a rasterized image. | |
| 190 | */ | |
| 191 | public static BufferedImage rasterize( final String svg ) | |
| 192 | throws TranscoderException, ParseException { | |
| 193 | return rasterize( toDocument( svg ) ); | |
| 194 | } | |
| 195 | ||
| 196 | /** | |
| 197 | * Rasterizes the given SVG input stream into an image at 96 DPI. | |
| 198 | * | |
| 199 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 200 | * @return The given input stream converted to a rasterized image. | |
| 201 | */ | |
| 202 | public static BufferedImage rasterize( final InputStream svg ) | |
| 203 | throws TranscoderException { | |
| 204 | return rasterize( svg, 96 ); | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * Rasterizes the given SVG input stream into an image. | |
| 209 | * | |
| 210 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 211 | * @param dpi Resolution to use when rasterizing (default is 96 DPI). | |
| 212 | * @return The given input stream converted to a rasterized image at the | |
| 213 | * given resolution. | |
| 214 | */ | |
| 215 | public static BufferedImage rasterize( | |
| 216 | final InputStream svg, final float dpi ) throws TranscoderException { | |
| 217 | return rasterize( | |
| 218 | new TranscoderInput( svg ), | |
| 219 | KEY_PIXEL_UNIT_TO_MILLIMETER, | |
| 220 | 1f / dpi * 25.4f | |
| 221 | ); | |
| 222 | } | |
| 223 | ||
| 224 | /** | |
| 225 | * Rasterizes the given document into an image. | |
| 226 | * | |
| 227 | * @param svg The SVG {@link Document} to rasterize. | |
| 228 | * @param width The rasterized image's width (in pixels). | |
| 229 | * @return The rasterized image. | |
| 230 | */ | |
| 231 | public static BufferedImage rasterize( | |
| 232 | final Document svg, final int width ) throws TranscoderException { | |
| 233 | return rasterize( | |
| 234 | new TranscoderInput( svg ), | |
| 235 | KEY_WIDTH, | |
| 236 | fit( svg.getDocumentElement(), width ) | |
| 237 | ); | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Rasterizes the given vector graphic file using the width dimension | |
| 242 | * specified by the document's width attribute. | |
| 243 | * | |
| 244 | * @param document The {@link Document} containing a vector graphic. | |
| 245 | * @return A rasterized image as an instance of {@link BufferedImage}, or | |
| 246 | * {@link #BROKEN_IMAGE_PLACEHOLDER} if the graphic could not be rasterized. | |
| 247 | */ | |
| 248 | public static BufferedImage rasterize( final Document document ) | |
| 249 | throws ParseException, TranscoderException { | |
| 250 | final var root = document.getDocumentElement(); | |
| 251 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 252 | ||
| 253 | return rasterize( document, INT_FORMAT.parse( width ).intValue() ); | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 258 | * happens, a broken image icon is returned instead. | |
| 259 | * | |
| 260 | * @param path The {@link Path} to a vector graphic file. | |
| 261 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 262 | * maintained. | |
| 263 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 264 | */ | |
| 265 | public static BufferedImage rasterize( final Path path, final int width ) { | |
| 266 | return rasterize( path.toUri(), width ); | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 271 | * happens, a broken image icon is returned instead. | |
| 272 | * | |
| 273 | * @param uri The URI to a vector graphic file, which must include the | |
| 274 | * protocol scheme (such as <code>file://</code> or | |
| 275 | * <code>https://</code>). | |
| 276 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 277 | * maintained. | |
| 278 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 279 | */ | |
| 280 | public static BufferedImage rasterize( final String uri, final int width ) { | |
| 281 | return rasterize( new File( uri ).toURI(), width ); | |
| 282 | } | |
| 283 | ||
| 284 | /** | |
| 285 | * Converts an SVG drawing into a rasterized image that can be drawn on | |
| 286 | * a graphics context. | |
| 287 | * | |
| 288 | * @param uri The path to the image (can be web address). | |
| 289 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 290 | * maintained. | |
| 291 | * @return The vector graphic transcoded into a raster image format. | |
| 292 | */ | |
| 293 | public static BufferedImage rasterize( final URI uri, final int width ) { | |
| 294 | try { | |
| 295 | return rasterize( FACTORY_DOM.createDocument( uri.toString() ), width ); | |
| 296 | } catch( final Exception ex ) { | |
| 297 | clue( ex ); | |
| 298 | } | |
| 299 | ||
| 300 | return BROKEN_IMAGE_PLACEHOLDER; | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 305 | * a graphics context. The dimensions are determined from the document. | |
| 306 | * | |
| 307 | * @param svg The SVG xml document. | |
| 308 | * @param scale The scaling factor to apply when transcoding. | |
| 309 | * @return The vector graphic transcoded into a raster image format. | |
| 310 | */ | |
| 311 | @SuppressWarnings( "unused" ) | |
| 312 | public static BufferedImage rasterizeImage( | |
| 313 | final String svg, final double scale ) | |
| 314 | throws ParseException, TranscoderException { | |
| 315 | final var document = toDocument( svg ); | |
| 316 | final var root = document.getDocumentElement(); | |
| 317 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 318 | final var height = root.getAttribute( SVG_HEIGHT_ATTRIBUTE ); | |
| 319 | final var w = INT_FORMAT.parse( width ).intValue() * scale; | |
| 320 | final var h = INT_FORMAT.parse( height ).intValue() * scale; | |
| 321 | ||
| 322 | return rasterize( svg, w, h ); | |
| 323 | } | |
| 324 | ||
| 325 | /** | |
| 326 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 327 | * a graphics context. | |
| 328 | * | |
| 329 | * @param svg The SVG xml document. | |
| 330 | * @param w Scale the image width to this size (aspect ratio is | |
| 331 | * maintained). | |
| 332 | * @return The vector graphic transcoded into a raster image format. | |
| 333 | */ | |
| 334 | public static BufferedImage rasterizeImage( final String svg, final int w ) | |
| 335 | throws TranscoderException { | |
| 336 | return rasterize( toDocument( svg ), w ); | |
| 337 | } | |
| 338 | ||
| 339 | /** | |
| 340 | * Given a document object model (DOM) {@link Element}, this will convert that | |
| 341 | * element to a string. | |
| 342 | * | |
| 343 | * @param root The DOM node to convert to a string. | |
| 344 | * @return The DOM node as an escaped, plain text string. | |
| 345 | */ | |
| 346 | public static String toSvg( final Element root ) { | |
| 347 | try { | |
| 348 | return transform( root ).replaceAll( "xmlns=\"\" ", "" ); | |
| 349 | } catch( final Exception ex ) { | |
| 350 | clue( ex ); | |
| 351 | } | |
| 352 | ||
| 353 | return BROKEN_IMAGE_SVG; | |
| 354 | } | |
| 355 | ||
| 356 | /** | |
| 357 | * Converts an SVG XML string into a new {@link Document} instance. | |
| 358 | * | |
| 359 | * @param xml The XML containing SVG elements. | |
| 360 | * @return The SVG contents parsed into a {@link Document} object model. | |
| 361 | */ | |
| 362 | private static Document toDocument( final String xml ) { | |
| 363 | try( final var reader = new StringReader( xml ) ) { | |
| 364 | return FACTORY_DOM.createSVGDocument( | |
| 365 | "http://www.w3.org/2000/svg", reader ); | |
| 366 | } catch( final Exception ex ) { | |
| 367 | throw new IllegalArgumentException( ex ); | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | /** | |
| 372 | * Creates a rasterized image of the given source document. | |
| 373 | * | |
| 374 | * @param input The source document to transcode. | |
| 375 | * @param hintKey Transcoding hint key. | |
| 376 | * @param hintValue Transcoding hint value. | |
| 377 | * @return A new {@link BufferedImageTranscoder} instance with the given | |
| 378 | * transcoding hint applied. | |
| 379 | */ | |
| 380 | private static BufferedImage rasterize( | |
| 381 | final TranscoderInput input, final Key hintKey, final float hintValue ) | |
| 382 | throws TranscoderException { | |
| 383 | final var hints = new HashMap<Key, Object>(); | |
| 384 | hints.put( hintKey, hintValue ); | |
| 385 | ||
| 386 | return rasterize( input, hints ); | |
| 387 | } | |
| 388 | ||
| 389 | private static BufferedImage rasterize( | |
| 390 | final String svg, final double w, final double h ) | |
| 391 | throws TranscoderException { | |
| 392 | final var hints = new HashMap<Key, Object>(); | |
| 393 | hints.put( KEY_WIDTH, (float) w ); | |
| 394 | hints.put( KEY_HEIGHT, (float) h ); | |
| 395 | ||
| 396 | return rasterize( new TranscoderInput( toDocument( svg ) ), hints ); | |
| 397 | } | |
| 398 | ||
| 399 | public static BufferedImage rasterize( | |
| 400 | final TranscoderInput input, | |
| 401 | final Map<TranscodingHints.Key, Object> hints ) throws TranscoderException { | |
| 402 | final var transcoder = new BufferedImageTranscoder(); | |
| 403 | ||
| 404 | for( final var hint : hints.entrySet() ) { | |
| 405 | transcoder.addTranscodingHint( hint.getKey(), hint.getValue() ); | |
| 406 | } | |
| 407 | ||
| 408 | transcoder.setErrorHandler( sErrorHandler ); | |
| 409 | transcoder.transcode( input, null ); | |
| 410 | ||
| 411 | return transcoder.getImage(); | |
| 412 | } | |
| 413 | ||
| 414 | /** | |
| 415 | * Returns either the given element's SVG document width, or the display | |
| 416 | * width, whichever is smaller. | |
| 417 | * | |
| 418 | * @param root The SVG document's root node. | |
| 419 | * @param width The display width (e.g., rendering canvas width). | |
| 420 | * @return The lower value of the document's width or the display width. | |
| 421 | */ | |
| 422 | @SuppressWarnings( "ConstantValue" ) | |
| 32 | import static io.sf.carte.echosvg.transcoder.SVGAbstractTranscoder.*; | |
| 33 | import static io.sf.carte.echosvg.transcoder.TranscodingHints.Key; | |
| 34 | import static io.sf.carte.echosvg.util.SVGConstants.SVG_HEIGHT_ATTRIBUTE; | |
| 35 | import static io.sf.carte.echosvg.util.SVGConstants.SVG_WIDTH_ATTRIBUTE; | |
| 36 | import static java.awt.image.BufferedImage.TYPE_INT_RGB; | |
| 37 | import static java.text.NumberFormat.getIntegerInstance; | |
| 38 | ||
| 39 | /** | |
| 40 | * Responsible for converting SVG images into rasterized PNG images. | |
| 41 | */ | |
| 42 | public final class SvgRasterizer { | |
| 43 | ||
| 44 | /** | |
| 45 | * Prevent rudely barfing stack traces to the console. | |
| 46 | */ | |
| 47 | private static final class SvgErrorHandler implements ErrorHandler { | |
| 48 | @Override | |
| 49 | public void error( final TranscoderException ex ) { | |
| 50 | clue( ex ); | |
| 51 | } | |
| 52 | ||
| 53 | @Override | |
| 54 | public void fatalError( final TranscoderException ex ) { | |
| 55 | clue( ex ); | |
| 56 | } | |
| 57 | ||
| 58 | @Override | |
| 59 | public void warning( final TranscoderException ex ) { | |
| 60 | clue( ex ); | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | private static final UserAgent USER_AGENT = new UserAgentAdapter(); | |
| 65 | private static final BridgeContext BRIDGE_CONTEXT = new BridgeContext( | |
| 66 | USER_AGENT, new DocumentLoader( USER_AGENT ) | |
| 67 | ); | |
| 68 | private static final ErrorHandler sErrorHandler = new SvgErrorHandler(); | |
| 69 | ||
| 70 | private static final SAXSVGDocumentFactory FACTORY_DOM = | |
| 71 | new SAXSVGDocumentFactory(); | |
| 72 | ||
| 73 | private static final NumberFormat INT_FORMAT = getIntegerInstance(); | |
| 74 | ||
| 75 | public static final BufferedImage BROKEN_IMAGE_PLACEHOLDER; | |
| 76 | ||
| 77 | /** | |
| 78 | * A FontAwesome camera icon, cleft asunder. | |
| 79 | */ | |
| 80 | public static final String BROKEN_IMAGE_SVG = | |
| 81 | "<svg height='19pt' viewBox='0 0 25 19' width='25pt' xmlns='http://www" + | |
| 82 | ".w3.org/2000/svg'><g fill='#454545'><path d='m8.042969 11.085938c" + | |
| 83 | ".332031 1.445312 1.660156 2.503906 3.214843 2.558593zm0 0'/><path " + | |
| 84 | "d='m6.792969 9.621094-.300781.226562.242187.195313c.015625-.144531" + | |
| 85 | ".03125-.28125.058594-.421875zm0 0'/><path d='m10.597656.949219-2" + | |
| 86 | ".511718.207031c-.777344.066406-1.429688.582031-1.636719 1.292969l-" + | |
| 87 | ".367188 1.253906-3.414062.28125c-1.027344.085937-1.792969.949219-1" + | |
| 88 | ".699219 1.925781l.976562 10.621094c.089844.976562.996094 1.699219 " + | |
| 89 | "2.023438 1.613281l11.710938-.972656-3.117188-2.484375c-.246094" + | |
| 90 | ".0625-.5.109375-.765625.132812-2.566406.210938-4.835937-1.597656-5" + | |
| 91 | ".0625-4.039062-.023437-.25-.019531-.496094 0-.738281l-.242187-" + | |
| 92 | ".195313.300781-.226562c.359375-1.929688 2.039062-3.472656 4" + | |
| 93 | ".191406-3.652344.207031-.015625.414063-.015625.617187-.007812l" + | |
| 94 | ".933594-.707032zm0 0'/><path d='m10.234375 11.070312 2.964844 2" + | |
| 95 | ".820313c.144531.015625.285156.027344.433593.027344 1.890626 0 3" + | |
| 96 | ".429688-1.460938 3.429688-3.257813 0-1.792968-1.539062-3.257812-3" + | |
| 97 | ".429688-3.257812-1.890624 0-3.429687 1.464844-3.429687 3.257812 0 " + | |
| 98 | ".140625.011719.277344.03125.410156zm0 0'/><path d='m14.488281" + | |
| 99 | ".808594 1.117188 4.554687-1.042969.546875c2.25.476563 3.84375 2" + | |
| 100 | ".472656 3.636719 4.714844-.199219 2.191406-2.050781 3.871094-4" + | |
| 101 | ".285157 4.039062l2.609376 2.957032 4.4375.371094c1.03125.085937 1" + | |
| 102 | ".9375-.640626 2.027343-1.617188l.976563-10.617188c.089844-.980468-" + | |
| 103 | ".667969-1.839843-1.699219-1.925781l-3.414063-.285156-.371093-1" + | |
| 104 | ".253906c-.207031-.710938-.859375-1.226563-1.636719-1.289063zm0 " + | |
| 105 | "0'/></g></svg>"; | |
| 106 | ||
| 107 | static { | |
| 108 | // The width and height cannot be embedded in the SVG above because the | |
| 109 | // path element values are relative to the viewBox dimensions. | |
| 110 | final int w = 75; | |
| 111 | final int h = 75; | |
| 112 | BufferedImage image; | |
| 113 | ||
| 114 | try { | |
| 115 | image = rasterizeImage( BROKEN_IMAGE_SVG, w ); | |
| 116 | } catch( final Exception ex ) { | |
| 117 | image = new BufferedImage( w, h, TYPE_INT_RGB ); | |
| 118 | final var graphics = (Graphics2D) image.getGraphics(); | |
| 119 | graphics.setRenderingHints( RENDERING_HINTS ); | |
| 120 | ||
| 121 | final var offset = (int) ((double) w / 4 / Math.PI); | |
| 122 | ||
| 123 | // Fall back to a (\) symbol. | |
| 124 | graphics.setColor( new Color( 204, 204, 204 ) ); | |
| 125 | graphics.fillRect( 0, 0, w, h ); | |
| 126 | graphics.setColor( new Color( 255, 204, 204 ) ); | |
| 127 | graphics.setStroke( new BasicStroke( 4 ) ); | |
| 128 | graphics.drawOval( w / 4, h / 4, w / 2, h / 2 ); | |
| 129 | graphics.drawLine( | |
| 130 | w / 4 + offset, | |
| 131 | h / 4 + offset, | |
| 132 | w / 2 + w / 4 - offset, | |
| 133 | h / 2 + h / 4 - offset | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | BROKEN_IMAGE_PLACEHOLDER = image; | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * Responsible for creating a new {@link ImageRenderer} implementation that | |
| 142 | * can render a DOM as an SVG image. | |
| 143 | */ | |
| 144 | private static class BufferedImageTranscoder extends ImageTranscoder { | |
| 145 | private BufferedImage mImage; | |
| 146 | ||
| 147 | /** | |
| 148 | * Prevent barfing a stack trace when the transcoder encounters problems | |
| 149 | * parsing SVG contents. | |
| 150 | */ | |
| 151 | @Override | |
| 152 | protected UserAgent createUserAgent() { | |
| 153 | return new SVGAbstractTranscoderUserAgent() { | |
| 154 | @Override | |
| 155 | public void displayError( final Exception ex ) { | |
| 156 | clue( ex ); | |
| 157 | } | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | @Override | |
| 162 | public BufferedImage createImage( final int w, final int h ) { | |
| 163 | return new BufferedImage( w, h, BufferedImage.TYPE_INT_ARGB ); | |
| 164 | } | |
| 165 | ||
| 166 | @Override | |
| 167 | public void writeImage( | |
| 168 | final BufferedImage image, final TranscoderOutput output ) { | |
| 169 | mImage = image; | |
| 170 | } | |
| 171 | ||
| 172 | public BufferedImage getImage() { | |
| 173 | return mImage; | |
| 174 | } | |
| 175 | ||
| 176 | @Override | |
| 177 | protected ImageRenderer createRenderer() { | |
| 178 | final ImageRenderer renderer = super.createRenderer(); | |
| 179 | final RenderingHints hints = renderer.getRenderingHints(); | |
| 180 | hints.putAll( RENDERING_HINTS ); | |
| 181 | renderer.setRenderingHints( hints ); | |
| 182 | ||
| 183 | return renderer; | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Rasterizes the given SVG input stream into an image. | |
| 189 | * | |
| 190 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 191 | * @return The given input stream converted to a rasterized image. | |
| 192 | */ | |
| 193 | public static BufferedImage rasterize( final String svg ) | |
| 194 | throws TranscoderException, ParseException { | |
| 195 | return rasterize( toDocument( svg ) ); | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Rasterizes the given SVG input stream into an image at 96 DPI. | |
| 200 | * | |
| 201 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 202 | * @return The given input stream converted to a rasterized image. | |
| 203 | */ | |
| 204 | public static BufferedImage rasterize( final InputStream svg ) | |
| 205 | throws TranscoderException { | |
| 206 | return rasterize( svg, 96 ); | |
| 207 | } | |
| 208 | ||
| 209 | /** | |
| 210 | * Rasterizes the given SVG input stream into an image. | |
| 211 | * | |
| 212 | * @param svg The SVG data to rasterize, must be closed by caller. | |
| 213 | * @param dpi Resolution to use when rasterizing (default is 96 DPI). | |
| 214 | * @return The given input stream converted to a rasterized image at the | |
| 215 | * given resolution. | |
| 216 | */ | |
| 217 | public static BufferedImage rasterize( | |
| 218 | final InputStream svg, final float dpi ) throws TranscoderException { | |
| 219 | return rasterize( | |
| 220 | new TranscoderInput( svg ), | |
| 221 | KEY_RESOLUTION_DPI, | |
| 222 | 1f / dpi * 25.4f | |
| 223 | ); | |
| 224 | } | |
| 225 | ||
| 226 | /** | |
| 227 | * Rasterizes the given document into an image. | |
| 228 | * | |
| 229 | * @param svg The SVG {@link Document} to rasterize. | |
| 230 | * @param width The rasterized image's width (in pixels). | |
| 231 | * @return The rasterized image. | |
| 232 | */ | |
| 233 | public static BufferedImage rasterize( | |
| 234 | final Document svg, final int width ) throws TranscoderException { | |
| 235 | return rasterize( | |
| 236 | new TranscoderInput( svg ), | |
| 237 | KEY_WIDTH, | |
| 238 | fit( svg.getDocumentElement(), width ) | |
| 239 | ); | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * Rasterizes the given vector graphic file using the width dimension | |
| 244 | * specified by the document's width attribute. | |
| 245 | * | |
| 246 | * @param document The {@link Document} containing a vector graphic. | |
| 247 | * @return A rasterized image as an instance of {@link BufferedImage}, or | |
| 248 | * {@link #BROKEN_IMAGE_PLACEHOLDER} if the graphic could not be rasterized. | |
| 249 | */ | |
| 250 | public static BufferedImage rasterize( final Document document ) | |
| 251 | throws ParseException, TranscoderException { | |
| 252 | final var root = document.getDocumentElement(); | |
| 253 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 254 | ||
| 255 | return rasterize( document, INT_FORMAT.parse( width ).intValue() ); | |
| 256 | } | |
| 257 | ||
| 258 | /** | |
| 259 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 260 | * happens, a broken image icon is returned instead. | |
| 261 | * | |
| 262 | * @param path The {@link Path} to a vector graphic file. | |
| 263 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 264 | * maintained. | |
| 265 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 266 | */ | |
| 267 | public static BufferedImage rasterize( final Path path, final int width ) { | |
| 268 | return rasterize( path.toUri(), width ); | |
| 269 | } | |
| 270 | ||
| 271 | /** | |
| 272 | * Rasterizes the vector graphic file at the given URI. If any exception | |
| 273 | * happens, a broken image icon is returned instead. | |
| 274 | * | |
| 275 | * @param uri The URI to a vector graphic file, which must include the | |
| 276 | * protocol scheme (such as <code>file://</code> or | |
| 277 | * <code>https://</code>). | |
| 278 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 279 | * maintained. | |
| 280 | * @return A rasterized image as an instance of {@link BufferedImage}. | |
| 281 | */ | |
| 282 | public static BufferedImage rasterize( final String uri, final int width ) { | |
| 283 | return rasterize( new File( uri ).toURI(), width ); | |
| 284 | } | |
| 285 | ||
| 286 | /** | |
| 287 | * Converts an SVG drawing into a rasterized image that can be drawn on | |
| 288 | * a graphics context. | |
| 289 | * | |
| 290 | * @param uri The path to the image (can be web address). | |
| 291 | * @param width Scale the image to the given width (px); aspect ratio is | |
| 292 | * maintained. | |
| 293 | * @return The vector graphic transcoded into a raster image format. | |
| 294 | */ | |
| 295 | public static BufferedImage rasterize( final URI uri, final int width ) { | |
| 296 | try { | |
| 297 | return rasterize( FACTORY_DOM.createDocument( uri.toString() ), width ); | |
| 298 | } catch( final Exception ex ) { | |
| 299 | clue( ex ); | |
| 300 | } | |
| 301 | ||
| 302 | return BROKEN_IMAGE_PLACEHOLDER; | |
| 303 | } | |
| 304 | ||
| 305 | /** | |
| 306 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 307 | * a graphics context. The dimensions are determined from the document. | |
| 308 | * | |
| 309 | * @param svg The SVG xml document. | |
| 310 | * @param scale The scaling factor to apply when transcoding. | |
| 311 | * @return The vector graphic transcoded into a raster image format. | |
| 312 | */ | |
| 313 | @SuppressWarnings("unused") | |
| 314 | public static BufferedImage rasterizeImage( | |
| 315 | final String svg, final double scale ) | |
| 316 | throws ParseException, TranscoderException { | |
| 317 | final var document = toDocument( svg ); | |
| 318 | final var root = document.getDocumentElement(); | |
| 319 | final var width = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); | |
| 320 | final var height = root.getAttribute( SVG_HEIGHT_ATTRIBUTE ); | |
| 321 | final var w = INT_FORMAT.parse( width ).intValue() * scale; | |
| 322 | final var h = INT_FORMAT.parse( height ).intValue() * scale; | |
| 323 | ||
| 324 | return rasterize( svg, w, h ); | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * Converts an SVG string into a rasterized image that can be drawn on | |
| 329 | * a graphics context. | |
| 330 | * | |
| 331 | * @param svg The SVG xml document. | |
| 332 | * @param w Scale the image width to this size (aspect ratio is | |
| 333 | * maintained). | |
| 334 | * @return The vector graphic transcoded into a raster image format. | |
| 335 | */ | |
| 336 | public static BufferedImage rasterizeImage( final String svg, final int w ) | |
| 337 | throws TranscoderException { | |
| 338 | return rasterize( toDocument( svg ), w ); | |
| 339 | } | |
| 340 | ||
| 341 | /** | |
| 342 | * Given a document object model (DOM) {@link Element}, this will convert that | |
| 343 | * element to a string. | |
| 344 | * | |
| 345 | * @param root The DOM node to convert to a string. | |
| 346 | * @return The DOM node as an escaped, plain text string. | |
| 347 | */ | |
| 348 | public static String toSvg( final Element root ) { | |
| 349 | try { | |
| 350 | return transform( root ).replaceAll( "xmlns=\"\" ", "" ); | |
| 351 | } catch( final Exception ex ) { | |
| 352 | clue( ex ); | |
| 353 | } | |
| 354 | ||
| 355 | return BROKEN_IMAGE_SVG; | |
| 356 | } | |
| 357 | ||
| 358 | /** | |
| 359 | * Converts an SVG XML string into a new {@link Document} instance. | |
| 360 | * | |
| 361 | * @param xml The XML containing SVG elements. | |
| 362 | * @return The SVG contents parsed into a {@link Document} object model. | |
| 363 | */ | |
| 364 | private static Document toDocument( final String xml ) { | |
| 365 | try( final var reader = new StringReader( xml ) ) { | |
| 366 | return FACTORY_DOM.createSVGDocument( | |
| 367 | "http://www.w3.org/2000/svg", reader ); | |
| 368 | } catch( final Exception ex ) { | |
| 369 | throw new IllegalArgumentException( ex ); | |
| 370 | } | |
| 371 | } | |
| 372 | ||
| 373 | /** | |
| 374 | * Creates a rasterized image of the given source document. | |
| 375 | * | |
| 376 | * @param input The source document to transcode. | |
| 377 | * @param hintKey Transcoding hint key. | |
| 378 | * @param hintValue Transcoding hint value. | |
| 379 | * @return A new {@link BufferedImageTranscoder} instance with the given | |
| 380 | * transcoding hint applied. | |
| 381 | */ | |
| 382 | private static BufferedImage rasterize( | |
| 383 | final TranscoderInput input, final Key hintKey, final float hintValue ) | |
| 384 | throws TranscoderException { | |
| 385 | final var hints = new HashMap<Key, Object>(); | |
| 386 | hints.put( hintKey, hintValue ); | |
| 387 | ||
| 388 | return rasterize( input, hints ); | |
| 389 | } | |
| 390 | ||
| 391 | private static BufferedImage rasterize( | |
| 392 | final String svg, final double w, final double h ) | |
| 393 | throws TranscoderException { | |
| 394 | final var hints = new HashMap<Key, Object>(); | |
| 395 | hints.put( KEY_WIDTH, (float) w ); | |
| 396 | hints.put( KEY_HEIGHT, (float) h ); | |
| 397 | ||
| 398 | return rasterize( new TranscoderInput( toDocument( svg ) ), hints ); | |
| 399 | } | |
| 400 | ||
| 401 | public static BufferedImage rasterize( | |
| 402 | final TranscoderInput input, | |
| 403 | final Map<TranscodingHints.Key, Object> hints ) throws TranscoderException { | |
| 404 | final var transcoder = new BufferedImageTranscoder(); | |
| 405 | ||
| 406 | for( final var hint : hints.entrySet() ) { | |
| 407 | transcoder.addTranscodingHint( hint.getKey(), hint.getValue() ); | |
| 408 | } | |
| 409 | ||
| 410 | transcoder.setErrorHandler( sErrorHandler ); | |
| 411 | transcoder.transcode( input, null ); | |
| 412 | ||
| 413 | return transcoder.getImage(); | |
| 414 | } | |
| 415 | ||
| 416 | /** | |
| 417 | * Returns either the given element's SVG document width, or the display | |
| 418 | * width, whichever is smaller. | |
| 419 | * | |
| 420 | * @param root The SVG document's root node. | |
| 421 | * @param width The display width (e.g., rendering canvas width). | |
| 422 | * @return The lower value of the document's width or the display width. | |
| 423 | */ | |
| 424 | @SuppressWarnings("ConstantValue") | |
| 423 | 425 | private static float fit( final Element root, final int width ) { |
| 424 | 426 | final var w = root.getAttribute( SVG_WIDTH_ATTRIBUTE ); |
| 90 | 90 | private Path mSourcePath; |
| 91 | 91 | private Path mTargetPath; |
| 92 | private ExportFormat mExportFormat; | |
| 93 | private Supplier<Boolean> mConcatenate = () -> true; | |
| 94 | private Supplier<String> mChapters = () -> ""; | |
| 95 | ||
| 96 | private Supplier<Path> mThemeDir = USER_DIRECTORY::toPath; | |
| 97 | private Supplier<Locale> mLocale = () -> Locale.ENGLISH; | |
| 98 | ||
| 99 | private Supplier<Map<String, String>> mDefinitions = HashMap::new; | |
| 100 | private Supplier<Map<String, String>> mMetadata = HashMap::new; | |
| 101 | private Supplier<Caret> mCaret = () -> Caret.builder().build(); | |
| 102 | ||
| 103 | private Supplier<Path> mImageDir = USER_DIRECTORY::toPath; | |
| 104 | private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME; | |
| 105 | private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT; | |
| 106 | private Supplier<Path> mCacheDir = USER_CACHE_DIR::toPath; | |
| 107 | private Supplier<Path> mFontDir = () -> getFontDirectory().toPath(); | |
| 108 | ||
| 109 | private Supplier<String> mModesEnabled = () -> ""; | |
| 110 | ||
| 111 | private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT; | |
| 112 | private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT; | |
| 113 | ||
| 114 | private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath; | |
| 115 | private Supplier<String> mRScript = () -> ""; | |
| 116 | ||
| 117 | private Supplier<Boolean> mCurlQuotes = () -> true; | |
| 118 | private Supplier<Boolean> mAutoRemove = () -> true; | |
| 119 | ||
| 120 | public void setSourcePath( final Path sourcePath ) { | |
| 121 | assert sourcePath != null; | |
| 122 | mSourcePath = sourcePath; | |
| 123 | } | |
| 124 | ||
| 125 | public void setTargetPath( final Path outputPath ) { | |
| 126 | assert outputPath != null; | |
| 127 | mTargetPath = outputPath; | |
| 128 | } | |
| 129 | ||
| 130 | public void setThemeDir( final Supplier<Path> themeDir ) { | |
| 131 | assert themeDir != null; | |
| 132 | mThemeDir = themeDir; | |
| 133 | } | |
| 134 | ||
| 135 | public void setCacheDir( final Supplier<File> cacheDir ) { | |
| 136 | assert cacheDir != null; | |
| 137 | ||
| 138 | mCacheDir = () -> { | |
| 139 | final var dir = cacheDir.get(); | |
| 140 | ||
| 141 | return (dir == null ? toFile( USER_DATA_DIR ) : dir).toPath(); | |
| 142 | }; | |
| 143 | } | |
| 144 | ||
| 145 | public void setImageDir( final Supplier<File> imageDir ) { | |
| 146 | assert imageDir != null; | |
| 147 | ||
| 148 | mImageDir = () -> { | |
| 149 | final var dir = imageDir.get(); | |
| 150 | ||
| 151 | return (dir == null ? USER_DIRECTORY : dir).toPath(); | |
| 152 | }; | |
| 153 | } | |
| 154 | ||
| 155 | public void setImageOrder( final Supplier<String> imageOrder ) { | |
| 156 | assert imageOrder != null; | |
| 157 | mImageOrder = imageOrder; | |
| 158 | } | |
| 159 | ||
| 160 | public void setImageServer( final Supplier<String> imageServer ) { | |
| 161 | assert imageServer != null; | |
| 162 | mImageServer = imageServer; | |
| 163 | } | |
| 164 | ||
| 165 | public void setFontDir( final Supplier<File> fontDir ) { | |
| 166 | assert fontDir != null; | |
| 167 | ||
| 168 | mFontDir = () -> { | |
| 169 | final var dir = fontDir.get(); | |
| 170 | ||
| 171 | return (dir == null ? USER_DIRECTORY : dir).toPath(); | |
| 172 | }; | |
| 173 | } | |
| 174 | ||
| 175 | public void setModesEnabled( final Supplier<String> modesEnabled ) { | |
| 176 | assert modesEnabled != null; | |
| 177 | mModesEnabled = modesEnabled; | |
| 178 | } | |
| 179 | ||
| 180 | public void setExportFormat( final ExportFormat exportFormat ) { | |
| 181 | assert exportFormat != null; | |
| 182 | mExportFormat = exportFormat; | |
| 183 | } | |
| 184 | ||
| 185 | public void setConcatenate( final Supplier<Boolean> concatenate ) { | |
| 186 | mConcatenate = concatenate; | |
| 187 | } | |
| 188 | ||
| 189 | public void setChapters( final Supplier<String> chapters ) { | |
| 190 | mChapters = chapters; | |
| 191 | } | |
| 192 | ||
| 193 | public void setLocale( final Supplier<Locale> locale ) { | |
| 194 | assert locale != null; | |
| 195 | mLocale = locale; | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Sets the list of fully interpolated key-value pairs to use when | |
| 200 | * substituting variable names back into the document as variable values. | |
| 201 | * This uses a {@link Callable} reference so that GUI and command-line | |
| 202 | * usage can insert their respective behaviours. That is, this method | |
| 203 | * prevents coupling the GUI to the CLI. | |
| 204 | * | |
| 205 | * @param supplier Defines how to retrieve the definitions. | |
| 206 | */ | |
| 207 | public void setDefinitions( final Supplier<Map<String, String>> supplier ) { | |
| 208 | assert supplier != null; | |
| 209 | mDefinitions = supplier; | |
| 210 | } | |
| 211 | ||
| 212 | /** | |
| 213 | * Sets metadata to use in the document header. These are made available | |
| 214 | * to the typesetting engine as {@code \documentvariable} values. | |
| 215 | * | |
| 216 | * @param metadata The key/value pairs to publish as document metadata. | |
| 217 | */ | |
| 218 | public void setMetadata( final Supplier<Map<String, String>> metadata ) { | |
| 219 | assert metadata != null; | |
| 220 | mMetadata = metadata.get() == null ? HashMap::new : metadata; | |
| 221 | } | |
| 222 | ||
| 223 | /** | |
| 224 | * Sets document variables to use when building the document. These | |
| 225 | * variables will override existing key/value pairs, or be added as | |
| 226 | * new key/value pairs if not already defined. This allows users to | |
| 227 | * inject variables into the document from the command-line, allowing | |
| 228 | * for dynamic assignment of in-text values when building documents. | |
| 229 | * | |
| 230 | * @param overrides The key/value pairs to add (or override) as variables. | |
| 231 | */ | |
| 232 | public void setOverrides( final Supplier<Map<String, String>> overrides ) { | |
| 233 | assert overrides != null; | |
| 234 | assert mDefinitions != null; | |
| 235 | assert mDefinitions.get() != null; | |
| 236 | ||
| 237 | final var map = overrides.get(); | |
| 238 | ||
| 239 | if( map != null ) { | |
| 240 | mDefinitions.get().putAll( map ); | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * Sets the source for deriving the {@link Caret}. Typically, this is | |
| 246 | * the text editor that has focus. | |
| 247 | * | |
| 248 | * @param caret The source for the currently active caret. | |
| 249 | */ | |
| 250 | public void setCaret( final Supplier<Caret> caret ) { | |
| 251 | assert caret != null; | |
| 252 | mCaret = caret; | |
| 253 | } | |
| 254 | ||
| 255 | public void setSigilBegan( final Supplier<String> sigilBegan ) { | |
| 256 | assert sigilBegan != null; | |
| 257 | mSigilBegan = sigilBegan; | |
| 258 | } | |
| 259 | ||
| 260 | public void setSigilEnded( final Supplier<String> sigilEnded ) { | |
| 261 | assert sigilEnded != null; | |
| 262 | mSigilEnded = sigilEnded; | |
| 263 | } | |
| 264 | ||
| 265 | public void setRWorkingDir( final Supplier<Path> rWorkingDir ) { | |
| 266 | assert rWorkingDir != null; | |
| 267 | mRWorkingDir = rWorkingDir; | |
| 268 | } | |
| 269 | ||
| 270 | public void setRScript( final Supplier<String> rScript ) { | |
| 271 | assert rScript != null; | |
| 272 | mRScript = rScript; | |
| 273 | } | |
| 274 | ||
| 275 | public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) { | |
| 276 | assert curlQuotes != null; | |
| 277 | mCurlQuotes = curlQuotes; | |
| 278 | } | |
| 279 | ||
| 280 | public void setAutoRemove( final Supplier<Boolean> autoRemove ) { | |
| 281 | assert autoRemove != null; | |
| 282 | mAutoRemove = autoRemove; | |
| 283 | } | |
| 284 | ||
| 285 | private boolean isExportFormat( final ExportFormat format ) { | |
| 286 | return mExportFormat == format; | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | public static GenericBuilder<Mutator, ProcessorContext> builder() { | |
| 291 | return GenericBuilder.of( Mutator::new, ProcessorContext::new ); | |
| 292 | } | |
| 293 | ||
| 294 | /** | |
| 295 | * Creates a new context for use by the {@link ProcessorFactory} when | |
| 296 | * instantiating new {@link Processor} instances. Although all the | |
| 297 | * parameters are required, not all {@link Processor} instances will use | |
| 298 | * all parameters. | |
| 299 | */ | |
| 300 | private ProcessorContext( final Mutator mutator ) { | |
| 301 | assert mutator != null; | |
| 302 | ||
| 303 | mMutator = mutator; | |
| 304 | } | |
| 305 | ||
| 306 | public Path getSourcePath() { | |
| 307 | return mMutator.mSourcePath; | |
| 308 | } | |
| 309 | ||
| 310 | /** | |
| 311 | * Answers what type of input document is to be processed. | |
| 312 | * | |
| 313 | * @return The input document's {@link MediaType}. | |
| 314 | */ | |
| 315 | public MediaType getSourceType() { | |
| 316 | return MediaTypeExtension.fromPath( mMutator.mSourcePath ); | |
| 317 | } | |
| 318 | ||
| 319 | /** | |
| 320 | * Fully qualified file name to use when exporting (e.g., document.pdf). | |
| 321 | * | |
| 322 | * @return Full path to a file name. | |
| 323 | */ | |
| 324 | public Path getTargetPath() { | |
| 325 | return mMutator.mTargetPath; | |
| 326 | } | |
| 327 | ||
| 328 | public ExportFormat getExportFormat() { | |
| 329 | return mMutator.mExportFormat; | |
| 330 | } | |
| 331 | ||
| 332 | public Locale getLocale() { | |
| 333 | return mMutator.mLocale.get(); | |
| 334 | } | |
| 335 | ||
| 336 | /** | |
| 337 | * Returns the variable map of definitions, without interpolation. | |
| 338 | * | |
| 339 | * @return A map to help dereference variables. | |
| 340 | */ | |
| 341 | public Map<String, String> getDefinitions() { | |
| 342 | return mMutator.mDefinitions.get(); | |
| 343 | } | |
| 344 | ||
| 345 | /** | |
| 346 | * Returns the variable map of definitions, with interpolation. | |
| 347 | * | |
| 348 | * @return A map to help dereference variables. | |
| 349 | */ | |
| 350 | public InterpolatingMap getInterpolatedDefinitions() { | |
| 351 | return new InterpolatingMap( | |
| 352 | createDefinitionKeyOperator(), getDefinitions() | |
| 353 | ).interpolate(); | |
| 354 | } | |
| 355 | ||
| 356 | public Map<String, String> getMetadata() { | |
| 357 | return mMutator.mMetadata.get(); | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * Returns the current caret position in the document being edited and is | |
| 362 | * always up-to-date. | |
| 363 | * | |
| 364 | * @return Caret position in the document. | |
| 365 | */ | |
| 366 | public Supplier<Caret> getCaret() { | |
| 367 | return mMutator.mCaret; | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * Returns the directory that contains the file being edited. When | |
| 372 | * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is | |
| 373 | * {@code null}. This will get absolute path to the file before trying to | |
| 374 | * get te parent path, which should always be a valid path. In the unlikely | |
| 375 | * event that the base path cannot be determined by the path alone, the | |
| 376 | * default user directory is returned. This is necessary for the creation | |
| 377 | * of new files. | |
| 378 | * | |
| 379 | * @return Path to the directory containing a file being edited, or the | |
| 380 | * default user directory if the base path cannot be determined. | |
| 381 | */ | |
| 382 | public Path getBaseDir() { | |
| 383 | final var path = getSourcePath().toAbsolutePath().getParent(); | |
| 384 | return path == null ? DEFAULT_DIRECTORY : path; | |
| 385 | } | |
| 386 | ||
| 387 | FileType getSourceFileType() { | |
| 388 | return lookup( getSourcePath() ); | |
| 389 | } | |
| 390 | ||
| 391 | public Path getThemeDir() { | |
| 392 | return mMutator.mThemeDir.get(); | |
| 393 | } | |
| 394 | ||
| 395 | public Path getImageDir() { | |
| 396 | return mMutator.mImageDir.get(); | |
| 397 | } | |
| 398 | ||
| 399 | public Path getCacheDir() { | |
| 400 | return mMutator.mCacheDir.get(); | |
| 401 | } | |
| 402 | ||
| 403 | public Iterable<String> getImageOrder() { | |
| 404 | assert mMutator.mImageOrder != null; | |
| 405 | ||
| 406 | final var order = mMutator.mImageOrder.get(); | |
| 407 | final var token = order.contains( "," ) ? ',' : ' '; | |
| 408 | ||
| 409 | return Splitter.on( token ).split( token + order ); | |
| 410 | } | |
| 411 | ||
| 412 | public String getImageServer() { | |
| 413 | return mMutator.mImageServer.get(); | |
| 414 | } | |
| 415 | ||
| 416 | public Path getFontDir() { | |
| 417 | return mMutator.mFontDir.get(); | |
| 418 | } | |
| 419 | ||
| 420 | public String getModesEnabled() { | |
| 421 | // Force the processor to select particular sigils. | |
| 422 | final var processor = new VariableProcessor( IDENTITY, this ); | |
| 423 | final var needles = processor.getDefinitions(); | |
| 424 | final var haystack = sanitize( mMutator.mModesEnabled.get() ); | |
| 425 | ||
| 426 | return needles.containsKey( haystack ) | |
| 427 | ? replace( haystack, needles ) | |
| 428 | : processor.hasSigils( haystack ) | |
| 429 | ? "" | |
| 430 | : haystack; | |
| 431 | } | |
| 432 | ||
| 433 | public boolean getAutoRemove() { | |
| 434 | return mMutator.mAutoRemove.get(); | |
| 435 | } | |
| 436 | ||
| 437 | public Path getRWorkingDir() { | |
| 438 | return mMutator.mRWorkingDir.get(); | |
| 439 | } | |
| 440 | ||
| 441 | public String getRScript() { | |
| 442 | return mMutator.mRScript.get(); | |
| 443 | } | |
| 444 | ||
| 445 | public boolean getCurlQuotes() { | |
| 446 | return mMutator.mCurlQuotes.get(); | |
| 92 | private String mHtmlHead = ""; | |
| 93 | private String mHtmlFoot = ""; | |
| 94 | private ExportFormat mExportFormat; | |
| 95 | private Supplier<Boolean> mConcatenate = () -> true; | |
| 96 | private Supplier<String> mChapters = () -> ""; | |
| 97 | ||
| 98 | private Supplier<Path> mThemeDir = USER_DIRECTORY::toPath; | |
| 99 | private Supplier<Locale> mLocale = () -> Locale.ENGLISH; | |
| 100 | ||
| 101 | private Supplier<Map<String, String>> mDefinitions = HashMap::new; | |
| 102 | private Supplier<Map<String, String>> mMetadata = HashMap::new; | |
| 103 | private Supplier<Caret> mCaret = () -> Caret.builder().build(); | |
| 104 | ||
| 105 | private Supplier<Path> mImageDir = USER_DIRECTORY::toPath; | |
| 106 | private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME; | |
| 107 | private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT; | |
| 108 | private Supplier<Path> mCacheDir = USER_CACHE_DIR::toPath; | |
| 109 | private Supplier<Path> mFontDir = () -> getFontDirectory().toPath(); | |
| 110 | ||
| 111 | private Supplier<String> mModesEnabled = () -> ""; | |
| 112 | ||
| 113 | private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT; | |
| 114 | private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT; | |
| 115 | ||
| 116 | private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath; | |
| 117 | private Supplier<String> mRScript = () -> ""; | |
| 118 | ||
| 119 | private Supplier<String> mCurlQuotes = () -> APOS_DEFAULT; | |
| 120 | private Supplier<Boolean> mAutoRemove = () -> true; | |
| 121 | ||
| 122 | public void setSourcePath( final Path path ) { | |
| 123 | assert path != null; | |
| 124 | mSourcePath = path; | |
| 125 | } | |
| 126 | ||
| 127 | public void setTargetPath( final Path path ) { | |
| 128 | assert path != null; | |
| 129 | mTargetPath = path; | |
| 130 | } | |
| 131 | ||
| 132 | public void setHtmlHead( final String text ) { | |
| 133 | assert text != null; | |
| 134 | mHtmlHead = text; | |
| 135 | } | |
| 136 | ||
| 137 | public void setHtmlFoot( final String text ) { | |
| 138 | assert text != null; | |
| 139 | mHtmlFoot = text; | |
| 140 | } | |
| 141 | ||
| 142 | public void setThemeDir( final Supplier<Path> themeDir ) { | |
| 143 | assert themeDir != null; | |
| 144 | mThemeDir = themeDir; | |
| 145 | } | |
| 146 | ||
| 147 | public void setCacheDir( final Supplier<File> cacheDir ) { | |
| 148 | assert cacheDir != null; | |
| 149 | ||
| 150 | mCacheDir = () -> { | |
| 151 | final var dir = cacheDir.get(); | |
| 152 | ||
| 153 | return (dir == null ? toFile( USER_DATA_DIR ) : dir).toPath(); | |
| 154 | }; | |
| 155 | } | |
| 156 | ||
| 157 | public void setImageDir( final Supplier<File> imageDir ) { | |
| 158 | assert imageDir != null; | |
| 159 | ||
| 160 | mImageDir = () -> { | |
| 161 | final var dir = imageDir.get(); | |
| 162 | ||
| 163 | return (dir == null ? USER_DIRECTORY : dir).toPath(); | |
| 164 | }; | |
| 165 | } | |
| 166 | ||
| 167 | public void setImageOrder( final Supplier<String> imageOrder ) { | |
| 168 | assert imageOrder != null; | |
| 169 | mImageOrder = imageOrder; | |
| 170 | } | |
| 171 | ||
| 172 | public void setImageServer( final Supplier<String> imageServer ) { | |
| 173 | assert imageServer != null; | |
| 174 | mImageServer = imageServer; | |
| 175 | } | |
| 176 | ||
| 177 | public void setFontDir( final Supplier<File> fontDir ) { | |
| 178 | assert fontDir != null; | |
| 179 | ||
| 180 | mFontDir = () -> { | |
| 181 | final var dir = fontDir.get(); | |
| 182 | ||
| 183 | return (dir == null ? USER_DIRECTORY : dir).toPath(); | |
| 184 | }; | |
| 185 | } | |
| 186 | ||
| 187 | public void setModesEnabled( final Supplier<String> modesEnabled ) { | |
| 188 | assert modesEnabled != null; | |
| 189 | mModesEnabled = modesEnabled; | |
| 190 | } | |
| 191 | ||
| 192 | public void setExportFormat( final ExportFormat exportFormat ) { | |
| 193 | assert exportFormat != null; | |
| 194 | mExportFormat = exportFormat; | |
| 195 | } | |
| 196 | ||
| 197 | public void setConcatenate( final Supplier<Boolean> concatenate ) { | |
| 198 | mConcatenate = concatenate; | |
| 199 | } | |
| 200 | ||
| 201 | public void setChapters( final Supplier<String> chapters ) { | |
| 202 | mChapters = chapters; | |
| 203 | } | |
| 204 | ||
| 205 | public void setLocale( final Supplier<Locale> locale ) { | |
| 206 | assert locale != null; | |
| 207 | mLocale = locale; | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * Sets the list of fully interpolated key-value pairs to use when | |
| 212 | * substituting variable names back into the document as variable values. | |
| 213 | * This uses a {@link Callable} reference so that GUI and command-line | |
| 214 | * usage can insert their respective behaviours. That is, this method | |
| 215 | * prevents coupling the GUI to the CLI. | |
| 216 | * | |
| 217 | * @param supplier Defines how to retrieve the definitions. | |
| 218 | */ | |
| 219 | public void setDefinitions( final Supplier<Map<String, String>> supplier ) { | |
| 220 | assert supplier != null; | |
| 221 | mDefinitions = supplier; | |
| 222 | } | |
| 223 | ||
| 224 | /** | |
| 225 | * Sets metadata to use in the document header. These are made available | |
| 226 | * to the typesetting engine as {@code \documentvariable} values. | |
| 227 | * | |
| 228 | * @param metadata The key/value pairs to publish as document metadata. | |
| 229 | */ | |
| 230 | public void setMetadata( final Supplier<Map<String, String>> metadata ) { | |
| 231 | assert metadata != null; | |
| 232 | mMetadata = metadata.get() == null ? HashMap::new : metadata; | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Sets document variables to use when building the document. These | |
| 237 | * variables will override existing key/value pairs, or be added as | |
| 238 | * new key/value pairs if not already defined. This allows users to | |
| 239 | * inject variables into the document from the command-line, allowing | |
| 240 | * for dynamic assignment of in-text values when building documents. | |
| 241 | * | |
| 242 | * @param overrides The key/value pairs to add (or override) as variables. | |
| 243 | */ | |
| 244 | public void setOverrides( final Supplier<Map<String, String>> overrides ) { | |
| 245 | assert overrides != null; | |
| 246 | assert mDefinitions != null; | |
| 247 | assert mDefinitions.get() != null; | |
| 248 | ||
| 249 | final var map = overrides.get(); | |
| 250 | ||
| 251 | if( map != null ) { | |
| 252 | mDefinitions.get().putAll( map ); | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Sets the source for deriving the {@link Caret}. Typically, this is | |
| 258 | * the text editor that has focus. | |
| 259 | * | |
| 260 | * @param caret The source for the currently active caret. | |
| 261 | */ | |
| 262 | public void setCaret( final Supplier<Caret> caret ) { | |
| 263 | assert caret != null; | |
| 264 | mCaret = caret; | |
| 265 | } | |
| 266 | ||
| 267 | public void setSigilBegan( final Supplier<String> sigilBegan ) { | |
| 268 | assert sigilBegan != null; | |
| 269 | mSigilBegan = sigilBegan; | |
| 270 | } | |
| 271 | ||
| 272 | public void setSigilEnded( final Supplier<String> sigilEnded ) { | |
| 273 | assert sigilEnded != null; | |
| 274 | mSigilEnded = sigilEnded; | |
| 275 | } | |
| 276 | ||
| 277 | public void setRWorkingDir( final Supplier<Path> rWorkingDir ) { | |
| 278 | assert rWorkingDir != null; | |
| 279 | mRWorkingDir = rWorkingDir; | |
| 280 | } | |
| 281 | ||
| 282 | public void setRScript( final Supplier<String> rScript ) { | |
| 283 | assert rScript != null; | |
| 284 | mRScript = rScript; | |
| 285 | } | |
| 286 | ||
| 287 | public void setCurlQuotes( final Supplier<String> encoding ) { | |
| 288 | assert encoding != null; | |
| 289 | mCurlQuotes = encoding; | |
| 290 | } | |
| 291 | ||
| 292 | public void setAutoRemove( final Supplier<Boolean> autoRemove ) { | |
| 293 | assert autoRemove != null; | |
| 294 | mAutoRemove = autoRemove; | |
| 295 | } | |
| 296 | ||
| 297 | private boolean isExportFormat( final ExportFormat format ) { | |
| 298 | return mExportFormat == format; | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | public static GenericBuilder<Mutator, ProcessorContext> builder() { | |
| 303 | return GenericBuilder.of( Mutator::new, ProcessorContext::new ); | |
| 304 | } | |
| 305 | ||
| 306 | /** | |
| 307 | * Creates a new context for use by the {@link ProcessorFactory} when | |
| 308 | * instantiating new {@link Processor} instances. Although all the | |
| 309 | * parameters are required, not all {@link Processor} instances will use | |
| 310 | * all parameters. | |
| 311 | */ | |
| 312 | private ProcessorContext( final Mutator mutator ) { | |
| 313 | assert mutator != null; | |
| 314 | ||
| 315 | mMutator = mutator; | |
| 316 | } | |
| 317 | ||
| 318 | public Path getSourcePath() { | |
| 319 | return mMutator.mSourcePath; | |
| 320 | } | |
| 321 | ||
| 322 | /** | |
| 323 | * Answers what type of input document is to be processed. | |
| 324 | * | |
| 325 | * @return The input document's {@link MediaType}. | |
| 326 | */ | |
| 327 | public MediaType getSourceType() { | |
| 328 | return MediaTypeExtension.fromPath( mMutator.mSourcePath ); | |
| 329 | } | |
| 330 | ||
| 331 | /** | |
| 332 | * Fully qualified file name to use when exporting (e.g., document.pdf). | |
| 333 | * | |
| 334 | * @return Full path to a file name. | |
| 335 | */ | |
| 336 | public Path getTargetPath() { | |
| 337 | return mMutator.mTargetPath; | |
| 338 | } | |
| 339 | ||
| 340 | public String getHtmlHead() { | |
| 341 | assert mMutator.mHtmlHead != null; | |
| 342 | ||
| 343 | return mMutator.mHtmlHead; | |
| 344 | } | |
| 345 | ||
| 346 | public String getHtmlFoot() { | |
| 347 | assert mMutator.mHtmlFoot != null; | |
| 348 | ||
| 349 | return mMutator.mHtmlFoot; | |
| 350 | } | |
| 351 | ||
| 352 | public ExportFormat getExportFormat() { | |
| 353 | return mMutator.mExportFormat; | |
| 354 | } | |
| 355 | ||
| 356 | public Locale getLocale() { | |
| 357 | return mMutator.mLocale.get(); | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * Returns the variable map of definitions, without interpolation. | |
| 362 | * | |
| 363 | * @return A map to help dereference variables. | |
| 364 | */ | |
| 365 | public Map<String, String> getDefinitions() { | |
| 366 | return mMutator.mDefinitions.get(); | |
| 367 | } | |
| 368 | ||
| 369 | /** | |
| 370 | * Returns the variable map of definitions, with interpolation. | |
| 371 | * | |
| 372 | * @return A map to help dereference variables. | |
| 373 | */ | |
| 374 | public InterpolatingMap getInterpolatedDefinitions() { | |
| 375 | return new InterpolatingMap( | |
| 376 | createDefinitionKeyOperator(), getDefinitions() | |
| 377 | ).interpolate(); | |
| 378 | } | |
| 379 | ||
| 380 | public Map<String, String> getMetadata() { | |
| 381 | return mMutator.mMetadata.get(); | |
| 382 | } | |
| 383 | ||
| 384 | /** | |
| 385 | * Returns the current caret position in the document being edited and is | |
| 386 | * always up-to-date. | |
| 387 | * | |
| 388 | * @return Caret position in the document. | |
| 389 | */ | |
| 390 | public Supplier<Caret> getCaret() { | |
| 391 | return mMutator.mCaret; | |
| 392 | } | |
| 393 | ||
| 394 | /** | |
| 395 | * Returns the directory that contains the file being edited. When | |
| 396 | * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is | |
| 397 | * {@code null}. This will get absolute path to the file before trying to | |
| 398 | * get te parent path, which should always be a valid path. In the unlikely | |
| 399 | * event that the base path cannot be determined by the path alone, the | |
| 400 | * default user directory is returned. This is necessary for the creation | |
| 401 | * of new files. | |
| 402 | * | |
| 403 | * @return Path to the directory containing a file being edited, or the | |
| 404 | * default user directory if the base path cannot be determined. | |
| 405 | */ | |
| 406 | public Path getBaseDir() { | |
| 407 | final var path = getSourcePath().toAbsolutePath().getParent(); | |
| 408 | return path == null ? DEFAULT_DIRECTORY : path; | |
| 409 | } | |
| 410 | ||
| 411 | FileType getSourceFileType() { | |
| 412 | return lookup( getSourcePath() ); | |
| 413 | } | |
| 414 | ||
| 415 | public Path getThemeDir() { | |
| 416 | return mMutator.mThemeDir.get(); | |
| 417 | } | |
| 418 | ||
| 419 | public Path getImageDir() { | |
| 420 | return mMutator.mImageDir.get(); | |
| 421 | } | |
| 422 | ||
| 423 | public Path getCacheDir() { | |
| 424 | return mMutator.mCacheDir.get(); | |
| 425 | } | |
| 426 | ||
| 427 | public Iterable<String> getImageOrder() { | |
| 428 | assert mMutator.mImageOrder != null; | |
| 429 | ||
| 430 | final var order = mMutator.mImageOrder.get(); | |
| 431 | final var token = order.contains( "," ) ? ',' : ' '; | |
| 432 | ||
| 433 | return Splitter.on( token ).split( token + order ); | |
| 434 | } | |
| 435 | ||
| 436 | public String getImageServer() { | |
| 437 | return mMutator.mImageServer.get(); | |
| 438 | } | |
| 439 | ||
| 440 | public Path getFontDir() { | |
| 441 | return mMutator.mFontDir.get(); | |
| 442 | } | |
| 443 | ||
| 444 | public String getModesEnabled() { | |
| 445 | // Force the processor to select particular sigils. | |
| 446 | final var processor = new VariableProcessor( IDENTITY, this ); | |
| 447 | final var needles = processor.getDefinitions(); | |
| 448 | final var haystack = sanitize( mMutator.mModesEnabled.get() ); | |
| 449 | ||
| 450 | return needles.containsKey( haystack ) | |
| 451 | ? replace( haystack, needles ) | |
| 452 | : processor.hasSigils( haystack ) | |
| 453 | ? "" | |
| 454 | : haystack; | |
| 455 | } | |
| 456 | ||
| 457 | public boolean getAutoRemove() { | |
| 458 | return mMutator.mAutoRemove.get(); | |
| 459 | } | |
| 460 | ||
| 461 | public Path getRWorkingDir() { | |
| 462 | return mMutator.mRWorkingDir.get(); | |
| 463 | } | |
| 464 | ||
| 465 | public String getRScript() { | |
| 466 | return mMutator.mRScript.get(); | |
| 467 | } | |
| 468 | ||
| 469 | public String getCurlQuotes() { | |
| 470 | final var result = mMutator.mCurlQuotes.get(); | |
| 471 | ||
| 472 | return "true".equalsIgnoreCase( result ) ? APOS_DEFAULT : result; | |
| 447 | 473 | } |
| 448 | 474 |
| 5 | 5 | package com.keenwrite.processors; |
| 6 | 6 | |
| 7 | import com.keenwrite.processors.html.HtmlProcessor; | |
| 7 | 8 | import com.keenwrite.processors.html.PreformattedProcessor; |
| 8 | 9 | import com.keenwrite.processors.html.XhtmlProcessor; |
| ... | ||
| 66 | 67 | final var successor = switch( outputType ) { |
| 67 | 68 | case NONE -> preview; |
| 68 | case XHTML_TEX -> createXhtmlProcessor( context ); | |
| 69 | case HTML_TEX_DELIMITED, HTML_TEX_SVG -> createHtmlProcessor( context ); | |
| 70 | case XHTML_TEX, XHTML_TEX_SVG -> createXhtmlProcessor( context ); | |
| 69 | 71 | case TEXT_TEX -> createTextProcessor( context ); |
| 70 | 72 | case APPLICATION_PDF -> createPdfProcessor( context ); |
| 71 | 73 | default -> createIdentityProcessor( context ); |
| 72 | 74 | }; |
| 73 | ||
| 74 | 75 | final var inputType = context.getSourceFileType(); |
| 75 | 76 | final Processor<String> processor; |
| ... | ||
| 135 | 136 | final ProcessorContext context ) { |
| 136 | 137 | return createXhtmlProcessor( IDENTITY, context ); |
| 137 | } | |
| 138 | ||
| 139 | private static Processor<String> createTextProcessor( | |
| 140 | final ProcessorContext context ) { | |
| 141 | return new TextProcessor( IDENTITY, context ); | |
| 142 | 138 | } |
| 143 | 139 | |
| ... | ||
| 151 | 147 | final var pdfProcessor = new PdfProcessor( context ); |
| 152 | 148 | return createXhtmlProcessor( pdfProcessor, context ); |
| 149 | } | |
| 150 | ||
| 151 | private static Processor<String> createHtmlProcessor( | |
| 152 | final ProcessorContext context ) { | |
| 153 | return new HtmlProcessor( IDENTITY, context ); | |
| 154 | } | |
| 155 | ||
| 156 | private static Processor<String> createTextProcessor( | |
| 157 | final ProcessorContext context ) { | |
| 158 | return new TextProcessor( IDENTITY, context ); | |
| 153 | 159 | } |
| 154 | 160 | |
| 1 | package com.keenwrite.processors.html; | |
| 2 | ||
| 3 | import com.whitemagicsoftware.keenquotes.lex.FilterType; | |
| 4 | import com.whitemagicsoftware.keenquotes.parser.Apostrophe; | |
| 5 | import com.whitemagicsoftware.keenquotes.parser.Contractions; | |
| 6 | import com.whitemagicsoftware.keenquotes.parser.Curler; | |
| 7 | ||
| 8 | import static com.whitemagicsoftware.keenquotes.parser.Contractions.*; | |
| 9 | ||
| 10 | /** | |
| 11 | * Ensures the contractions aren't created multiple times when creating | |
| 12 | * a class that changes typographic straight quotes into curly quotes. | |
| 13 | */ | |
| 14 | public final class Configuration { | |
| 15 | /** | |
| 16 | * Creates contracts with a custom set of unambiguous English contractions. | |
| 17 | */ | |
| 18 | private final static Contractions CONTRACTIONS = new Builder().build(); | |
| 19 | ||
| 20 | public static Curler createCurler( | |
| 21 | final FilterType filterType, | |
| 22 | final Apostrophe apostrophe ) { | |
| 23 | return new Curler( CONTRACTIONS, filterType, apostrophe ); | |
| 24 | } | |
| 25 | } | |
| 1 | 26 |
| 4 | 4 | import com.keenwrite.preview.HtmlPreview; |
| 5 | 5 | import com.keenwrite.processors.ExecutorProcessor; |
| 6 | import com.keenwrite.processors.ProcessorContext; | |
| 7 | import com.whitemagicsoftware.keenquotes.parser.Apostrophe; | |
| 8 | import com.whitemagicsoftware.keenquotes.parser.Curler; | |
| 9 | ||
| 10 | import static com.keenwrite.processors.html.Configuration.createCurler; | |
| 11 | import static com.whitemagicsoftware.keenquotes.lex.FilterType.FILTER_XML; | |
| 6 | 12 | |
| 7 | 13 | /** |
| 8 | 14 | * Responsible for notifying the {@link HtmlPreview} when the succession |
| 9 | 15 | * chain has updated. This decouples knowledge of changes to the editor panel |
| 10 | 16 | * from the HTML preview panel as well as any processing that takes place |
| 11 | 17 | * before the final HTML preview is rendered. This is the last link in the |
| 12 | 18 | * processor chain. |
| 13 | 19 | */ |
| 14 | 20 | public final class HtmlPreviewProcessor extends ExecutorProcessor<String> { |
| 21 | /** | |
| 22 | * Force the straight quotes to be curled to \’ in the preview. | |
| 23 | */ | |
| 24 | private final static Curler CURLER = createCurler( | |
| 25 | FILTER_XML, Apostrophe.CONVERT_RSQUOTE | |
| 26 | ); | |
| 27 | ||
| 28 | /** | |
| 29 | * Allows quote curling in the preview panel. | |
| 30 | */ | |
| 31 | private final ProcessorContext mContext; | |
| 32 | ||
| 15 | 33 | /** |
| 16 | 34 | * There is only one preview panel. |
| 17 | 35 | */ |
| 18 | private static HtmlPreview sHtmlPreview; | |
| 36 | private static HtmlPreview sPreview; | |
| 19 | 37 | |
| 20 | 38 | /** |
| 21 | 39 | * Constructs the end of a processing chain. |
| 22 | 40 | * |
| 23 | * @param htmlPreview The pane to update with the post-processed document. | |
| 41 | * @param context Typesetting options. | |
| 42 | * @param preview The pane to update with the post-processed document. | |
| 24 | 43 | */ |
| 25 | public HtmlPreviewProcessor( final HtmlPreview htmlPreview ) { | |
| 26 | sHtmlPreview = htmlPreview; | |
| 44 | public HtmlPreviewProcessor( | |
| 45 | final ProcessorContext context, | |
| 46 | final HtmlPreview preview ) { | |
| 47 | mContext = context; | |
| 48 | sPreview = preview; | |
| 27 | 49 | } |
| 28 | 50 | |
| ... | ||
| 38 | 60 | assert html != null; |
| 39 | 61 | |
| 40 | sHtmlPreview.render( html ); | |
| 62 | final var apos = mContext.getCurlQuotes(); | |
| 63 | final var document = apos.isBlank() ? html : CURLER.apply( html ); | |
| 64 | ||
| 65 | sPreview.render( document ); | |
| 41 | 66 | return html; |
| 42 | 67 | } |
| 1 | /* Copyright 2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.processors.html; | |
| 6 | ||
| 7 | import com.keenwrite.processors.ExecutorProcessor; | |
| 8 | import com.keenwrite.processors.Processor; | |
| 9 | import com.keenwrite.processors.ProcessorContext; | |
| 10 | import com.whitemagicsoftware.keenquotes.parser.Apostrophe; | |
| 11 | ||
| 12 | import static com.keenwrite.processors.html.Configuration.createCurler; | |
| 13 | import static com.whitemagicsoftware.keenquotes.lex.FilterType.FILTER_XML; | |
| 14 | ||
| 15 | /** | |
| 16 | * This is the processor used when an HTML file name extension is encountered. | |
| 17 | */ | |
| 18 | public final class HtmlProcessor extends ExecutorProcessor<String> { | |
| 19 | private final ProcessorContext mContext; | |
| 20 | ||
| 21 | /** | |
| 22 | * Constructs an HTML processor capable of curling straight quotes. | |
| 23 | * | |
| 24 | * @param successor The next chained processor for text processing. | |
| 25 | */ | |
| 26 | public HtmlProcessor( | |
| 27 | final Processor<String> successor, | |
| 28 | final ProcessorContext context ) { | |
| 29 | super( successor ); | |
| 30 | ||
| 31 | mContext = context; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * Returns the given string with quotations marks encoded as HTML entities, | |
| 36 | * provided the user opted to curl quotation marks. | |
| 37 | * | |
| 38 | * @param t The string having quotation marks to replace. | |
| 39 | * @return The string with quotation marks curled. | |
| 40 | */ | |
| 41 | @Override | |
| 42 | public String apply( final String t ) { | |
| 43 | final var curl = mContext.getCurlQuotes(); | |
| 44 | final var curler = createCurler( | |
| 45 | FILTER_XML, Apostrophe.fromType( curl ) | |
| 46 | ); | |
| 47 | ||
| 48 | return curler.apply( t ); | |
| 49 | } | |
| 50 | } | |
| 1 | 51 |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2024 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors.html; |
| 3 | 3 | |
| ... | ||
| 15 | 15 | * {@code null}). |
| 16 | 16 | */ |
| 17 | private IdentityProcessor() { | |
| 18 | } | |
| 17 | private IdentityProcessor() {} | |
| 19 | 18 | |
| 20 | 19 | /** |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.processors.html; | |
| 6 | ||
| 7 | import com.keenwrite.dom.DocumentParser; | |
| 8 | import com.keenwrite.io.MediaTypeExtension; | |
| 9 | import com.keenwrite.processors.ExecutorProcessor; | |
| 10 | import com.keenwrite.processors.Processor; | |
| 11 | import com.keenwrite.processors.ProcessorContext; | |
| 12 | import com.keenwrite.ui.heuristics.WordCounter; | |
| 13 | import com.keenwrite.util.DataTypeConverter; | |
| 14 | import com.whitemagicsoftware.keenquotes.parser.Contractions; | |
| 15 | import com.whitemagicsoftware.keenquotes.parser.Curler; | |
| 16 | import org.w3c.dom.Document; | |
| 17 | ||
| 18 | import java.io.File; | |
| 19 | import java.io.FileNotFoundException; | |
| 20 | import java.nio.file.Path; | |
| 21 | import java.util.*; | |
| 22 | ||
| 23 | import static com.keenwrite.Bootstrap.APP_TITLE_ABBR; | |
| 24 | import static com.keenwrite.dom.DocumentParser.*; | |
| 25 | import static com.keenwrite.events.StatusEvent.clue; | |
| 26 | import static com.keenwrite.io.SysFile.toFile; | |
| 27 | import static com.keenwrite.io.downloads.DownloadManager.open; | |
| 28 | import static com.keenwrite.util.ProtocolScheme.getProtocol; | |
| 29 | import static com.whitemagicsoftware.keenquotes.lex.FilterType.FILTER_XML; | |
| 30 | import static java.lang.String.format; | |
| 31 | import static java.lang.String.valueOf; | |
| 32 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 33 | import static java.nio.file.Files.copy; | |
| 34 | import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; | |
| 35 | ||
| 36 | /** | |
| 37 | * Responsible for making an XHTML document complete by wrapping it with html | |
| 38 | * and body elements. This doesn't have to be super-efficient because it's | |
| 39 | * not run in real time. | |
| 40 | */ | |
| 41 | public final class XhtmlProcessor extends ExecutorProcessor<String> { | |
| 42 | private static final Curler sCurler = | |
| 43 | new Curler( createContractions(), FILTER_XML, true ); | |
| 44 | ||
| 45 | private final ProcessorContext mContext; | |
| 46 | ||
| 47 | public XhtmlProcessor( | |
| 48 | final Processor<String> successor, final ProcessorContext context ) { | |
| 49 | super( successor ); | |
| 50 | ||
| 51 | assert context != null; | |
| 52 | mContext = context; | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * Responsible for producing a well-formed XML document complete with | |
| 57 | * metadata (title, author, keywords, copyright, and date). | |
| 58 | * | |
| 59 | * @param html The HTML document to transform into an XHTML document. | |
| 60 | * @return The transformed HTML document. | |
| 61 | */ | |
| 62 | @Override | |
| 63 | public String apply( final String html ) { | |
| 64 | clue( "Main.status.typeset.xhtml" ); | |
| 65 | ||
| 66 | try { | |
| 67 | final var doc = parse( html ); | |
| 68 | setMetaData( doc ); | |
| 69 | ||
| 70 | visit( doc, "//img", node -> { | |
| 71 | try { | |
| 72 | final var attrs = node.getAttributes(); | |
| 73 | final var attr = attrs.getNamedItem( "src" ); | |
| 74 | ||
| 75 | if( attr != null ) { | |
| 76 | final var src = attr.getTextContent(); | |
| 77 | final Path location; | |
| 78 | final Path imagesDir; | |
| 79 | ||
| 80 | // Download into a cache directory, which can be written to without | |
| 81 | // any possibility of overwriting local image files. Further, the | |
| 82 | // filenames are hashed as a second layer of protection. | |
| 83 | if( getProtocol( src ).isRemote() ) { | |
| 84 | location = downloadImage( src ); | |
| 85 | imagesDir = getCachesPath(); | |
| 86 | } | |
| 87 | else { | |
| 88 | location = resolveImage( src ); | |
| 89 | imagesDir = getImagesPath(); | |
| 90 | } | |
| 91 | ||
| 92 | final var relative = imagesDir.relativize( location ); | |
| 93 | ||
| 94 | attr.setTextContent( relative.toString() ); | |
| 95 | } | |
| 96 | } catch( final Exception ex ) { | |
| 97 | clue( ex ); | |
| 98 | } | |
| 99 | } ); | |
| 100 | ||
| 101 | final var document = DocumentParser.toString( doc ); | |
| 102 | final var curl = mContext.getCurlQuotes(); | |
| 103 | ||
| 104 | return curl ? sCurler.apply( document ) : document; | |
| 105 | } catch( final Exception ex ) { | |
| 106 | clue( ex ); | |
| 107 | } | |
| 108 | ||
| 109 | return html; | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * Applies the metadata fields to the document. | |
| 114 | * | |
| 115 | * @param doc The document to adorn with metadata. | |
| 116 | */ | |
| 117 | private void setMetaData( final Document doc ) { | |
| 118 | final var metadata = createMetaDataMap( doc ); | |
| 119 | final var title = metadata.get( "title" ); | |
| 120 | ||
| 121 | visit( doc, "/html/head", node -> { | |
| 122 | // Insert <title>text</title> inside <head>. | |
| 123 | node.appendChild( createElement( doc, "title", title ) ); | |
| 124 | // Insert <meta charset="utf-8"> inside <head>. | |
| 125 | node.appendChild( createEncoding( doc, UTF_8.toString() ) ); | |
| 126 | ||
| 127 | // Insert each <meta name=x content=y /> inside <head>. | |
| 128 | metadata.entrySet().forEach( | |
| 129 | entry -> node.appendChild( createMeta( doc, entry ) ) | |
| 130 | ); | |
| 131 | } ); | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Generates document metadata, including word count. | |
| 136 | * | |
| 137 | * @param doc The document containing the text to tally. | |
| 138 | * @return A map of metadata key/value pairs. | |
| 139 | */ | |
| 140 | private Map<String, String> createMetaDataMap( final Document doc ) { | |
| 141 | final var result = new LinkedHashMap<String, String>(); | |
| 142 | final var map = mContext.getInterpolatedDefinitions(); | |
| 143 | final var metadata = getMetadata(); | |
| 144 | ||
| 145 | metadata.forEach( | |
| 146 | ( key, value ) -> { | |
| 147 | final var interpolated = map.interpolate( value ); | |
| 148 | ||
| 149 | if( !interpolated.isEmpty() ) { | |
| 150 | result.put( key, interpolated ); | |
| 151 | } | |
| 152 | } | |
| 153 | ); | |
| 154 | result.put( "count", wordCount( doc ) ); | |
| 155 | ||
| 156 | return result; | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * The metadata is in list form because the user interface for entering the | |
| 161 | * key-value pairs is a table, which requires a generic {@link List} rather | |
| 162 | * than a generic {@link Map}. | |
| 163 | * | |
| 164 | * @return The document metadata. | |
| 165 | */ | |
| 166 | private Map<String, String> getMetadata() { | |
| 167 | final var result = mContext.getMetadata(); | |
| 168 | return result == null ? new HashMap<>() : result; | |
| 169 | } | |
| 170 | ||
| 171 | /** | |
| 172 | * Hashes the URL so that the number of files doesn't eat up disk space | |
| 173 | * over time. For static resources, a feature could be added to prevent | |
| 174 | * downloading the URL if the hashed filename already exists. | |
| 175 | * | |
| 176 | * @param src The source file's URL to download. | |
| 177 | * @return A {@link Path} to the local file containing the URL's contents. | |
| 178 | * @throws Exception Could not download or save the file. | |
| 179 | */ | |
| 180 | private Path downloadImage( final String src ) throws Exception { | |
| 181 | final Path imagePath; | |
| 182 | final File imageFile; | |
| 183 | final var cachesPath = getCachesPath(); | |
| 184 | ||
| 185 | clue( "Main.status.image.xhtml.image.download", src ); | |
| 186 | ||
| 187 | try( final var response = open( src ) ) { | |
| 188 | final var mediaType = response.getMediaType(); | |
| 189 | ||
| 190 | final var ext = MediaTypeExtension.valueFrom( mediaType ).getExtension(); | |
| 191 | final var hash = DataTypeConverter.toHex( DataTypeConverter.hash( src ) ); | |
| 192 | final var id = hash.toLowerCase(); | |
| 193 | ||
| 194 | imagePath = cachesPath.resolve( APP_TITLE_ABBR + id + '.' + ext ); | |
| 195 | imageFile = toFile( imagePath ); | |
| 196 | ||
| 197 | // Preserve image files if auto-remove is turned off. | |
| 198 | if( autoRemove() ) { | |
| 199 | imageFile.deleteOnExit(); | |
| 200 | } | |
| 201 | ||
| 202 | try( final var image = response.getInputStream() ) { | |
| 203 | copy( image, imagePath, REPLACE_EXISTING ); | |
| 204 | } | |
| 205 | ||
| 206 | if( mediaType.isSvg() ) { | |
| 207 | sanitize( imagePath ); | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | final var key = imageFile.exists() | |
| 212 | ? "Main.status.image.xhtml.image.saved" | |
| 213 | : "Main.status.image.xhtml.image.failed"; | |
| 214 | clue( key, imageFile ); | |
| 215 | ||
| 216 | return imagePath; | |
| 217 | } | |
| 218 | ||
| 219 | private Path resolveImage( final String src ) throws Exception { | |
| 220 | var imagePath = getImagesPath(); | |
| 221 | var found = false; | |
| 222 | ||
| 223 | Path imageFile = null; | |
| 224 | ||
| 225 | clue( "Main.status.image.xhtml.image.resolve", src ); | |
| 226 | ||
| 227 | for( final var extension : getImageOrder() ) { | |
| 228 | final var filename = format( | |
| 229 | "%s%s%s", src, extension.isBlank() ? "" : ".", extension ); | |
| 230 | imageFile = imagePath.resolve( filename ); | |
| 231 | ||
| 232 | if( toFile( imageFile ).exists() ) { | |
| 233 | found = true; | |
| 234 | break; | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | if( !found ) { | |
| 239 | imagePath = getDocumentDir(); | |
| 240 | imageFile = imagePath.resolve( src ); | |
| 241 | ||
| 242 | if( !toFile( imageFile ).exists() ) { | |
| 243 | final var filename = imageFile.toString(); | |
| 244 | clue( "Main.status.image.xhtml.image.missing", filename ); | |
| 245 | ||
| 246 | throw new FileNotFoundException( filename ); | |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 250 | clue( "Main.status.image.xhtml.image.found", imageFile.toString() ); | |
| 251 | ||
| 252 | return imageFile; | |
| 253 | } | |
| 254 | ||
| 255 | private Path getImagesPath() { | |
| 256 | return mContext.getImageDir(); | |
| 257 | } | |
| 258 | ||
| 259 | private Path getCachesPath() { | |
| 260 | return mContext.getCacheDir(); | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * By including an "empty" extension, the first element returned | |
| 265 | * will be the empty string. Thus, the first extension to try is the | |
| 266 | * file's default extension. Subsequent iterations will try to find | |
| 267 | * a file that has a name matching one of the preferred extensions. | |
| 268 | * | |
| 269 | * @return A list of extensions, including an empty string at the start. | |
| 270 | */ | |
| 271 | private Iterable<String> getImageOrder() { | |
| 272 | return mContext.getImageOrder(); | |
| 273 | } | |
| 274 | ||
| 275 | /** | |
| 276 | * Returns the absolute path to the document being edited, which can be used | |
| 277 | * to find files included using relative paths. | |
| 278 | * | |
| 279 | * @return The directory containing the edited file. | |
| 280 | */ | |
| 281 | private Path getDocumentDir() { | |
| 282 | return mContext.getBaseDir(); | |
| 283 | } | |
| 284 | ||
| 285 | private Locale getLocale() { | |
| 286 | return mContext.getLocale(); | |
| 287 | } | |
| 288 | ||
| 289 | private boolean autoRemove() { | |
| 290 | return mContext.getAutoRemove(); | |
| 291 | } | |
| 292 | ||
| 293 | private String wordCount( final Document doc ) { | |
| 294 | final var sb = new StringBuilder( 65536 * 10 ); | |
| 295 | ||
| 296 | visit( | |
| 297 | doc, | |
| 298 | "//*[normalize-space( text() ) != '']", | |
| 299 | node -> sb.append( node.getTextContent() ) | |
| 300 | ); | |
| 301 | ||
| 302 | return valueOf( WordCounter.create( getLocale() ).count( sb.toString() ) ); | |
| 303 | } | |
| 304 | ||
| 305 | /** | |
| 306 | * Creates contracts with a custom set of unambiguous strings. | |
| 307 | * | |
| 308 | * @return List of contractions to use for curling straight quotes. | |
| 309 | */ | |
| 310 | private static Contractions createContractions() { | |
| 311 | return new Contractions.Builder().build(); | |
| 1 | /* Copyright 2023-2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.processors.html; | |
| 6 | ||
| 7 | import com.keenwrite.collections.InterpolatingMap; | |
| 8 | import com.keenwrite.dom.DocumentConverter; | |
| 9 | import com.keenwrite.dom.DocumentParser; | |
| 10 | import com.keenwrite.io.MediaTypeExtension; | |
| 11 | import com.keenwrite.processors.ExecutorProcessor; | |
| 12 | import com.keenwrite.processors.Processor; | |
| 13 | import com.keenwrite.processors.ProcessorContext; | |
| 14 | import com.keenwrite.ui.heuristics.WordCounter; | |
| 15 | import com.keenwrite.util.DataTypeConverter; | |
| 16 | import com.whitemagicsoftware.keenquotes.parser.Apostrophe; | |
| 17 | import org.w3c.dom.Document; | |
| 18 | import org.w3c.dom.Node; | |
| 19 | import org.w3c.dom.NodeList; | |
| 20 | ||
| 21 | import java.io.File; | |
| 22 | import java.io.FileNotFoundException; | |
| 23 | import java.nio.file.Path; | |
| 24 | import java.util.*; | |
| 25 | import java.util.function.Consumer; | |
| 26 | ||
| 27 | import static com.keenwrite.Bootstrap.APP_TITLE_ABBR; | |
| 28 | import static com.keenwrite.dom.DocumentParser.*; | |
| 29 | import static com.keenwrite.events.StatusEvent.clue; | |
| 30 | import static com.keenwrite.io.SysFile.toFile; | |
| 31 | import static com.keenwrite.io.downloads.DownloadManager.open; | |
| 32 | import static com.keenwrite.processors.html.Configuration.createCurler; | |
| 33 | import static com.keenwrite.util.ProtocolScheme.getProtocol; | |
| 34 | import static com.whitemagicsoftware.keenquotes.lex.FilterType.FILTER_XML; | |
| 35 | import static java.lang.String.format; | |
| 36 | import static java.lang.String.valueOf; | |
| 37 | import static java.nio.file.Files.copy; | |
| 38 | import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; | |
| 39 | ||
| 40 | /** | |
| 41 | * Responsible for making an XHTML document complete by wrapping it with html | |
| 42 | * and body elements. This doesn't have to be super-efficient because it's | |
| 43 | * not run in real time. | |
| 44 | */ | |
| 45 | public final class XhtmlProcessor extends ExecutorProcessor<String> { | |
| 46 | private static final String DTD = | |
| 47 | "<!DOCTYPE html PUBLIC " + | |
| 48 | "\"-//W3C//DTD XHTML 1.0 Transitional//EN\" " + | |
| 49 | "\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">"; | |
| 50 | ||
| 51 | private static final DocumentConverter CONVERTER = new DocumentConverter(); | |
| 52 | ||
| 53 | private final ProcessorContext mContext; | |
| 54 | ||
| 55 | public XhtmlProcessor( | |
| 56 | final Processor<String> successor, final ProcessorContext context ) { | |
| 57 | super( successor ); | |
| 58 | ||
| 59 | assert context != null; | |
| 60 | mContext = context; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Responsible for producing a well-formed XML document complete with | |
| 65 | * metadata (title, author, keywords, copyright, and date). | |
| 66 | * | |
| 67 | * @param html The HTML document to transform into an XHTML document. | |
| 68 | * @return The transformed HTML document. | |
| 69 | */ | |
| 70 | @Override | |
| 71 | public String apply( final String html ) { | |
| 72 | clue( "Main.status.typeset.xhtml" ); | |
| 73 | ||
| 74 | try { | |
| 75 | final var doc = parse( html ); | |
| 76 | ||
| 77 | visit( | |
| 78 | doc, "//img", node -> { | |
| 79 | try { | |
| 80 | final var attrs = node.getAttributes(); | |
| 81 | final var attr = attrs.getNamedItem( "src" ); | |
| 82 | ||
| 83 | if( attr != null ) { | |
| 84 | final var src = attr.getTextContent(); | |
| 85 | final Path location; | |
| 86 | final Path imagesDir; | |
| 87 | ||
| 88 | // Download into a cache directory, which can be written to | |
| 89 | // without any possibility of overwriting local image files. | |
| 90 | // Further, the filenames are hashed as a second layer of | |
| 91 | // protection. | |
| 92 | if( getProtocol( src ).isRemote() ) { | |
| 93 | location = downloadImage( src ); | |
| 94 | imagesDir = getCachesPath(); | |
| 95 | } | |
| 96 | else { | |
| 97 | location = resolveImage( src ); | |
| 98 | imagesDir = getImagesPath(); | |
| 99 | } | |
| 100 | ||
| 101 | final var relative = imagesDir.relativize( location ); | |
| 102 | ||
| 103 | attr.setTextContent( relative.toString() ); | |
| 104 | } | |
| 105 | } | |
| 106 | catch( final Exception ex ) { | |
| 107 | clue( ex ); | |
| 108 | } | |
| 109 | } | |
| 110 | ); | |
| 111 | ||
| 112 | final var headText = mContext.getHtmlHead(); | |
| 113 | final var footText = mContext.getHtmlFoot(); | |
| 114 | ||
| 115 | append( headText, doc ); | |
| 116 | append( footText, doc ); | |
| 117 | ||
| 118 | final var map = mContext.getInterpolatedDefinitions(); | |
| 119 | final var locale = mContext.getLocale(); | |
| 120 | final var title = map.get( "document.title" ); | |
| 121 | final var metadata = createMetaDataMap( doc, map ); | |
| 122 | final var xhtml = DocumentParser.create( doc, metadata, locale, title ); | |
| 123 | final var document = DTD + DocumentParser.toString( xhtml ); | |
| 124 | final var curl = mContext.getCurlQuotes(); | |
| 125 | final var curler = createCurler( | |
| 126 | FILTER_XML, Apostrophe.fromType( curl ) | |
| 127 | ); | |
| 128 | ||
| 129 | return curler.apply( document ); | |
| 130 | } | |
| 131 | catch( final Exception ex ) { | |
| 132 | clue( ex ); | |
| 133 | } | |
| 134 | ||
| 135 | return html; | |
| 136 | } | |
| 137 | ||
| 138 | public void append( final String html, final Document target ) { | |
| 139 | assert html != null; | |
| 140 | assert target != null; | |
| 141 | ||
| 142 | try { | |
| 143 | final var source = DocumentConverter.parse( html ); | |
| 144 | final var sourceBody = source.head(); | |
| 145 | final var sourceDoc = CONVERTER.fromJsoup( sourceBody ); | |
| 146 | final var sourceRoot = sourceDoc.getDocumentElement(); | |
| 147 | final var children = sourceRoot.getChildNodes(); | |
| 148 | ||
| 149 | forEachChild( | |
| 150 | children, child -> { | |
| 151 | if( child.getNodeType() == Node.ELEMENT_NODE ) { | |
| 152 | final var name = child.getNodeName(); | |
| 153 | final var targetElement = find( target, name ); | |
| 154 | ||
| 155 | append( child, target, targetElement ); | |
| 156 | } | |
| 157 | } | |
| 158 | ); | |
| 159 | ||
| 160 | } | |
| 161 | catch( final Exception ex ) { | |
| 162 | clue( ex ); | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | private Node find( final Document document, final String name ) { | |
| 167 | assert document != null; | |
| 168 | assert name != null; | |
| 169 | assert !name.isBlank(); | |
| 170 | ||
| 171 | try { | |
| 172 | // Both documents are well-formed at this point. | |
| 173 | return DocumentParser.evaluate( "//" + name, document ); | |
| 174 | } | |
| 175 | catch( final Exception ex ) { | |
| 176 | // Implies that the head/body elements are missing from the document. | |
| 177 | final var element = createElement( document, name, null ); | |
| 178 | document.getDocumentElement().appendChild( element ); | |
| 179 | ||
| 180 | return element; | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | private void append( | |
| 185 | final Node source, | |
| 186 | final Document targetDoc, | |
| 187 | final Node targetNode | |
| 188 | ) { | |
| 189 | assert source != null; | |
| 190 | assert targetDoc != null; | |
| 191 | assert targetNode != null; | |
| 192 | ||
| 193 | final var children = source.getChildNodes(); | |
| 194 | ||
| 195 | forEachChild( | |
| 196 | children, child -> { | |
| 197 | final var imported = targetDoc.importNode( child, true ); | |
| 198 | targetNode.appendChild( imported ); | |
| 199 | } | |
| 200 | ); | |
| 201 | } | |
| 202 | ||
| 203 | private void forEachChild( | |
| 204 | final NodeList children, | |
| 205 | final Consumer<Node> action | |
| 206 | ) { | |
| 207 | assert children != null; | |
| 208 | assert action != null; | |
| 209 | ||
| 210 | final var childCount = children.getLength(); | |
| 211 | ||
| 212 | for( var i = 0; i < childCount; i++ ) { | |
| 213 | action.accept( children.item( i ) ); | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * Generates document metadata, including word count. | |
| 219 | * | |
| 220 | * @param doc The document containing the text to tally. | |
| 221 | * @return A map of metadata key/value pairs. | |
| 222 | */ | |
| 223 | private Map<String, String> createMetaDataMap( | |
| 224 | final Document doc, final InterpolatingMap map | |
| 225 | ) { | |
| 226 | final var result = new LinkedHashMap<String, String>(); | |
| 227 | final var metadata = getMetadata(); | |
| 228 | ||
| 229 | metadata.forEach( | |
| 230 | ( key, value ) -> { | |
| 231 | final var interpolated = map.interpolate( value ); | |
| 232 | ||
| 233 | if( !interpolated.isEmpty() ) { | |
| 234 | result.put( key, interpolated ); | |
| 235 | } | |
| 236 | } | |
| 237 | ); | |
| 238 | result.put( "count", wordCount( doc ) ); | |
| 239 | ||
| 240 | return result; | |
| 241 | } | |
| 242 | ||
| 243 | /** | |
| 244 | * The metadata is in list form because the user interface for entering the | |
| 245 | * key-value pairs is a table, which requires a generic {@link List} rather | |
| 246 | * than a generic {@link Map}. | |
| 247 | * | |
| 248 | * @return The document metadata. | |
| 249 | */ | |
| 250 | private Map<String, String> getMetadata() { | |
| 251 | final var result = mContext.getMetadata(); | |
| 252 | return result == null ? new HashMap<>() : result; | |
| 253 | } | |
| 254 | ||
| 255 | /** | |
| 256 | * Hashes the URL so that the number of files doesn't eat up disk space | |
| 257 | * over time. For static resources, a feature could be added to prevent | |
| 258 | * downloading the URL if the hashed filename already exists. | |
| 259 | * | |
| 260 | * @param src The source file's URL to download. | |
| 261 | * @return A {@link Path} to the local file containing the URL's contents. | |
| 262 | * @throws Exception Could not download or save the file. | |
| 263 | */ | |
| 264 | private Path downloadImage( final String src ) throws Exception { | |
| 265 | final Path imagePath; | |
| 266 | final File imageFile; | |
| 267 | final var cachesPath = getCachesPath(); | |
| 268 | ||
| 269 | clue( "Main.status.image.xhtml.image.download", src ); | |
| 270 | ||
| 271 | try( final var response = open( src ) ) { | |
| 272 | final var mediaType = response.getMediaType(); | |
| 273 | ||
| 274 | final var ext = MediaTypeExtension.valueFrom( mediaType ).getExtension(); | |
| 275 | final var hash = DataTypeConverter.toHex( DataTypeConverter.hash( src ) ); | |
| 276 | final var id = hash.toLowerCase(); | |
| 277 | ||
| 278 | imagePath = cachesPath.resolve( APP_TITLE_ABBR + id + '.' + ext ); | |
| 279 | imageFile = toFile( imagePath ); | |
| 280 | ||
| 281 | // Preserve image files if auto-remove is turned off. | |
| 282 | if( autoRemove() ) { | |
| 283 | imageFile.deleteOnExit(); | |
| 284 | } | |
| 285 | ||
| 286 | try( final var image = response.getInputStream() ) { | |
| 287 | copy( image, imagePath, REPLACE_EXISTING ); | |
| 288 | } | |
| 289 | ||
| 290 | if( mediaType.isSvg() ) { | |
| 291 | sanitize( imagePath ); | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | final var key = imageFile.exists() | |
| 296 | ? "Main.status.image.xhtml.image.saved" | |
| 297 | : "Main.status.image.xhtml.image.failed"; | |
| 298 | clue( key, imageFile ); | |
| 299 | ||
| 300 | return imagePath; | |
| 301 | } | |
| 302 | ||
| 303 | private Path resolveImage( final String src ) throws Exception { | |
| 304 | var imagePath = getImagesPath(); | |
| 305 | var found = false; | |
| 306 | ||
| 307 | Path imageFile = null; | |
| 308 | ||
| 309 | clue( "Main.status.image.xhtml.image.resolve", src ); | |
| 310 | ||
| 311 | for( final var extension : getImageOrder() ) { | |
| 312 | final var filename = format( | |
| 313 | "%s%s%s", src, extension.isBlank() ? "" : ".", extension ); | |
| 314 | imageFile = imagePath.resolve( filename ); | |
| 315 | ||
| 316 | if( toFile( imageFile ).exists() ) { | |
| 317 | found = true; | |
| 318 | break; | |
| 319 | } | |
| 320 | } | |
| 321 | ||
| 322 | if( !found ) { | |
| 323 | imagePath = getDocumentDir(); | |
| 324 | imageFile = imagePath.resolve( src ); | |
| 325 | ||
| 326 | if( !toFile( imageFile ).exists() ) { | |
| 327 | final var filename = imageFile.toString(); | |
| 328 | clue( "Main.status.image.xhtml.image.missing", filename ); | |
| 329 | ||
| 330 | throw new FileNotFoundException( filename ); | |
| 331 | } | |
| 332 | } | |
| 333 | ||
| 334 | clue( "Main.status.image.xhtml.image.found", imageFile.toString() ); | |
| 335 | ||
| 336 | return imageFile; | |
| 337 | } | |
| 338 | ||
| 339 | private Path getImagesPath() { | |
| 340 | return mContext.getImageDir(); | |
| 341 | } | |
| 342 | ||
| 343 | private Path getCachesPath() { | |
| 344 | return mContext.getCacheDir(); | |
| 345 | } | |
| 346 | ||
| 347 | /** | |
| 348 | * By including an "empty" extension, the first element returned | |
| 349 | * will be the empty string. Thus, the first extension to try is the | |
| 350 | * file's default extension. Subsequent iterations will try to find | |
| 351 | * a file that has a name matching one of the preferred extensions. | |
| 352 | * | |
| 353 | * @return A list of extensions, including an empty string at the start. | |
| 354 | */ | |
| 355 | private Iterable<String> getImageOrder() { | |
| 356 | return mContext.getImageOrder(); | |
| 357 | } | |
| 358 | ||
| 359 | /** | |
| 360 | * Returns the absolute path to the document being edited, which can be used | |
| 361 | * to find files included using relative paths. | |
| 362 | * | |
| 363 | * @return The directory containing the edited file. | |
| 364 | */ | |
| 365 | private Path getDocumentDir() { | |
| 366 | return mContext.getBaseDir(); | |
| 367 | } | |
| 368 | ||
| 369 | private Locale getLocale() { | |
| 370 | return mContext.getLocale(); | |
| 371 | } | |
| 372 | ||
| 373 | private boolean autoRemove() { | |
| 374 | return mContext.getAutoRemove(); | |
| 375 | } | |
| 376 | ||
| 377 | private String wordCount( final Document doc ) { | |
| 378 | final var sb = new StringBuilder( 65536 * 10 ); | |
| 379 | ||
| 380 | visit( | |
| 381 | doc, | |
| 382 | "//*[normalize-space( text() ) != '']", | |
| 383 | node -> sb.append( node.getTextContent() ) | |
| 384 | ); | |
| 385 | ||
| 386 | return valueOf( WordCounter.create( getLocale() ).count( sb.toString() ) ); | |
| 312 | 387 | } |
| 313 | 388 | } |
| 11 | 11 | import com.keenwrite.processors.markdown.extensions.captions.CaptionExtension; |
| 12 | 12 | import com.keenwrite.processors.markdown.extensions.fences.FencedDivExtension; |
| 13 | import com.keenwrite.processors.markdown.extensions.quotes.EscapedQuotesExtension; | |
| 13 | 14 | import com.keenwrite.processors.markdown.extensions.r.RInlineExtension; |
| 14 | 15 | import com.keenwrite.processors.markdown.extensions.references.CrossReferenceExtension; |
| ... | ||
| 34 | 35 | */ |
| 35 | 36 | public class BaseMarkdownProcessor extends ExecutorProcessor<String> { |
| 36 | ||
| 37 | 37 | private final IParse mParser; |
| 38 | 38 | private final IRender mRenderer; |
| ... | ||
| 74 | 74 | extensions.add( CrossReferenceExtension.create() ); |
| 75 | 75 | extensions.add( CaptionExtension.create() ); |
| 76 | extensions.add( EscapedQuotesExtension.create() ); | |
| 76 | 77 | |
| 77 | 78 | return extensions; |
| 31 | 31 | */ |
| 32 | 32 | public final class MarkdownProcessor extends BaseMarkdownProcessor { |
| 33 | ||
| 34 | 33 | private MarkdownProcessor( |
| 35 | 34 | final Processor<String> successor, final ProcessorContext context ) { |
| 1 | /* Copyright 2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.processors.markdown.extensions.quotes; | |
| 6 | ||
| 7 | import com.vladsch.flexmark.ast.Text; | |
| 8 | import com.vladsch.flexmark.html.HtmlWriter; | |
| 9 | import com.vladsch.flexmark.html.renderer.NodeRenderer; | |
| 10 | import com.vladsch.flexmark.html.renderer.NodeRendererContext; | |
| 11 | import com.vladsch.flexmark.html.renderer.NodeRendererFactory; | |
| 12 | import com.vladsch.flexmark.html.renderer.NodeRenderingHandler; | |
| 13 | import com.vladsch.flexmark.util.data.DataHolder; | |
| 14 | import org.jetbrains.annotations.NotNull; | |
| 15 | ||
| 16 | import java.util.HashSet; | |
| 17 | import java.util.Set; | |
| 18 | ||
| 19 | /** | |
| 20 | * Responsible for preventing escaped quotes from being converted to regular | |
| 21 | * quotes. | |
| 22 | */ | |
| 23 | public class EscapedQuoteNodeRenderer implements NodeRenderer { | |
| 24 | public static class Factory implements NodeRendererFactory { | |
| 25 | @Override | |
| 26 | public @NotNull NodeRenderer apply( @NotNull DataHolder options ) { | |
| 27 | return new EscapedQuoteNodeRenderer(); | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | @Override | |
| 32 | public Set<NodeRenderingHandler<?>> getNodeRenderingHandlers() { | |
| 33 | final var handlers = new HashSet<NodeRenderingHandler<?>>(); | |
| 34 | ||
| 35 | handlers.add( new NodeRenderingHandler<>( Text.class, this::renderText ) ); | |
| 36 | ||
| 37 | return handlers; | |
| 38 | } | |
| 39 | ||
| 40 | private void renderText( | |
| 41 | final Text node, | |
| 42 | final NodeRendererContext context, | |
| 43 | final HtmlWriter html ) { | |
| 44 | // By default, rendering will unescape escaped quotation marks. Overriding | |
| 45 | // how text is produced with the verbatim characters ensures the escape | |
| 46 | // sequence is retained (i.e., \"). | |
| 47 | html.text( node.getChars().toString() ); | |
| 48 | } | |
| 49 | } | |
| 1 | 50 |
| 1 | /* Copyright 2024 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 5 | package com.keenwrite.processors.markdown.extensions.quotes; | |
| 6 | ||
| 7 | import com.vladsch.flexmark.util.data.MutableDataHolder; | |
| 8 | import org.jetbrains.annotations.NotNull; | |
| 9 | ||
| 10 | import static com.keenwrite.processors.markdown.extensions.quotes.EscapedQuoteNodeRenderer.Factory; | |
| 11 | import static com.vladsch.flexmark.html.HtmlRenderer.Builder; | |
| 12 | import static com.vladsch.flexmark.html.HtmlRenderer.HtmlRendererExtension; | |
| 13 | ||
| 14 | /** | |
| 15 | * Responsible for extending the Markdown library with the ability to | |
| 16 | * retain escaped characters, rather than unescape the text. This is needed | |
| 17 | * so that the character sequence {@code \"} can be interpreted as a straight | |
| 18 | * quotation mark when output into the final document. | |
| 19 | */ | |
| 20 | public class EscapedQuotesExtension | |
| 21 | implements HtmlRendererExtension { | |
| 22 | ||
| 23 | @Override | |
| 24 | public void rendererOptions( @NotNull final MutableDataHolder options ) {} | |
| 25 | ||
| 26 | @Override | |
| 27 | public void extend( | |
| 28 | final Builder builder, | |
| 29 | @NotNull final String rendererType ) { | |
| 30 | builder.nodeRendererFactory( new Factory() ); | |
| 31 | } | |
| 32 | ||
| 33 | public static EscapedQuotesExtension create() { | |
| 34 | return new EscapedQuotesExtension(); | |
| 35 | } | |
| 36 | } | |
| 1 | 37 |
| 71 | 71 | * Use {@link #installTrustManager()}. |
| 72 | 72 | */ |
| 73 | private PermissiveCertificate() { | |
| 74 | } | |
| 73 | private PermissiveCertificate() {} | |
| 75 | 74 | } |
| 76 | 75 |
| 19 | 19 | import static com.keenwrite.events.StatusEvent.clue; |
| 20 | 20 | import static com.keenwrite.util.Time.toElapsedTime; |
| 21 | import static java.lang.String.*; | |
| 22 | 21 | import static java.lang.String.format; |
| 23 | 22 | import static java.lang.System.currentTimeMillis; |
| 27 | 27 | */ |
| 28 | 28 | public abstract class ManagerOutputPane extends InstallerPane { |
| 29 | private final static String PROP_EXECUTOR = | |
| 29 | private static final String PROP_EXECUTOR = | |
| 30 | 30 | ManagerOutputPane.class.getCanonicalName(); |
| 31 | 31 |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.Messages; |
| 5 | import com.keenwrite.io.SysFile; | |
| 5 | 6 | import com.keenwrite.preferences.Workspace; |
| 6 | 7 | import javafx.beans.property.ObjectProperty; |
| ... | ||
| 59 | 60 | final Window owner, final SelectionType options ) { |
| 60 | 61 | final var picker = new NativeFilePicker( owner, options ); |
| 62 | final var directory = SysFile.normalize( mDirectory.get() ); | |
| 61 | 63 | |
| 62 | picker.setInitialDirectory( mDirectory.get().toPath() ); | |
| 64 | picker.setInitialDirectory( directory ); | |
| 63 | 65 | |
| 64 | 66 | return picker; |
| 65 | 65 | |
| 66 | 66 | while( mItems.size() > CACHE_SIZE ) { |
| 67 | mItems.remove( 0 ); | |
| 67 | mItems.removeFirst(); | |
| 68 | 68 | } |
| 69 | 69 | |
| ... | ||
| 153 | 153 | private void initActions() { |
| 154 | 154 | final var stage = getStage(); |
| 155 | stage.setOnCloseRequest( event -> stage.hide() ); | |
| 155 | stage.setOnCloseRequest( _ -> stage.hide() ); | |
| 156 | 156 | } |
| 157 | 157 | |
| 34 | 34 | workspace.typeset.typography=Typography |
| 35 | 35 | workspace.typeset.typography.quotes=Quotation Marks |
| 36 | workspace.typeset.typography.quotes.desc=Export straight quotes and apostrophes as curled equivalents. | |
| 36 | workspace.typeset.typography.quotes.desc=Defines encoding for apostrophes when curling quotation marks (regular means none; apos is typical). | |
| 37 | 37 | workspace.typeset.typography.quotes.title=Curl |
| 38 | 38 | workspace.typeset.modes=Modes |
| ... | ||
| 327 | 327 | Wizard.typesetter.name=ConTeXt |
| 328 | 328 | Wizard.typesetter.container.name=Podman |
| 329 | Wizard.typesetter.container.version=4.8.2 | |
| 330 | Wizard.typesetter.container.checksum=250b12c24444005e09306eda38fa63c60cb1bdadf040f4e3f24f976e213cd462 | |
| 329 | Wizard.typesetter.container.version=5.6.0 | |
| 330 | Wizard.typesetter.container.checksum=fc8960481e6165b5d1ef05970a11b691b13d434d1f97ceb29b8be6f3902ba86c | |
| 331 | 331 | Wizard.typesetter.container.image.name=typesetter |
| 332 | Wizard.typesetter.container.image.version=3.2.0 | |
| 332 | Wizard.typesetter.container.image.version=3.3.0 | |
| 333 | 333 | Wizard.typesetter.container.image.tag=${Wizard.typesetter.container.image.name}:${Wizard.typesetter.container.image.version} |
| 334 | 334 | Wizard.typesetter.container.image.url=https://repository.keenwrite.com/containers/${Wizard.typesetter.container.image.tag} |
| 335 | Wizard.typesetter.themes.version=1.10.2 | |
| 336 | Wizard.typesetter.themes.checksum=d2d3674434d914378af9a845fc363194cb4bc7a983eb3f8b7af38309faae19f6 | |
| 335 | Wizard.typesetter.themes.version=1.11.0 | |
| 336 | Wizard.typesetter.themes.checksum=87e258329d2a4c2802135c4828c2b095a92d62adf7e02925dba24ec4cc1fee6e | |
| 337 | 337 | |
| 338 | 338 | Wizard.container.install.command=Installing container using: ''{0}'' |
| 339 | 339 | Wizard.container.install.await=Waiting for installer to finish |
| 340 | 340 | Wizard.container.install.download.started=Download ''{0}'' started |
| 341 | 341 | Wizard.container.install.download.running=Download in progress, please wait |
| 342 | 342 | Wizard.container.process.enter=Running ''{0}'' ''{1}'' |
| 343 | 343 | Wizard.container.process.exit=Process exit code (zero means success): {0} |
| 344 | 344 | Wizard.container.executable.run.scan=''{0}'' is executable: {1} |
| 345 | Wizard.container.executable.run.missing=Cannot find executable: ''{0}'' | |
| 346 | Wizard.container.executable.run.found=Found executable: ''{0}'' | |
| 345 | 347 | Wizard.container.executable.run.error=Cannot run container |
| 346 | 348 | Wizard.container.executable.which=Cannot find container using search command |
| 34 | 34 | final var actualExtension = valueFrom( media ).getExtension(); |
| 35 | 35 | final var expectedExtension = getExtension( image.toString() ); |
| 36 | System.out.println( String.format( "%s%s", image, " -> \{media}" ) ); | |
| 36 | System.out.printf( "%s -> %s%n", image, media ); | |
| 37 | 37 | |
| 38 | 38 | assertEquals( expectedExtension, actualExtension ); |
| 51 | 51 | //@formatter:off |
| 52 | 52 | final var map = Map.of( |
| 53 | "https://kroki.io/robots.txt", TEXT_PLAIN, | |
| 54 | "https://place-hold.it/300x500", IMAGE_GIF, | |
| 55 | "https://loremflickr.com/200/300", IMAGE_JPEG, | |
| 56 | "https://upload.wikimedia.org/wikipedia/commons/9/9f/Vimlogo.svg", IMAGE_SVG_XML, | |
| 53 | "https://placehold.co/robots.txt", TEXT_PLAIN, | |
| 54 | "https://placehold.co/600x400.gif", IMAGE_GIF, | |
| 55 | "https://placehold.co/600x400.jpg", IMAGE_JPEG, | |
| 56 | "https://placehold.co/600x400.svg", IMAGE_SVG_XML, | |
| 57 | 57 | "https://kroki.io//graphviz/svg/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", IMAGE_SVG_XML |
| 58 | 58 | ); |
| 15 | 15 | import static com.keenwrite.ExportFormat.HTML_TEX_DELIMITED; |
| 16 | 16 | import static com.keenwrite.ExportFormat.XHTML_TEX; |
| 17 | import static com.keenwrite.constants.Constants.APOS_DEFAULT; | |
| 17 | 18 | import static com.keenwrite.processors.ProcessorContext.builder; |
| 18 | 19 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| 20 | import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX; | |
| 19 | 21 | import static java.util.Locale.ENGLISH; |
| 20 | 22 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| ... | ||
| 56 | 58 | .with( ProcessorContext.Mutator::setRScript, () -> "" ) |
| 57 | 59 | .with( ProcessorContext.Mutator::setRWorkingDir, () -> Path.of( "r" ) ) |
| 58 | .with( ProcessorContext.Mutator::setCurlQuotes, () -> true ) | |
| 60 | .with( ProcessorContext.Mutator::setCurlQuotes, () -> APOS_DEFAULT ) | |
| 59 | 61 | .with( ProcessorContext.Mutator::setAutoRemove, () -> true ) |
| 60 | 62 | .build(); |
| ... | ||
| 72 | 74 | XHTML_TEX, |
| 73 | 75 | """ |
| 74 | <html><head><title/><meta charset="UTF-8"/><meta content="2" name="count"/></head><body><p>the 👍 emoji</p> | |
| 76 | <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en" xmlns="http://www.w3.org/1999/xhtml"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta content="2" name="count"/></head><body><p>the 👍 emoji</p> | |
| 75 | 77 | </body></html>""" |
| 76 | 78 | ) |
| 1 | MIT License | |
| 2 | ||
| 3 | Copyright (c) 2022 KeenWrite | |
| 4 | ||
| 5 | Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | of this software and associated documentation files (the "Software"), to deal | |
| 7 | in the Software without restriction, including without limitation the rights | |
| 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | copies of the Software, and to permit persons to whom the Software is | |
| 10 | furnished to do so, subject to the following conditions: | |
| 11 | ||
| 12 | The above copyright notice and this permission notice shall be included in all | |
| 13 | copies or substantial portions of the Software. | |
| 14 | ||
| 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | SOFTWARE. | |
| 22 | 1 |
| 1 | 1 | |
| 2 |  | |
| 3 | ||
| 4 |
| 1 | #!/usr/bin/env bash | |
| 2 | ||
| 3 | awk '{s+=$1} END {print s}' downloads/*-count.txt 2> /dev/null || echo 0 | |
| 4 | ||
| 5 | 1 |
| 1 | version.txt | |
| 2 | 1 |
| 1 | SetEnv no-gzip dont-vary | |
| 2 | ||
| 3 | <IfModule mod_rewrite.c> | |
| 4 | RewriteEngine On | |
| 5 | ||
| 6 | # Ensure the file exists before attemping to download it. | |
| 7 | RewriteCond %{REQUEST_FILENAME} -f | |
| 8 | ||
| 9 | # Rewrite requests for file extensions to track. | |
| 10 | RewriteRule ^([^/]+\.(zip|app|bin|exe|jar))$ counter.php?filename=$1 [L] | |
| 11 | </IfModule> | |
| 12 | 1 |
| 1 | <?php | |
| 2 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 3 | * | |
| 4 | * SPDX-License-Identifier: MIT | |
| 5 | */ | |
| 6 | ||
| 7 | // Log all errors to a temporary file. | |
| 8 | ini_set( 'log_errors', 1 ); | |
| 9 | ini_set( 'error_log', '/tmp/php-errors.log' ); | |
| 10 | ||
| 11 | // Do not impose a time limit for downloads. | |
| 12 | set_time_limit( 0 ); | |
| 13 | ||
| 14 | // Flush any previous output buffers. | |
| 15 | while( ob_get_level() > 0 ) { | |
| 16 | ob_end_flush(); | |
| 17 | } | |
| 18 | ||
| 19 | if( session_id() === "" ) { | |
| 20 | session_start(); | |
| 21 | } | |
| 22 | ||
| 23 | // Keep running upon client disconnect (helps catch file transfer failures). | |
| 24 | // This setting requires checking whether the connection has been aborted at | |
| 25 | // a regular interval to prevent bogging the server with abandoned requests. | |
| 26 | ignore_user_abort( true ); | |
| 27 | ||
| 28 | $filename = get_sanitized_filename(); | |
| 29 | $valid_filename = !empty( $filename ); | |
| 30 | $expiry = 24 * 60 * 60; | |
| 31 | ||
| 32 | if( $valid_filename && download( $filename ) && token_expired( $expiry ) ) { | |
| 33 | increment_count( "$filename-count.txt" ); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Retrieve the file name being downloaded from the HTTP GET request. | |
| 38 | * | |
| 39 | * @return string The sanitized file name (without path information). | |
| 40 | */ | |
| 41 | function get_sanitized_filename() { | |
| 42 | $filepath = isset( $_GET[ 'filename' ] ) ? $_GET[ 'filename' ] : ''; | |
| 43 | $fileinfo = pathinfo( $filepath ); | |
| 44 | ||
| 45 | // Remove path information (no /etc/passwd or ../../etc/passwd for you). | |
| 46 | $basename = $fileinfo[ 'basename' ]; | |
| 47 | ||
| 48 | if( isset( $_SERVER[ 'HTTP_USER_AGENT' ] ) ) { | |
| 49 | $periods = substr_count( $basename, '.' ); | |
| 50 | ||
| 51 | // Address IE bug regarding multiple periods in filename. | |
| 52 | $basename = strstr( $_SERVER[ 'HTTP_USER_AGENT' ], 'MSIE' ) | |
| 53 | ? mb_ereg_replace( '/\./', '%2e', $basename, $periods - 1 ) | |
| 54 | : $basename; | |
| 55 | } | |
| 56 | ||
| 57 | // Trim all external and internal spaces. | |
| 58 | $basename = mb_ereg_replace( '/\s+/', '', $basename ); | |
| 59 | ||
| 60 | // Sanitize. | |
| 61 | $basename = mb_ereg_replace( '([^\w\d\-_~,;\[\]\(\).])', '', $basename ); | |
| 62 | ||
| 63 | return $basename; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * Answers whether the user's download token has expired. | |
| 68 | * | |
| 69 | * @param int $lifetime Number of seconds before expiring the token. | |
| 70 | * | |
| 71 | * @return bool True indicates the token has expired (or was not set). | |
| 72 | */ | |
| 73 | function token_expired( $lifetime ) { | |
| 74 | $TOKEN_NAME = 'LAST_DOWNLOAD'; | |
| 75 | $now = time(); | |
| 76 | $expired = !isset( $_SESSION[ $TOKEN_NAME ] ); | |
| 77 | ||
| 78 | if( !$expired && ($now - $_SESSION[ $TOKEN_NAME ] > $lifetime) ) { | |
| 79 | $expired = true; | |
| 80 | $_SESSION = array(); | |
| 81 | ||
| 82 | session_destroy(); | |
| 83 | } | |
| 84 | ||
| 85 | $_SESSION[ $TOKEN_NAME ] = $now; | |
| 86 | ||
| 87 | $TOKEN_CREATE = 'CREATED'; | |
| 88 | ||
| 89 | if( !isset( $_SESSION[ $TOKEN_CREATE ] ) ) { | |
| 90 | $_SESSION[ $TOKEN_CREATE ] = $now; | |
| 91 | } | |
| 92 | else if( $now - $_SESSION[ $TOKEN_CREATE ] > $lifetime ) { | |
| 93 | // Avoid session fixation attacks by regenerating tokens. | |
| 94 | session_regenerate_id( true ); | |
| 95 | $_SESSION[ $TOKEN_CREATE ] = $now; | |
| 96 | } | |
| 97 | ||
| 98 | return $expired; | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * Downloads a file, allowing for resuming partial downloads. | |
| 103 | * | |
| 104 | * @param string $filename File to download, must be in script directory. | |
| 105 | * | |
| 106 | * @return bool True if the file was transferred. | |
| 107 | */ | |
| 108 | function download( $filename ) { | |
| 109 | // Don't cache the file stats result (e.g., file size). | |
| 110 | clearstatcache(); | |
| 111 | ||
| 112 | $size = @filesize( $filename ); | |
| 113 | $size = $size === false || empty( $size ) ? 0 : $size; | |
| 114 | $content_type = mime_content_type( $filename ); | |
| 115 | list( $seek_start, $content_length ) = parse_range( $size ); | |
| 116 | ||
| 117 | // Added by PHP, removed by us. | |
| 118 | header_remove( 'x-powered-by' ); | |
| 119 | ||
| 120 | // HTTP/1.1 clients must treat invalid date formats, especially 0, as past. | |
| 121 | header( 'Expires: 0' ); | |
| 122 | ||
| 123 | // Prevent local caching. | |
| 124 | header( 'Cache-Control: public, must-revalidate, post-check=0, pre-check=0' ); | |
| 125 | ||
| 126 | // No response message portion may be cached (e.g., by a proxy server). | |
| 127 | header( 'Cache-Control: private', false ); | |
| 128 | ||
| 129 | // Force the browser to download, rather than display the file inline. | |
| 130 | header( "Content-Disposition: attachment; filename=\"$filename\"" ); | |
| 131 | header( 'Accept-Ranges: bytes' ); | |
| 132 | header( "Content-Length: $content_length" ); | |
| 133 | header( "Content-Type: $content_type" ); | |
| 134 | ||
| 135 | $method = isset( $_SERVER[ 'REQUEST_METHOD' ] ) | |
| 136 | ? $_SERVER[ 'REQUEST_METHOD' ] | |
| 137 | : 'GET'; | |
| 138 | ||
| 139 | // Honour HTTP HEAD requests. | |
| 140 | return $method === 'HEAD' | |
| 141 | ? false | |
| 142 | : transmit( $filename, $seek_start, $size ); | |
| 143 | } | |
| 144 | ||
| 145 | /** | |
| 146 | * Parses the HTTP range request header, provided one was sent by the | |
| 147 | * client. This provides download resume functionality. | |
| 148 | * | |
| 149 | * @param int $size The total file size (as stored on disk). | |
| 150 | * | |
| 151 | * @return array The starting offset for resuming the download, or 0 to | |
| 152 | * download the entire file (i.e., no offset could be parsed); also the | |
| 153 | * number of bytes to be transferred. | |
| 154 | */ | |
| 155 | function parse_range( $size ) { | |
| 156 | // By default, start transmitting at the beginning of the file. | |
| 157 | $seek_start = 0; | |
| 158 | $content_length = $size; | |
| 159 | ||
| 160 | // Check if a range is sent by browser or download manager. | |
| 161 | if( isset( $_SERVER[ 'HTTP_RANGE' ] ) ) { | |
| 162 | $range_format = '/^bytes=\d*-\d*(,\d*-\d*)*$/'; | |
| 163 | $request_range = $_SERVER[ 'HTTP_RANGE' ]; | |
| 164 | ||
| 165 | // Ensure the content request range is in a valid format. | |
| 166 | if( !preg_match( $range_format, $request_range, $matches ) ) { | |
| 167 | header( 'HTTP/1.1 416 Requested Range Not Satisfiable' ); | |
| 168 | header( "Content-Range: bytes */$size" ); | |
| 169 | ||
| 170 | // Terminate because the range is invalid. | |
| 171 | exit; | |
| 172 | } | |
| 173 | ||
| 174 | // Multiple ranges could be specified, but only serve the first range. | |
| 175 | $seek_start = isset( $matches[ 1 ] ) ? $matches[ 1 ] + 0 : 0; | |
| 176 | $seek_end = isset( $matches[ 2 ] ) ? $matches[ 2 ] + 0 : $size - 1; | |
| 177 | $range_bytes = $seek_start . '-' . $seek_end . '/' . $size; | |
| 178 | $content_length = $seek_end - $seek_start + 1; | |
| 179 | ||
| 180 | header( 'HTTP/1.1 206 Partial Content' ); | |
| 181 | header( "Content-Range: bytes $range_bytes" ); | |
| 182 | } | |
| 183 | ||
| 184 | return array( $seek_start, $content_length ); | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Transmits a file from the server to the client. | |
| 189 | * | |
| 190 | * @param string $filename File to download, must be this script directory. | |
| 191 | * @param int $seek_start Offset into file to start downloading. | |
| 192 | * @param int $size Total size of the file. | |
| 193 | * | |
| 194 | * @return bool True if the file was transferred. | |
| 195 | */ | |
| 196 | function transmit( $filename, $seek_start, $size ) { | |
| 197 | // Buffer after sending HTTP headers to allow client download estimates. | |
| 198 | if( ob_get_level() == 0 ) { | |
| 199 | ob_start(); | |
| 200 | } | |
| 201 | ||
| 202 | // Don't count missing files as download hits. | |
| 203 | $bytes_sent = -1; | |
| 204 | ||
| 205 | // Open the file to be downloaded. | |
| 206 | $fp = @fopen( $filename, 'rb' ); | |
| 207 | ||
| 208 | if( $fp !== false ) { | |
| 209 | @fseek( $fp, $seek_start ); | |
| 210 | ||
| 211 | $aborted = false; | |
| 212 | $bytes_sent = $seek_start; | |
| 213 | $chunk_size = 1024 * 16; | |
| 214 | ||
| 215 | while( !feof( $fp ) && !$aborted ) { | |
| 216 | // Stream the file. | |
| 217 | print( @fread( $fp, $chunk_size ) ); | |
| 218 | ||
| 219 | // Track running total of bytes sent. | |
| 220 | $bytes_sent += $chunk_size; | |
| 221 | ||
| 222 | // Send the file to download in small chunks. | |
| 223 | if( ob_get_level() > 0 ) { | |
| 224 | ob_flush(); | |
| 225 | } | |
| 226 | ||
| 227 | flush(); | |
| 228 | ||
| 229 | // Chunking the file allows detecting when the connection has closed. | |
| 230 | $aborted = connection_aborted() || connection_status() != 0; | |
| 231 | } | |
| 232 | ||
| 233 | // Indicate that transmission is complete. | |
| 234 | if( ob_get_level() > 0 ) { | |
| 235 | ob_end_flush(); | |
| 236 | } | |
| 237 | ||
| 238 | fclose( $fp ); | |
| 239 | } | |
| 240 | ||
| 241 | // Download succeeded if the total bytes matches or exceeds the file size. | |
| 242 | return $bytes_sent >= $size; | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Increments the number in a file using an exclusive lock. The file | |
| 247 | * is set to an initial value set to 0 if it doesn't exist. | |
| 248 | * | |
| 249 | * @param string $filename The file containing a number to increment. | |
| 250 | */ | |
| 251 | function increment_count( $filename ) { | |
| 252 | try { | |
| 253 | lock_open( $filename ); | |
| 254 | ||
| 255 | // Coerce value to largest natural numeric data type. | |
| 256 | $count = @file_get_contents( $filename ) + 0; | |
| 257 | ||
| 258 | // Write the new counter value. | |
| 259 | file_put_contents( $filename, $count + 1 ); | |
| 260 | } | |
| 261 | finally { | |
| 262 | lock_close( $filename ); | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | /** | |
| 267 | * Acquires a lock for a particular file. Callers would be prudent to | |
| 268 | * call this function from within a try/finally block and close the lock | |
| 269 | * in the finally section. The amount of time between opening and closing | |
| 270 | * the lock must be minimal because parallel processes will be waiting on | |
| 271 | * the lock's release. | |
| 272 | * | |
| 273 | * @param string $filename The name of file to lock. | |
| 274 | * | |
| 275 | * @return bool True if the lock was obtained, false upon excessive attempts. | |
| 276 | */ | |
| 277 | function lock_open( $filename ) { | |
| 278 | $lockdir = create_lock_filename( $filename ); | |
| 279 | ||
| 280 | // Track the number of times a lock attempt is made. | |
| 281 | $iterations = 0; | |
| 282 | ||
| 283 | do { | |
| 284 | // Creates and tests lock file existence atomically. | |
| 285 | if( @mkdir( $lockdir, 0777 ) ) { | |
| 286 | // Exit the loop. | |
| 287 | $iterations = 0; | |
| 288 | } | |
| 289 | else { | |
| 290 | $iterations++; | |
| 291 | $lifetime = time() - filemtime( $lockdir ); | |
| 292 | ||
| 293 | if( $lifetime > 10 ) { | |
| 294 | // If the lock has gone stale, delete it. | |
| 295 | @rmdir( $lockdir ); | |
| 296 | } | |
| 297 | else { | |
| 298 | // Wait a random duration to avoid concurrency conflicts. | |
| 299 | usleep( rand( 1000, 10000 ) ); | |
| 300 | } | |
| 301 | } | |
| 302 | } | |
| 303 | while( $iterations > 0 && $iterations < 10 ); | |
| 304 | ||
| 305 | // Indicate whether the maximum number of lock attempts were exceeded. | |
| 306 | return $iterations == 0; | |
| 307 | } | |
| 308 | ||
| 309 | /** | |
| 310 | * Releases the lock on a particular file. | |
| 311 | * | |
| 312 | * @param string $filename The name of file that was locked. | |
| 313 | */ | |
| 314 | function lock_close( $filename ) { | |
| 315 | @rmdir( create_lock_filename( $filename ) ); | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * Creates a uniquely named lock directory name. | |
| 320 | * | |
| 321 | * @param string $filename The name of the file under contention. | |
| 322 | * | |
| 323 | * @return string A unique lock file reference for the given file name. | |
| 324 | */ | |
| 325 | function create_lock_filename( $filename ) { | |
| 326 | return $filename .'.lock'; | |
| 327 | } | |
| 328 | ?> | |
| 329 | 1 |
| 1 | 1 |
| 1 | <svg width="157.331" height="75" xmlns="http://www.w3.org/2000/svg"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc.--><path d="M56.862 15.625a3.122 3.122 0 0 0-3.125-3.125 3.122 3.122 0 0 0-3.125 3.125v23.701l-7.168-7.168a3.13 3.13 0 0 0-4.424 0 3.13 3.13 0 0 0 0 4.424l12.5 12.5a3.13 3.13 0 0 0 4.424 0l12.5-12.5a3.13 3.13 0 0 0 0-4.424 3.13 3.13 0 0 0-4.424 0l-7.158 7.168zm-21.875 31.25a6.256 6.256 0 0 0-6.25 6.25v3.125a6.256 6.256 0 0 0 6.25 6.25h37.5a6.256 6.256 0 0 0 6.25-6.25v-3.125a6.256 6.256 0 0 0-6.25-6.25h-9.913l-4.423 4.424a6.248 6.248 0 0 1-8.838 0l-4.414-4.424zm35.937 10.156a2.338 2.338 0 0 1-2.344-2.343 2.338 2.338 0 0 1 2.344-2.344 2.338 2.338 0 0 1 2.344 2.344 2.338 2.338 0 0 1-2.344 2.343z" style="fill:#000;fill-opacity:1;stroke-width:.0976562"/><path d="M121.707 38.922c-.022-4.096 1.83-7.189 5.581-9.466-2.098-3.003-5.268-4.655-9.454-4.978-3.963-.313-8.294 2.31-9.88 2.31-1.674 0-5.514-2.199-8.528-2.199-6.229.1-12.848 4.968-12.848 14.87q0 4.386 1.607 9.063c1.43 4.097 6.586 14.143 11.967 13.976 2.813-.067 4.8-1.998 8.461-1.998 3.55 0 5.392 1.998 8.528 1.998 5.426-.078 10.092-9.21 11.453-13.317-7.278-3.427-6.887-10.047-6.887-10.259zm-6.318-18.329c3.047-3.617 2.768-6.91 2.679-8.093-2.69.156-5.805 1.83-7.58 3.896-1.953 2.21-3.103 4.945-2.857 8.026 2.913.223 5.57-1.273 7.758-3.829z" style="fill:#000;fill-opacity:1;stroke-width:.111627"/></svg> | |
| 1 |
| 1 | <svg width="157.331" height="75" xmlns="http://www.w3.org/2000/svg"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc.--><path d="M53.685 15.625A3.122 3.122 0 0 0 50.56 12.5a3.122 3.122 0 0 0-3.125 3.125v23.701l-7.168-7.168a3.13 3.13 0 0 0-4.424 0 3.13 3.13 0 0 0 0 4.424l12.5 12.5a3.13 3.13 0 0 0 4.424 0l12.5-12.5a3.13 3.13 0 0 0 0-4.424 3.13 3.13 0 0 0-4.424 0l-7.158 7.168zM31.81 46.875a6.256 6.256 0 0 0-6.25 6.25v3.125a6.256 6.256 0 0 0 6.25 6.25h37.5a6.256 6.256 0 0 0 6.25-6.25v-3.125a6.256 6.256 0 0 0-6.25-6.25h-9.912l-4.424 4.424a6.248 6.248 0 0 1-8.838 0l-4.414-4.424Zm35.938 10.156a2.338 2.338 0 0 1-2.344-2.343 2.338 2.338 0 0 1 2.344-2.344 2.338 2.338 0 0 1 2.343 2.343 2.338 2.338 0 0 1-2.343 2.344z" style="fill:#000;fill-opacity:1;stroke-width:.0976562"/><path d="M121.742 43.056c.957-.654 2.285-1.22 2.285-1.22s-3.78.683-7.54.996c-4.599.38-9.54.459-12.02.127-5.87-.782 3.222-2.94 3.222-2.94s-3.525-.234-7.87 1.856c-5.128 2.48 12.694 3.613 21.923 1.181zm-8.34-3.134c-1.855-4.17-8.115-7.832 0-14.239 10.123-7.988 4.932-13.183 4.932-13.183 2.1 8.252-7.383 10.752-10.81 15.879-2.335 3.506 1.142 7.265 5.878 11.543zm11.191-17.207c.01 0-17.109 4.277-8.935 13.69 2.412 2.774-.635 5.274-.635 5.274s6.123-3.164 3.31-7.119c-2.626-3.691-4.638-5.527 6.26-11.845zm-.595 26.415a1.19 1.19 0 0 1-.196.254c12.53-3.29 7.92-11.61 1.934-9.502a1.692 1.692 0 0 0-.801.616 6.88 6.88 0 0 1 1.074-.293c3.028-.635 7.373 4.052-2.011 8.925zm4.605 6.084s1.416 1.162-1.553 2.07c-5.654 1.71-23.515 2.227-28.476.069-1.787-.772 1.563-1.855 2.617-2.08 1.094-.234 1.729-.195 1.729-.195-1.983-1.397-12.822 2.744-5.508 3.925 19.945 3.242 36.367-1.455 31.19-3.789zm-21.832-4.043c-7.685 2.149 4.678 6.582 14.463 2.393a18.153 18.153 0 0 1-2.754-1.348c-4.365.83-6.387.889-10.351.44-3.272-.371-1.358-1.485-1.358-1.485zm17.559 9.492c-7.686 1.446-17.168 1.28-22.783.352 0-.01 1.152.947 7.07 1.328 9.004.576 22.832-.322 23.154-4.58 0 0-.625 1.611-7.441 2.9zm-4.258-13.69c-5.781 1.112-9.13 1.083-13.36.644-3.27-.342-1.132-1.924-1.132-1.924-8.477 2.812 4.707 5.996 16.552 2.53a5.895 5.895 0 0 1-2.06-1.25z" style="fill:#000;fill-opacity:1;stroke-width:.097655"/></svg> | |
| 1 |
| 1 | <svg width="157.331" height="75" xmlns="http://www.w3.org/2000/svg"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc.--><path d="M111.91 24.54c.098.049.176.166.293.166.108 0 .274-.04.284-.147.02-.136-.186-.224-.313-.283-.166-.068-.38-.097-.537-.01-.039.02-.078.069-.059.108.03.127.225.107.332.166zm-2.138.166c.117 0 .195-.117.293-.166.107-.059.303-.04.342-.156.02-.04-.02-.088-.059-.108-.156-.088-.371-.058-.537.01-.127.059-.332.147-.313.283.01.098.176.147.274.137zm21.59 27.224c-.352-.39-.518-1.132-.703-1.923-.176-.791-.381-1.64-1.025-2.188a2.639 2.639 0 0 0-.391-.283 2.167 2.167 0 0 0-.4-.195c.898-2.666.546-5.322-.362-7.724-1.113-2.94-3.056-5.508-4.54-7.265-1.67-2.1-3.291-4.092-3.262-7.03.049-4.483.498-12.812-7.402-12.822-9.999-.02-7.499 10.097-7.606 13.202-.166 2.285-.625 4.082-2.197 6.318-1.846 2.197-4.443 5.741-5.674 9.442-.586 1.748-.86 3.525-.605 5.205-.635.566-1.113 1.435-1.621 1.972-.41.42-1.006.577-1.66.811-.654.234-1.367.586-1.807 1.416-.205.38-.273.79-.273 1.21 0 .382.059.772.117 1.153.117.79.244 1.533.078 2.031-.508 1.406-.576 2.383-.215 3.095.371.713 1.114 1.026 1.963 1.202 1.69.351 3.984.263 5.79 1.22 1.934 1.016 3.897 1.377 5.46 1.016 1.132-.254 2.06-.938 2.528-1.973 1.22-.01 2.568-.527 4.717-.644 1.455-.117 3.28.517 5.38.4.059.225.137.45.244.654v.01c.81 1.631 2.324 2.373 3.935 2.246 1.621-.127 3.33-1.074 4.717-2.724 1.328-1.602 3.515-2.266 4.97-3.144.723-.44 1.309-.987 1.357-1.787.04-.801-.43-1.69-1.513-2.9zm-19.168-30.905c.957-2.168 3.34-2.13 4.296-.04.635 1.387.352 3.018-.42 3.945-.156-.078-.576-.253-1.23-.478.107-.117.303-.264.38-.45.47-1.151-.019-2.636-.888-2.665-.713-.049-1.357 1.055-1.152 2.246-.4-.195-.918-.342-1.27-.43a3.89 3.89 0 0 1 .284-2.128zm-3.975-1.123c.987 0 2.031 1.386 1.865 3.27a3.511 3.511 0 0 0-.996.45c.118-.869-.322-1.963-.937-1.914-.82.069-.957 2.07-.176 2.744.098.078.186-.02-.576.537-1.523-1.426-1.025-5.087.82-5.087zm-1.328 5.927c.606-.45 1.328-.977 1.377-1.025.46-.43 1.318-1.387 2.725-1.387.693 0 1.523.225 2.529.869.615.4 1.103.43 2.206.908.82.342 1.338.947 1.026 1.777-.254.694-1.074 1.406-2.217 1.768-1.084.351-1.933 1.562-3.73 1.455a2.722 2.722 0 0 1-.937-.205c-.782-.342-1.192-1.016-1.953-1.465-.84-.469-1.29-1.016-1.436-1.494-.137-.479 0-.879.41-1.201zm.323 32.614c-.264 3.428-4.287 3.36-7.353 1.758-2.92-1.543-6.699-.635-7.47-2.138-.235-.46-.235-1.24.253-2.578v-.02c.235-.742.059-1.562-.058-2.334-.117-.761-.176-1.464.088-1.953.341-.654.83-.888 1.445-1.103 1.006-.361 1.152-.332 1.914-.967.537-.556.927-1.26 1.396-1.757.498-.538.977-.791 1.729-.674.79.117 1.474.664 2.138 1.562l1.914 3.476c.928 1.944 4.209 4.727 4.004 6.728zm-.137-2.529c-.4-.644-.938-1.328-1.406-1.914.693 0 1.386-.214 1.63-.869.225-.605 0-1.455-.722-2.431-1.318-1.777-3.74-3.174-3.74-3.174-1.318-.82-2.06-1.826-2.402-2.92-.342-1.093-.293-2.275-.03-3.437.508-2.236 1.817-4.413 2.656-5.78.225-.166.079.312-.85 2.03-.83 1.573-2.382 5.205-.253 8.047.059-2.021.537-4.082 1.348-6.005 1.171-2.676 3.642-7.314 3.837-11.005.108.078.45.312.606.4.449.264.79.654 1.23 1.006 1.21.976 2.783.898 4.14.117.606-.342 1.094-.732 1.553-.879.967-.303 1.738-.84 2.177-1.465.752 2.969 2.51 7.256 3.633 9.345.596 1.113 1.787 3.467 2.304 6.308.323-.01.684.04 1.065.137 1.347-3.486-1.143-7.245-2.275-8.29-.46-.45-.479-.645-.254-.635 1.23 1.094 2.851 3.29 3.437 5.761.273 1.133.322 2.315.039 3.486 1.601.664 3.505 1.748 2.998 3.398-.215-.01-.313 0-.41 0 .312-.986-.381-1.718-2.227-2.548-1.914-.84-3.515-.84-3.74 1.22-1.181.41-1.787 1.436-2.09 2.666-.273 1.094-.35 2.412-.43 3.896-.048.752-.35 1.758-.663 2.832-3.135 2.236-7.49 3.213-11.161.703zm25.134-1.123c-.087 1.64-4.023 1.944-6.17 4.541-1.29 1.533-2.872 2.383-4.258 2.49-1.387.107-2.588-.469-3.291-1.885-.46-1.084-.234-2.255.107-3.544.362-1.387.899-2.813.967-3.965.078-1.484.166-2.783.41-3.779.254-1.006.645-1.68 1.338-2.06.03-.02.068-.03.098-.049.078 1.289.712 2.598 1.835 2.88 1.23.323 2.998-.732 3.75-1.591.879-.03 1.533-.088 2.207.498.967.83.693 2.959 1.67 4.062 1.035 1.133 1.367 1.904 1.337 2.402zm-24.939-27.77c.195.185.46.439.781.692.645.508 1.543 1.036 2.666 1.036 1.133 0 2.197-.577 3.105-1.055.479-.254 1.065-.684 1.446-1.016.38-.332.576-.615.302-.644-.273-.03-.254.254-.586.498-.43.312-.947.723-1.357.957-.723.41-1.904.996-2.92.996-1.015 0-1.826-.469-2.431-.947-.303-.244-.557-.489-.752-.674-.146-.137-.186-.45-.42-.479-.137-.01-.176.362.166.635z" style="fill:#000;fill-opacity:1;stroke-width:.097648"/><path d="M52.578 15.625a3.122 3.122 0 0 0-3.125-3.125 3.122 3.122 0 0 0-3.125 3.125v23.701l-7.168-7.168a3.13 3.13 0 0 0-4.423 0 3.13 3.13 0 0 0 0 4.424l12.5 12.5a3.13 3.13 0 0 0 4.423 0l12.5-12.5a3.13 3.13 0 0 0 0-4.424 3.13 3.13 0 0 0-4.423 0l-7.159 7.168zm-21.875 31.25a6.256 6.256 0 0 0-6.25 6.25v3.125a6.256 6.256 0 0 0 6.25 6.25h37.5a6.256 6.256 0 0 0 6.25-6.25v-3.125a6.256 6.256 0 0 0-6.25-6.25h-9.912l-4.424 4.424a6.248 6.248 0 0 1-8.837 0l-4.415-4.424zm35.938 10.156a2.338 2.338 0 0 1-2.344-2.343 2.338 2.338 0 0 1 2.344-2.344 2.338 2.338 0 0 1 2.344 2.344 2.338 2.338 0 0 1-2.344 2.343z" style="fill:#000;fill-opacity:1;stroke-width:.0976562"/></svg> | |
| 1 |
| 1 | <svg width="157.331" height="75" xmlns="http://www.w3.org/2000/svg"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc.--><path d="m83.666 19.386 20.49-2.823v19.799h-20.49Zm0 36.228 20.49 2.823V38.884h-20.49Zm22.745 3.125 27.255 3.761V38.884H106.41zm0-42.478v20.1h27.255V12.5Z" style="fill:#000;fill-opacity:1;stroke-width:.111607"/><path d="M51.79 15.625a3.122 3.122 0 0 0-3.124-3.125 3.122 3.122 0 0 0-3.125 3.125v23.701l-7.168-7.168a3.13 3.13 0 0 0-4.424 0 3.13 3.13 0 0 0 0 4.424l12.5 12.5a3.13 3.13 0 0 0 4.424 0l12.5-12.5a3.13 3.13 0 0 0 0-4.424 3.13 3.13 0 0 0-4.424 0l-7.158 7.168zm-21.874 31.25a6.256 6.256 0 0 0-6.25 6.25v3.125a6.256 6.256 0 0 0 6.25 6.25h37.5a6.256 6.256 0 0 0 6.25-6.25v-3.125a6.256 6.256 0 0 0-6.25-6.25h-9.913l-4.423 4.424a6.248 6.248 0 0 1-8.838 0l-4.414-4.424Zm35.937 10.156a2.338 2.338 0 0 1-2.344-2.343 2.338 2.338 0 0 1 2.344-2.344 2.338 2.338 0 0 1 2.344 2.343 2.338 2.338 0 0 1-2.344 2.344z" style="fill:#000;fill-opacity:1;stroke-width:.0976562"/></svg> | |
| 1 |
| 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | |
| 2 | <svg | |
| 3 | height="493.14542" | |
| 4 | viewBox="0 0 500.05118 493.14542" | |
| 5 | width="500.05118" | |
| 6 | version="1.1" | |
| 7 | id="svg37" | |
| 8 | sodipodi:docname="logo-icon.svg" | |
| 9 | inkscape:version="1.2.1 (9c6d41e410, 2022-07-14, custom)" | |
| 10 | xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | |
| 11 | xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | |
| 12 | xmlns="http://www.w3.org/2000/svg" | |
| 13 | xmlns:svg="http://www.w3.org/2000/svg" | |
| 14 | xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | |
| 15 | xmlns:cc="http://creativecommons.org/ns#" | |
| 16 | xmlns:dc="http://purl.org/dc/elements/1.1/"> | |
| 17 | <sodipodi:namedview | |
| 18 | id="namedview18" | |
| 19 | pagecolor="#ffffff" | |
| 20 | bordercolor="#666666" | |
| 21 | borderopacity="1.0" | |
| 22 | inkscape:showpageshadow="2" | |
| 23 | inkscape:pageopacity="0.0" | |
| 24 | inkscape:pagecheckerboard="0" | |
| 25 | inkscape:deskcolor="#d1d1d1" | |
| 26 | showgrid="false" | |
| 27 | inkscape:zoom="2.1352728" | |
| 28 | inkscape:cx="250.3193" | |
| 29 | inkscape:cy="246.57271" | |
| 30 | inkscape:current-layer="svg37" /> | |
| 31 | <metadata | |
| 32 | id="metadata43"> | |
| 33 | <rdf:RDF> | |
| 34 | <cc:Work | |
| 35 | rdf:about=""> | |
| 36 | <dc:format>image/svg+xml</dc:format> | |
| 37 | <dc:type | |
| 38 | rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> | |
| 39 | </cc:Work> | |
| 40 | </rdf:RDF> | |
| 41 | </metadata> | |
| 42 | <defs | |
| 43 | id="defs41"> | |
| 44 | <linearGradient | |
| 45 | id="a" | |
| 46 | gradientTransform="matrix(-8.7796153,42.985832,-42.985832,-8.7796153,514.83476,136.06192)" | |
| 47 | gradientUnits="userSpaceOnUse" | |
| 48 | x1="0.152358" | |
| 49 | x2="0.96880901" | |
| 50 | y1="-0.044911999" | |
| 51 | y2="-0.049470998"> | |
| 52 | <stop | |
| 53 | offset="0" | |
| 54 | stop-color="#ec706a" | |
| 55 | id="stop2" /> | |
| 56 | <stop | |
| 57 | offset="1" | |
| 58 | stop-color="#ecd980" | |
| 59 | id="stop4" /> | |
| 60 | </linearGradient> | |
| 61 | </defs> | |
| 62 | <g | |
| 63 | id="g485" | |
| 64 | transform="matrix(2.5605898,1.4612315,-1.4612315,2.5605898,-947.38048,-777.17055)"> | |
| 65 | <path | |
| 66 | style="fill:url(#a);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.226;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0" | |
| 67 | paint-order="stroke" | |
| 68 | d="m 496.76229,150.80474 c -4.25368,20.68081 3.28191,25.95476 3.28191,25.95476 v 0 c 0,0 3.00963,-13.19543 8.64082,-10.76172 v 0 c 4.83401,2.08299 1.12516,10.97002 1.12516,10.97002 v 0 c 0,0 31.78993,-30.5076 7.60484,-40.99434 v 0 c 0,0 -5.30287,-2.76791 -10.69842,-0.65209 v 0 c -3.94735,1.54891 -7.94375,5.71058 -9.95431,15.48337" | |
| 69 | stroke-linecap="round" | |
| 70 | id="path14-3" /> | |
| 71 | <path | |
| 72 | d="m 530.80335,138.63592 -10.99206,-16.95952 1.75995,-6.49966 10.01483,2.71233 z" | |
| 73 | fill="#126d95" | |
| 74 | id="path9" /> | |
| 75 | <path | |
| 76 | d="m 533.0598,112.36676 -0.91739,3.38458 -9.99361,-2.70665 0.91739,-3.38458 z" | |
| 77 | fill="#126d95" | |
| 78 | id="path11" /> | |
| 79 | <g | |
| 80 | fill="#51a9cf" | |
| 81 | id="g19" | |
| 82 | transform="translate(-295.50101,-692.52836)"> | |
| 83 | <path | |
| 84 | d="m 834.01973,741.0381 c -1.68105,0.0185 -3.22054,1.13771 -3.68367,2.84981 -0.56186,2.07405 0.665,4.21099 2.73743,4.77241 l -13.96475,51.52944 -9.99361,-2.70665 c 8.36013,-31.46487 4.99411,-51.98144 4.99411,-51.98144 14.99782,-11.92097 23.67,-25.56577 27.63101,-32.97331 z" | |
| 85 | id="path13" /> | |
| 86 | <path | |
| 87 | d="m 818.56767,802.18881 -0.9174,3.38458 -10.03996,-2.72957 0.91314,-3.37522 z" | |
| 88 | id="path15" /> | |
| 89 | <path | |
| 90 | d="m 817.07405,807.70594 -1.75995,6.49966 -18.03534,9.08805 9.78412,-18.31044 z" | |
| 91 | id="path17" /> | |
| 92 | </g> | |
| 93 | <path | |
| 94 | d="m 540.69709,49.12083 7.72577,-28.52932 c -0.3195,8.40427 0.28451,24.55036 7.21678,42.41047 0,0 -11.89603,16.50235 -21.99788,47.3763 l -10.03442,-2.71758 13.96533,-51.5284 c 2.08221,0.56405 4.21039,-0.66603 4.77182,-2.73844 0.45427,-1.67248 -0.26571,-3.38317 -1.64739,-4.27302" | |
| 95 | fill="#126d95" | |
| 96 | id="path21" /> | |
| 97 | <text | |
| 98 | transform="translate(-295.73751,-689.6407)" | |
| 99 | id="text25" /> | |
| 100 | </g> | |
| 101 | </svg> | |
| 102 | 1 |
| 1 | <svg width="300" height="71.784" viewBox="0 0 79.375 18.993" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient xlink:href="#a" id="b" x1=".152" x2=".969" y1="-.045" y2="-.049" gradientTransform="rotate(101.544 290.55 422.146) scale(26.05808)" gradientUnits="userSpaceOnUse"/><linearGradient id="a" x1=".152" x2=".969" y1="-.045" y2="-.049" gradientTransform="rotate(101.544 290.55 422.146) scale(26.05808)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ec706a"/><stop offset="1" stop-color="#ecd980"/></linearGradient></defs><path style="fill:#51a9cf;fill-opacity:1" d="M76.697 4.286c-.936 0-1.79.24-2.565.718-.768.473-1.376 1.143-1.823 2.01-.452.854-.678 1.816-.678 2.887 0 .65.118 1.25.355 1.798.237.543.613.981 1.13 1.315.494.333 1.134.5 1.919.5l.001.001c.365 0 .739-.044 1.12-.13a6.09 6.09 0 0 0 1.114-.378c.36-.167.688-.363.984-.59.3-.225.55-.473.75-.742l-.726-1.355c-.145.193-.363.39-.653.589a4.83 4.83 0 0 1-.944.5 2.583 2.583 0 0 1-.952.202c-.387 0-.667-.083-.84-.25-.171-.167-.274-.423-.306-.767v-.226a14.49 14.49 0 0 0 1.88-.613 7.222 7.222 0 0 0 1.525-.847 3.661 3.661 0 0 0 1.016-1.113c.247-.425.37-.906.37-1.444 0-.403-.099-.758-.298-1.065-.193-.311-.49-.556-.887-.734-.393-.177-.89-.266-1.492-.266zm-.63 1.533c.22 0 .374.064.46.193.092.124.138.329.138.614 0 .29-.062.572-.186.847-.124.269-.29.513-.5.734-.205.22-.43.397-.678.532a1.78 1.78 0 0 1-.75.225c.01-.275.046-.619.105-1.032.059-.414.201-.8.428-1.16.172-.27.33-.496.475-.679.145-.182.315-.274.508-.274zM15.617 4.286c-.936 0-1.79.24-2.565.718-.768.473-1.376 1.143-1.823 2.01-.452.854-.678 1.816-.678 2.887 0 .65.118 1.25.355 1.798.237.544.613.982 1.13 1.316.494.333 1.134.5 1.919.5h.001c.365 0 .739-.044 1.12-.13a6.09 6.09 0 0 0 1.114-.378c.36-.167.688-.363.984-.59.3-.225.55-.473.75-.742l-.726-1.355c-.145.193-.363.39-.654.589-.29.199-.604.366-.943.5a2.583 2.583 0 0 1-.952.202c-.387 0-.667-.083-.84-.25-.171-.167-.273-.423-.306-.767v-.226a14.49 14.49 0 0 0 1.88-.613 7.197 7.197 0 0 0 1.524-.847 3.656 3.656 0 0 0 1.017-1.113c.247-.425.37-.906.37-1.444 0-.403-.099-.758-.298-1.065-.193-.311-.49-.556-.887-.734-.393-.177-.89-.266-1.492-.266zm-.63 1.533c.22 0 .374.064.46.193.092.124.138.329.138.614 0 .29-.062.572-.186.847-.123.269-.29.513-.5.734-.205.22-.43.397-.678.532-.247.135-.497.21-.75.226.01-.275.046-.62.105-1.033.059-.414.201-.8.428-1.16.172-.27.33-.496.475-.679.145-.182.315-.274.508-.274zM24.377 4.286c-.935 0-1.79.24-2.565.718-.769.473-1.377 1.143-1.823 2.01-.452.854-.678 1.816-.678 2.887 0 .65.118 1.25.355 1.798.236.544.613.982 1.129 1.316.495.333 1.135.5 1.92.5.366 0 .74-.044 1.122-.13a6.091 6.091 0 0 0 1.113-.378 5.22 5.22 0 0 0 .984-.59 3.6 3.6 0 0 0 .75-.742l-.726-1.355c-.145.193-.363.39-.653.589-.29.199-.605.366-.944.5a2.583 2.583 0 0 1-.951.202c-.388 0-.668-.083-.84-.25-.171-.167-.274-.423-.307-.767v-.226a14.49 14.49 0 0 0 1.88-.613 7.222 7.222 0 0 0 1.525-.847 3.673 3.673 0 0 0 1.017-1.113c.247-.425.37-.906.37-1.444 0-.403-.1-.758-.298-1.065-.194-.311-.49-.556-.888-.734-.392-.177-.89-.266-1.492-.266zm-.63 1.533c.22 0 .375.064.461.193.092.124.137.329.137.614 0 .29-.062.572-.186.847-.123.269-.29.513-.5.734-.204.22-.43.397-.678.532a1.79 1.79 0 0 1-.75.226c.011-.275.046-.62.105-1.033.06-.414.202-.8.428-1.16.172-.27.33-.496.476-.679.145-.182.314-.274.508-.274z"/><path d="M751.566 230.706c-2.527 12.283 1.949 15.415 1.949 15.415s1.787-7.837 5.132-6.391c2.871 1.237.668 6.515.668 6.515s18.882-18.12 4.517-24.348c0 0-3.15-1.644-6.354-.387-2.345.92-4.718 3.391-5.912 9.196" paint-order="stroke" style="fill:url(#b);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.72817;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0" transform="translate(-95.423 -31.173) scale(.20372)"/><path fill="#126d95" d="m771.784 223.478-6.529-10.073 1.046-3.86 5.948 1.61zm1.34-15.602-.545 2.01-5.935-1.607.545-2.01z" style="stroke-width:.59394" transform="translate(-95.423 -31.173) scale(.20372)"/><path d="M62.74 3.45a.467.467 0 0 0-.446.344.47.47 0 0 0 .332.578l-1.69 6.235-1.21-.328c1.012-3.807.605-6.29.605-6.29A13.105 13.105 0 0 0 63.674 0zm-1.87 7.399-.11.41-1.215-.33.11-.41zm-.18.667-.213.787-2.182 1.1 1.183-2.216z" fill="#51a9cf"/><path fill="#126d95" d="m777.66 170.312 4.589-16.945c-.19 4.992.169 14.581 4.286 25.19 0 0-7.065 9.8-13.065 28.138l-5.96-1.614 8.295-30.605a2.308 2.308 0 0 0 1.855-4.164" style="stroke-width:.59394" transform="translate(-95.423 -31.173) scale(.20372)"/><path style="fill:#51a9cf;fill-opacity:1" d="M52.691 13.385 53.917 4.4h2.71l-.129 1.872q.178-.525.516-.976.34-.452.799-.726.46-.283.992-.283.428 0 .549.097l-.5 2.856q-.057-.097-.299-.154-.242-.056-.476-.056-.347 0-.645.072-.29.065-.565.218-.266.154-.548.404l-.823 5.662zM40.107 13.385 38.639 1.334h2.823l.452 7.711.161 1.759.532-1.759 2.033-5.05-.29-2.661h2.726l.468 7.711.178 1.759.42-1.759 2.629-7.711h2.872l-4.68 12.051h-3.258l-.452-4.307-.145-1.323-.484 1.323-1.565 4.307ZM28.088 13.385 29.314 4.4h2.694l-.113 1.63q.549-.823 1.29-1.283.75-.46 1.63-.46 1.065 0 1.622.549.556.548.556 1.823 0 .177-.056.645-.049.46-.13 1.025-.072.556-.137 1.024l-.088.597q-.057.436-.137 1-.073.557-.154 1.097-.072.54-.129.912l-.056.427h-2.807q.12-.806.217-1.516.105-.718.186-1.34.08-.628.145-1.16.097-.816.153-1.356.065-.549.073-.839.016-.427-.145-.597-.161-.177-.5-.177-.218 0-.46.105-.234.104-.468.306-.234.194-.452.468-.21.274-.379.605l-.774 5.501zM0 13.385 1.726 1.334h2.71l-.774 5.275 4.243-5.275H11.1L6.324 6.642q.113.21.274.532.17.315.404.79.242.468.572 1.138.34.67.8 1.59.209.427.41.814.202.379.388.718.185.33.355.62l.33.541H6.582q-.032-.056-.12-.234-.09-.177-.227-.476-.137-.306-.33-.726l-.42-.968-1-2.307-1.29 1.356-.485 3.355zM67.21 13.547q-.854 0-1.355-.444-.5-.444-.5-1.25 0-.073.024-.347l.057-.613q.04-.347.08-.638l.565-4.146h-.661l.29-1.71h.823l.774-2.162h2.114L69.114 4.4h1.759l-.178 1.71h-1.79l-.307 2.227-.194 1.387q-.072.508-.104.774l-.049.388q-.008.12-.008.226 0 .242.113.379t.436.137q.169 0 .427-.089.258-.097.516-.242.259-.153.428-.315l.436 1.34q-.42.322-.944.605-.517.274-1.13.451-.613.17-1.314.17z"/></svg> | |
| 1 |
| 1 | <!DOCTYPE html> | |
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <title>KeenWrite</title> | |
| 5 | <meta charset="UTF-8"> | |
| 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 7 | <meta name="description" content="cross-platform, open-source desktop editor"> | |
| 8 | <meta name="keywords" content="markdown, text, editor, software"> | |
| 9 | <meta name="robots" content="index, follow"> | |
| 10 | <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self' 'unsafe-inline';"> | |
| 11 | <style><!--#include file="styles/base.css" --></style> | |
| 12 | </head> | |
| 13 | <body> | |
| 14 | <header> | |
| 15 | <img src="images/logo/title.svg" alt="KeenWrite" class="title"> | |
| 16 | <p> | |
| 17 | A free, cross-platform desktop text editor for producing beautifully | |
| 18 | typeset PDF files. | |
| 19 | </p> | |
| 20 | </header> | |
| 21 | <main> | |
| 22 | <div class="downloads"> | |
| 23 | <a href="downloads/keenwrite.bin" | |
| 24 | class="download" | |
| 25 | title="Download for 64-bit Linux (x86)" | |
| 26 | aria-label="Download for Linux"><img | |
| 27 | src="images/icons/linux.svg" | |
| 28 | alt="Download for Linux" | |
| 29 | class="download"></a> | |
| 30 | <a href="downloads/keenwrite.jar" | |
| 31 | class="download" | |
| 32 | title="Download for Java virtual machine" | |
| 33 | aria-label="Download for Java"><img | |
| 34 | src="images/icons/java.svg" | |
| 35 | alt="Download for Java" | |
| 36 | class="download"></a> | |
| 37 | <a href="downloads/KeenWrite.exe" | |
| 38 | class="download" | |
| 39 | title="Download for 64-bit Windows (x86)" | |
| 40 | aria-label="Download for Windows"><img | |
| 41 | src="images/icons/windows.svg" | |
| 42 | alt="Download for Windows" | |
| 43 | class="download"></a> | |
| 44 | <a href="downloads/keenwrite.app" | |
| 45 | class="download" | |
| 46 | title="Download for 64-bit MacOS (x86)" | |
| 47 | aria-label="Download for MacOS"><img | |
| 48 | src="images/icons/apple.svg" | |
| 49 | alt="Download for MacOS" | |
| 50 | class="download"></a> | |
| 51 | </div> | |
| 52 | <!--#config timefmt="%d-%b-%Y" --> | |
| 53 | <p class="version"> | |
| 54 | <strong>Version <!--#include file="downloads/version.txt" --></strong> | |
| 55 | <br><!--#flastmod virtual="downloads/version.txt" --> | |
| 56 | <br><!--#exec cmd="./count.sh" --> downloads | |
| 57 | </p> | |
| 58 | </main> | |
| 59 | <nav> | |
| 60 | <ul> | |
| 61 | <li><a href="screenshots.html">screenshots</a></li> | |
| 62 | <li><a href="https://www.youtube.com/playlist?list=PLB-WIt1cZYLm1MMx2FBG9KWzPIoWZMKu_">tutorials</a></li> | |
| 63 | <li><a href="https://gitlab.com/DaveJarvis/KeenWrite">sources</a></li> | |
| 64 | <li><a href="https://gitlab.com/DaveJarvis/KeenWrite/issues">issues</a></li> | |
| 65 | <li><a href="https://gitlab.com/DaveJarvis/KeenWrite/-/blob/main/docs/README.md">documentation</a></li> | |
| 66 | </ul> | |
| 67 | </nav> | |
| 68 | <footer> | |
| 69 | © 2023, White Magic Software, Ltd. | |
| 70 | </footer> | |
| 71 | </body> | |
| 72 | </html> | |
| 73 | ||
| 74 | 1 |
| 1 | user-agent: * | |
| 2 | disallow: | |
| 3 | crawl-delay: 60 | |
| 4 | ||
| 5 | user-agent: Googlebot | |
| 6 | disallow: /repository/* | |
| 7 | disallow: /downloads/* | |
| 8 | user-agent: Bingbot | |
| 9 | disallow: /repository/* | |
| 10 | disallow: /downloads/* | |
| 11 | user-agent: YandexBot | |
| 12 | disallow: /repository/* | |
| 13 | disallow: /downloads/* | |
| 14 | user-agent: MicrosoftBot | |
| 15 | disallow: /repository/* | |
| 16 | disallow: /downloads/* | |
| 17 | ||
| 18 | 1 |
| 1 | <!DOCTYPE html> | |
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <title>KeenWrite</title> | |
| 5 | <meta charset="UTF-8"> | |
| 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 7 | <meta name="description" content="cross-platform, open-source desktop editor"> | |
| 8 | <meta name="keywords" content="markdown, text, editor, software"> | |
| 9 | <meta name="robots" content="index, follow"> | |
| 10 | <link rel="stylesheet" href="styles/base.css"> | |
| 11 | </head> | |
| 12 | <body> | |
| 13 | <header> | |
| 14 | <img src="images/logo/title.svg" alt="KeenWrite" class="title"> | |
| 15 | <p> | |
| 16 | A free, cross-platform desktop text editor for producing beautifully typeset PDF files. | |
| 17 | </p> | |
| 18 | </header> | |
| 19 | <main class="screenshots"> | |
| 20 | <p> | |
| 21 | Interpolated variable replacement | |
| 22 | </p> | |
| 23 | <img src="images/screenshots/05.png" class="screenshot" alt="variables"> | |
| 24 | <p> | |
| 25 | Technical diagrams | |
| 26 | </p> | |
| 27 | <img src="images/screenshots/01.png" class="screenshot" alt="diagrams"> | |
| 28 | <p> | |
| 29 | Internationalization | |
| 30 | </p> | |
| 31 | <img src="images/screenshots/02.png" class="screenshot" alt="internationalization"> | |
| 32 | <p> | |
| 33 | Mathematics | |
| 34 | </p> | |
| 35 | <img src="images/screenshots/03.png" class="screenshot" alt="math"> | |
| 36 | <p> | |
| 37 | Document outline | |
| 38 | </p> | |
| 39 | <img src="images/screenshots/04.png" class="screenshot" alt="outline"> | |
| 40 | <p> | |
| 41 | Single sourced metadata | |
| 42 | </p> | |
| 43 | <img src="images/screenshots/06.png" class="screenshot" alt="multi-window"> | |
| 44 | <p> | |
| 45 | R computations | |
| 46 | </p> | |
| 47 | <img src="images/screenshots/07.png" class="screenshot" alt="computation"> | |
| 48 | <p> | |
| 49 | Theme-driven PDF output | |
| 50 | </p> | |
| 51 | <img src="images/screenshots/08.png" class="screenshot" alt="output"> | |
| 52 | </main> | |
| 53 | <nav> | |
| 54 | <ul> | |
| 55 | <li><a href="index.html">home</a></li> | |
| 56 | <li><a href="https://www.youtube.com/playlist?list=PLB-WIt1cZYLm1MMx2FBG9KWzPIoWZMKu_">tutorials</a></li> | |
| 57 | <li><a href="https://gitlab.com/DaveJarvis/KeenWrite">sources</a></li> | |
| 58 | <li><a href="https://gitlab.com/DaveJarvis/KeenWrite/issues">issues</a></li> | |
| 59 | <li><a href="https://gitlab.com/DaveJarvis/KeenWrite/-/blob/main/docs/README.md">documentation</a></li> | |
| 60 | </ul> | |
| 61 | </nav> | |
| 62 | <footer> | |
| 63 | © 2023, White Magic Software, Ltd. | |
| 64 | </footer> | |
| 65 | </body> | |
| 66 | </html> | |
| 67 | ||
| 68 | 1 |
| 1 | /* | |
| 2 | * Page | |
| 3 | */ | |
| 4 | :root { | |
| 5 | --accent-colour: #ec706a; | |
| 6 | --link-colour: #8cc6de; | |
| 7 | } | |
| 8 | ||
| 9 | body { | |
| 10 | /* Ensure the page doesn't extend full screen on large monitors. */ | |
| 11 | max-width: 1000px; | |
| 12 | margin: 0 auto; | |
| 13 | ||
| 14 | background: #363636; | |
| 15 | color: #eaeaea; | |
| 16 | } | |
| 17 | ||
| 18 | /* Text alignment. */ | |
| 19 | header, nav, footer { | |
| 20 | text-align: center; | |
| 21 | } | |
| 22 | ||
| 23 | /* | |
| 24 | * Header | |
| 25 | */ | |
| 26 | header { | |
| 27 | /* Avoid being flush with top of page, put space between the title and | |
| 28 | * the download buttons, ensure any text won't be flush with edges. | |
| 29 | */ | |
| 30 | margin: 2em; | |
| 31 | margin-top: 1em; | |
| 32 | } | |
| 33 | ||
| 34 | header p { | |
| 35 | line-height: 1.5em; | |
| 36 | } | |
| 37 | ||
| 38 | /* Ensure the application title is large enough. */ | |
| 39 | header > img.title { | |
| 40 | width: 100%; | |
| 41 | height: 72pt; | |
| 42 | } | |
| 43 | ||
| 44 | /* | |
| 45 | * Screenshots | |
| 46 | */ | |
| 47 | main.screenshots { | |
| 48 | text-align: center; | |
| 49 | } | |
| 50 | ||
| 51 | main.screenshots > p { | |
| 52 | padding-top: 1em; | |
| 53 | } | |
| 54 | ||
| 55 | main > img.screenshot { | |
| 56 | width: 80%; | |
| 57 | ||
| 58 | display: block; | |
| 59 | margin-left: auto; | |
| 60 | margin-right: auto; | |
| 61 | ||
| 62 | transition: all .2s ease-in-out; | |
| 63 | } | |
| 64 | ||
| 65 | main > img.screenshot:hover { | |
| 66 | width: 100%; | |
| 67 | transform: scale(1); | |
| 68 | } | |
| 69 | ||
| 70 | /* | |
| 71 | * Version information | |
| 72 | */ | |
| 73 | main > p.version { | |
| 74 | text-align: center; | |
| 75 | } | |
| 76 | ||
| 77 | /* | |
| 78 | * Download buttons | |
| 79 | */ | |
| 80 | main > div.downloads { | |
| 81 | /* Arrange the buttons in a responsive, 2 x 2 grid. */ | |
| 82 | display: grid; | |
| 83 | grid-template-rows: 1fr 1fr; | |
| 84 | grid-template-columns: max-content max-content; | |
| 85 | justify-content: center; | |
| 86 | } | |
| 87 | ||
| 88 | /* Make hyperlinks resemble buttons. */ | |
| 89 | a.download { | |
| 90 | display: inline-block; | |
| 91 | ||
| 92 | /* Separate the buttons from one another. */ | |
| 93 | margin-top: 2em; | |
| 94 | margin-left: 1em; | |
| 95 | margin-right: 1em; | |
| 96 | ||
| 97 | /* Fancy buttons. */ | |
| 98 | border-radius: 1em; | |
| 99 | background: var( --accent-colour ); | |
| 100 | } | |
| 101 | ||
| 102 | a.download:hover { | |
| 103 | background: var( --link-colour ); | |
| 104 | } | |
| 105 | ||
| 106 | img.download { | |
| 107 | /* Replace icon black with another colour. */ | |
| 108 | filter: invert(6%) | |
| 109 | sepia(58%) saturate(857%) hue-rotate(158deg) brightness(91%) contrast(91%); | |
| 110 | ||
| 111 | width: 157px; | |
| 112 | height: 75px; | |
| 113 | } | |
| 114 | ||
| 115 | /* | |
| 116 | * Navigation | |
| 117 | */ | |
| 118 | nav { | |
| 119 | /* Don't crowd navigation links against the download buttons. */ | |
| 120 | margin-top: 4em; | |
| 121 | } | |
| 122 | ||
| 123 | nav ul { | |
| 124 | /* Remove the bullets */ | |
| 125 | list-style: none; | |
| 126 | padding: 0; | |
| 127 | margin: 0; | |
| 128 | } | |
| 129 | ||
| 130 | nav li { | |
| 131 | /* Put navigation items along a single line. */ | |
| 132 | display: inline; | |
| 133 | } | |
| 134 | ||
| 135 | nav li:not(:last-child)::after { | |
| 136 | /* Separate navigation items with a bar. */ | |
| 137 | content: " | "; | |
| 138 | } | |
| 139 | ||
| 140 | nav a, nav a:visited { | |
| 141 | color: var( --link-colour ); | |
| 142 | } | |
| 143 | ||
| 144 | nav a:link:hover, nav a:visited:hover { | |
| 145 | color: var( --accent-colour ); | |
| 146 | } | |
| 147 | ||
| 148 | /* | |
| 149 | * Footer | |
| 150 | */ | |
| 151 | footer { | |
| 152 | margin-top: 2em; | |
| 153 | margin-bottom: 1em; | |
| 154 | } | |
| 155 | ||
| 156 | 1 |