Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M .gitattributes
77
* text eol=lf
88
9
109
# BINARY FILES:
1110
#   Disable line ending normalize on checkin.
...
2625
*.otf binary
2726
*.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
2833
2934
M .gitignore
1313
themes/
1414
quotes/
15
!src/main/java/com/keenwrite/processors/markdown/extensions/quotes
1516
tex/
1617
spell/
M BUILD.md
77
Download and install the following software packages:
88
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)
1212
* [warp v0.4.0-alpha](https://github.com/Reisz/warp/releases/tag/v0.4.0)
1313
M R/csv.R
2828
# file must be in the working directory as specified by setwd.
2929
#
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).
3232
# @param totals Include total sums (default TRUE).
3333
# @param align Right-align numbers (default TRUE).
3434
# -----------------------------------------------------------------------------
3535
csv2md <- function( f, decimals = 2, totals = T, align = T, caption = "" ) {
3636
  # 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 )
3838
3939
  if( totals ) {
M README.md
1
# ![Logo](docs/images/app-title.png)
1
# ![KeenWrite](docs/images/app-title.png)
22
33
A free, open-source, cross-platform desktop Markdown editor that can produce beautifully typeset PDFs.
...
3737
Using Java, first follow these one-time setup steps:
3838
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).
4040
   * JavaFX, which is bundled with BellSoft's *Full version*, is required.
4141
1. Install the JRE (include JRE's `bin` directory in the `PATH` environment variable).
A bin/LICENSE-linux-x64.warp-packer
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.
122
A bin/LICENSE-osslsigncode
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.
131
A bin/LICENSE-warp-packer
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.
122
A bin/linux-x64.warp-packer
Binary file
A bin/osslsigncode
Binary file
A bin/warp-packer
Binary file
M build.gradle
55
    mavenCentral()
66
    maven {
7
      url "https://plugins.gradle.org/m2/"
7
      url = 'https://plugins.gradle.org/m2/'
88
    }
99
  }
...
3232
  mavenCentral()
3333
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' }
3636
3737
  maven {
38
    url 'https://css4j.github.io/maven'
38
    url = 'https://css4j.github.io/maven'
3939
    mavenContent {
4040
      releasesOnly()
4141
    }
4242
    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\\..*'
4744
    }
4845
  }
4946
}
5047
5148
// Assume a cross-platform überjar unless targetOs is set.
52
String[] os = ['win', 'mac', 'linux']
49
String[] os = [ 'win', 'mac', 'linux' ]
5350
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' ]
5956
  } else {
60
    os = [targetOs]
57
    os = [ targetOs ]
6158
  }
6259
}
...
8885
javafx {
8986
  version = javaVersion
90
  modules = ['javafx.base', 'javafx.controls', 'javafx.graphics', 'javafx.swing']
87
  modules = [ 'javafx.base', 'javafx.controls', 'javafx.graphics', 'javafx.swing' ]
9188
  configuration = 'compileOnly'
9289
}
9390
9491
dependencies {
95
  def v_junit = '5.10.3'
92
  def v_junit = '5.13.4'
93
  def v_platform = '1.13.4'
9694
  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'
10098
10199
  // 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'
105103
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
104
  implementation 'org.openjfx:javafx-media:26-ea+3'
106105
  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'
108107
109108
  // Markdown
...
116115
117116
  // YAML
118
  implementation 'org.yaml:snakeyaml:2.2'
117
  implementation 'org.yaml:snakeyaml:2.4'
119118
  implementation "com.fasterxml.jackson.core:jackson-core:${v_jackson}"
120119
  implementation "com.fasterxml.jackson.core:jackson-databind:${v_jackson}"
121120
  implementation "com.fasterxml.jackson.core:jackson-annotations:${v_jackson}"
122121
  implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${v_jackson}"
123122
124123
  // 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'
127126
128127
  // 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'
131131
  implementation 'org.renjin:renjin-script-engine:3.5-beta76'
132132
  implementation 'org.renjin.cran:rjson:0.2.15-renjin-21'
...
151151
  implementation 'jakarta.validation:jakarta.validation-api:3.1.0'
152152
  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'
153157
154158
  // Command-line parsing
155159
  implementation "info.picocli:picocli:${v_picocli}"
156160
  annotationProcessor "info.picocli:picocli-codegen:${v_picocli}"
157161
158162
  // KeenQuotes, KeenType, KeenSpell, KeenCount.
159
  implementation fileTree( include: ['**/*.jar'], dir: 'libs' )
163
  implementation fileTree( include: [ '**/*.jar' ], dir: 'libs' )
160164
161
  def fx = ['controls', 'graphics', 'fxml', 'swing']
165
  def fx = [ 'controls', 'graphics', 'fxml', 'swing' ]
162166
163167
  fx.each { fxitem ->
164168
    os.each { ositem ->
165169
      runtimeOnly "org.openjfx:javafx-${fxitem}:${javafx.version}:${ositem}"
166170
    }
167171
  }
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}"
168176
177
  testImplementation 'org.junit.jupiter:junit-jupiter:${v_junit}'
169178
  testImplementation "org.junit.jupiter:junit-jupiter-api:${v_junit}"
170179
  testImplementation "org.junit.jupiter:junit-jupiter-params:${v_junit}"
171180
  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'
174182
}
175183
...
251259
252260
    contents {
253
      from { ['LICENSE.md', 'README.md'] }
261
      from { [ 'LICENSE.md', 'README.md' ] }
254262
      into( 'images' ) {
255263
        from { 'images' }
...
268276
tasks.withType( JavaCompile ).configureEach {
269277
  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'
278278
}
279279
M container/.gitignore
1
token.txt
1
host-path.txt
22
M container/Containerfile
66
#
77
# ########################################################################
8
FROM alpine:latest
89
910
LABEL org.opencontainers.image.description Configures a typesetting system.
1011
11
FROM alpine:latest
1212
ENV ENV="/etc/profile"
1313
ENV PROFILE=/etc/profile
...
2323
2424
ENV CONTEXT_HOME=$INSTALL_DIR/context
25
ENV CONTEXT_ARCH=linuxmusl-64
2526
2627
# ########################################################################
...
5354
5455
# 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"
5657
5758
# ########################################################################
5859
#
5960
# Install components, modules, configure system, remove unnecessary files
6061
#
6162
# ########################################################################
6263
WORKDIR $CONTEXT_HOME
6364
6465
RUN \
66
  apk update && \
6567
  apk add -t py3-cssselect && \
6668
  apk add -t py3-lxml && \
6769
  apk add -t py3-numpy && \
6870
  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 \
7076
  mkdir -p \
7177
    "$FONTS_DIR" \
7278
    "$INSTALL_DIR" \
7379
    "$TARGET_DIR" \
7480
    "$SOURCE_DIR" \
7581
    "$THEMES_DIR" \
7682
    "$IMAGES_DIR" \
7783
    "$CACHES_DIR" && \
7884
  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 && \
8086
  echo "export OSFONTDIR=\"/usr/share/fonts//\"" >> $PROFILE && \
8187
  echo "PS1='\\u@typesetter:\\w\\$ '" >> $PROFILE && \
...
110116
  mkdir -p $CONTEXT_HOME/tex/texmf-fonts/tex/context/user && \
111117
  ln -s $CONTEXT_HOME/tex/texmf-fonts/tex/context/user $HOME/fonts && \
112
  source $PROFILE && \
118
  source $PROFILE \
113119
  mtxrun --generate && \
114120
  find \
M container/README.md
1313
# Upgrade
1414
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
1546
Upgrade the containerization software (e.g., podman or docker) as follows:
1647
...
2960
3061
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.
3263
1. Set `Wizard.typesetter.container.checksum` to the Windows version checksum.
3364
1. Set `Wizard.typesetter.container.image.version` to the new image version.
3465
1. Save the file.
3566
3667
The containerization software version is changed.
3768
3869
# Publish
3970
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:
4174
4275
``` bash
43
./manage.sh --delete --build --export --publish
76
./manage.sh --verbose --delete --build --export --publish
4477
```
4578
M container/manage.sh
77
# ---------------------------------------------------------------------------
88
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 "$@"
313343
A docs/quotes.md
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 \&#x2bc;, the modifier letter apostrophe.
8
* **apos** -- Encode as \&apos;, curled when typeset to PDF.
9
* **aposhex** -- Encode as \&#x27;, the apostrophe's numeric value.
10
* **quote** -- Encode as \&rsquo;, the right single quotation mark, which
11
is typically curled in HTML and XHTML documents by default.
12
* **quotehex** -- Encode \&#8217;, the right single quotation mark's numeric
13
value.
14
15
When typsetting into a PDF document, only the semantically correct value
16
of \&apos; 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
149
A fonts/install.sh
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
138
A fonts/urls.csv
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
118
M installer.sh
5353
DEPENDENCIES=(
5454
  "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"
5855
  "tar,https://www.gnu.org/software/tar"
5956
  "wine,https://www.winehq.org"
...
184181
readonly SCRIPT_SRC="\$(dirname "\${BASH_SOURCE[\${#BASH_SOURCE[@]} - 1]}")"
185182
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}" "\$@"
187184
__EOT
188185
...
200197
201198
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}" %*
203200
__EOT
204201
...
247244
248245
  $log "Sign ${FILE_BINARY}"
249
  osslsigncode sign \
246
  ${SCRIPT_DIR}/bin/osslsigncode sign \
250247
    -pkcs12 "${FILE_CERTIFICATE}" \
251248
    -askpass \
...
271268
# ---------------------------------------------------------------------------
272269
utile_create_launcher() {
273
  packer=warp-packer
270
  packer=${SCRIPT_DIR}/bin/warp-packer
274271
  packer_opt_pack="pack"
275272
  packer_opt_input="input-dir"
...
288285
  # The warp-packer fork that fixes Windows doesn't support MacOS.
289286
  if [ "${ARG_JAVA_OS}" = "macos" ]; then
290
    packer=linux-x64.warp-packer
287
    packer=${SCRIPT_DIR}/bin/linux-x64.warp-packer
291288
    packer_opt_pack=""
292289
    packer_opt_input="input_dir"
M java.version
1
23+38
1
23.0.1+13
22
M keenwrite.sh
55
java \
66
  -Dprism.order=sw \
7
  --enable-preview \
87
  --add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED \
98
  --add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED \
M libs/keenquotes.jar
Binary file
M libs/keentype-lib.jar
Binary file
M scripts/rcedit-x64.exe
Binary file
M src/main/java/com/keenwrite/AppCommands.java
1818
import java.util.concurrent.ExecutorService;
1919
import java.util.concurrent.Future;
20
import java.util.concurrent.atomic.AtomicInteger;
2120
22
import static com.keenwrite.Launcher.terminate;
2321
import static com.keenwrite.events.StatusEvent.clue;
2422
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
...
4139
4240
  public static void run( final Arguments args ) {
43
    final var exitCode = new AtomicInteger();
44
4541
    final var future = new CompletableFuture<Path>() {
4642
      @Override
4743
      public boolean complete( final Path path ) {
4844
        return super.complete( path );
4945
      }
5046
5147
      @Override
5248
      public boolean completeExceptionally( final Throwable ex ) {
5349
        clue( ex );
54
        exitCode.set( 1 );
5550
5651
        return super.completeExceptionally( ex );
5752
      }
5853
    };
5954
6055
    file_export( args, future );
6156
    sExecutor.shutdown();
6257
    future.join();
63
    terminate( exitCode.get() );
6458
  }
6559
M src/main/java/com/keenwrite/ExportFormat.java
55
package com.keenwrite;
66
7
import com.keenwrite.events.StatusEvent;
78
import com.keenwrite.io.MediaType;
89
import com.keenwrite.io.MediaTypeExtension;
...
3637
   */
3738
  XHTML_TEX( ".xhtml" ),
39
40
  /**
41
   * For XHTML exports, encode TeX as SVG. Treat image links relatively.
42
   */
43
  XHTML_TEX_SVG( ".xhtml" ),
3844
3945
  /**
...
8591
    throws IllegalArgumentException {
8692
    assert extension != null;
93
    final var mediaType = MediaTypeExtension.fromExtension( extension );
8794
88
    return valueFrom( MediaTypeExtension.fromExtension( extension ), modifier );
95
    return valueFrom( mediaType, modifier );
8996
  }
9097
...
99106
  public static ExportFormat valueFrom(
100107
    final MediaType type, final String modifier ) {
108
    final var svg = "svg".equalsIgnoreCase( modifier.trim() );
109
101110
    return switch( type ) {
102
      case TEXT_HTML, TEXT_XHTML -> "svg".equalsIgnoreCase( modifier.trim() )
111
      case TEXT_HTML -> svg
103112
        ? HTML_TEX_SVG
104113
        : HTML_TEX_DELIMITED;
114
      case TEXT_XML, APP_XHTML -> svg
115
        ? XHTML_TEX_SVG
116
        : XHTML_TEX;
105117
      case APP_PDF -> APPLICATION_PDF;
106
      case TEXT_XML -> XHTML_TEX;
107118
      default -> throw new IllegalArgumentException( format(
108119
        "Unrecognized format type and subtype: '%s' and '%s'", type, modifier
A src/main/java/com/keenwrite/GuiApp.java
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
}
1229
M src/main/java/com/keenwrite/Launcher.java
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
 */
25
package com.keenwrite;
36
47
import com.keenwrite.cmdline.Arguments;
58
import com.keenwrite.cmdline.ColourScheme;
69
import com.keenwrite.cmdline.HeadlessApp;
710
import picocli.CommandLine;
811
912
import java.io.IOException;
1013
import java.io.InputStream;
14
import java.io.PrintStream;
1115
import java.util.Properties;
1216
import java.util.function.Consumer;
...
2125
2226
/**
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.
2431
 *
2532
 * <p>
2633
 * This is required until modules are implemented, which may never happen
2734
 * because the application should be ported away from Java and JavaFX.
2835
 * </p>
2936
 */
3037
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
  }
3142
3243
  /**
3344
   * Needed for the GUI.
3445
   */
3546
  private final String[] mArgs;
47
48
  /**
49
   * Where to write error messages.
50
   */
51
  private static final PrintStream ERRORS = System.err;
3652
3753
  /**
...
87103
   * @param exitCode Code to provide back to the calling shell.
88104
   */
89
  public static void terminate( final int exitCode ) {
105
  private static void terminate( final int exitCode ) {
90106
    System.exit( exitCode );
91107
  }
...
101117
    parser.setUnmatchedArgumentsAllowed( false );
102118
103
    final var exitCode = parser.execute( args );
104
    final var parseResult = parser.getParseResult();
119
    final var parseResult = parser.parseArgs( args );
105120
106
    if( parseResult.isUsageHelpRequested() ) {
107
      terminate( exitCode );
108
    }
109
    else if( parseResult.isVersionHelpRequested() ) {
121
    if( parseResult.isVersionHelpRequested() ) {
110122
      showAppInfo();
111
      terminate( exitCode );
112123
    }
124
125
    final var exitCode = parser.execute( args );
126
    terminate( exitCode );
113127
  }
114128
...
143157
    if( message != null && message.toLowerCase().contains( "javafx" ) ) {
144158
      message = "Run using a Java Runtime Environment that includes JavaFX.";
145
      out( "ERROR: %s", message );
159
      log( "ERROR: %s", message );
146160
    }
147161
    else {
148
      error.printStackTrace( System.err );
162
      error.printStackTrace( ERRORS );
149163
    }
150164
  }
151165
152166
  /**
153
   * Suppress writing to standard error, suppresses writing log messages.
167
   * Suppress writing log messages.
154168
   */
155169
  private static void disableLogging() {
156170
    LogManager.getLogManager().reset();
157
    // TODO: Delete this after JavaFX/GTK 3 no longer barfs useless warnings.
158
    System.err.close();
159171
  }
160172
161173
  /**
162174
   * Writes the given placeholder text to standard output with a new line
163175
   * appended.
164176
   *
165177
   * @param message The format string specifier.
166178
   * @param args    The arguments to substitute into the format string.
167179
   */
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();
170183
  }
171184
172185
  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 );
187188
  }
188189
...
197198
   * Called after the arguments have been parsed.
198199
   *
199
   * @param args The parsed command-line arguments.
200
   * @param arguments The parsed command-line arguments.
200201
   */
201202
  @Override
202
  public void accept( final Arguments args ) {
203
    assert args != null;
203
  public void accept( final Arguments arguments ) {
204
    assert arguments != null;
204205
205206
    try {
206207
      int argCount = mArgs.length;
207208
208
      if( args.quiet() ) {
209
      if( arguments.quiet() ) {
209210
        argCount--;
210211
      }
211212
      else {
212213
        showAppInfo();
213214
      }
214215
215
      if( args.debug() ) {
216
      if( arguments.debug() ) {
216217
        argCount--;
218
        arguments.iterate( null, Launcher::log );
217219
      }
218220
      else {
219221
        disableLogging();
220222
      }
221223
222224
      if( argCount <= 0 ) {
223225
        // When no command-line arguments are provided, launch the GUI.
224
        MainApp.main( mArgs );
226
        GuiApp.run( mArgs );
225227
      }
226228
      else {
227229
        // When command-line arguments are supplied, run in headless mode.
228
        HeadlessApp.main( args );
230
        HeadlessApp.run( arguments, ERRORS );
229231
      }
230232
    } catch( final Throwable t ) {
231233
      log( t );
232234
    }
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 );
233247
  }
234248
}
D src/main/java/com/keenwrite/MainApp.java
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
}
2031
M src/main/java/com/keenwrite/MainPane.java
1818
import com.keenwrite.preferences.Workspace;
1919
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() );
12951308
  }
12961309
M src/main/java/com/keenwrite/MainScene.java
6666
6767
  /**
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}
6969
   * created by an instance of {@link MainScene}.
7070
   *
...
8888
    final var node = mStatusBar;
8989
    node.setVisible( !node.isVisible() );
90
  }
91
92
  MenuBar getMenuBar() {
93
    return mMenuBar;
9490
  }
9591
9692
  public StatusBar getStatusBar() {return mStatusBar;}
9793
9894
  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 );
10096
    final var external = workspace.fileProperty( KEY_UI_SKIN_CUSTOM );
10197
    final var inSkin = internal.get();
M src/main/java/com/keenwrite/cmdline/Arguments.java
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() ) {
352407
      // Trim the leading period, which is always present.
353408
      map.put( keyName.substring( 1 ), node.getValue().asText() );
M src/main/java/com/keenwrite/cmdline/HeadlessApp.java
1
/* Copyright 2020-2024 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
15
package com.keenwrite.cmdline;
26
37
import com.keenwrite.AppCommands;
48
import com.keenwrite.events.StatusEvent;
59
import org.greenrobot.eventbus.Subscribe;
10
11
import java.io.PrintStream;
612
713
import static com.keenwrite.events.Bus.register;
814
import static java.lang.String.format;
915
1016
/**
1117
 * Responsible for running the application in headless mode.
1218
 */
1319
public class HeadlessApp {
14
1520
  /**
1621
   * Contains directives that control text file processing.
1722
   */
1823
  private final Arguments mArgs;
24
25
  /**
26
   * Where to write error messages.
27
   */
28
  private final PrintStream mErrStream;
1929
2030
  /**
2131
   * Creates a new command-line version of the application.
2232
   *
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.
2435
   */
25
  public HeadlessApp( final Arguments args ) {
36
  public HeadlessApp( final Arguments args, final PrintStream errStream ) {
2637
    assert args != null;
2738
2839
    mArgs = args;
40
    mErrStream = errStream;
2941
3042
    register( this );
31
    AppCommands.run( mArgs );
3243
  }
3344
...
4960
      final var msg = format( "%s%s", event, problem );
5061
51
      System.out.println( msg );
62
      mErrStream.println( msg );
5263
    }
64
  }
65
66
  private void run() {
67
    AppCommands.run( mArgs );
5368
  }
5469
5570
  /**
5671
   * Entry point for running the application in headless mode.
5772
   *
58
   * @param args The parsed command-line arguments.
73
   * @param args      The parsed command-line arguments.
74
   * @param errStream Where to write error messages.
5975
   */
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();
6381
  }
6482
}
M src/main/java/com/keenwrite/constants/Constants.java
244244
245245
  /**
246
   * The default apostrophe to use when exporting.
247
   */
248
  public static final String APOS_DEFAULT = "apos";
249
250
  /**
246251
   * Prevent instantiation.
247252
   */
248
  private Constants() {
249
  }
253
  private Constants() {}
250254
251255
  /**
M src/main/java/com/keenwrite/dom/DocumentConverter.java
1919
import static com.keenwrite.dom.DocumentParser.sDomImplementation;
2020
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;
2223
2324
/**
...
4849
        final var parent = node.parentNode();
4950
        final var name = parent == null ? "root" : parent.nodeName();
50
51
        if( !("pre".equalsIgnoreCase( name ) ||
51
        final var codeBlock =
52
          "pre".equalsIgnoreCase( name ) ||
5253
          "code".equalsIgnoreCase( name ) ||
5354
          "kbd".equalsIgnoreCase( name ) ||
5455
          "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
5761
          // to ensure that preformatted text maintains its formatting.
5862
          textNode.text( replace( textNode.getWholeText(), LIGATURES ) );
5963
        }
6064
      }
6165
    }
6266
6367
    @Override
64
    public void tail( final @NotNull Node node, final int depth ) { }
68
    public void tail( final @NotNull Node node, final int depth ) {
69
    }
6570
  };
6671
M src/main/java/com/keenwrite/dom/DocumentParser.java
55
package com.keenwrite.dom;
66
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
  }
310446
}
311447
M src/main/java/com/keenwrite/editors/definition/yaml/YamlTreeTransformer.java
121121
   */
122122
  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 ) );
124125
  }
125126
M src/main/java/com/keenwrite/editors/markdown/MarkdownEditor.java
3737
import java.util.regex.Pattern;
3838
39
import static com.keenwrite.MainApp.keyDown;
39
import static com.keenwrite.GuiApp.keyDown;
4040
import static com.keenwrite.constants.Constants.*;
4141
import static com.keenwrite.events.StatusEvent.clue;
M src/main/java/com/keenwrite/events/Bus.java
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
 */
25
package com.keenwrite.events;
36
47
import org.greenrobot.eventbus.EventBus;
8
9
import static org.greenrobot.eventbus.EventBus.builder;
510
611
/**
712
 * Responsible for delegating interactions to the event bus library. This
813
 * class decouples the rest of the application from a particular event bus
914
 * implementation.
1015
 */
1116
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();
1420
1521
  public static <Subscriber> void register( final Subscriber subscriber ) {
M src/main/java/com/keenwrite/events/StatusEvent.java
1010
import static com.keenwrite.constants.Constants.NEWLINE;
1111
import static com.keenwrite.constants.Constants.STATUS_BAR_OK;
12
import static com.keenwrite.util.Strings.sanitize;
1213
import static java.lang.String.format;
1314
import static java.lang.String.join;
...
5657
   */
5758
  public StatusEvent( final String message, final Throwable problem ) {
58
    mMessage = message == null ? "" : message;
59
    mMessage = sanitize( message );
5960
    mProblem = problem;
6061
  }
...
8687
    final var message = mMessage == null ? "UNKNOWN" : mMessage;
8788
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
    );
9295
  }
9396
M src/main/java/com/keenwrite/io/MediaType.java
3232
  APP_PDF( APPLICATION, "pdf" ),
3333
  APP_ZIP( APPLICATION, "zip" ),
34
  APP_XHTML( APPLICATION, "xhtml+xml" ),
3435
3536
  /*
...
109110
  TEXT_PROPERTIES( TEXT, "x-java-properties" ),
110111
  TEXT_HTML( TEXT, "html" ),
111
  TEXT_XHTML( TEXT, "xhtml+xml" ),
112112
  TEXT_XML( TEXT, "xml" ),
113113
M src/main/java/com/keenwrite/io/MediaTypeExtension.java
3535
  MEDIA_IMAGE_GIF( IMAGE_GIF ),
3636
  MEDIA_IMAGE_JPEG( IMAGE_JPEG,
37
                    of( "jpg", "jpe", "jpeg", "jfif", "pjpeg", "pjp" ) ),
37
    of( "jpg", "jpe", "jpeg", "jfif", "pjpeg", "pjp" ) ),
3838
  MEDIA_IMAGE_PNG( IMAGE_PNG ),
3939
  MEDIA_IMAGE_PSD( IMAGE_PHOTOSHOP, of( "psd" ) ),
...
5252
  MEDIA_TEXT_R_MARKDOWN( TEXT_R_MARKDOWN, of( "Rmd" ) ),
5353
  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" ) ),
5556
  MEDIA_TEXT_XML( TEXT_XML ),
5657
  MEDIA_TEXT_YAML( TEXT_YAML, of( "yaml", "yml" ) ),
...
153154
   */
154155
  public String getExtension() {
155
    return mExtensions.get( 0 );
156
    return mExtensions.getFirst();
156157
  }
157158
A src/main/java/com/keenwrite/io/PathScanner.java
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
}
149
M src/main/java/com/keenwrite/io/SysFile.java
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
 */
25
package com.keenwrite.io;
36
...
1013
import java.security.MessageDigest;
1114
import java.security.NoSuchAlgorithmException;
12
import java.util.ArrayList;
15
import java.util.List;
1316
import java.util.Optional;
1417
import java.util.function.Function;
1518
import java.util.function.Predicate;
1619
1720
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
1821
import static com.keenwrite.events.StatusEvent.clue;
22
import static com.keenwrite.io.PathScanner.scan;
23
import static com.keenwrite.io.PathScanner.scanExtensions;
1924
import static com.keenwrite.io.WindowsRegistry.pathsWindows;
2025
import static com.keenwrite.util.DataTypeConverter.toHex;
2126
import static com.keenwrite.util.SystemUtils.IS_OS_WINDOWS;
2227
import static java.lang.System.getenv;
23
import static java.nio.file.Files.isExecutable;
2428
import static java.util.regex.Pattern.quote;
2529
2630
/**
2731
 * Responsible for file-related functionality.
2832
 */
2933
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
3734
  private static final String WHERE_COMMAND =
3835
    IS_OS_WINDOWS ? "where" : "which";
...
7168
   */
7269
  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();
7478
  }
7579
...
9094
   */
9195
  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 );
10398
104
    final var dirs = dirList.toArray( new String[]{} );
10599
    var path = locate( dirs, "Wizard.container.executable.path" );
106100
...
123117
  }
124118
125
  private Optional<Path> locate( final String[] dirs, final String msg ) {
119
  private Optional<Path> locate( final List<String> dirs, final String msg ) {
126120
    final var exe = getName();
127121
128122
    for( final var dir : dirs ) {
129
      Path p;
130
131123
      try {
132
        p = Path.of( dir ).resolve( exe );
124
        return scanExtensions( dir, exe );
133125
      } catch( final Exception ex ) {
134126
        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
        }
144127
      }
145128
    }
...
152135
    final Function<String, String> map, final String msg ) {
153136
    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();
170137
171
    return result.map( Path::of );
138
    return locate( List.of( paths ), msg );
172139
  }
173140
...
222189
      return toHex( digest.digest() );
223190
    }
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 );
224207
  }
225208
...
284267
      ? path
285268
      : 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() );
286275
  }
287276
A src/main/java/com/keenwrite/preferences/AposProperty.java
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
}
157
M src/main/java/com/keenwrite/preferences/PreferencesController.java
2424
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
2525
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 );
427429
  }
428430
M src/main/java/com/keenwrite/preferences/SkinProperty.java
1919
   * Ordered set of available skins.
2020
   */
21
  private static final Set<String> sSkins = new LinkedHashSet<>();
21
  private static final Set<String> sProperties = new LinkedHashSet<>();
2222
2323
  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 );
3236
  }
3337
3438
  /**
3539
   * Returns the list of available skin names to change the UI fonts and
3640
   * colours.
3741
   *
3842
   * @return A selection of skins.
3943
   */
4044
  public static ObservableList<String> skinListProperty() {
41
    assert !sSkins.isEmpty();
45
    assert !sProperties.isEmpty();
4246
43
    return listProperty( sSkins );
47
    return listProperty( sProperties );
4448
  }
4549
4650
  /**
4751
   * Returns the given skin name as a sanitized file name, which must map
4852
   * to a stylesheet file bundled with the application. This does not include
4953
   * the path to the stylesheet. If the given name is not known, the file
5054
   * name for {@link Constants#SKIN_DEFAULT} is returned. The extension must
5155
   * be added separately.
5256
   *
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
5559
   * underscores, without the ".css" extension appended.
5660
   */
57
  public static String toFilename( final String skin ) {
58
    assert skin != null;
61
  public static String toFilename( final String property ) {
62
    assert property != null;
5963
60
    return sanitize( skin ).toLowerCase().replace( ' ', '_' );
64
    return sanitize( property ).toLowerCase().replace( ' ', '_' );
6165
  }
6266
6367
  /**
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.
6569
   *
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.
6872
   */
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;
7475
75
  public SkinProperty( final String skin ) {
76
    super( skin );
76
    return sProperties.contains( property ) ? property : SKIN_DEFAULT;
7777
  }
7878
}
M src/main/java/com/keenwrite/preferences/Workspace.java
138138
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
139139
    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 ) {
490498
    assert key != null;
491499
    return valuesProperty( key );
M src/main/java/com/keenwrite/preview/HtmlPreview.java
158158
  public void render( final String html ) {
159159
    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
160164
    final var doc = CONVERTER.fromJsoup( jsoupDoc );
161165
    final var uri = getBaseUri();
...
357361
358362
  @Override
359
  public void componentMoved( final ComponentEvent e ) { }
363
  public void componentMoved( final ComponentEvent e ) {}
360364
361365
  @Override
362
  public void componentShown( final ComponentEvent e ) { }
366
  public void componentShown( final ComponentEvent e ) {}
363367
364368
  @Override
365
  public void componentHidden( final ComponentEvent e ) { }
369
  public void componentHidden( final ComponentEvent e ) {}
366370
367371
  private static String toStylesheetString( final URL url ) {
M src/main/java/com/keenwrite/preview/SvgRasterizer.java
3030
import static io.sf.carte.echosvg.bridge.UnitProcessor.createContext;
3131
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")
423425
  private static float fit( final Element root, final int width ) {
424426
    final var w = root.getAttribute( SVG_WIDTH_ATTRIBUTE );
M src/main/java/com/keenwrite/processors/ProcessorContext.java
9090
    private Path mSourcePath;
9191
    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;
447473
  }
448474
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
55
package com.keenwrite.processors;
66
7
import com.keenwrite.processors.html.HtmlProcessor;
78
import com.keenwrite.processors.html.PreformattedProcessor;
89
import com.keenwrite.processors.html.XhtmlProcessor;
...
6667
    final var successor = switch( outputType ) {
6768
      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 );
6971
      case TEXT_TEX -> createTextProcessor( context );
7072
      case APPLICATION_PDF -> createPdfProcessor( context );
7173
      default -> createIdentityProcessor( context );
7274
    };
73
7475
    final var inputType = context.getSourceFileType();
7576
    final Processor<String> processor;
...
135136
    final ProcessorContext context ) {
136137
    return createXhtmlProcessor( IDENTITY, context );
137
  }
138
139
  private static Processor<String> createTextProcessor(
140
    final ProcessorContext context ) {
141
    return new TextProcessor( IDENTITY, context );
142138
  }
143139
...
151147
    final var pdfProcessor = new PdfProcessor( context );
152148
    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 );
153159
  }
154160
A src/main/java/com/keenwrite/processors/html/Configuration.java
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
}
126
M src/main/java/com/keenwrite/processors/html/HtmlPreviewProcessor.java
44
import com.keenwrite.preview.HtmlPreview;
55
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;
612
713
/**
814
 * Responsible for notifying the {@link HtmlPreview} when the succession
915
 * chain has updated. This decouples knowledge of changes to the editor panel
1016
 * from the HTML preview panel as well as any processing that takes place
1117
 * before the final HTML preview is rendered. This is the last link in the
1218
 * processor chain.
1319
 */
1420
public final class HtmlPreviewProcessor extends ExecutorProcessor<String> {
21
  /**
22
   * Force the straight quotes to be curled to \&rsquo; 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
1533
  /**
1634
   * There is only one preview panel.
1735
   */
18
  private static HtmlPreview sHtmlPreview;
36
  private static HtmlPreview sPreview;
1937
2038
  /**
2139
   * Constructs the end of a processing chain.
2240
   *
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.
2443
   */
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;
2749
  }
2850
...
3860
    assert html != null;
3961
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 );
4166
    return html;
4267
  }
A src/main/java/com/keenwrite/processors/html/HtmlProcessor.java
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
}
151
M src/main/java/com/keenwrite/processors/html/IdentityProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2024 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors.html;
33
...
1515
   * {@code null}).
1616
   */
17
  private IdentityProcessor() {
18
  }
17
  private IdentityProcessor() {}
1918
2019
  /**
M src/main/java/com/keenwrite/processors/html/XhtmlProcessor.java
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() ) );
312387
  }
313388
}
M src/main/java/com/keenwrite/processors/markdown/BaseMarkdownProcessor.java
1111
import com.keenwrite.processors.markdown.extensions.captions.CaptionExtension;
1212
import com.keenwrite.processors.markdown.extensions.fences.FencedDivExtension;
13
import com.keenwrite.processors.markdown.extensions.quotes.EscapedQuotesExtension;
1314
import com.keenwrite.processors.markdown.extensions.r.RInlineExtension;
1415
import com.keenwrite.processors.markdown.extensions.references.CrossReferenceExtension;
...
3435
 */
3536
public class BaseMarkdownProcessor extends ExecutorProcessor<String> {
36
3737
  private final IParse mParser;
3838
  private final IRender mRenderer;
...
7474
    extensions.add( CrossReferenceExtension.create() );
7575
    extensions.add( CaptionExtension.create() );
76
    extensions.add( EscapedQuotesExtension.create() );
7677
7778
    return extensions;
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
3131
 */
3232
public final class MarkdownProcessor extends BaseMarkdownProcessor {
33
3433
  private MarkdownProcessor(
3534
    final Processor<String> successor, final ProcessorContext context ) {
A src/main/java/com/keenwrite/processors/markdown/extensions/quotes/EscapedQuoteNodeRenderer.java
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
}
150
A src/main/java/com/keenwrite/processors/markdown/extensions/quotes/EscapedQuotesExtension.java
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
}
137
M src/main/java/com/keenwrite/security/PermissiveCertificate.java
7171
   * Use {@link #installTrustManager()}.
7272
   */
73
  private PermissiveCertificate() {
74
  }
73
  private PermissiveCertificate() {}
7574
}
7675
M src/main/java/com/keenwrite/typesetting/Typesetter.java
1919
import static com.keenwrite.events.StatusEvent.clue;
2020
import static com.keenwrite.util.Time.toElapsedTime;
21
import static java.lang.String.*;
2221
import static java.lang.String.format;
2322
import static java.lang.System.currentTimeMillis;
M src/main/java/com/keenwrite/typesetting/installer/panes/ManagerOutputPane.java
2727
 */
2828
public abstract class ManagerOutputPane extends InstallerPane {
29
  private final static String PROP_EXECUTOR =
29
  private static final String PROP_EXECUTOR =
3030
    ManagerOutputPane.class.getCanonicalName();
3131
M src/main/java/com/keenwrite/ui/explorer/FilePickerFactory.java
33
44
import com.keenwrite.Messages;
5
import com.keenwrite.io.SysFile;
56
import com.keenwrite.preferences.Workspace;
67
import javafx.beans.property.ObjectProperty;
...
5960
    final Window owner, final SelectionType options ) {
6061
    final var picker = new NativeFilePicker( owner, options );
62
    final var directory = SysFile.normalize( mDirectory.get() );
6163
62
    picker.setInitialDirectory( mDirectory.get().toPath() );
64
    picker.setInitialDirectory( directory );
6365
6466
    return picker;
M src/main/java/com/keenwrite/ui/logging/LogView.java
6565
6666
        while( mItems.size() > CACHE_SIZE ) {
67
          mItems.remove( 0 );
67
          mItems.removeFirst();
6868
        }
6969
...
153153
  private void initActions() {
154154
    final var stage = getStage();
155
    stage.setOnCloseRequest( event -> stage.hide() );
155
    stage.setOnCloseRequest( _ -> stage.hide() );
156156
  }
157157
M src/main/resources/com/keenwrite/messages.properties
3434
workspace.typeset.typography=Typography
3535
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).
3737
workspace.typeset.typography.quotes.title=Curl
3838
workspace.typeset.modes=Modes
...
327327
Wizard.typesetter.name=ConTeXt
328328
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
331331
Wizard.typesetter.container.image.name=typesetter
332
Wizard.typesetter.container.image.version=3.2.0
332
Wizard.typesetter.container.image.version=3.3.0
333333
Wizard.typesetter.container.image.tag=${Wizard.typesetter.container.image.name}:${Wizard.typesetter.container.image.version}
334334
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
337337
338338
Wizard.container.install.command=Installing container using: ''{0}''
339339
Wizard.container.install.await=Waiting for installer to finish
340340
Wizard.container.install.download.started=Download ''{0}'' started
341341
Wizard.container.install.download.running=Download in progress, please wait
342342
Wizard.container.process.enter=Running ''{0}'' ''{1}''
343343
Wizard.container.process.exit=Process exit code (zero means success): {0}
344344
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}''
345347
Wizard.container.executable.run.error=Cannot run container
346348
Wizard.container.executable.which=Cannot find container using search command
M src/test/java/com/keenwrite/io/MediaTypeSnifferTest.java
3434
        final var actualExtension = valueFrom( media ).getExtension();
3535
        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 );
3737
3838
        assertEquals( expectedExtension, actualExtension );
M src/test/java/com/keenwrite/io/MediaTypeTest.java
5151
    //@formatter:off
5252
    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,
5757
       "https://kroki.io//graphviz/svg/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", IMAGE_SVG_XML
5858
    );
M src/test/java/com/keenwrite/processors/html/XhtmlProcessorTest.java
1515
import static com.keenwrite.ExportFormat.HTML_TEX_DELIMITED;
1616
import static com.keenwrite.ExportFormat.XHTML_TEX;
17
import static com.keenwrite.constants.Constants.APOS_DEFAULT;
1718
import static com.keenwrite.processors.ProcessorContext.builder;
1819
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
20
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
1921
import static java.util.Locale.ENGLISH;
2022
import static org.junit.jupiter.api.Assertions.assertEquals;
...
5658
      .with( ProcessorContext.Mutator::setRScript, () -> "" )
5759
      .with( ProcessorContext.Mutator::setRWorkingDir, () -> Path.of( "r" ) )
58
      .with( ProcessorContext.Mutator::setCurlQuotes, () -> true )
60
      .with( ProcessorContext.Mutator::setCurlQuotes, () -> APOS_DEFAULT )
5961
      .with( ProcessorContext.Mutator::setAutoRemove, () -> true )
6062
      .build();
...
7274
        XHTML_TEX,
7375
        """
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>
7577
          </body></html>"""
7678
      )
M src/test/resources/com/keenwrite/io/images/example_animation.mng
Binary file
D www/LICENSE
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.
221
D www/README.md
11
2
![KeenWrite logo](images/logo/icon.png)
3
4
D www/count.sh
1
#!/usr/bin/env bash
2
3
awk '{s+=$1} END {print s}' downloads/*-count.txt 2> /dev/null || echo 0
4
51
D www/downloads/.gitignore
1
version.txt
21
D www/downloads/.htaccess
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>
121
D www/downloads/counter.php
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
?>
3291
D www/favicon.ico
11
D www/images/icons/apple.svg
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
D www/images/icons/java.svg
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
D www/images/icons/linux.svg
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
D www/images/icons/windows.svg
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
D www/images/logo/icon.png
Binary file
D www/images/logo/icon.svg
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>
1021
D www/images/logo/title.svg
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
D www/images/screenshots/01.png
Binary file
D www/images/screenshots/02.png
Binary file
D www/images/screenshots/03.png
Binary file
D www/images/screenshots/04.png
Binary file
D www/images/screenshots/05.png
Binary file
D www/images/screenshots/06.png
Binary file
D www/images/screenshots/07.png
Binary file
D www/images/screenshots/08.png
Binary file
D www/index.shtml
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
  &copy; 2023, White Magic Software, Ltd.
70
</footer>
71
</body>
72
</html>
73
741
D www/robots.txt
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
181
D www/screenshots.html
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
  &copy; 2023, White Magic Software, Ltd.
64
</footer>
65
</body>
66
</html>
67
681
D www/styles/base.css
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
1561