Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M R/conversion.R
198198
}
199199
200
cms.big <- function( n ) {
201
}
202
203
204200
# -----------------------------------------------------------------------------
205201
# Returns a number as a comma-delimited string. This is a work-around
...
425421
round.up <- function( n, base = 5 ) {
426422
  base * round( x( n ) / base )
423
}
424
425
# -----------------------------------------------------------------------------
426
# Removes common accents from letters.
427
#
428
# @param s The string to remove diacritics from.
429
# -----------------------------------------------------------------------------
430
accentless <- function( s ) {
431
  chartr(
432
    "áéóūáéíóúÁÉÍÓÚýÝàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛãõÃÕñÑäëïöüÄËÏÖÜÿçÇ",
433
    "aeouaeiouAEIOUyYaeiouAEIOUaeiouAEIOUaoAOnNaeiouAEIOUycC",
434
    s );
427435
}
428436
M R/pluralize.R
153153
154154
.irregular_patterns <- c( 
155
  "fish$", "ois$", "-sheep$", "deer$", "pox$", "[A-Z].*ese$", "itis$"
155
  "fish$", "ois$", "sheep$", "deer$", "pox$", "[A-Z].*ese$", "itis$"
156156
)
157157
...
268268
.irregular_nouns <- list( 
269269
  "beef"      = c( "a" = "beefs",       "c" = "beeves" ),
270
  "biscotto"  = c( "a" = "biscotti",    "c" = NA_character_ ),
270271
  "brother"   = c( "a" = "brothers",    "c" = "brethren" ),
272
  "cactus"    = c( "a" = NA_character_, "c" = "catci" ),
271273
  "child"     = c( "a" = NA_character_, "c" = "children" ),
272274
  "cherub"    = c( "a" = "cherubim",    "c" = NA_character_ ),
273275
  "cow"       = c( "a" = "cows",        "c" = "kine" ),
276
  "crisis"    = c( "a" = NA_character_, "c" = "crises" ),
277
  "data"      = c( "a" = "data",        "c" = "data" ),
274278
  "ephemeris" = c( "a" = NA_character_, "c" = "ephemerides" ),
275279
  "genie"     = c( "a" = "genies",      "c" = "genii" ),
280
  "graffito"  = c( "a" = "graffiti",    "c" = NA_character_ ),
276281
  "matrix"    = c( "a" = NA_character_, "c" = "matrices" ),
277282
  "money"     = c( "a" = "moneys",      "c" = "monies" ),
278283
  "mongoose"  = c( "a" = "mongooses",   "c" = NA_character_ ),
284
  "minimum"   = c( "a" = "minimums",    "c" = "minima" ),
279285
  "mythos"    = c( "a" = NA_character_, "c" = "mythoi" ),
280
  "octopus"   = c( "a" = "octopuses",   "c" = "octopodes" ),
286
  "octopus"   = c( "a" = NA_character_, "c" = "octopodes" ),
281287
  "ox"        = c( "a" = NA_character_, "c" = "oxen" ),
282288
  "passerby"  = c( "a" = NA_character_, "c" = "passersby" ),
289
  "panino"    = c( "a" = "panini",      "c" = NA_character_ ),
290
  "pieróg"    = c( "a" = "pierogi",     "c" = NA_character_ ),
291
  "pierog"    = c( "a" = "pierogi",     "c" = NA_character_ ),
292
  "radius"    = c( "a" = NA_character_, "c" = "radii" ),
293
  "referendum"= c( "a" = "referendums", "c" = "referenda" ),
283294
  "soliloquy" = c( "a" = "soliloquies", "c" = NA_character_ ),
284295
  "seraph"    = c( "a" = "seraphim",    "c" = NA_character_ ),
296
  "stadium"   = c( "a" = "stadiums",    "c" = "stadia" ),
285297
  "trilby"    = c( "a" = "trilbys",     "c" = NA_character_ ),
286298
  "vertex"    = c( "a" = NA_character_, "c" = "vertices" ),
A R.zip
Binary file
M build.gradle
1
import static org.gradle.api.JavaVersion.*
1
//file:noinspection SpellCheckingInspection
22
33
buildscript {
44
  repositories {
55
    mavenCentral()
6
    maven {
7
      url "https://plugins.gradle.org/m2/"
8
    }
69
  }
710
  dependencies {
811
    classpath 'org.owasp:dependency-check-gradle:7.4.3'
12
    classpath "com.github.spotbugs.snom:spotbugs-gradle-plugin:5.0.13"
913
  }
1014
}
1115
1216
plugins {
1317
  id 'application'
1418
  id 'org.openjfx.javafxplugin' version '0.0.13'
1519
  id 'com.palantir.git-version' version '0.15.0'
20
  //id "com.github.spotbugs" version "5.0.13"
1621
}
1722
...
6368
    '--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED',
6469
]
70
71
java {
72
  sourceCompatibility = 19
73
  targetCompatibility = 19
74
}
6575
6676
javafx {
...
7383
  def v_junit = '5.9.1'
7484
  def v_flexmark = '0.64.0'
75
  def v_jackson = '2.14.0'
85
  def v_jackson = '2.14.2'
7686
  def v_echosvg = '0.2.2'
7787
  def v_picocli = '4.7.0'
...
95105
96106
  // YAML
107
  implementation 'org.yaml:snakeyaml:2.0'
97108
  implementation "com.fasterxml.jackson.core:jackson-core:${v_jackson}"
98109
  implementation "com.fasterxml.jackson.core:jackson-databind:${v_jackson}"
...
106117
107118
  // R
119
  implementation 'org.apache.commons:commons-compress:1.23.0'
120
  implementation 'org.codehaus.plexus:plexus-utils:3.5.1'
108121
  implementation 'org.renjin:renjin-script-engine:3.5-beta76'
109122
  implementation 'org.renjin.cran:rjson:0.2.15-renjin-21'
...
126139
  // Misc.
127140
  implementation 'org.ahocorasick:ahocorasick:0.6.3'
128
  implementation 'org.apache.commons:commons-configuration2:2.8.0'
141
  implementation 'org.apache.commons:commons-configuration2:2.9.0'
129142
  implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3'
130143
  implementation 'javax.validation:validation-api:2.0.1.Final'
131144
  implementation 'org.greenrobot:eventbus-java:3.3.1'
132
  implementation 'commons-beanutils:commons-beanutils:1.9.4'
145
  //implementation 'commons-beanutils:commons-beanutils:1.9.4'
133146
134147
  // Command-line parsing
...
163176
final String applicationPackage = "com.${applicationName}"
164177
final String applicationClass = "${applicationPackage}.Launcher"
165
166
java {
167
  sourceCompatibility = VERSION_17
168
  targetCompatibility = VERSION_17
169
}
170178
171179
compileJava {
M gradle.properties
33
org.gradle.parallel=true
44
5
A images/kitten.jpg
11
A images/kitten.png
11
M installer.sh
3131
ARG_JAVA_OS="linux"
3232
ARG_JAVA_ARCH="amd64"
33
ARG_JAVA_VERSION="19.0.1"
34
ARG_JAVA_UPDATE="11"
33
ARG_JAVA_VERSION="20.0.1"
34
ARG_JAVA_UPDATE="10"
3535
ARG_JAVA_DIR="java"
3636
M libs/keenquotes.jar
Binary file
M libs/tokenize.jar
Binary file
D r.zip
Binary file
A scripts/.gitignore
1
sign.sh
12
M src/main/java/com/keenwrite/AppCommands.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
15
package com.keenwrite;
26
37
import com.keenwrite.cmdline.Arguments;
4
import com.keenwrite.util.AlphanumComparator;
8
import com.keenwrite.commands.ConcatenateCommand;
9
import com.keenwrite.processors.Processor;
10
import com.keenwrite.processors.ProcessorContext;
11
import com.keenwrite.processors.RBootstrapProcessor;
512
613
import java.io.IOException;
714
import java.nio.file.Path;
8
import java.util.ArrayList;
915
import java.util.concurrent.Callable;
1016
import java.util.concurrent.CompletableFuture;
1117
import java.util.concurrent.ExecutorService;
1218
import java.util.concurrent.atomic.AtomicInteger;
1319
1420
import static com.keenwrite.Launcher.terminate;
1521
import static com.keenwrite.events.StatusEvent.clue;
22
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
1623
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
17
import static com.keenwrite.util.FileWalker.walk;
18
import static java.lang.System.lineSeparator;
1924
import static java.nio.file.Files.readString;
2025
import static java.nio.file.Files.writeString;
...
2833
public class AppCommands {
2934
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
30
31
  /**
32
   * Sci-fi genres, which are can be longer than other genres, typically fall
33
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
34
   * memory when concatenating files together when exporting novels.
35
   */
36
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
3735
3836
  private AppCommands() {
...
7876
      try {
7977
        final var context = args.createProcessorContext();
80
        final var concat = context.getConcatenate();
81
        final var inputPath = context.getSourcePath();
8278
        final var outputPath = context.getTargetPath();
8379
        final var chain = createProcessors( context );
84
        final var inputDoc = read( inputPath, concat );
85
        final var outputDoc = chain.apply( inputDoc );
80
        final var processor = createBootstrapProcessor( chain, context );
81
        final var inputDoc = read( context );
82
        final var outputDoc = processor.apply( inputDoc );
8683
8784
        // Processors can export binary files. In such cases, processors will
...
10097
    // Prevent the application from blocking while the processor executes.
10198
    sExecutor.submit( callableTask );
99
  }
100
101
  private static Processor<String> createBootstrapProcessor(
102
    final Processor<String> chain, final ProcessorContext context ) {
103
104
    return context.getSourceType() == TEXT_R_MARKDOWN
105
      ? new RBootstrapProcessor( chain, context )
106
      : chain;
102107
  }
103108
...
116121
   * </p>
117122
   *
118
   * @param inputPath The path to the source file to read.
119
   * @param concat    {@code true} to concatenate all files with the same
120
   *                  extension as the source path.
123
   * @param context The {@link ProcessorContext} containing input path,
124
   *                and other command-line parameters.
121125
   * @return All files in the same directory as the file being edited
122126
   * concatenated into a single string.
123127
   */
124
  private static String read( final Path inputPath, final boolean concat )
128
  private static String read( final ProcessorContext context )
125129
    throws IOException {
130
    final var concat = context.getConcatenate();
131
    final var inputPath = context.getSourcePath();
126132
    final var parent = inputPath.getParent();
127133
    final var filename = inputPath.getFileName().toString();
128134
    final var extension = getExtension( filename );
129135
130136
    // Short-circuit because: only one file was requested; there is no parent
131137
    // directory to scan for files; or there's no extension for globbing.
132138
    if( !concat || parent == null || extension.isBlank() ) {
133139
      return readString( inputPath );
134
    }
135
136
    final var glob = "**/*." + extension;
137
    final var files = new ArrayList<Path>();
138
    walk( parent, glob, files::add );
139
    files.sort( new AlphanumComparator<>() );
140
141
    final var text = new StringBuilder( DOCUMENT_LENGTH );
142
    final var eol = lineSeparator();
143
144
    for( final var file : files ) {
145
      text.append( readString( file ) );
146
      text.append( eol );
147140
    }
148141
149
    return text.toString();
142
    final var command = new ConcatenateCommand(
143
      parent, extension, context.getChapters() );
144
    return command.call();
150145
  }
151146
}
M src/main/java/com/keenwrite/ExportFormat.java
9595
        : HTML_TEX_DELIMITED;
9696
      case APP_PDF -> APPLICATION_PDF;
97
      case TEXT_XML -> XHTML_TEX;
9798
      default -> throw new IllegalArgumentException( format(
9899
        "Unrecognized format type and subtype: '%s' and '%s'", type, modifier
M src/main/java/com/keenwrite/MainPane.java
10541054
      .with( Mutator::setLocale, w::getLocale )
10551055
      .with( Mutator::setMetadata, w::getMetadata )
1056
      .with( Mutator::setThemesPath, w::getThemesPath )
1057
      .with( Mutator::setCachesPath,
1056
      .with( Mutator::setThemesDir, w::getThemesPath )
1057
      .with( Mutator::setCachesDir,
10581058
             () -> w.getFile( KEY_CACHES_DIR ) )
1059
      .with( Mutator::setImagesPath,
1059
      .with( Mutator::setImagesDir,
10601060
             () -> w.getFile( KEY_IMAGES_DIR ) )
10611061
      .with( Mutator::setImageOrder,
10621062
             () -> w.getString( KEY_IMAGES_ORDER ) )
10631063
      .with( Mutator::setImageServer,
10641064
             () -> w.getString( KEY_IMAGES_SERVER ) )
1065
      .with( Mutator::setFontsPath,
1065
      .with( Mutator::setFontsDir,
10661066
             () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) )
10671067
      .with( Mutator::setCaret,
...
10821082
10831083
  public ProcessorContext createProcessorContext() {
1084
    return createProcessorContext( null, NONE );
1084
    return createProcessorContextBuilder( NONE ).build();
1085
  }
1086
1087
  private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder(
1088
    final ExportFormat format ) {
1089
    final var textEditor = getTextEditor();
1090
    final var sourcePath = textEditor.getPath();
1091
1092
    return processorContextBuilder()
1093
      .with( Mutator::setSourcePath, sourcePath )
1094
      .with( Mutator::setExportFormat, format );
10851095
  }
10861096
...
10931103
  public ProcessorContext createProcessorContext(
10941104
    final Path targetPath, final ExportFormat format ) {
1095
    final var textEditor = getTextEditor();
1096
    final var sourcePath = textEditor.getPath();
1105
    assert targetPath != null;
1106
    assert format != null;
10971107
1098
    return processorContextBuilder()
1099
      .with( Mutator::setSourcePath, sourcePath )
1108
    return createProcessorContextBuilder( format )
11001109
      .with( Mutator::setTargetPath, targetPath )
1101
      .with( Mutator::setExportFormat, format )
11021110
      .build();
11031111
  }
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
 */
15
package com.keenwrite.cmdline;
26
...
4852
  )
4953
  private boolean mKeepFiles;
54
55
  @CommandLine.Option(
56
    names = {"-c", "--chapters"},
57
    description =
58
      "Export chapter ranges, no spaces (e.g., -3,5-9,15-)",
59
    paramLabel = "String"
60
  )
61
  private String mChapters;
5062
5163
  @CommandLine.Option(
5264
    names = {"--curl-quotes"},
5365
    description =
5466
      "Replace straight quotes with curly quotes (${DEFAULT-VALUE})",
5567
    defaultValue = "true"
5668
  )
57
  private Boolean mCurlQuotes;
69
  private boolean mCurlQuotes;
5870
5971
  @CommandLine.Option(
...
229241
      .with( Mutator::setSourcePath, mSourcePath )
230242
      .with( Mutator::setTargetPath, mTargetPath )
231
      .with( Mutator::setThemesPath, () -> mThemesDir )
232
      .with( Mutator::setCachesPath, () -> mCachesDir )
233
      .with( Mutator::setImagesPath, () -> mImagesDir )
243
      .with( Mutator::setThemesDir, () -> mThemesDir )
244
      .with( Mutator::setCachesDir, () -> mCachesDir )
245
      .with( Mutator::setImagesDir, () -> mImagesDir )
234246
      .with( Mutator::setImageServer, () -> mImageServer )
235247
      .with( Mutator::setImageOrder, () -> mImageOrder )
236
      .with( Mutator::setFontsPath, () -> mFontDir )
248
      .with( Mutator::setFontsDir, () -> mFontDir )
237249
      .with( Mutator::setExportFormat, format )
238250
      .with( Mutator::setDefinitions, () -> definitions )
239251
      .with( Mutator::setMetadata, () -> mMetadata )
240252
      .with( Mutator::setLocale, () -> locale )
241
      .with( Mutator::setConcatenate, mConcatenate )
253
      .with( Mutator::setConcatenate, () -> mConcatenate )
254
      .with( Mutator::setChapters, () -> mChapters )
242255
      .with( Mutator::setSigilBegan, () -> mSigilBegan )
243256
      .with( Mutator::setSigilEnded, () -> mSigilEnded )
244
      .with( Mutator::setRWorkingDir, () -> mRWorkingDir )
245257
      .with( Mutator::setRScript, () -> rScript )
258
      .with( Mutator::setRWorkingDir, () -> mRWorkingDir )
246259
      .with( Mutator::setCurlQuotes, () -> mCurlQuotes )
247260
      .with( Mutator::setAutoRemove, () -> !mKeepFiles )
A src/main/java/com/keenwrite/commands/ConcatenateCommand.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.commands;
6
7
import com.keenwrite.util.AlphanumComparator;
8
import com.keenwrite.util.RangeValidator;
9
10
import java.io.IOException;
11
import java.nio.file.Path;
12
import java.util.ArrayList;
13
import java.util.concurrent.Callable;
14
import java.util.concurrent.atomic.AtomicInteger;
15
16
import static com.keenwrite.events.StatusEvent.clue;
17
import static com.keenwrite.util.FileWalker.walk;
18
import static java.lang.System.lineSeparator;
19
import static java.nio.file.Files.readString;
20
21
/**
22
 * Responsible for concatenating files according to user-defined chapter ranges.
23
 */
24
public class ConcatenateCommand implements Callable<String> {
25
  /**
26
   * Sci-fi genres, which are can be longer than other genres, typically fall
27
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
28
   * memory when concatenating files together when exporting novels.
29
   */
30
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
31
32
  private final Path mParent;
33
  private final String mExtension;
34
  private final String mRange;
35
36
  public ConcatenateCommand(
37
    final Path parent,
38
    final String extension,
39
    final String range ) {
40
    assert parent != null;
41
    assert extension != null;
42
    assert range != null;
43
44
    mParent = parent;
45
    mExtension = extension;
46
    mRange = range;
47
  }
48
49
  public String call() throws IOException {
50
    final var glob = "**/*." + mExtension;
51
    final var files = new ArrayList<Path>();
52
    final var text = new StringBuilder( DOCUMENT_LENGTH );
53
    final var chapter = new AtomicInteger();
54
    final var eol = lineSeparator();
55
56
    final var validator = new RangeValidator( mRange );
57
58
    walk( mParent, glob, files::add );
59
    files.sort( new AlphanumComparator<>() );
60
    files.forEach( file -> {
61
      try {
62
        if( validator.test( chapter.incrementAndGet() ) ) {
63
          clue( "Main.status.export.concat", file );
64
65
          text.append( readString( file ) )
66
              .append( eol );
67
        }
68
      } catch( final IOException ex ) {
69
        clue( "Main.status.export.concat.io", file );
70
      }
71
    } );
72
73
    return text.toString();
74
  }
75
}
176
M src/main/java/com/keenwrite/dom/DocumentParser.java
102102
103103
      return sDocumentBuilder.parse( input );
104
    } catch( final Exception ex ) {
105
      clue( ex );
104
    } catch( final Throwable t ) {
105
      clue( t );
106106
107107
      return sDocumentBuilder.newDocument();
M src/main/java/com/keenwrite/io/MediaType.java
291291
  }
292292
293
  /**
294
   * Answers whether this instance is an image, vector or raster.
295
   *
296
   * @return {@code true} if this instance represents any type of image.
297
   */
298
  public boolean isImage() {
299
    return isType( IMAGE );
300
  }
301
293302
  public boolean isUndefined() {
294303
    return equals( UNDEFINED );
M src/main/java/com/keenwrite/io/MediaTypeExtension.java
55
66
import java.io.File;
7
import java.nio.file.Path;
78
import java.util.List;
89
...
99100
  public static MediaType fromFile( final File file ) {
100101
    return fromExtension( FilenameUtils.getExtension( file.getName() ) );
102
  }
103
104
  public static MediaType fromPath( final Path path ) {
105
    return fromFile( path.toFile() );
101106
  }
102107
M src/main/java/com/keenwrite/io/MediaTypeSniffer.java
88
import static com.keenwrite.io.MediaType.*;
99
import static java.lang.System.arraycopy;
10
import static java.util.Arrays.fill;
1011
1112
/**
...
2223
 */
2324
public class MediaTypeSniffer {
24
  private static final int FORMAT_LENGTH = 11;
25
  private static final int END_OF_DATA = -2;
25
  /**
26
   * The maximum buffer size of magic bytes to analyze.
27
   */
28
  private static final int BUFFER = 12;
29
30
  /**
31
   * The media type data can have any value at a corresponding offset.
32
   */
33
  private static final int ANY = -1;
34
35
  /**
36
   * Denotes there are fewer than {@link #BUFFER} bytes to compare.
37
   */
38
  private static final int EOS = -2;
2639
2740
  private static final Map<int[], MediaType> FORMAT = new LinkedHashMap<>();
2841
2942
  private static void put( final int[] data, final MediaType mediaType ) {
3043
    FORMAT.put( data, mediaType );
3144
  }
3245
46
  /* The insertion order attempts to approximate the real-world likelihood of
47
   * encountering particular file formats in an application.
48
   */
3349
  static {
3450
    //@formatter:off
3551
    put( ints( 0x3C, 0x73, 0x76, 0x67, 0x20 ), IMAGE_SVG_XML );
3652
    put( ints( 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ), IMAGE_PNG );
3753
    put( ints( 0xFF, 0xD8, 0xFF, 0xE0 ), IMAGE_JPEG );
3854
    put( ints( 0xFF, 0xD8, 0xFF, 0xEE ), IMAGE_JPEG );
39
    put( ints( 0xFF, 0xD8, 0xFF, 0xE1, -1, -1, 0x45, 0x78, 0x69, 0x66, 0x00 ), IMAGE_JPEG );
40
    put( ints( 0x49, 0x49, 0x2A, 0x00 ), IMAGE_TIFF );
41
    put( ints( 0x4D, 0x4D, 0x00, 0x2A ), IMAGE_TIFF );
42
    put( ints( 0x47, 0x49, 0x46, 0x38 ), IMAGE_GIF );
43
    put( ints( 0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50 ), IMAGE_WEBP );
44
    put( ints( 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E ), APP_PDF );
45
    put( ints( 0x25, 0x21, 0x50, 0x53, 0x2D, 0x41, 0x64, 0x6F, 0x62, 0x65, 0x2D ), APP_EPS );
46
    put( ints( 0x25, 0x21, 0x50, 0x53 ), APP_PS );
47
    put( ints( 0x38, 0x42, 0x50, 0x53, 0x00, 0x01 ), IMAGE_PHOTOSHOP );
48
    put( ints( 0x8A, 0x4D, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ), VIDEO_MNG );
49
    put( ints( 0x42, 0x4D ), IMAGE_BMP );
50
    put( ints( 0xFF, 0xFB, 0x30 ), AUDIO_MP3 );
51
    put( ints( 0x49, 0x44, 0x33 ), AUDIO_MP3 );
55
    put( ints( 0xFF, 0xD8, 0xFF, 0xE1, ANY, ANY, 0x45, 0x78, 0x69, 0x66, 0x00 ), IMAGE_JPEG );
5256
    put( ints( 0x3C, 0x21 ), TEXT_HTML );
5357
    put( ints( 0x3C, 0x68, 0x74, 0x6D, 0x6C ), TEXT_HTML );
...
6064
    put( ints( 0xFE, 0xFF, 0x00, 0x3C, 0x00, 0x3f, 0x00, 0x78 ), TEXT_XML );
6165
    put( ints( 0xFF, 0xFE, 0x3C, 0x00, 0x3F, 0x00, 0x78, 0x00 ), TEXT_XML );
66
    put( ints( 0x47, 0x49, 0x46, 0x38 ), IMAGE_GIF );
67
    put( ints( 0x42, 0x4D ), IMAGE_BMP );
68
    put( ints( 0x49, 0x49, 0x2A, 0x00 ), IMAGE_TIFF );
69
    put( ints( 0x4D, 0x4D, 0x00, 0x2A ), IMAGE_TIFF );
70
    put( ints( 0x52, 0x49, 0x46, 0x46, ANY, ANY, ANY, ANY, 0x57, 0x45, 0x42, 0x50 ), IMAGE_WEBP );
71
    put( ints( 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E ), APP_PDF );
72
    put( ints( 0x25, 0x21, 0x50, 0x53, 0x2D, 0x41, 0x64, 0x6F, 0x62, 0x65, 0x2D ), APP_EPS );
73
    put( ints( 0x25, 0x21, 0x50, 0x53 ), APP_PS );
74
    put( ints( 0x38, 0x42, 0x50, 0x53, 0x00, 0x01 ), IMAGE_PHOTOSHOP );
75
    put( ints( 0xFF, 0xFB, 0x30 ), AUDIO_MP3 );
76
    put( ints( 0x49, 0x44, 0x33 ), AUDIO_MP3 );
77
    put( ints( 0x8A, 0x4D, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ), VIDEO_MNG );
6278
    put( ints( 0x23, 0x64, 0x65, 0x66 ), IMAGE_X_BITMAP );
6379
    put( ints( 0x21, 0x20, 0x58, 0x50, 0x4D, 0x32 ), IMAGE_X_PIXMAP );
6480
    put( ints( 0x2E, 0x73, 0x6E, 0x64 ), AUDIO_SIMPLE );
6581
    put( ints( 0x64, 0x6E, 0x73, 0x2E ), AUDIO_SIMPLE );
6682
    put( ints( 0x52, 0x49, 0x46, 0x46 ), AUDIO_WAV );
6783
    put( ints( 0x50, 0x4B ), APP_ZIP );
68
    put( ints( 0x41, 0x43, -1, -1, -1, -1, 0x00, 0x00, 0x00, 0x00, 0x00 ), APP_ACAD );
84
    put( ints( 0x41, 0x43, ANY, ANY, ANY, ANY, 0x00, 0x00, 0x00, 0x00, 0x00 ), APP_ACAD );
6985
    put( ints( 0xCA, 0xFE, 0xBA, 0xBE ), APP_JAVA );
7086
    put( ints( 0xAC, 0xED ), APP_JAVA_OBJECT );
...
8197
  public static MediaType getMediaType( final byte[] data ) {
8298
    assert data != null;
99
    assert data.length > 0;
83100
84101
    final var source = new int[]{
85
      0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
102
      0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
103
      0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
104
      0xFF, 0XFF, EOS
86105
    };
87106
88
    for( int i = 0; i < data.length; i++ ) {
107
    for( int i = 0; i < Math.min( data.length, source.length ); i++ ) {
89108
      source[ i ] = data[ i ] & 0xFF;
90109
    }
91110
92
    for( final var key : FORMAT.keySet() ) {
111
    for( final var entry : FORMAT.entrySet() ) {
112
      final var key = entry.getKey();
113
93114
      int i = -1;
94115
      boolean matches = true;
95116
96
      while( ++i < FORMAT_LENGTH && key[ i ] != END_OF_DATA && matches ) {
97
        matches = key[ i ] == source[ i ] || key[ i ] == -1;
117
      while( ++i < BUFFER && key[ i ] != EOS && matches ) {
118
        matches = key[ i ] == source[ i ] || key[ i ] == ANY;
98119
      }
99120
100121
      if( matches ) {
101
        return FORMAT.get( key );
122
        return entry.getValue();
102123
      }
103124
    }
...
126147
   * Convenience method to return the probed media type for the given
127148
   * {@link BufferedInputStream} instance. <strong>This resets the stream
128
   * pointer</strong> making the call idempotent. Users of this class should
129
   * prefer to call this method when operating on streams to avoid advancing
130
   * the stream.
149
   * pointer</strong> making the call idempotent. Prefer calling this
150
   * method when operating on streams to avoid advancing the stream.
131151
   *
132152
   * @param bis Data source to ascertain the {@link MediaType}.
133153
   * @return The IANA-defined {@link MediaType}, or
134154
   * {@link MediaType#UNDEFINED} if indeterminate.
135155
   * @throws IOException Could not read from the stream.
136156
   */
137157
  public static MediaType getMediaType( final BufferedInputStream bis )
138158
    throws IOException {
139
    bis.mark( FORMAT_LENGTH );
159
    bis.mark( BUFFER );
140160
    final var result = getMediaType( (InputStream) bis );
141161
    bis.reset();
...
157177
  private static MediaType getMediaType( final InputStream is )
158178
    throws IOException {
159
    final var input = new byte[ FORMAT_LENGTH ];
160
    final var count = is.read( input, 0, FORMAT_LENGTH );
179
    final var input = new byte[ BUFFER ];
180
    final var count = is.read( input, 0, BUFFER );
161181
162182
    if( count > 1 ) {
...
170190
171191
  /**
172
   * Creates integer array from the given data, padded with
173
   * {@link #END_OF_DATA} values up to {@link #FORMAT_LENGTH}.
192
   * Creates an integer array from the given data, padded with {@link #EOS}
193
   * values up to {@link #BUFFER} in length.
174194
   *
175195
   * @param data The input byte values to pad.
176196
   * @return The data with padding.
177197
   */
178198
  private static int[] ints( final int... data ) {
179
    final var magic = new int[ FORMAT_LENGTH + 1 ];
180
    int i = -1;
199
    assert data != null;
181200
182
    while( ++i < data.length ) {
183
      magic[ i ] = data[ i ];
184
    }
201
    final var magic = new int[ data.length + 1 ];
185202
186
    while( i < FORMAT_LENGTH ) {
187
      magic[ i++ ] = END_OF_DATA;
188
    }
203
    fill( magic, EOS );
204
    arraycopy( data, 0, magic, 0, data.length );
189205
190206
    return magic;
M src/main/java/com/keenwrite/io/StreamGobbler.java
88
import java.util.function.Consumer;
99
10
import static java.nio.charset.StandardCharsets.UTF_8;
1011
import static java.util.concurrent.Executors.newFixedThreadPool;
1112
...
5152
  @Override
5253
  public Boolean call() throws IOException {
53
    try( final var input = new InputStreamReader( mInput );
54
    try( final var input = new InputStreamReader( mInput, UTF_8 );
5455
         final var buffer = new BufferedReader( input ) ) {
5556
      buffer.lines().forEach( mConsumer );
M src/main/java/com/keenwrite/io/downloads/DownloadManager.java
88
import java.net.HttpURLConnection;
99
import java.net.URI;
10
import java.net.URISyntaxException;
1011
import java.net.URL;
1112
import java.time.Duration;
1213
import java.util.zip.GZIPInputStream;
1314
1415
import static java.lang.Math.toIntExact;
1516
import static java.lang.String.format;
16
import static java.lang.System.*;
17
import static java.lang.System.getProperty;
18
import static java.lang.System.setProperty;
1719
import static java.net.HttpURLConnection.HTTP_OK;
1820
import static java.net.HttpURLConnection.setFollowRedirects;
...
167169
   * Opens the input stream for the resource to download.
168170
   *
169
   * @param url The {@link URL} resource to download.
171
   * @param uri The {@link URI} resource to download.
170172
   * @return A token that can be used for downloading the content with
171173
   * periodic updates or retrieving the stream for downloading the content.
172
   * @throws IOException The stream could not be opened.
174
   * @throws IOException        The stream could not be opened.
175
   * @throws URISyntaxException Invalid URI.
173176
   */
174
  public static DownloadToken open( final String url ) throws IOException {
177
  public static DownloadToken open( final String uri )
178
    throws IOException, URISyntaxException {
175179
    // Pass an undefined media type so that any type of file can be retrieved.
176
    return open( new URL( url ) );
180
    return open( new URI( uri ) );
177181
  }
178182
...
199203
  public static DownloadToken open( final URL url ) throws IOException {
200204
    final var conn = connect( url );
205
    final var contentType = conn.getContentType();
201206
202
    MediaType contentType;
207
    MediaType remoteType;
203208
204209
    try {
205
      contentType = MediaType.valueFrom( conn.getContentType() );
210
      remoteType = MediaType.valueFrom( contentType );
206211
    } catch( final Exception ex ) {
207212
      // If the media type couldn't be detected, try using the stream.
208
      contentType = MediaType.UNDEFINED;
213
      remoteType = MediaType.UNDEFINED;
209214
    }
210215
211216
    final var input = open( conn );
212217
213218
    // Peek at the magic header bytes to determine the media type.
214219
    final var magicType = MediaTypeSniffer.getMediaType( input );
215220
216221
    // If the transport protocol's Content-Type doesn't align with the
217
    // media type for the magic header, defer to the transport protocol.
218
    final MediaType mediaType =
219
      !contentType.equals( magicType ) && !magicType.isUndefined()
220
        ? contentType
221
        : magicType;
222
    // media type for the magic header, defer to the transport protocol (so
223
    // long as the content type was sent from the remote side).
224
    final MediaType mediaType = remoteType.equals( magicType )
225
      ? remoteType
226
      : contentType != null && !contentType.isBlank()
227
      ? remoteType
228
      : magicType.isUndefined()
229
      ? remoteType
230
      : magicType;
222231
223232
    return new DownloadToken( conn, input, mediaType );
M src/main/java/com/keenwrite/preferences/SimpleTableControl.java
2525
import static java.util.Arrays.asList;
2626
import static javafx.scene.control.SelectionMode.MULTIPLE;
27
import static javafx.scene.control.TableView.CONSTRAINED_RESIZE_POLICY;
27
import static javafx.scene.control.TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN;
2828
2929
public class SimpleTableControl<K, V, F extends TableField<Entry<K, V>>>
...
4141
    final var table = new TableView<>( model );
4242
43
    table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY );
43
    table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN );
4444
    table.setEditable( true );
4545
    table.getColumns().addAll(
M src/main/java/com/keenwrite/preview/ChainedReplacedElementFactory.java
22
package com.keenwrite.preview;
33
4
import com.keenwrite.ui.adapters.ReplacedElementAdapter;
54
import com.keenwrite.collections.BoundedCache;
5
import com.keenwrite.ui.adapters.ReplacedElementAdapter;
66
import org.w3c.dom.Element;
77
import org.xhtmlrenderer.extend.ReplacedElement;
...
1616
import java.util.Set;
1717
18
import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE;
19
import static com.keenwrite.preview.SvgReplacedElementFactory.HTML_IMAGE_SRC;
18
import static com.keenwrite.preview.ImageReplacedElementFactory.HTML_IMAGE;
19
import static com.keenwrite.preview.ImageReplacedElementFactory.HTML_IMAGE_SRC;
2020
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
2121
import static java.lang.Math.min;
...
7777
      }
7878
79
      final var replaced = mCache.computeIfAbsent(
80
        source, k -> {
81
          final var r = f.createReplacedElement( c, box, uac, width, height );
82
          return r instanceof final ImageReplacedElement ire
83
            ? createImageElement( box, ire )
84
            : r;
85
        }
86
      );
79
      final var replaced = mCache.computeIfAbsent( source, k -> {
80
        final var r = f.createReplacedElement( c, box, uac, width, height );
81
82
        return r instanceof final ImageReplacedElement ire
83
          ? createImageElement( box, ire )
84
          : r;
85
      } );
8786
8887
      if( replaced != null ) {
...
106105
      factory.remove( element );
107106
    }
108
  }
109
110
  public void addFactory( final ReplacedElementFactory factory ) {
111
    mFactories.add( factory );
112107
  }
113108
M src/main/java/com/keenwrite/preview/FlyingSaucerPanel.java
9393
    // content. Consequently, the chained factory must maintain insertion order.
9494
    mFactory = new ChainedReplacedElementFactory(
95
      new SvgReplacedElementFactory(),
95
      new ImageReplacedElementFactory(),
9696
      new SwingReplacedElementFactory()
9797
    );
A src/main/java/com/keenwrite/preview/ImageReplacedElementFactory.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import com.keenwrite.io.MediaType;
5
import com.keenwrite.ui.adapters.ReplacedElementAdapter;
6
import io.sf.carte.echosvg.transcoder.TranscoderException;
7
import org.w3c.dom.Element;
8
import org.xhtmlrenderer.extend.ReplacedElement;
9
import org.xhtmlrenderer.extend.UserAgentCallback;
10
import org.xhtmlrenderer.layout.LayoutContext;
11
import org.xhtmlrenderer.render.BlockBox;
12
import org.xhtmlrenderer.swing.ImageReplacedElement;
13
14
import javax.imageio.ImageIO;
15
import java.awt.image.BufferedImage;
16
import java.io.IOException;
17
import java.net.URI;
18
import java.net.URISyntaxException;
19
import java.nio.file.Files;
20
import java.nio.file.Path;
21
import java.text.ParseException;
22
23
import static com.keenwrite.events.StatusEvent.clue;
24
import static com.keenwrite.io.downloads.DownloadManager.open;
25
import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER;
26
import static com.keenwrite.preview.SvgRasterizer.rasterize;
27
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
28
import static com.keenwrite.util.ProtocolScheme.getProtocol;
29
30
/**
31
 * Responsible for running {@link SvgRasterizer} on SVG images detected within
32
 * a document to transform them into rasterized versions. This will fall back
33
 * to loading rasterized images from a file if not detected as SVG.
34
 */
35
public final class ImageReplacedElementFactory extends ReplacedElementAdapter {
36
37
  public static final String HTML_IMAGE = "img";
38
  public static final String HTML_IMAGE_SRC = "src";
39
40
  private static final ImageReplacedElement BROKEN_IMAGE =
41
    createElement( BROKEN_IMAGE_PLACEHOLDER );
42
43
  @Override
44
  public ReplacedElement createReplacedElement(
45
    final LayoutContext c,
46
    final BlockBox box,
47
    final UserAgentCallback uac,
48
    final int cssWidth,
49
    final int cssHeight ) {
50
    final var e = box.getElement();
51
52
    try {
53
      final BufferedImage raster =
54
        switch( e.getNodeName() ) {
55
          case HTML_IMAGE -> createHtmlImage( box, e, uac );
56
          case HTML_TEX -> createTexImage( e );
57
          default -> null;
58
        };
59
60
      return createElement( raster );
61
    } catch( final Exception ex ) {
62
      clue( ex );
63
    }
64
65
    return BROKEN_IMAGE;
66
  }
67
68
  /**
69
   * Convert an HTML element to a raster graphic.
70
   */
71
  private static BufferedImage createHtmlImage(
72
    final BlockBox box,
73
    final Element e,
74
    final UserAgentCallback uac )
75
    throws TranscoderException, URISyntaxException, IOException {
76
    final var source = e.getAttribute( HTML_IMAGE_SRC );
77
    final var mediaType = MediaType.fromFilename( source );
78
79
    URI uri = null;
80
    BufferedImage raster = null;
81
82
    final var w = box.getContentWidth();
83
84
    if( getProtocol( source ).isRemote() ) {
85
      try( final var response = open( source );
86
           final var stream = response.getInputStream() ) {
87
88
        // Rasterize SVG from URL resource.
89
        raster = response.isSvg()
90
          ? rasterize( stream, w )
91
          : ImageIO.read( stream );
92
93
        clue( "Main.status.image.request.fetch", source );
94
      }
95
    }
96
    else if( mediaType.isSvg() ) {
97
      uri = resolve( source, uac, e );
98
    }
99
100
    if( uri != null && w > 0 ) {
101
      raster = rasterize( uri, w );
102
    }
103
104
    // Not an SVG, attempt to read a local rasterized image.
105
    if( raster == null && mediaType.isImage() ) {
106
      uri = resolve( source, uac, e );
107
      final var path = Path.of( uri.getPath() );
108
109
      try( final var stream = Files.newInputStream( path ) ) {
110
        raster = ImageIO.read( stream );
111
      }
112
    }
113
114
    return raster;
115
  }
116
117
  private static URI resolve(
118
    final String source,
119
    final UserAgentCallback uac,
120
    final Element e )
121
    throws URISyntaxException {
122
    // Attempt to rasterize based on file name.
123
    final var baseUri = new URI( uac.getBaseURL() );
124
    final var path = baseUri.resolve( source ).normalize();
125
126
    if( path.isAbsolute() ) {
127
      return path;
128
    }
129
    else {
130
      final var base = new URI( e.getBaseURI() ).getPath();
131
      return Path.of( base, source ).toUri();
132
    }
133
  }
134
135
  /**
136
   * Convert the TeX element to a raster graphic.
137
   */
138
  private BufferedImage createTexImage( final Element e )
139
    throws TranscoderException, ParseException {
140
    return rasterize( MathRenderer.toString( e.getTextContent() ) );
141
  }
142
143
  private static ImageReplacedElement createElement( final BufferedImage bi ) {
144
    return bi == null
145
      ? BROKEN_IMAGE
146
      : new ImageReplacedElement( bi, bi.getWidth(), bi.getHeight() );
147
  }
148
}
1149
M src/main/java/com/keenwrite/preview/SmoothImageReplacedElement.java
5757
    }
5858
59
    if( newW <= 0 && newH <= 0 ) {
60
      newW = oldW;
61
      newH = oldH;
62
    }
63
5964
    return new Dimension( newW, newH );
6065
  }
D src/main/java/com/keenwrite/preview/SvgReplacedElementFactory.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.preview;
3
4
import com.keenwrite.io.MediaType;
5
import com.keenwrite.ui.adapters.ReplacedElementAdapter;
6
import org.xhtmlrenderer.extend.ReplacedElement;
7
import org.xhtmlrenderer.extend.UserAgentCallback;
8
import org.xhtmlrenderer.layout.LayoutContext;
9
import org.xhtmlrenderer.render.BlockBox;
10
import org.xhtmlrenderer.swing.ImageReplacedElement;
11
12
import java.awt.image.BufferedImage;
13
import java.io.File;
14
import java.net.URI;
15
import java.nio.file.Path;
16
17
import static com.keenwrite.events.StatusEvent.clue;
18
import static com.keenwrite.io.downloads.DownloadManager.open;
19
import static com.keenwrite.preview.SvgRasterizer.*;
20
import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX;
21
import static com.keenwrite.util.ProtocolScheme.getProtocol;
22
23
/**
24
 * Responsible for running {@link SvgRasterizer} on SVG images detected within
25
 * a document to transform them into rasterized versions.
26
 */
27
public final class SvgReplacedElementFactory extends ReplacedElementAdapter {
28
29
  public static final String HTML_IMAGE = "img";
30
  public static final String HTML_IMAGE_SRC = "src";
31
32
  private static final ImageReplacedElement BROKEN_IMAGE =
33
    createImageReplacedElement( BROKEN_IMAGE_PLACEHOLDER );
34
35
  @Override
36
  public ReplacedElement createReplacedElement(
37
    final LayoutContext c,
38
    final BlockBox box,
39
    final UserAgentCallback uac,
40
    final int cssWidth,
41
    final int cssHeight ) {
42
    final var e = box.getElement();
43
44
    ImageReplacedElement image = null;
45
46
    try {
47
      BufferedImage raster = null;
48
49
      switch( e.getNodeName() ) {
50
        case HTML_IMAGE -> {
51
          final var source = e.getAttribute( HTML_IMAGE_SRC );
52
53
          URI uri = null;
54
55
          if( getProtocol( source ).isHttp() ) {
56
            try( final var response = open( source ) ) {
57
              if( response.isSvg() ) {
58
                // Rasterize SVG from URL resource.
59
                raster = rasterize(
60
                  response.getInputStream(),
61
                  box.getContentWidth()
62
                );
63
              }
64
65
              clue( "Main.status.image.request.fetch", source );
66
            }
67
          }
68
          else if( MediaType.fromFilename( source ).isSvg() ) {
69
            // Attempt to rasterize based on file name.
70
            final var srcUri = new URI( source ).getPath();
71
            final var path = Path.of( new File( srcUri ).getCanonicalPath() );
72
73
            if( path.isAbsolute() ) {
74
              uri = path.toUri();
75
            }
76
            else {
77
              final var base = new URI( e.getBaseURI() ).getPath();
78
              uri = Path.of( base, source ).toUri();
79
            }
80
          }
81
82
          if( uri != null ) {
83
            raster = rasterize( uri, box.getContentWidth() );
84
          }
85
        }
86
        case HTML_TEX ->
87
          // Convert the TeX element to a raster graphic.
88
          raster = rasterize( MathRenderer.toString( e.getTextContent() ) );
89
      }
90
91
      if( raster != null ) {
92
        image = createImageReplacedElement( raster );
93
      }
94
    } catch( final Exception ex ) {
95
      image = BROKEN_IMAGE;
96
      clue( ex );
97
    }
98
99
    return image == null ? BROKEN_IMAGE : image;
100
  }
101
102
  private static ImageReplacedElement createImageReplacedElement(
103
    final BufferedImage bi ) {
104
    return new ImageReplacedElement( bi, bi.getWidth(), bi.getHeight() );
105
  }
106
}
1071
M src/main/java/com/keenwrite/preview/images/ConstrainedDimension.java
22
 * Copyright 2013, Morten Nobel-Joergensen
33
 *
4
 * License: The BSD 3-Clause License
5
 * http://opensource.org/licenses/BSD-3-Clause
4
 * SPDX-License-Identifier: BSD-3-Clause
65
 */
76
package com.keenwrite.preview.images;
87
98
import java.awt.*;
109
1110
/**
12
 * This class let you create dimension constrains based on a actual image.
11
 * This class let you create dimension constrains based on an actual image.
1312
 */
1413
public class ConstrainedDimension {
...
3635
  public static ConstrainedDimension createAbsolutionDimension(
3736
    final int width, final int height ) {
38
    assert width > 0 && height > 0 : "Dimensions must be positive integers";
37
    assert width > 0 : "Width must be positive integer";
38
    assert height > 0 : "Height must be positive integer";
39
3940
    return new ConstrainedDimension() {
4041
      public Dimension getDimension( Dimension dimension ) {
M src/main/java/com/keenwrite/preview/images/ResampleOp.java
6666
6767
  public ResampleOp(
68
    final ResampleFilter filter, final int destWidth, final int destHeight ) {
69
    this( filter,
70
          createAbsolutionDimension( destWidth, destHeight ) );
68
    final ResampleFilter filter,
69
    final int dstWidth,
70
    final int dstHeight ) {
71
    this( filter, createAbsolutionDimension( dstWidth, dstHeight ) );
7172
  }
7273
7374
  public ResampleOp(
7475
    final ResampleFilter filter, ConstrainedDimension dimensionConstrain ) {
7576
    super( dimensionConstrain );
7677
    mFilter = filter;
7778
  }
7879
7980
  public BufferedImage doFilter(
80
    BufferedImage srcImg, BufferedImage dest, int dstWidth, int dstHeight ) {
81
    BufferedImage srcImg, BufferedImage dst, int dstWidth, int dstHeight ) {
8182
    this.dstWidth = dstWidth;
8283
    this.dstHeight = dstHeight;
...
147148
    final BufferedImage out;
148149
149
    if( dest != null &&
150
      dstWidth == dest.getWidth() &&
151
      dstHeight == dest.getHeight() ) {
152
      out = dest;
153
      int nrDestChannels = ImageUtils.nrChannels( dest );
150
    if( dst != null &&
151
      dstWidth == dst.getWidth() &&
152
      dstHeight == dst.getHeight() ) {
153
      out = dst;
154
      int nrDestChannels = ImageUtils.nrChannels( dst );
154155
      if( nrDestChannels != nrChannels ) {
155156
        final var errorMgs = format(
...
287288
        for( int k = 0; k < max; k++ ) { tot += arrWeight[ subindex + k ]; }
288289
        assert tot != 0 : "should never happen except bug in filter";
289
        if( tot != 0f ) {
290
          for( int k = 0; k < max; k++ ) { arrWeight[ subindex + k ] /= tot; }
291
        }
290
        for( int k = 0; k < max; k++ ) { arrWeight[ subindex + k ] /= tot; }
292291
      }
293292
    }
M src/main/java/com/keenwrite/processors/PdfProcessor.java
4343
        .with( Mutator::setSourcePath, writeString( document, xhtml ) )
4444
        .with( Mutator::setTargetPath, context.getTargetPath() )
45
        .with( Mutator::setThemesPath, context.getThemesPath() )
46
        .with( Mutator::setImagesPath, context.getImagesPath() )
45
        .with( Mutator::setThemesPath, context.getThemesDir() )
46
        .with( Mutator::setImagesPath, context.getImagesDir() )
4747
        .with( Mutator::setCachesPath, context.getCachesPath() )
48
        .with( Mutator::setFontsPath, context.getFontsPath() )
48
        .with( Mutator::setFontsPath, context.getFontsDir() )
4949
        .build();
5050
M src/main/java/com/keenwrite/processors/ProcessorContext.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.processors;
3
4
import com.keenwrite.ExportFormat;
5
import com.keenwrite.collections.InterpolatingMap;
6
import com.keenwrite.constants.Constants;
7
import com.keenwrite.editors.common.Caret;
8
import com.keenwrite.io.FileType;
9
import com.keenwrite.sigils.PropertyKeyOperator;
10
import com.keenwrite.sigils.SigilKeyOperator;
11
import com.keenwrite.util.GenericBuilder;
12
import org.renjin.repackaged.guava.base.Splitter;
13
14
import java.io.File;
15
import java.nio.file.Path;
16
import java.util.HashMap;
17
import java.util.Locale;
18
import java.util.Map;
19
import java.util.concurrent.Callable;
20
import java.util.function.Supplier;
21
22
import static com.keenwrite.Bootstrap.USER_CACHE_DIR;
23
import static com.keenwrite.Bootstrap.USER_DATA_DIR;
24
import static com.keenwrite.constants.Constants.*;
25
import static com.keenwrite.io.FileType.UNKNOWN;
26
import static com.keenwrite.io.MediaType.TEXT_PROPERTIES;
27
import static com.keenwrite.io.MediaType.valueFrom;
28
import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
29
30
/**
31
 * Provides a context for configuring a chain of {@link Processor} instances.
32
 */
33
public final class ProcessorContext {
34
35
  private final Mutator mMutator;
36
37
  /**
38
   * Determines the file type from the path extension. This should only be
39
   * called when it is known that the file type won't be a definition file
40
   * (e.g., YAML or other definition source), but rather an editable file
41
   * (e.g., Markdown, R Markdown, etc.).
42
   *
43
   * @param path The path with a file name extension.
44
   * @return The FileType for the given path.
45
   */
46
  private static FileType lookup( final Path path ) {
47
    assert path != null;
48
49
    final var prefix = GLOB_PREFIX_FILE;
50
    final var keys = sSettings.getKeys( prefix );
51
52
    var found = false;
53
    var fileType = UNKNOWN;
54
55
    while( keys.hasNext() && !found ) {
56
      final var key = keys.next();
57
      final var patterns = sSettings.getStringSettingList( key );
58
      final var predicate = createFileTypePredicate( patterns );
59
60
      if( predicate.test( path.toFile() ) ) {
61
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
62
        // to a standard name (as defined in the settings.properties file).
63
        final String suffix = key.replace( prefix + '.', "" );
64
        fileType = FileType.from( suffix );
65
        found = true;
66
      }
67
    }
68
69
    return fileType;
70
  }
71
72
  public boolean isExportFormat( final ExportFormat exportFormat ) {
73
    return mMutator.mExportFormat == exportFormat;
74
  }
75
76
  /**
77
   * Responsible for populating the instance variables required by the
78
   * context.
79
   */
80
  public static class Mutator {
81
    private Path mSourcePath;
82
    private Path mTargetPath;
83
    private ExportFormat mExportFormat;
84
    private boolean mConcatenate;
85
86
    private Supplier<Path> mThemesPath = USER_DIRECTORY::toPath;
87
    private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
88
89
    private Supplier<Map<String, String>> mDefinitions = HashMap::new;
90
    private Supplier<Map<String, String>> mMetadata = HashMap::new;
91
    private Supplier<Caret> mCaret = () -> Caret.builder().build();
92
93
    private Supplier<Path> mFontsPath = () -> getFontDirectory().toPath();
94
95
    private Supplier<Path> mImagesPath = USER_DIRECTORY::toPath;
96
    private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
97
    private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
98
99
    private Supplier<Path> mCachesPath = USER_CACHE_DIR::toPath;
100
101
    private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
102
    private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
103
104
    private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath;
105
    private Supplier<String> mRScript = () -> "";
106
107
    private Supplier<Boolean> mCurlQuotes = () -> true;
108
    private Supplier<Boolean> mAutoRemove = () -> true;
109
110
    public void setSourcePath( final Path sourcePath ) {
111
      assert sourcePath != null;
112
      mSourcePath = sourcePath;
113
    }
114
115
    public void setTargetPath( final Path outputPath ) {
116
      assert outputPath != null;
117
      mTargetPath = outputPath;
118
    }
119
120
    public void setTargetPath( final File targetPath ) {
121
      assert targetPath != null;
122
      setTargetPath( targetPath.toPath() );
123
    }
124
125
    public void setThemesPath( final Supplier<Path> themesPath ) {
126
      assert themesPath != null;
127
      mThemesPath = themesPath;
128
    }
129
130
    public void setCachesPath( final Supplier<File> cachesDir ) {
131
      assert cachesDir != null;
132
133
      mCachesPath = () -> {
134
        final var dir = cachesDir.get();
135
136
        return (dir == null ? USER_DATA_DIR.toFile() : dir).toPath();
137
      };
138
    }
139
140
    public void setImagesPath( final Supplier<File> imagesDir ) {
141
      assert imagesDir != null;
142
143
      mImagesPath = () -> {
144
        final var dir = imagesDir.get();
145
146
        return (dir == null ? USER_DIRECTORY : dir).toPath();
147
      };
148
    }
149
150
    public void setImageOrder( final Supplier<String> imageOrder ) {
151
      assert imageOrder != null;
152
      mImageOrder = imageOrder;
153
    }
154
155
    public void setImageServer( final Supplier<String> imageServer ) {
156
      assert imageServer != null;
157
      mImageServer = imageServer;
158
    }
159
160
    public void setFontsPath( final Supplier<File> fontsPath ) {
161
      assert fontsPath != null;
162
      mFontsPath = () -> {
163
        final var dir = fontsPath.get();
164
165
        return (dir == null ? USER_DIRECTORY : dir).toPath();
166
      };
167
    }
168
169
    public void setExportFormat( final ExportFormat exportFormat ) {
170
      assert exportFormat != null;
171
      mExportFormat = exportFormat;
172
    }
173
174
    public void setConcatenate( final boolean concatenate ) {
175
      mConcatenate = concatenate;
176
    }
177
178
    public void setLocale( final Supplier<Locale> locale ) {
179
      assert locale != null;
180
      mLocale = locale;
181
    }
182
183
    /**
184
     * Sets the list of fully interpolated key-value pairs to use when
185
     * substituting variable names back into the document as variable values.
186
     * This uses a {@link Callable} reference so that GUI and command-line
187
     * usage can insert their respective behaviours. That is, this method
188
     * prevents coupling the GUI to the CLI.
189
     *
190
     * @param supplier Defines how to retrieve the definitions.
191
     */
192
    public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
193
      assert supplier != null;
194
      mDefinitions = supplier;
195
    }
196
197
    public void setMetadata( final Supplier<Map<String, String>> metadata ) {
198
      assert metadata != null;
199
      mMetadata = metadata.get() == null ? HashMap::new : metadata;
200
    }
201
202
    /**
203
     * Sets the source for deriving the {@link Caret}. Typically, this is
204
     * the text editor that has focus.
205
     *
206
     * @param caret The source for the currently active caret.
207
     */
208
    public void setCaret( final Supplier<Caret> caret ) {
209
      assert caret != null;
210
      mCaret = caret;
211
    }
212
213
    public void setSigilBegan( final Supplier<String> sigilBegan ) {
214
      assert sigilBegan != null;
215
      mSigilBegan = sigilBegan;
216
    }
217
218
    public void setSigilEnded( final Supplier<String> sigilEnded ) {
219
      assert sigilEnded != null;
220
      mSigilEnded = sigilEnded;
221
    }
222
223
    public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
224
      assert rWorkingDir != null;
225
226
      mRWorkingDir = rWorkingDir;
227
    }
228
229
    public void setRScript( final Supplier<String> rScript ) {
230
      assert rScript != null;
231
      mRScript = rScript;
232
    }
233
234
    public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
235
      assert curlQuotes != null;
236
      mCurlQuotes = curlQuotes;
237
    }
238
239
    public void setAutoRemove( final Supplier<Boolean> autoRemove ) {
240
      assert autoRemove != null;
241
      mAutoRemove = autoRemove;
242
    }
243
244
    private boolean isExportFormat( final ExportFormat format ) {
245
      return mExportFormat == format;
246
    }
247
  }
248
249
  public static GenericBuilder<Mutator, ProcessorContext> builder() {
250
    return GenericBuilder.of( Mutator::new, ProcessorContext::new );
251
  }
252
253
  /**
254
   * Creates a new context for use by the {@link ProcessorFactory} when
255
   * instantiating new {@link Processor} instances. Although all the
256
   * parameters are required, not all {@link Processor} instances will use
257
   * all parameters.
258
   */
259
  private ProcessorContext( final Mutator mutator ) {
260
    assert mutator != null;
261
262
    mMutator = mutator;
263
  }
264
265
  public Path getSourcePath() {
266
    return mMutator.mSourcePath;
267
  }
268
269
  /**
270
   * Fully qualified file name to use when exporting (e.g., document.pdf).
271
   *
272
   * @return Full path to a file name.
273
   */
274
  public Path getTargetPath() {
275
    return mMutator.mTargetPath;
276
  }
277
278
  public ExportFormat getExportFormat() {
279
    return mMutator.mExportFormat;
280
  }
281
282
  public Locale getLocale() {
283
    return mMutator.mLocale.get();
284
  }
285
286
  /**
287
   * Returns the variable map of definitions, without interpolation.
288
   *
289
   * @return A map to help dereference variables.
290
   */
291
  public Map<String, String> getDefinitions() {
292
    return mMutator.mDefinitions.get();
293
  }
294
295
  /**
296
   * Returns the variable map of definitions, with interpolation.
297
   *
298
   * @return A map to help dereference variables.
299
   */
300
  public InterpolatingMap getInterpolatedDefinitions() {
301
    return new InterpolatingMap(
302
      createDefinitionKeyOperator(), getDefinitions()
303
    ).interpolate();
304
  }
305
306
  public Map<String, String> getMetadata() {
307
    return mMutator.mMetadata.get();
308
  }
309
310
  /**
311
   * Returns the current caret position in the document being edited and is
312
   * always up-to-date.
313
   *
314
   * @return Caret position in the document.
315
   */
316
  public Supplier<Caret> getCaret() {
317
    return mMutator.mCaret;
318
  }
319
320
  /**
321
   * Returns the directory that contains the file being edited. When
322
   * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is
323
   * {@code null}. This will get absolute path to the file before trying to
324
   * get te parent path, which should always be a valid path. In the unlikely
325
   * event that the base path cannot be determined by the path alone, the
326
   * default user directory is returned. This is necessary for the creation
327
   * of new files.
328
   *
329
   * @return Path to the directory containing a file being edited, or the
330
   * default user directory if the base path cannot be determined.
331
   */
332
  public Path getBaseDir() {
333
    final var path = getSourcePath().toAbsolutePath().getParent();
334
    return path == null ? DEFAULT_DIRECTORY : path;
335
  }
336
337
  FileType getSourceFileType() {
338
    return lookup( getSourcePath() );
339
  }
340
341
  public Path getThemesPath() {
342
    return mMutator.mThemesPath.get();
343
  }
344
345
  public Path getImagesPath() {
346
    return mMutator.mImagesPath.get();
347
  }
348
349
  public Path getCachesPath() {
350
    return mMutator.mCachesPath.get();
351
  }
352
353
  public Iterable<String> getImageOrder() {
354
    assert mMutator.mImageOrder != null;
355
356
    final var order = mMutator.mImageOrder.get();
357
    final var token = order.contains( "," ) ? ',' : ' ';
358
359
    return Splitter.on( token ).split( token + order );
360
  }
361
362
  public String getImageServer() {
363
    return mMutator.mImageServer.get();
364
  }
365
366
  public Path getFontsPath() {
367
    return mMutator.mFontsPath.get();
368
  }
369
370
  public boolean getAutoRemove() {
371
    return mMutator.mAutoRemove.get();
372
  }
373
374
  public Path getRWorkingDir() {
375
    return mMutator.mRWorkingDir.get();
376
  }
377
378
  public String getRScript() {
379
    return mMutator.mRScript.get();
380
  }
381
382
  public boolean getCurlQuotes() {
383
    return mMutator.mCurlQuotes.get();
384
  }
385
386
  /**
387
   * Answers whether to process a single text file or all text files in
388
   * the same directory as a single text file. See {@link #getSourcePath()}
389
   * for the file to process (or all files in its directory).
390
   *
391
   * @return {@code true} means to process all text files, {@code false}
392
   * means to process a single file.
393
   */
394
  public boolean getConcatenate() {
395
    return mMutator.mConcatenate;
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.processors;
6
7
import com.keenwrite.ExportFormat;
8
import com.keenwrite.collections.InterpolatingMap;
9
import com.keenwrite.constants.Constants;
10
import com.keenwrite.editors.common.Caret;
11
import com.keenwrite.io.FileType;
12
import com.keenwrite.io.MediaType;
13
import com.keenwrite.io.MediaTypeExtension;
14
import com.keenwrite.sigils.PropertyKeyOperator;
15
import com.keenwrite.sigils.SigilKeyOperator;
16
import com.keenwrite.util.GenericBuilder;
17
import org.renjin.repackaged.guava.base.Splitter;
18
19
import java.io.File;
20
import java.nio.file.Path;
21
import java.util.HashMap;
22
import java.util.Locale;
23
import java.util.Map;
24
import java.util.concurrent.Callable;
25
import java.util.function.Supplier;
26
27
import static com.keenwrite.Bootstrap.USER_CACHE_DIR;
28
import static com.keenwrite.Bootstrap.USER_DATA_DIR;
29
import static com.keenwrite.constants.Constants.*;
30
import static com.keenwrite.io.FileType.UNKNOWN;
31
import static com.keenwrite.io.MediaType.TEXT_PROPERTIES;
32
import static com.keenwrite.io.MediaType.valueFrom;
33
import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
34
35
/**
36
 * Provides a context for configuring a chain of {@link Processor} instances.
37
 */
38
public final class ProcessorContext {
39
40
  private final Mutator mMutator;
41
42
  /**
43
   * Determines the file type from the path extension. This should only be
44
   * called when it is known that the file type won't be a definition file
45
   * (e.g., YAML or other definition source), but rather an editable file
46
   * (e.g., Markdown, R Markdown, etc.).
47
   *
48
   * @param path The path with a file name extension.
49
   * @return The FileType for the given path.
50
   */
51
  private static FileType lookup( final Path path ) {
52
    assert path != null;
53
54
    final var prefix = GLOB_PREFIX_FILE;
55
    final var keys = sSettings.getKeys( prefix );
56
57
    var found = false;
58
    var fileType = UNKNOWN;
59
60
    while( keys.hasNext() && !found ) {
61
      final var key = keys.next();
62
      final var patterns = sSettings.getStringSettingList( key );
63
      final var predicate = createFileTypePredicate( patterns );
64
65
      if( predicate.test( path.toFile() ) ) {
66
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
67
        // to a standard name (as defined in the settings.properties file).
68
        final String suffix = key.replace( prefix + '.', "" );
69
        fileType = FileType.from( suffix );
70
        found = true;
71
      }
72
    }
73
74
    return fileType;
75
  }
76
77
  public boolean isExportFormat( final ExportFormat exportFormat ) {
78
    return mMutator.mExportFormat == exportFormat;
79
  }
80
81
  /**
82
   * Responsible for populating the instance variables required by the
83
   * context.
84
   */
85
  public static class Mutator {
86
    private Path mSourcePath;
87
    private Path mTargetPath;
88
    private ExportFormat mExportFormat;
89
    private Supplier<Boolean> mConcatenate = () -> true;
90
    private Supplier<String> mChapters = () -> "";
91
92
    private Supplier<Path> mThemesDir = USER_DIRECTORY::toPath;
93
    private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
94
95
    private Supplier<Map<String, String>> mDefinitions = HashMap::new;
96
    private Supplier<Map<String, String>> mMetadata = HashMap::new;
97
    private Supplier<Caret> mCaret = () -> Caret.builder().build();
98
99
    private Supplier<Path> mFontsDir = () -> getFontDirectory().toPath();
100
101
    private Supplier<Path> mImagesDir = USER_DIRECTORY::toPath;
102
    private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
103
    private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
104
105
    private Supplier<Path> mCachesPath = USER_CACHE_DIR::toPath;
106
107
    private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
108
    private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
109
110
    private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath;
111
    private Supplier<String> mRScript = () -> "";
112
113
    private Supplier<Boolean> mCurlQuotes = () -> true;
114
    private Supplier<Boolean> mAutoRemove = () -> true;
115
116
    public void setSourcePath( final Path sourcePath ) {
117
      assert sourcePath != null;
118
      mSourcePath = sourcePath;
119
    }
120
121
    public void setTargetPath( final Path outputPath ) {
122
      assert outputPath != null;
123
      mTargetPath = outputPath;
124
    }
125
126
    public void setTargetPath( final File targetPath ) {
127
      assert targetPath != null;
128
      setTargetPath( targetPath.toPath() );
129
    }
130
131
    public void setThemesDir( final Supplier<Path> themesDir ) {
132
      assert themesDir != null;
133
      mThemesDir = themesDir;
134
    }
135
136
    public void setCachesDir( final Supplier<File> cachesDir ) {
137
      assert cachesDir != null;
138
139
      mCachesPath = () -> {
140
        final var dir = cachesDir.get();
141
142
        return (dir == null ? USER_DATA_DIR.toFile() : dir).toPath();
143
      };
144
    }
145
146
    public void setImagesDir( final Supplier<File> imagesDir ) {
147
      assert imagesDir != null;
148
149
      mImagesDir = () -> {
150
        final var dir = imagesDir.get();
151
152
        return (dir == null ? USER_DIRECTORY : dir).toPath();
153
      };
154
    }
155
156
    public void setImageOrder( final Supplier<String> imageOrder ) {
157
      assert imageOrder != null;
158
      mImageOrder = imageOrder;
159
    }
160
161
    public void setImageServer( final Supplier<String> imageServer ) {
162
      assert imageServer != null;
163
      mImageServer = imageServer;
164
    }
165
166
    public void setFontsDir( final Supplier<File> fontsDir ) {
167
      assert fontsDir != null;
168
      mFontsDir = () -> {
169
        final var dir = fontsDir.get();
170
171
        return (dir == null ? USER_DIRECTORY : dir).toPath();
172
      };
173
    }
174
175
    public void setExportFormat( final ExportFormat exportFormat ) {
176
      assert exportFormat != null;
177
      mExportFormat = exportFormat;
178
    }
179
180
    public void setConcatenate( final Supplier<Boolean> concatenate ) {
181
      mConcatenate = concatenate;
182
    }
183
184
    public void setChapters( final Supplier<String> chapters ) {
185
      mChapters = chapters;
186
    }
187
188
    public void setLocale( final Supplier<Locale> locale ) {
189
      assert locale != null;
190
      mLocale = locale;
191
    }
192
193
    /**
194
     * Sets the list of fully interpolated key-value pairs to use when
195
     * substituting variable names back into the document as variable values.
196
     * This uses a {@link Callable} reference so that GUI and command-line
197
     * usage can insert their respective behaviours. That is, this method
198
     * prevents coupling the GUI to the CLI.
199
     *
200
     * @param supplier Defines how to retrieve the definitions.
201
     */
202
    public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
203
      assert supplier != null;
204
      mDefinitions = supplier;
205
    }
206
207
    public void setMetadata( final Supplier<Map<String, String>> metadata ) {
208
      assert metadata != null;
209
      mMetadata = metadata.get() == null ? HashMap::new : metadata;
210
    }
211
212
    /**
213
     * Sets the source for deriving the {@link Caret}. Typically, this is
214
     * the text editor that has focus.
215
     *
216
     * @param caret The source for the currently active caret.
217
     */
218
    public void setCaret( final Supplier<Caret> caret ) {
219
      assert caret != null;
220
      mCaret = caret;
221
    }
222
223
    public void setSigilBegan( final Supplier<String> sigilBegan ) {
224
      assert sigilBegan != null;
225
      mSigilBegan = sigilBegan;
226
    }
227
228
    public void setSigilEnded( final Supplier<String> sigilEnded ) {
229
      assert sigilEnded != null;
230
      mSigilEnded = sigilEnded;
231
    }
232
233
    public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
234
      assert rWorkingDir != null;
235
236
      mRWorkingDir = rWorkingDir;
237
    }
238
239
    public void setRScript( final Supplier<String> rScript ) {
240
      assert rScript != null;
241
      mRScript = rScript;
242
    }
243
244
    public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
245
      assert curlQuotes != null;
246
      mCurlQuotes = curlQuotes;
247
    }
248
249
    public void setAutoRemove( final Supplier<Boolean> autoRemove ) {
250
      assert autoRemove != null;
251
      mAutoRemove = autoRemove;
252
    }
253
254
    private boolean isExportFormat( final ExportFormat format ) {
255
      return mExportFormat == format;
256
    }
257
  }
258
259
  public static GenericBuilder<Mutator, ProcessorContext> builder() {
260
    return GenericBuilder.of( Mutator::new, ProcessorContext::new );
261
  }
262
263
  /**
264
   * Creates a new context for use by the {@link ProcessorFactory} when
265
   * instantiating new {@link Processor} instances. Although all the
266
   * parameters are required, not all {@link Processor} instances will use
267
   * all parameters.
268
   */
269
  private ProcessorContext( final Mutator mutator ) {
270
    assert mutator != null;
271
272
    mMutator = mutator;
273
  }
274
275
  public Path getSourcePath() {
276
    return mMutator.mSourcePath;
277
  }
278
279
  /**
280
   * Answers what type of input document is to be processed.
281
   *
282
   * @return The input document's {@link MediaType}.
283
   */
284
  public MediaType getSourceType() {
285
    return MediaTypeExtension.fromPath( mMutator.mSourcePath );
286
  }
287
288
  /**
289
   * Fully qualified file name to use when exporting (e.g., document.pdf).
290
   *
291
   * @return Full path to a file name.
292
   */
293
  public Path getTargetPath() {
294
    return mMutator.mTargetPath;
295
  }
296
297
  public ExportFormat getExportFormat() {
298
    return mMutator.mExportFormat;
299
  }
300
301
  public Locale getLocale() {
302
    return mMutator.mLocale.get();
303
  }
304
305
  /**
306
   * Returns the variable map of definitions, without interpolation.
307
   *
308
   * @return A map to help dereference variables.
309
   */
310
  public Map<String, String> getDefinitions() {
311
    return mMutator.mDefinitions.get();
312
  }
313
314
  /**
315
   * Returns the variable map of definitions, with interpolation.
316
   *
317
   * @return A map to help dereference variables.
318
   */
319
  public InterpolatingMap getInterpolatedDefinitions() {
320
    return new InterpolatingMap(
321
      createDefinitionKeyOperator(), getDefinitions()
322
    ).interpolate();
323
  }
324
325
  public Map<String, String> getMetadata() {
326
    return mMutator.mMetadata.get();
327
  }
328
329
  /**
330
   * Returns the current caret position in the document being edited and is
331
   * always up-to-date.
332
   *
333
   * @return Caret position in the document.
334
   */
335
  public Supplier<Caret> getCaret() {
336
    return mMutator.mCaret;
337
  }
338
339
  /**
340
   * Returns the directory that contains the file being edited. When
341
   * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is
342
   * {@code null}. This will get absolute path to the file before trying to
343
   * get te parent path, which should always be a valid path. In the unlikely
344
   * event that the base path cannot be determined by the path alone, the
345
   * default user directory is returned. This is necessary for the creation
346
   * of new files.
347
   *
348
   * @return Path to the directory containing a file being edited, or the
349
   * default user directory if the base path cannot be determined.
350
   */
351
  public Path getBaseDir() {
352
    final var path = getSourcePath().toAbsolutePath().getParent();
353
    return path == null ? DEFAULT_DIRECTORY : path;
354
  }
355
356
  FileType getSourceFileType() {
357
    return lookup( getSourcePath() );
358
  }
359
360
  public Path getThemesDir() {
361
    return mMutator.mThemesDir.get();
362
  }
363
364
  public Path getImagesDir() {
365
    return mMutator.mImagesDir.get();
366
  }
367
368
  public Path getCachesPath() {
369
    return mMutator.mCachesPath.get();
370
  }
371
372
  public Iterable<String> getImageOrder() {
373
    assert mMutator.mImageOrder != null;
374
375
    final var order = mMutator.mImageOrder.get();
376
    final var token = order.contains( "," ) ? ',' : ' ';
377
378
    return Splitter.on( token ).split( token + order );
379
  }
380
381
  public String getImageServer() {
382
    return mMutator.mImageServer.get();
383
  }
384
385
  public Path getFontsDir() {
386
    return mMutator.mFontsDir.get();
387
  }
388
389
  public boolean getAutoRemove() {
390
    return mMutator.mAutoRemove.get();
391
  }
392
393
  public Path getRWorkingDir() {
394
    return mMutator.mRWorkingDir.get();
395
  }
396
397
  public String getRScript() {
398
    return mMutator.mRScript.get();
399
  }
400
401
  public boolean getCurlQuotes() {
402
    return mMutator.mCurlQuotes.get();
403
  }
404
405
  /**
406
   * Answers whether to process a single text file or all text files in
407
   * the same directory as a single text file. See {@link #getSourcePath()}
408
   * for the file to process (or all files in its directory).
409
   *
410
   * @return {@code true} means to process all text files, {@code false}
411
   * means to process a single file.
412
   */
413
  public boolean getConcatenate() {
414
    return mMutator.mConcatenate.get();
415
  }
416
417
  public String getChapters() {
418
    return mMutator.mChapters.get();
396419
  }
397420
A src/main/java/com/keenwrite/processors/RBootstrapProcessor.java
1
/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
5
package com.keenwrite.processors;
6
7
import com.keenwrite.processors.r.RBootstrapController;
8
9
public class RBootstrapProcessor extends ExecutorProcessor<String> {
10
  private final Processor<String> mSuccessor;
11
  private final ProcessorContext mContext;
12
13
  public RBootstrapProcessor(
14
    final Processor<String> successor,
15
    final ProcessorContext context ) {
16
    assert successor != null;
17
    assert context != null;
18
19
    mSuccessor = successor;
20
    mContext = context;
21
  }
22
23
  /**
24
   * Processes the given text document by replacing variables with their values.
25
   *
26
   * @param text The document text that includes variables that should be
27
   *             replaced with values when rendered as HTML.
28
   * @return The text with all variables replaced.
29
   */
30
  @Override
31
  public String apply( final String text ) {
32
    assert text != null;
33
34
    final var bootstrap = mContext.getRScript();
35
    final var workingDir = mContext.getRWorkingDir().toString();
36
    final var definitions = mContext.getDefinitions();
37
38
    RBootstrapController.update( bootstrap, workingDir, definitions );
39
40
    return mSuccessor.apply( text );
41
  }
42
}
143
M src/main/java/com/keenwrite/processors/XhtmlProcessor.java
238238
239239
  private Path getImagesPath() {
240
    return mContext.getImagesPath();
240
    return mContext.getImagesDir();
241241
  }
242242
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
25
package com.keenwrite.processors.markdown;
36
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
1414
import java.io.File;
1515
import java.nio.file.Path;
16
import java.util.Optional;
1617
17
import static com.keenwrite.ExportFormat.NONE;
1818
import static com.keenwrite.events.StatusEvent.clue;
1919
import static com.keenwrite.util.ProtocolScheme.getProtocol;
...
8686
     */
8787
    private ResolvedLink forImage( final ResolvedLink link, final Node node ) {
88
      var uri = link.getUrl();
89
      final var protocol = getProtocol( uri );
88
      final var url = link.getUrl();
89
      final var protocolScheme = getProtocol( url );
9090
91
      if( protocol.isRemote() ) {
92
        return valid( link, uri );
93
      }
91
      return protocolScheme.isRemote()
92
        ? valid( link, url )
93
        : resolveImageFile( link, node, url );
94
    }
95
96
    private ResolvedLink resolveImageFile(
97
      final ResolvedLink link,
98
      final Node node,
99
      final String url ) {
100
      final var userPath = new File( url );
101
102
      // If the user specified a fully qualified path name, use it verbatim.
103
      return readable( userPath )
104
        ? valid( link, url )
105
        : resolveUnqualifiedImageFile( link, node, url );
106
    }
94107
108
    private ResolvedLink resolveUnqualifiedImageFile(
109
      final ResolvedLink link,
110
      final Node node,
111
      final String url ) {
95112
      final var baseDir = getBaseDir();
113
      final var fqfn = baseDir.resolve( Path.of( url ) );
96114
97
      // Determine the fully-qualified file name (fqfn).
98
      final var fqfn = Path.of( baseDir.toString(), uri ).toFile();
115
      // If the image can be found relative to the base directory, then
116
      // use the link as is when resolving the path.
117
      return readable( fqfn.toFile() )
118
        ? valid( link, url )
119
        : resolveExtensionlessImageFile( link, node, url );
120
    }
99121
100
      if( fqfn.isFile() && fqfn.canRead() ||
101
        mContext.getExportFormat() != NONE ) {
102
        return valid( link, uri );
103
      }
122
    private ResolvedLink resolveExtensionlessImageFile(
123
      final ResolvedLink link,
124
      final Node node,
125
      final String url
126
    ) {
127
      final var imagePath = new File( url );
128
      final var file = resolveImageExtension( imagePath );
129
130
      return file.isPresent() && readable( file.get() )
131
        ? valid( link, file.get().toString() )
132
        : resolveRelativeImageFile( link, node, url );
133
    }
134
135
    private ResolvedLink resolveRelativeImageFile(
136
      final ResolvedLink link,
137
      final Node node,
138
      final String url ) {
139
      final var baseDir = getBaseDir();
104140
105141
      try {
106142
        // Compute the path to the image file. The base directory should
107143
        // be an absolute path to the file being edited, without an extension.
108144
        final var imagesDir = getImageDir();
109
        final var relativeDir = imagesDir.toString().isEmpty()
110
          ? imagesDir : baseDir.relativize( imagesDir );
111
        final var imageFile = Path.of(
112
          baseDir.toString(), relativeDir.toString(), uri );
113
114
        for( final var ext : getImageOrder() ) {
115
          var file = new File( imageFile.toString() + '.' + ext );
145
        final var baseImagesDir = baseDir.resolve( imagesDir );
146
        final var imagePath = baseImagesDir.resolve( url );
147
        final var file = resolveImageExtension( imagePath.toFile() );
116148
117
          if( file.exists() && file.canRead() ) {
118
            uri = file.toURI().toString();
119
            return valid( link, uri );
120
          }
149
        if( file.isPresent() ) {
150
          final var resolved = imagesDir.resolve( file.get().toPath() );
151
          final var relative = baseDir.relativize( resolved );
152
          return valid( link, relative.toString() );
121153
        }
122154
123155
        clue( "Main.status.error.file.missing.near",
124
              imageFile + ".*", node.getLineNumber()
156
              imagePath + ".*", node.getLineNumber()
125157
        );
126158
      } catch( final Exception ex ) {
127159
        clue( ex );
128160
      }
129161
130162
      return link;
163
    }
164
165
    private Optional<File> resolveImageExtension( final File imagePath ) {
166
      for( final var ext : getImageOrder() ) {
167
        final var file = new File( imagePath.toString() + '.' + ext );
168
169
        if( readable( file ) ) {
170
          return Optional.of( file );
171
        }
172
      }
173
174
      return Optional.empty();
131175
    }
132176
133177
    private ResolvedLink valid( final ResolvedLink link, final String url ) {
134178
      return link.withStatus( VALID ).withUrl( url );
135179
    }
136180
137181
    private Path getImageDir() {
138
      return mContext.getImagesPath();
182
      return mContext.getImagesDir();
139183
    }
140184
141185
    private Iterable<String> getImageOrder() {
142186
      return mContext.getImageOrder();
143187
    }
144188
145189
    private Path getBaseDir() {
146190
      return mContext.getBaseDir();
147191
    }
192
  }
193
194
  private static boolean readable( final File file ) {
195
    return file.isFile() && file.canRead();
148196
  }
149197
}
M src/main/java/com/keenwrite/processors/r/RBootstrapController.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020 White Magic Software, Ltd. -- All rights reserved.
2
 *
3
 * SPDX-License-Identifier: MIT
4
 */
25
package com.keenwrite.processors.r;
36
...
4851
      final var dir = getRWorkingDirectory();
4952
      final var definitions = mDefinitions.get();
53
54
      // A problem with the bootstrap script is likely caused by variables
55
      // not being loaded. This implies that the R processor is being invoked
56
      // too soon.
57
      update( bootstrap, dir, definitions );
58
    }
59
  }
60
61
  public static void update(
62
    final String bootstrap,
63
    final String workingDir,
64
    final Map<String, String> definitions ) {
65
66
    if( !bootstrap.isBlank() ) {
5067
      final var map = new HashMap<String, String>( definitions.size() + 1 );
5168
5269
      definitions.forEach(
5370
        ( k, v ) -> map.put( KEY_OPERATOR.apply( k ), escape( v ) )
5471
      );
5572
      map.put(
5673
        KEY_OPERATOR.apply( "application.r.working.directory" ),
57
        escape( dir )
74
        escape( workingDir )
5875
      );
5976
6077
      try {
6178
        Engine.eval( replace( bootstrap, map ) );
6279
      } catch( final Exception ex ) {
6380
        clue( ex );
64
        // A problem with the bootstrap script is likely caused by variables
65
        // not being loaded. This implies that the R processor is being invoked
66
        // too soon.
6781
      }
6882
    }
M src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
7474
          addAction( "file.export.pdf.dir", e -> actions.file_export_pdf_dir() ),
7575
          addAction( "file.export.pdf.repeat", e -> actions.file_export_repeat() ),
76
          addAction( "file.export.html.dir", e -> actions.file_export_html_dir() ),
7677
          addAction( "file.export.html_svg", e -> actions.file_export_html_svg() ),
7778
          addAction( "file.export.html_tex", e -> actions.file_export_html_tex() ),
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
55
import com.keenwrite.MainPane;
66
import com.keenwrite.MainScene;
7
import com.keenwrite.editors.TextDefinition;
8
import com.keenwrite.editors.TextEditor;
9
import com.keenwrite.editors.markdown.HyperlinkModel;
10
import com.keenwrite.editors.markdown.LinkVisitor;
11
import com.keenwrite.events.CaretMovedEvent;
12
import com.keenwrite.events.ExportFailedEvent;
13
import com.keenwrite.preferences.Key;
14
import com.keenwrite.preferences.PreferencesController;
15
import com.keenwrite.preferences.Workspace;
16
import com.keenwrite.processors.markdown.MarkdownProcessor;
17
import com.keenwrite.search.SearchModel;
18
import com.keenwrite.typesetting.Typesetter;
19
import com.keenwrite.ui.controls.SearchBar;
20
import com.keenwrite.ui.dialogs.ExportDialog;
21
import com.keenwrite.ui.dialogs.ExportSettings;
22
import com.keenwrite.ui.dialogs.ImageDialog;
23
import com.keenwrite.ui.dialogs.LinkDialog;
24
import com.keenwrite.ui.explorer.FilePicker;
25
import com.keenwrite.ui.explorer.FilePickerFactory;
26
import com.keenwrite.ui.logging.LogView;
27
import com.keenwrite.util.AlphanumComparator;
28
import com.keenwrite.util.RangeValidator;
29
import com.vladsch.flexmark.ast.Link;
30
import javafx.concurrent.Service;
31
import javafx.concurrent.Task;
32
import javafx.scene.control.Alert;
33
import javafx.scene.control.Dialog;
34
import javafx.stage.Window;
35
import javafx.stage.WindowEvent;
36
37
import java.io.File;
38
import java.io.IOException;
39
import java.nio.file.Path;
40
import java.util.ArrayList;
41
import java.util.List;
42
import java.util.Optional;
43
import java.util.concurrent.atomic.AtomicInteger;
44
45
import static com.keenwrite.Bootstrap.*;
46
import static com.keenwrite.ExportFormat.*;
47
import static com.keenwrite.Messages.get;
48
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
49
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
50
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
51
import static com.keenwrite.events.StatusEvent.clue;
52
import static com.keenwrite.preferences.AppKeys.*;
53
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
54
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
55
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
56
import static com.keenwrite.util.FileWalker.walk;
57
import static java.lang.System.lineSeparator;
58
import static java.nio.file.Files.readString;
59
import static java.nio.file.Files.writeString;
60
import static javafx.application.Platform.runLater;
61
import static javafx.event.Event.fireEvent;
62
import static javafx.scene.control.Alert.AlertType.INFORMATION;
63
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
64
import static org.apache.commons.io.FilenameUtils.getExtension;
65
66
/**
67
 * Responsible for abstracting how functionality is mapped to the application.
68
 * This allows users to customize accelerator keys and will provide pluggable
69
 * functionality so that different text markup languages can change documents
70
 * using their respective syntax.
71
 */
72
public final class GuiCommands {
73
  private static final String STYLE_SEARCH = "search";
74
75
  /**
76
   * Sci-fi genres, which are can be longer than other genres, typically fall
77
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
78
   * memory when concatenating files together when exporting novels.
79
   */
80
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
81
82
  /**
83
   * When an action is executed, this is one of the recipients.
84
   */
85
  private final MainPane mMainPane;
86
87
  private final MainScene mMainScene;
88
89
  private final LogView mLogView;
90
91
  /**
92
   * Tracks finding text in the active document.
93
   */
94
  private final SearchModel mSearchModel;
95
96
  private boolean mCanTypeset;
97
98
  /**
99
   * A {@link Task} can only be run once, so wrap it in a {@link Service} to
100
   * allow re-launching the typesetting task repeatedly.
101
   */
102
  private Service<Path> mTypesetService;
103
104
  /**
105
   * Prevent a race-condition between checking to see if the typesetting task
106
   * is running and restarting the task itself.
107
   */
108
  private final Object mMutex = new Object();
109
110
  public GuiCommands( final MainScene scene, final MainPane pane ) {
111
    mMainScene = scene;
112
    mMainPane = pane;
113
    mLogView = new LogView();
114
    mSearchModel = new SearchModel();
115
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
116
      final var editor = getActiveTextEditor();
117
118
      // Clear highlighted areas before highlighting a new region.
119
      if( o != null ) {
120
        editor.unstylize( STYLE_SEARCH );
121
      }
122
123
      if( n != null ) {
124
        editor.moveTo( n.getStart() );
125
        editor.stylize( n, STYLE_SEARCH );
126
      }
127
    } );
128
129
    // When the active text editor changes ...
130
    mMainPane.textEditorProperty().addListener(
131
      ( c, o, n ) -> {
132
        // ... update the haystack.
133
        mSearchModel.search( getActiveTextEditor().getText() );
134
135
        // ... update the status bar with the current caret position.
136
        if( n != null ) {
137
          final var w = getWorkspace();
138
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
139
140
          // ... preserve the most recent document.
141
          recentDoc.setValue( n.getFile() );
142
          CaretMovedEvent.fire( n.getCaret() );
143
        }
144
      }
145
    );
146
  }
147
148
  public void file_new() {
149
    getMainPane().newTextEditor();
150
  }
151
152
  public void file_open() {
153
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
154
  }
155
156
  public void file_close() {
157
    getMainPane().close();
158
  }
159
160
  public void file_close_all() {
161
    getMainPane().closeAll();
162
  }
163
164
  public void file_save() {
165
    getMainPane().save();
166
  }
167
168
  public void file_save_as() {
169
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
170
  }
171
172
  public void file_save_all() {
173
    getMainPane().saveAll();
174
  }
175
176
  /**
177
   * Converts the actively edited file in the given file format.
178
   *
179
   * @param format The destination file format.
180
   */
181
  private void file_export( final ExportFormat format ) {
182
    file_export( format, false );
183
  }
184
185
  /**
186
   * Converts one or more files into the given file format. If {@code dir}
187
   * is set to true, this will first append all files in the same directory
188
   * as the actively edited file.
189
   *
190
   * @param format The destination file format.
191
   * @param dir    Export all files in the actively edited file's directory.
192
   */
193
  private void file_export( final ExportFormat format, final boolean dir ) {
194
    final var editor = getMainPane().getTextEditor();
195
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
196
    final var exportParent = exported.get().toPath().getParent();
197
    final var editorParent = editor.getPath().getParent();
198
    final var userHomeParent = USER_DIRECTORY.toPath();
199
    final var exportPath = exportParent != null
200
      ? exportParent
201
      : editorParent != null
202
      ? editorParent
203
      : userHomeParent;
204
205
    final var filename = format.toExportFilename( editor.getPath() );
206
    final var selected = PDF_DEFAULT
207
      .getName()
208
      .equals( exported.get().getName() );
209
    final var selection = pickFile(
210
      selected
211
        ? filename
212
        : exported.get(),
213
      exportPath,
214
      FILE_EXPORT
215
    );
216
217
    selection.ifPresent( files -> file_export( editor, format, files, dir ) );
218
  }
219
220
  private void file_export(
221
    final TextEditor editor,
222
    final ExportFormat format,
223
    final List<File> files,
224
    final boolean dir ) {
225
    editor.save();
226
    final var main = getMainPane();
227
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
228
229
    final var sourceFile = files.get( 0 );
230
    final var sourcePath = sourceFile.toPath();
231
    final var document = dir ? append( editor ) : editor.getText();
232
    final var context = main.createProcessorContext( sourcePath, format );
233
234
    final var service = new Service<Path>() {
235
      @Override
236
      protected Task<Path> createTask() {
237
        final var task = new Task<Path>() {
238
          @Override
239
          protected Path call() throws Exception {
240
            final var chain = createProcessors( context );
241
            final var export = chain.apply( document );
242
243
            // Processors can export binary files. In such cases, processors
244
            // return null to prevent further processing.
245
            return export == null ? null : writeString( sourcePath, export );
246
          }
247
        };
248
249
        task.setOnSucceeded(
250
          e -> {
251
            // Remember the exported file name for next time.
252
            exported.setValue( sourceFile );
253
254
            final var result = task.getValue();
255
256
            // Binary formats must notify users of success independently.
257
            if( result != null ) {
258
              clue( "Main.status.export.success", result );
259
            }
260
          }
261
        );
262
263
        task.setOnFailed( e -> {
264
          final var ex = task.getException();
265
          clue( ex );
266
267
          if( ex instanceof TypeNotPresentException ) {
268
            fireExportFailedEvent();
269
          }
270
        } );
271
272
        return task;
273
      }
274
    };
275
276
    mTypesetService = service;
277
    typeset( service );
278
  }
279
280
  /**
281
   * @param dir {@code true} means to export all files in the active file
282
   *            editor's directory; {@code false} means to export only the
283
   *            actively edited file.
284
   */
285
  private void file_export_pdf( final boolean dir ) {
286
    final var workspace = getWorkspace();
287
    final var themes = workspace.getFile(
288
      KEY_TYPESET_CONTEXT_THEMES_PATH
289
    );
290
    final var theme = workspace.stringProperty(
291
      KEY_TYPESET_CONTEXT_THEME_SELECTION
292
    );
293
    final var chapters = workspace.stringProperty(
294
      KEY_TYPESET_CONTEXT_CHAPTERS
295
    );
296
    final var settings = ExportSettings
297
      .builder()
298
      .with( ExportSettings.Mutator::setTheme, theme )
299
      .with( ExportSettings.Mutator::setChapters, chapters )
300
      .build();
301
302
    // Don't re-validate the typesetter installation each time. If the
303
    // user mucks up the typesetter installation, it'll get caught the
304
    // next time the application is started. Don't use |= because it
305
    // won't short-circuit.
306
    mCanTypeset = mCanTypeset || Typesetter.canRun();
307
308
    if( mCanTypeset ) {
309
      // If the typesetter is installed, allow the user to select a theme. If
310
      // the themes aren't installed, a status message will appear.
311
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
312
        file_export( APPLICATION_PDF, dir );
313
      }
314
    }
315
    else {
316
      fireExportFailedEvent();
317
    }
318
  }
319
320
  public void file_export_pdf() {
321
    file_export_pdf( false );
322
  }
323
324
  public void file_export_pdf_dir() {
325
    file_export_pdf( true );
326
  }
327
328
  public void file_export_repeat() {
329
    typeset( mTypesetService );
330
  }
331
332
  public void file_export_html_svg() {
333
    file_export( HTML_TEX_SVG );
334
  }
335
336
  public void file_export_html_tex() {
337
    file_export( HTML_TEX_DELIMITED );
338
  }
339
340
  public void file_export_xhtml_tex() {
341
    file_export( XHTML_TEX );
342
  }
343
344
  private void fireExportFailedEvent() {
345
    runLater( ExportFailedEvent::fire );
346
  }
347
348
  public void file_exit() {
349
    final var window = getWindow();
350
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
351
  }
352
353
  public void edit_undo() {
354
    getActiveTextEditor().undo();
355
  }
356
357
  public void edit_redo() {
358
    getActiveTextEditor().redo();
359
  }
360
361
  public void edit_cut() {
362
    getActiveTextEditor().cut();
363
  }
364
365
  public void edit_copy() {
366
    getActiveTextEditor().copy();
367
  }
368
369
  public void edit_paste() {
370
    getActiveTextEditor().paste();
371
  }
372
373
  public void edit_select_all() {
374
    getActiveTextEditor().selectAll();
375
  }
376
377
  public void edit_find() {
378
    final var nodes = getMainScene().getStatusBar().getLeftItems();
379
380
    if( nodes.isEmpty() ) {
381
      final var searchBar = new SearchBar();
382
383
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
384
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
385
386
      searchBar.setOnCancelAction( event -> {
387
        final var editor = getActiveTextEditor();
388
        nodes.remove( searchBar );
389
        editor.unstylize( STYLE_SEARCH );
390
        editor.getNode().requestFocus();
391
      } );
392
393
      searchBar.addInputListener( ( c, o, n ) -> {
394
        if( n != null && !n.isEmpty() ) {
395
          mSearchModel.search( n, getActiveTextEditor().getText() );
396
        }
397
      } );
398
399
      searchBar.setOnNextAction( event -> edit_find_next() );
400
      searchBar.setOnPrevAction( event -> edit_find_prev() );
401
402
      nodes.add( searchBar );
403
      searchBar.requestFocus();
404
    }
405
    else {
406
      nodes.clear();
407
    }
408
  }
409
410
  public void edit_find_next() {
411
    mSearchModel.advance();
412
  }
413
414
  public void edit_find_prev() {
415
    mSearchModel.retreat();
416
  }
417
418
  public void edit_preferences() {
419
    try {
420
      new PreferencesController( getWorkspace() ).show();
421
    } catch( final Exception ex ) {
422
      clue( ex );
423
    }
424
  }
425
426
  public void format_bold() {
427
    getActiveTextEditor().bold();
428
  }
429
430
  public void format_italic() {
431
    getActiveTextEditor().italic();
432
  }
433
434
  public void format_monospace() {
435
    getActiveTextEditor().monospace();
436
  }
437
438
  public void format_superscript() {
439
    getActiveTextEditor().superscript();
440
  }
441
442
  public void format_subscript() {
443
    getActiveTextEditor().subscript();
444
  }
445
446
  public void format_strikethrough() {
447
    getActiveTextEditor().strikethrough();
448
  }
449
450
  public void insert_blockquote() {
451
    getActiveTextEditor().blockquote();
452
  }
453
454
  public void insert_code() {
455
    getActiveTextEditor().code();
456
  }
457
458
  public void insert_fenced_code_block() {
459
    getActiveTextEditor().fencedCodeBlock();
460
  }
461
462
  public void insert_link() {
463
    insertObject( createLinkDialog() );
464
  }
465
466
  public void insert_image() {
467
    insertObject( createImageDialog() );
468
  }
469
470
  private void insertObject( final Dialog<String> dialog ) {
471
    final var textArea = getActiveTextEditor().getTextArea();
472
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
473
  }
474
475
  private Dialog<String> createLinkDialog() {
476
    return new LinkDialog( getWindow(), createHyperlinkModel() );
477
  }
478
479
  private Dialog<String> createImageDialog() {
480
    final var path = getActiveTextEditor().getPath();
481
    final var parentDir = path.getParent();
482
    return new ImageDialog( getWindow(), parentDir );
483
  }
484
485
  /**
486
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
487
   * the Markdown AST.
488
   *
489
   * @return An instance containing the link URL and display text.
490
   */
491
  private HyperlinkModel createHyperlinkModel() {
492
    final var context = getMainPane().createProcessorContext();
493
    final var editor = getActiveTextEditor();
494
    final var textArea = editor.getTextArea();
495
    final var selectedText = textArea.getSelectedText();
496
497
    // Convert current paragraph to Markdown nodes.
498
    final var mp = MarkdownProcessor.create( context );
499
    final var p = textArea.getCurrentParagraph();
500
    final var paragraph = textArea.getText( p );
501
    final var node = mp.toNode( paragraph );
502
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
503
    final var link = visitor.process( node );
504
505
    if( link != null ) {
506
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
507
    }
508
509
    return createHyperlinkModel( link, selectedText );
510
  }
511
512
  private HyperlinkModel createHyperlinkModel(
513
    final Link link, final String selection ) {
514
515
    return link == null
516
      ? new HyperlinkModel( selection, "https://localhost" )
517
      : new HyperlinkModel( link );
518
  }
519
520
  public void insert_heading_1() {
521
    insert_heading( 1 );
522
  }
523
524
  public void insert_heading_2() {
525
    insert_heading( 2 );
526
  }
527
528
  public void insert_heading_3() {
529
    insert_heading( 3 );
530
  }
531
532
  private void insert_heading( final int level ) {
533
    getActiveTextEditor().heading( level );
534
  }
535
536
  public void insert_unordered_list() {
537
    getActiveTextEditor().unorderedList();
538
  }
539
540
  public void insert_ordered_list() {
541
    getActiveTextEditor().orderedList();
542
  }
543
544
  public void insert_horizontal_rule() {
545
    getActiveTextEditor().horizontalRule();
546
  }
547
548
  public void definition_create() {
549
    getActiveTextDefinition().createDefinition();
550
  }
551
552
  public void definition_rename() {
553
    getActiveTextDefinition().renameDefinition();
554
  }
555
556
  public void definition_delete() {
557
    getActiveTextDefinition().deleteDefinitions();
558
  }
559
560
  public void definition_autoinsert() {
561
    getMainPane().autoinsert();
562
  }
563
564
  public void view_refresh() {
565
    getMainPane().viewRefresh();
566
  }
567
568
  public void view_preview() {
569
    getMainPane().viewPreview();
570
  }
571
572
  public void view_outline() {
573
    getMainPane().viewOutline();
574
  }
575
576
  public void view_files() { getMainPane().viewFiles(); }
577
578
  public void view_statistics() {
579
    getMainPane().viewStatistics();
580
  }
581
582
  public void view_menubar() {
583
    getMainScene().toggleMenuBar();
584
  }
585
586
  public void view_toolbar() {
587
    getMainScene().toggleToolBar();
588
  }
589
590
  public void view_statusbar() {
591
    getMainScene().toggleStatusBar();
592
  }
593
594
  public void view_log() {
595
    mLogView.view();
596
  }
597
598
  public void help_about() {
599
    final var alert = new Alert( INFORMATION );
600
    final var prefix = "Dialog.about.";
601
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
602
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
603
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
604
    alert.setGraphic( ICON_DIALOG_NODE );
605
    alert.initOwner( getWindow() );
606
    alert.showAndWait();
607
  }
608
609
  private <T> void typeset( final Service<T> service ) {
610
    synchronized( mMutex ) {
611
      if( service != null && !service.isRunning() ) {
612
        service.reset();
613
        service.start();
614
      }
615
    }
616
  }
617
618
  /**
619
   * Concatenates all the files in the same directory as the given file into
620
   * a string. The extension is determined by the given file name pattern; the
621
   * order files are concatenated is based on their numeric sort order (this
622
   * avoids lexicographic sorting).
623
   * <p>
624
   * If the parent path to the file being edited in the text editor cannot
625
   * be found then this will return the editor's text, without iterating through
626
   * the parent directory. (Should never happen, but who knows?)
627
   * </p>
628
   * <p>
629
   * New lines are automatically appended to separate each file.
630
   * </p>
631
   *
632
   * @param editor The text editor containing
633
   * @return All files in the same directory as the file being edited
634
   * concatenated into a single string.
635
   */
636
  private String append( final TextEditor editor ) {
637
    final var pattern = editor.getPath();
638
    final var parent = pattern.getParent();
639
640
    // Short-circuit because nothing else can be done.
641
    if( parent == null ) {
642
      clue( "Main.status.export.concat.parent", pattern );
643
      return editor.getText();
644
    }
645
646
    final var filename = pattern.getFileName().toString();
647
    final var extension = getExtension( filename );
648
649
    if( extension.isBlank() ) {
650
      clue( "Main.status.export.concat.extension", filename );
651
      return editor.getText();
652
    }
653
654
    try {
655
      final var glob = "**/*." + extension;
656
      final var files = new ArrayList<Path>();
657
      final var text = new StringBuilder( DOCUMENT_LENGTH );
658
      final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS );
659
      final var validator = new RangeValidator( range );
660
      final var chapter = new AtomicInteger();
661
662
      walk( parent, glob, files::add );
663
      files.sort( new AlphanumComparator<>() );
664
      files.forEach( file -> {
665
        try {
666
          clue( "Main.status.export.concat", file );
667
668
          if( validator.test( chapter.incrementAndGet() ) ) {
669
            // Ensure multiple files are separated by an EOL.
670
            text.append( readString( file ) ).append( lineSeparator() );
671
          }
672
        } catch( final IOException ex ) {
673
          clue( "Main.status.export.concat.io", file );
674
        }
675
      } );
676
677
      return text.toString();
7
import com.keenwrite.commands.ConcatenateCommand;
8
import com.keenwrite.editors.TextDefinition;
9
import com.keenwrite.editors.TextEditor;
10
import com.keenwrite.editors.markdown.HyperlinkModel;
11
import com.keenwrite.editors.markdown.LinkVisitor;
12
import com.keenwrite.events.CaretMovedEvent;
13
import com.keenwrite.events.ExportFailedEvent;
14
import com.keenwrite.preferences.Key;
15
import com.keenwrite.preferences.PreferencesController;
16
import com.keenwrite.preferences.Workspace;
17
import com.keenwrite.processors.markdown.MarkdownProcessor;
18
import com.keenwrite.search.SearchModel;
19
import com.keenwrite.typesetting.Typesetter;
20
import com.keenwrite.ui.controls.SearchBar;
21
import com.keenwrite.ui.dialogs.ExportDialog;
22
import com.keenwrite.ui.dialogs.ExportSettings;
23
import com.keenwrite.ui.dialogs.ImageDialog;
24
import com.keenwrite.ui.dialogs.LinkDialog;
25
import com.keenwrite.ui.explorer.FilePicker;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.logging.LogView;
28
import com.vladsch.flexmark.ast.Link;
29
import javafx.concurrent.Service;
30
import javafx.concurrent.Task;
31
import javafx.scene.control.Alert;
32
import javafx.scene.control.Dialog;
33
import javafx.stage.Window;
34
import javafx.stage.WindowEvent;
35
36
import java.io.File;
37
import java.nio.file.Path;
38
import java.util.List;
39
import java.util.Optional;
40
41
import static com.keenwrite.Bootstrap.*;
42
import static com.keenwrite.ExportFormat.*;
43
import static com.keenwrite.Messages.get;
44
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
45
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
46
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
47
import static com.keenwrite.events.StatusEvent.clue;
48
import static com.keenwrite.preferences.AppKeys.*;
49
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
50
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
51
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
52
import static java.nio.file.Files.writeString;
53
import static javafx.application.Platform.runLater;
54
import static javafx.event.Event.fireEvent;
55
import static javafx.scene.control.Alert.AlertType.INFORMATION;
56
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
57
import static org.apache.commons.io.FilenameUtils.getExtension;
58
59
/**
60
 * Responsible for abstracting how functionality is mapped to the application.
61
 * This allows users to customize accelerator keys and will provide pluggable
62
 * functionality so that different text markup languages can change documents
63
 * using their respective syntax.
64
 */
65
public final class GuiCommands {
66
  private static final String STYLE_SEARCH = "search";
67
68
  /**
69
   * When an action is executed, this is one of the recipients.
70
   */
71
  private final MainPane mMainPane;
72
73
  private final MainScene mMainScene;
74
75
  private final LogView mLogView;
76
77
  /**
78
   * Tracks finding text in the active document.
79
   */
80
  private final SearchModel mSearchModel;
81
82
  private boolean mCanTypeset;
83
84
  /**
85
   * A {@link Task} can only be run once, so wrap it in a {@link Service} to
86
   * allow re-launching the typesetting task repeatedly.
87
   */
88
  private Service<Path> mTypesetService;
89
90
  /**
91
   * Prevent a race-condition between checking to see if the typesetting task
92
   * is running and restarting the task itself.
93
   */
94
  private final Object mMutex = new Object();
95
96
  public GuiCommands( final MainScene scene, final MainPane pane ) {
97
    mMainScene = scene;
98
    mMainPane = pane;
99
    mLogView = new LogView();
100
    mSearchModel = new SearchModel();
101
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
102
      final var editor = getActiveTextEditor();
103
104
      // Clear highlighted areas before highlighting a new region.
105
      if( o != null ) {
106
        editor.unstylize( STYLE_SEARCH );
107
      }
108
109
      if( n != null ) {
110
        editor.moveTo( n.getStart() );
111
        editor.stylize( n, STYLE_SEARCH );
112
      }
113
    } );
114
115
    // When the active text editor changes ...
116
    mMainPane.textEditorProperty().addListener(
117
      ( c, o, n ) -> {
118
        // ... update the haystack.
119
        mSearchModel.search( getActiveTextEditor().getText() );
120
121
        // ... update the status bar with the current caret position.
122
        if( n != null ) {
123
          final var w = getWorkspace();
124
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
125
126
          // ... preserve the most recent document.
127
          recentDoc.setValue( n.getFile() );
128
          CaretMovedEvent.fire( n.getCaret() );
129
        }
130
      }
131
    );
132
  }
133
134
  public void file_new() {
135
    getMainPane().newTextEditor();
136
  }
137
138
  public void file_open() {
139
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
140
  }
141
142
  public void file_close() {
143
    getMainPane().close();
144
  }
145
146
  public void file_close_all() {
147
    getMainPane().closeAll();
148
  }
149
150
  public void file_save() {
151
    getMainPane().save();
152
  }
153
154
  public void file_save_as() {
155
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
156
  }
157
158
  public void file_save_all() {
159
    getMainPane().saveAll();
160
  }
161
162
  /**
163
   * Converts the actively edited file in the given file format.
164
   *
165
   * @param format The destination file format.
166
   */
167
  private void file_export( final ExportFormat format ) {
168
    file_export( format, false );
169
  }
170
171
  /**
172
   * Converts one or more files into the given file format. If {@code dir}
173
   * is set to true, this will first append all files in the same directory
174
   * as the actively edited file.
175
   *
176
   * @param format The destination file format.
177
   * @param dir    Export all files in the actively edited file's directory.
178
   */
179
  private void file_export( final ExportFormat format, final boolean dir ) {
180
    final var editor = getMainPane().getTextEditor();
181
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
182
    final var exportParent = exported.get().toPath().getParent();
183
    final var editorParent = editor.getPath().getParent();
184
    final var userHomeParent = USER_DIRECTORY.toPath();
185
    final var exportPath = exportParent != null
186
      ? exportParent
187
      : editorParent != null
188
      ? editorParent
189
      : userHomeParent;
190
191
    final var filename = format.toExportFilename( editor.getPath() );
192
    final var selected = PDF_DEFAULT
193
      .getName()
194
      .equals( exported.get().getName() );
195
    final var selection = pickFile(
196
      selected
197
        ? filename
198
        : exported.get(),
199
      exportPath,
200
      FILE_EXPORT
201
    );
202
203
    selection.ifPresent( files -> file_export( editor, format, files, dir ) );
204
  }
205
206
  private void file_export(
207
    final TextEditor editor,
208
    final ExportFormat format,
209
    final List<File> files,
210
    final boolean dir ) {
211
    editor.save();
212
    final var main = getMainPane();
213
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
214
215
    final var sourceFile = files.get( 0 );
216
    final var sourcePath = sourceFile.toPath();
217
    final var document = dir ? append( editor ) : editor.getText();
218
    final var context = main.createProcessorContext( sourcePath, format );
219
220
    final var service = new Service<Path>() {
221
      @Override
222
      protected Task<Path> createTask() {
223
        final var task = new Task<Path>() {
224
          @Override
225
          protected Path call() throws Exception {
226
            final var chain = createProcessors( context );
227
            final var export = chain.apply( document );
228
229
            // Processors can export binary files. In such cases, processors
230
            // return null to prevent further processing.
231
            return export == null ? null : writeString( sourcePath, export );
232
          }
233
        };
234
235
        task.setOnSucceeded(
236
          e -> {
237
            // Remember the exported file name for next time.
238
            exported.setValue( sourceFile );
239
240
            final var result = task.getValue();
241
242
            // Binary formats must notify users of success independently.
243
            if( result != null ) {
244
              clue( "Main.status.export.success", result );
245
            }
246
          }
247
        );
248
249
        task.setOnFailed( e -> {
250
          final var ex = task.getException();
251
          clue( ex );
252
253
          if( ex instanceof TypeNotPresentException ) {
254
            fireExportFailedEvent();
255
          }
256
        } );
257
258
        return task;
259
      }
260
    };
261
262
    mTypesetService = service;
263
    typeset( service );
264
  }
265
266
  /**
267
   * @param dir {@code true} means to export all files in the active file
268
   *            editor's directory; {@code false} means to export only the
269
   *            actively edited file.
270
   */
271
  private void file_export_pdf( final boolean dir ) {
272
    final var workspace = getWorkspace();
273
    final var themes = workspace.getFile(
274
      KEY_TYPESET_CONTEXT_THEMES_PATH
275
    );
276
    final var theme = workspace.stringProperty(
277
      KEY_TYPESET_CONTEXT_THEME_SELECTION
278
    );
279
    final var chapters = workspace.stringProperty(
280
      KEY_TYPESET_CONTEXT_CHAPTERS
281
    );
282
    final var settings = ExportSettings
283
      .builder()
284
      .with( ExportSettings.Mutator::setTheme, theme )
285
      .with( ExportSettings.Mutator::setChapters, chapters )
286
      .build();
287
288
    // Don't re-validate the typesetter installation each time. If the
289
    // user mucks up the typesetter installation, it'll get caught the
290
    // next time the application is started. Don't use |= because it
291
    // won't short-circuit.
292
    mCanTypeset = mCanTypeset || Typesetter.canRun();
293
294
    if( mCanTypeset ) {
295
      // If the typesetter is installed, allow the user to select a theme. If
296
      // the themes aren't installed, a status message will appear.
297
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
298
        file_export( APPLICATION_PDF, dir );
299
      }
300
    }
301
    else {
302
      fireExportFailedEvent();
303
    }
304
  }
305
306
  public void file_export_pdf() {
307
    file_export_pdf( false );
308
  }
309
310
  public void file_export_pdf_dir() {
311
    file_export_pdf( true );
312
  }
313
314
  public void file_export_html_dir() {
315
    file_export( XHTML_TEX, true );
316
  }
317
318
  public void file_export_repeat() {
319
    typeset( mTypesetService );
320
  }
321
322
  public void file_export_html_svg() {
323
    file_export( HTML_TEX_SVG );
324
  }
325
326
  public void file_export_html_tex() {
327
    file_export( HTML_TEX_DELIMITED );
328
  }
329
330
  public void file_export_xhtml_tex() {
331
    file_export( XHTML_TEX );
332
  }
333
334
  private void fireExportFailedEvent() {
335
    runLater( ExportFailedEvent::fire );
336
  }
337
338
  public void file_exit() {
339
    final var window = getWindow();
340
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
341
  }
342
343
  public void edit_undo() {
344
    getActiveTextEditor().undo();
345
  }
346
347
  public void edit_redo() {
348
    getActiveTextEditor().redo();
349
  }
350
351
  public void edit_cut() {
352
    getActiveTextEditor().cut();
353
  }
354
355
  public void edit_copy() {
356
    getActiveTextEditor().copy();
357
  }
358
359
  public void edit_paste() {
360
    getActiveTextEditor().paste();
361
  }
362
363
  public void edit_select_all() {
364
    getActiveTextEditor().selectAll();
365
  }
366
367
  public void edit_find() {
368
    final var nodes = getMainScene().getStatusBar().getLeftItems();
369
370
    if( nodes.isEmpty() ) {
371
      final var searchBar = new SearchBar();
372
373
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
374
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
375
376
      searchBar.setOnCancelAction( event -> {
377
        final var editor = getActiveTextEditor();
378
        nodes.remove( searchBar );
379
        editor.unstylize( STYLE_SEARCH );
380
        editor.getNode().requestFocus();
381
      } );
382
383
      searchBar.addInputListener( ( c, o, n ) -> {
384
        if( n != null && !n.isEmpty() ) {
385
          mSearchModel.search( n, getActiveTextEditor().getText() );
386
        }
387
      } );
388
389
      searchBar.setOnNextAction( event -> edit_find_next() );
390
      searchBar.setOnPrevAction( event -> edit_find_prev() );
391
392
      nodes.add( searchBar );
393
      searchBar.requestFocus();
394
    }
395
  }
396
397
  public void edit_find_next() {
398
    mSearchModel.advance();
399
  }
400
401
  public void edit_find_prev() {
402
    mSearchModel.retreat();
403
  }
404
405
  public void edit_preferences() {
406
    try {
407
      new PreferencesController( getWorkspace() ).show();
408
    } catch( final Exception ex ) {
409
      clue( ex );
410
    }
411
  }
412
413
  public void format_bold() {
414
    getActiveTextEditor().bold();
415
  }
416
417
  public void format_italic() {
418
    getActiveTextEditor().italic();
419
  }
420
421
  public void format_monospace() {
422
    getActiveTextEditor().monospace();
423
  }
424
425
  public void format_superscript() {
426
    getActiveTextEditor().superscript();
427
  }
428
429
  public void format_subscript() {
430
    getActiveTextEditor().subscript();
431
  }
432
433
  public void format_strikethrough() {
434
    getActiveTextEditor().strikethrough();
435
  }
436
437
  public void insert_blockquote() {
438
    getActiveTextEditor().blockquote();
439
  }
440
441
  public void insert_code() {
442
    getActiveTextEditor().code();
443
  }
444
445
  public void insert_fenced_code_block() {
446
    getActiveTextEditor().fencedCodeBlock();
447
  }
448
449
  public void insert_link() {
450
    insertObject( createLinkDialog() );
451
  }
452
453
  public void insert_image() {
454
    insertObject( createImageDialog() );
455
  }
456
457
  private void insertObject( final Dialog<String> dialog ) {
458
    final var textArea = getActiveTextEditor().getTextArea();
459
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
460
  }
461
462
  private Dialog<String> createLinkDialog() {
463
    return new LinkDialog( getWindow(), createHyperlinkModel() );
464
  }
465
466
  private Dialog<String> createImageDialog() {
467
    final var path = getActiveTextEditor().getPath();
468
    final var parentDir = path.getParent();
469
    return new ImageDialog( getWindow(), parentDir );
470
  }
471
472
  /**
473
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
474
   * the Markdown AST.
475
   *
476
   * @return An instance containing the link URL and display text.
477
   */
478
  private HyperlinkModel createHyperlinkModel() {
479
    final var context = getMainPane().createProcessorContext();
480
    final var editor = getActiveTextEditor();
481
    final var textArea = editor.getTextArea();
482
    final var selectedText = textArea.getSelectedText();
483
484
    // Convert current paragraph to Markdown nodes.
485
    final var mp = MarkdownProcessor.create( context );
486
    final var p = textArea.getCurrentParagraph();
487
    final var paragraph = textArea.getText( p );
488
    final var node = mp.toNode( paragraph );
489
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
490
    final var link = visitor.process( node );
491
492
    if( link != null ) {
493
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
494
    }
495
496
    return createHyperlinkModel( link, selectedText );
497
  }
498
499
  private HyperlinkModel createHyperlinkModel(
500
    final Link link, final String selection ) {
501
502
    return link == null
503
      ? new HyperlinkModel( selection, "https://localhost" )
504
      : new HyperlinkModel( link );
505
  }
506
507
  public void insert_heading_1() {
508
    insert_heading( 1 );
509
  }
510
511
  public void insert_heading_2() {
512
    insert_heading( 2 );
513
  }
514
515
  public void insert_heading_3() {
516
    insert_heading( 3 );
517
  }
518
519
  private void insert_heading( final int level ) {
520
    getActiveTextEditor().heading( level );
521
  }
522
523
  public void insert_unordered_list() {
524
    getActiveTextEditor().unorderedList();
525
  }
526
527
  public void insert_ordered_list() {
528
    getActiveTextEditor().orderedList();
529
  }
530
531
  public void insert_horizontal_rule() {
532
    getActiveTextEditor().horizontalRule();
533
  }
534
535
  public void definition_create() {
536
    getActiveTextDefinition().createDefinition();
537
  }
538
539
  public void definition_rename() {
540
    getActiveTextDefinition().renameDefinition();
541
  }
542
543
  public void definition_delete() {
544
    getActiveTextDefinition().deleteDefinitions();
545
  }
546
547
  public void definition_autoinsert() {
548
    getMainPane().autoinsert();
549
  }
550
551
  public void view_refresh() {
552
    getMainPane().viewRefresh();
553
  }
554
555
  public void view_preview() {
556
    getMainPane().viewPreview();
557
  }
558
559
  public void view_outline() {
560
    getMainPane().viewOutline();
561
  }
562
563
  public void view_files() { getMainPane().viewFiles(); }
564
565
  public void view_statistics() {
566
    getMainPane().viewStatistics();
567
  }
568
569
  public void view_menubar() {
570
    getMainScene().toggleMenuBar();
571
  }
572
573
  public void view_toolbar() {
574
    getMainScene().toggleToolBar();
575
  }
576
577
  public void view_statusbar() {
578
    getMainScene().toggleStatusBar();
579
  }
580
581
  public void view_log() {
582
    mLogView.view();
583
  }
584
585
  public void help_about() {
586
    final var alert = new Alert( INFORMATION );
587
    final var prefix = "Dialog.about.";
588
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
589
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
590
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
591
    alert.setGraphic( ICON_DIALOG_NODE );
592
    alert.initOwner( getWindow() );
593
    alert.showAndWait();
594
  }
595
596
  private <T> void typeset( final Service<T> service ) {
597
    synchronized( mMutex ) {
598
      if( service != null && !service.isRunning() ) {
599
        service.reset();
600
        service.start();
601
      }
602
    }
603
  }
604
605
  /**
606
   * Concatenates all the files in the same directory as the given file into
607
   * a string. The extension is determined by the given file name pattern; the
608
   * order files are concatenated is based on their numeric sort order (this
609
   * avoids lexicographic sorting).
610
   * <p>
611
   * If the parent path to the file being edited in the text editor cannot
612
   * be found then this will return the editor's text, without iterating through
613
   * the parent directory. (Should never happen, but who knows?)
614
   * </p>
615
   * <p>
616
   * New lines are automatically appended to separate each file.
617
   * </p>
618
   *
619
   * @param editor The text editor containing
620
   * @return All files in the same directory as the file being edited
621
   * concatenated into a single string.
622
   */
623
  private String append( final TextEditor editor ) {
624
    final var pattern = editor.getPath();
625
    final var parent = pattern.getParent();
626
627
    // Short-circuit because nothing else can be done.
628
    if( parent == null ) {
629
      clue( "Main.status.export.concat.parent", pattern );
630
      return editor.getText();
631
    }
632
633
    final var filename = pattern.getFileName().toString();
634
    final var extension = getExtension( filename );
635
636
    if( extension.isBlank() ) {
637
      clue( "Main.status.export.concat.extension", filename );
638
      return editor.getText();
639
    }
640
641
    try {
642
      final var command = new ConcatenateCommand(
643
        parent, extension, getString( KEY_TYPESET_CONTEXT_CHAPTERS ) );
644
      return command.call();
678645
    } catch( final Throwable t ) {
679646
      clue( t );
A src/main/java/com/keenwrite/ui/actions/Keyboard.java
1
package com.keenwrite.ui.actions;
2
3
import javafx.scene.input.KeyCodeCombination;
4
import javafx.scene.input.KeyEvent;
5
6
import static javafx.scene.input.KeyCode.C;
7
import static javafx.scene.input.KeyCode.INSERT;
8
import static javafx.scene.input.KeyCombination.CONTROL_ANY;
9
10
public class Keyboard {
11
  public static final KeyCodeCombination CTRL_C =
12
    new KeyCodeCombination( C, CONTROL_ANY );
13
  public static final KeyCodeCombination CTRL_INSERT =
14
    new KeyCodeCombination( INSERT, CONTROL_ANY );
15
16
  /**
17
   * Answers whether the user issued a copy request via the keyboard.
18
   *
19
   * @param event The keyboard event to examine.
20
   * @return {@code true} if the user pressed Ctrl+C or Ctrl+Insert.
21
   */
22
  public static boolean isCopy( final KeyEvent event ) {
23
    return CTRL_C.match( event ) || CTRL_INSERT.match( event );
24
  }
25
}
126
M src/main/java/com/keenwrite/ui/clipboard/Clipboard.java
22
package com.keenwrite.ui.clipboard;
33
4
import javafx.scene.control.TableView;
45
import javafx.scene.input.ClipboardContent;
6
7
import java.util.TreeSet;
58
69
import static javafx.scene.input.Clipboard.getSystemClipboard;
...
2831
  public static void write( final StringBuilder text ) {
2932
    write( text.toString() );
33
  }
34
35
  /**
36
   * Copies the contents of the selected rows into the clipboard; code is from
37
   * <a href="https://stackoverflow.com/a/48126059/59087">StackOverflow</a>.
38
   *
39
   * @param table The {@link TableView} having selected rows to copy.
40
   */
41
  public static <T> void write( final TableView<T> table ) {
42
    final var sb = new StringBuilder( 2048 );
43
    final var rows = new TreeSet<Integer>();
44
    final var cols = table.getColumns();
45
46
    for( final var position : table.getSelectionModel().getSelectedCells() ) {
47
      rows.add( position.getRow() );
48
    }
49
50
    String rSep = "";
51
52
    for( final var row : rows ) {
53
      sb.append( rSep );
54
55
      String cSep = "";
56
57
      for( final var column : cols ) {
58
        sb.append( cSep );
59
60
        final var data = column.getCellData( row );
61
        sb.append( data == null ? "" : data.toString() );
62
63
        cSep = "\t";
64
      }
65
66
      rSep = "\n";
67
    }
68
69
    write( sb );
3070
  }
3171
}
M src/main/java/com/keenwrite/ui/dialogs/ExportDialog.java
190190
        if( result.isPresent() ) {
191191
          final var theme = mComboBox.getSelectionModel().getSelectedItem();
192
          final var path = theme.path().getFileName().toString();
193
          mSettings.themeProperty().setValue( path );
192
          final var path = theme.path();
193
          final var filename = path.getFileName().toString();
194
          mSettings.themeProperty().setValue( filename );
194195
195196
          return true;
M src/main/java/com/keenwrite/ui/dialogs/ImageDialog.java
108108
      //---- urlField ----
109109
      urlField.setEscapeCharacters( "()" );
110
      urlField.setText( "http://yourlink.com" );
111
      urlField.setPromptText( "http://yourlink.com" );
110
      urlField.setText( "https://yourlink.com" );
111
      urlField.setPromptText( "https://yourlink.com" );
112112
      pane.add( urlField, "cell 1 0" );
113113
      pane.add( linkBrowseFileButton, "cell 2 0" );
M src/main/java/com/keenwrite/ui/explorer/FilesView.java
3333
import static java.util.Comparator.comparing;
3434
import static javafx.collections.FXCollections.observableArrayList;
35
import static javafx.scene.control.TableView.CONSTRAINED_RESIZE_POLICY;
35
import static javafx.scene.control.TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN;
3636
import static javafx.scene.input.KeyCode.ENTER;
3737
import static javafx.scene.layout.Priority.ALWAYS;
...
133133
134134
    mDirectory.addListener( ( c, o, n ) -> {
135
      if( n != null ) {field.setText( n.getAbsolutePath() );}
135
      if( n != null ) { field.setText( n.getAbsolutePath() ); }
136136
    } );
137137
...
156156
    final var style = "-fx-alignment: BASELINE_LEFT;";
157157
    final var table = new TableView<FilesView.PathEntry>();
158
    table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY );
158
    table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN );
159159
160160
    table.setRowFactory( tv -> {
M src/main/java/com/keenwrite/ui/heuristics/DocumentStatistics.java
55
import com.keenwrite.events.WordCountEvent;
66
import com.keenwrite.preferences.Workspace;
7
import com.keenwrite.ui.actions.Keyboard;
8
import com.keenwrite.ui.clipboard.Clipboard;
79
import com.whitemagicsoftware.wordcount.TokenizerException;
810
import javafx.beans.property.IntegerProperty;
...
2426
import static javafx.application.Platform.runLater;
2527
import static javafx.collections.FXCollections.observableArrayList;
28
import static javafx.scene.control.SelectionMode.MULTIPLE;
2629
2730
/**
...
5053
    initListeners( workspace );
5154
    register( this );
52
53
    final var fontName = workspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
54
55
    fontName.addListener(
56
      ( c, o, n ) -> {
57
        if( n != null ) {
58
          setFontFamily( n );
59
        }
60
      }
61
    );
62
63
    setFontFamily( fontName.getValue() );
6455
  }
6556
...
8273
        final var document = event.getDocument();
8374
        final var wordCount = mWordCounter.count(
84
          document, ( k, count ) -> {
85
            // Generate statistics for words that occur thrice or more.
86
            if( count > 2 ) {
87
              mItems.add( new StatEntry( k, count ) );
88
            }
89
          }
75
          document, ( k, count ) ->
76
            mItems.add( new StatEntry( k, count ) )
9077
        );
9178
...
11299
    setMaxWidth( Double.MAX_VALUE );
113100
    setPrefWidth( 128 );
114
    setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY );
101
    setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN );
115102
    getSortOrder().setAll( colCount, colWord );
116103
117104
    getStyleClass().add( "" );
118105
  }
119106
120107
  private void initListeners( final Workspace workspace ) {
108
    initLocaleListener( workspace );
109
    initFontListener( workspace );
110
    initKeyboardListener();
111
  }
112
113
  private void initLocaleListener( final Workspace workspace ) {
121114
    final var property = workspace.localeProperty( KEY_LANGUAGE_LOCALE );
122115
    property.addListener(
123116
      ( c, o, n ) -> mWordCounter = WordCounter.create( property.toLocale() )
117
    );
118
  }
119
120
  private void initFontListener( final Workspace workspace ) {
121
    final var fontName = workspace.stringProperty( KEY_UI_FONT_EDITOR_NAME );
122
123
    fontName.addListener(
124
      ( c, o, n ) -> {
125
        if( n != null ) {
126
          setFontFamily( n );
127
        }
128
      }
124129
    );
130
131
    setFontFamily( fontName.getValue() );
132
  }
133
134
  private void initKeyboardListener() {
135
    getSelectionModel().setSelectionMode( MULTIPLE );
136
    setOnKeyPressed( event -> {
137
      if( Keyboard.isCopy( event ) ) {
138
        Clipboard.write( this );
139
      }
140
    } );
125141
  }
126142
M src/main/java/com/keenwrite/ui/logging/LogView.java
33
44
import com.keenwrite.events.StatusEvent;
5
import com.keenwrite.ui.actions.Keyboard;
56
import com.keenwrite.ui.clipboard.Clipboard;
67
import javafx.beans.property.SimpleStringProperty;
78
import javafx.beans.property.StringProperty;
89
import javafx.collections.ObservableList;
910
import javafx.scene.control.*;
10
import javafx.scene.input.KeyCodeCombination;
1111
import javafx.stage.Stage;
1212
import org.greenrobot.eventbus.Subscribe;
1313
1414
import java.time.LocalDateTime;
1515
import java.util.Objects;
16
import java.util.TreeSet;
1716
1817
import static com.keenwrite.Messages.get;
...
2928
import static javafx.scene.control.ButtonType.OK;
3029
import static javafx.scene.control.SelectionMode.MULTIPLE;
31
import static javafx.scene.input.KeyCode.C;
32
import static javafx.scene.input.KeyCode.INSERT;
33
import static javafx.scene.input.KeyCombination.CONTROL_ANY;
3430
import static javafx.stage.Modality.NONE;
3531
...
9490
9591
  private void initTableView() {
96
    final var ctrlC = new KeyCodeCombination( C, CONTROL_ANY );
97
    final var ctrlInsert = new KeyCodeCombination( INSERT, CONTROL_ANY );
98
9992
    final var colDate = new TableColumn<LogEntry, String>( "Timestamp" );
10093
    final var colMessage = new TableColumn<LogEntry, String>( "Message" );
...
114107
    mTable.getSelectionModel().setSelectionMode( MULTIPLE );
115108
    mTable.setOnKeyPressed( event -> {
116
      if( ctrlC.match( event ) || ctrlInsert.match( event ) ) {
117
        copyToClipboard( mTable );
109
      if( Keyboard.isCopy( event ) ) {
110
        Clipboard.write( mTable );
118111
      }
119112
    } );
...
202195
      final var trace = mTrace == null ? "" : mTrace.get();
203196
204
      return "LogEntry{" +
197
      return getClass().getSimpleName() + "{" +
205198
        "mDate=" + (date == null ? "''" : date) +
206199
        ", mMessage=" + (message == null ? "''" : message) +
207200
        ", mTrace=" + (trace == null ? "''" : trace) +
208201
        '}';
209202
    }
210203
211204
    private String toString( final LocalDateTime date ) {
212205
      return date.format( ofPattern( "d MMM u HH:mm:ss" ) );
213
    }
214
  }
215
216
  /**
217
   * Copies the contents of the selected rows into the clipboard; code is from
218
   * <a href="https://stackoverflow.com/a/48126059/59087">StackOverflow</a>.
219
   *
220
   * @param table The {@link TableView} having selected rows to copy.
221
   */
222
  public void copyToClipboard( final TableView<?> table ) {
223
    final var sb = new StringBuilder();
224
    final var rows = new TreeSet<Integer>();
225
    boolean firstRow = true;
226
227
    for( final var position : table.getSelectionModel().getSelectedCells() ) {
228
      rows.add( position.getRow() );
229
    }
230
231
    for( final var row : rows ) {
232
      if( !firstRow ) {
233
        sb.append( '\n' );
234
      }
235
236
      firstRow = false;
237
      boolean firstCol = true;
238
239
      for( final var column : table.getColumns() ) {
240
        if( !firstCol ) {
241
          sb.append( '\t' );
242
        }
243
244
        firstCol = false;
245
        final var data = column.getCellData( row );
246
        sb.append( data == null ? "" : data.toString() );
247
      }
248206
    }
249
250
    Clipboard.write( sb );
251207
  }
252208
}
M src/main/java/com/keenwrite/util/ProtocolScheme.java
2020
   * Denotes a local file.
2121
   */
22
  FILE,
22
  FILE( "file" ),
2323
  /**
2424
   * Denotes either HTTP or HTTPS.
2525
   */
26
  HTTP,
26
  HTTP( "http" ),
2727
  /**
2828
   * Denotes the File Transfer Protocol.
2929
   */
30
  FTP,
30
  FTP( "ftp" ),
3131
  /**
3232
   * Denotes Java archive file.
3333
   */
34
  JAR,
34
  JAR( "jar" ),
3535
  /**
36
   * Could not determine schema (or is not supported by the application).
36
   * Could not determine scheme (or is not supported by the application).
3737
   */
38
  UNKNOWN;
38
  UNKNOWN( "unknown" );
39
40
  private final String mPrefix;
41
42
  ProtocolScheme( final String prefix ) {
43
    mPrefix = prefix;
44
  }
3945
4046
  /**
...
132138
   * @return {@link true} if the protocol must be fetched via HTTP or FTP.
133139
   */
140
  @SuppressWarnings( "unused" )
134141
  public static boolean isRemote( final URL url ) {
135142
    return valueFrom( url ).isRemote();
...
161168
   */
162169
  public boolean isFtp() {
163
    return this == HTTP;
170
    return this == FTP;
164171
  }
165172
...
180187
  public boolean isJar() {
181188
    return this == JAR;
189
  }
190
191
  /**
192
   * Prepends the protocol scheme to the given path, without a host name.
193
   *
194
   * @param path The path to decorate as a URI, including the scheme.
195
   * @return The
196
   */
197
  public String decorate( final String path ) {
198
    return getPrefix() + "://" + path;
199
  }
200
201
  private String getPrefix() {
202
    return mPrefix;
182203
  }
183204
}
M src/main/resources/com/keenwrite/messages.properties
530530
Action.file.export.pdf.repeat.icon=FILE_PDF_ALT
531531
532
Action.file.export.html.dir.description=Export files in document directory as HTML
533
Action.file.export.html.dir.accelerator=Shortcut+Shift+H
534
Action.file.export.html.dir.text=Joined _HTML
535
Action.file.export.html.dir.icon=HTML5
536
532537
Action.file.export.html_svg.description=Export the current document as HTML + SVG
533538
Action.file.export.text=_Export As
M src/test/java/com/keenwrite/io/FileWatchServiceTest.java
77
import java.io.File;
88
import java.io.IOException;
9
import java.nio.file.Files;
910
import java.util.concurrent.Semaphore;
1011
import java.util.function.Consumer;
1112
1213
import static java.io.File.createTempFile;
13
import static java.nio.file.Files.write;
1414
import static java.nio.file.StandardOpenOption.APPEND;
1515
import static java.nio.file.StandardOpenOption.CREATE;
...
4444
    thread.start();
4545
    service.addListener( listener );
46
    write( file.toPath(), text.getBytes(), CREATE, APPEND );
46
    Files.writeString( file.toPath(), text, CREATE, APPEND );
4747
    semaphor.acquire();
4848
    service.stop();
M src/test/java/com/keenwrite/processors/html/XhtmlProcessorTest.java
4747
      .with( ProcessorContext.Mutator::setLocale, () -> ENGLISH )
4848
      .with( ProcessorContext.Mutator::setMetadata, HashMap::new )
49
      .with( ProcessorContext.Mutator::setThemesPath, () -> Path.of( "b" ) )
49
      .with( ProcessorContext.Mutator::setThemesDir, () -> Path.of( "b" ) )
5050
      .with( ProcessorContext.Mutator::setCaret, () -> caret )
51
      .with( ProcessorContext.Mutator::setImagesPath, () -> new File( "i" ) )
51
      .with( ProcessorContext.Mutator::setImagesDir, () -> new File( "i" ) )
5252
      .with( ProcessorContext.Mutator::setImageOrder, () -> "" )
5353
      .with( ProcessorContext.Mutator::setImageServer, () -> "" )
...
6565
      Arguments.of(
6666
        HTML_TEX_DELIMITED,
67
        "<p id=\"caret\">the \uD83D\uDC4D emoji</p>\n"
67
        """
68
          <html><head></head><body><p>the 👍 emoji</p>
69
          </body></html>"""
6870
      ),
6971
      Arguments.of(
7072
        XHTML_TEX,
7173
        """
72
          <html>
73
            <head>
74
              <title> </title>
75
              <meta charset="utf8"/>
76
              <meta content="2" name="count"/>
77
            </head>
78
            <body>
79
              <p id="caret">the 👍 emoji</p>
80
          </body>
81
          </html>
82
          """
74
          <html><head></head><body><p>the 👍 emoji</p>
75
          </body></html>"""
8376
      )
8477
    );
M src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
22
package com.keenwrite.processors.markdown;
33
4
import com.keenwrite.AwaitFxExtension;
54
import com.keenwrite.editors.common.Caret;
65
import com.keenwrite.processors.Processor;
76
import com.keenwrite.processors.ProcessorContext;
87
import com.keenwrite.processors.markdown.extensions.ImageLinkExtension;
98
import com.vladsch.flexmark.html.HtmlRenderer;
109
import com.vladsch.flexmark.parser.Parser;
11
import javafx.stage.Stage;
1210
import org.junit.jupiter.api.Test;
13
import org.junit.jupiter.api.extension.ExtendWith;
14
import org.testfx.framework.junit5.ApplicationExtension;
15
import org.testfx.framework.junit5.Start;
1611
1712
import java.io.File;
1813
import java.net.URI;
19
import java.net.URL;
2014
import java.nio.file.Path;
2115
import java.nio.file.Paths;
22
import java.util.HashMap;
16
import java.util.LinkedHashMap;
2317
import java.util.List;
2418
import java.util.Map;
2519
2620
import static com.keenwrite.ExportFormat.XHTML_TEX;
2721
import static com.keenwrite.constants.Constants.DOCUMENT_DEFAULT;
2822
import static java.lang.String.format;
29
import static javafx.application.Platform.runLater;
3023
import static org.junit.jupiter.api.Assertions.assertEquals;
3124
import static org.junit.jupiter.api.Assertions.assertNotNull;
32
import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents;
3325
3426
/**
3527
 * Responsible for testing that linked images render into HTML according to
3628
 * the {@link ImageLinkExtension} rules.
3729
 */
38
@ExtendWith( {ApplicationExtension.class, AwaitFxExtension.class} )
3930
@SuppressWarnings( "SameParameterValue" )
4031
public class ImageLinkExtensionTest {
41
  private static final Map<String, String> IMAGES = new HashMap<>();
42
43
  private static final String URI_WEB = "placekitten.com/200/200";
44
  private static final String URI_DIRNAME = "images";
45
  private static final String URI_FILENAME = "kitten";
46
47
  /**
48
   * Path to use for testing image file name resolution. Note that resources use
49
   * forward slashes, regardless of OS.
50
   */
51
  private static final String URI_PATH = URI_DIRNAME + '/' + URI_FILENAME;
52
53
  /**
54
   * Extension for the first existing image that matches the preferred image
55
   * extension order.
56
   */
57
  private static final String URI_IMAGE_EXT = ".png";
32
  private static final String UIR_DIR = "images";
33
  private static final String URI_FILE = "kitten";
34
  private static final String URI_PATH = UIR_DIR + '/' + URI_FILE;
35
  private static final String PATH_KITTEN_JPG = URI_PATH + ".jpg";
36
  private static final String PATH_KITTEN_PNG = URI_PATH + ".png";
5837
59
  /**
60
   * Relative path to an image that exists.
61
   */
62
  private static final String URI_IMAGE = URI_PATH + URI_IMAGE_EXT;
38
  private static final Map<String, String> IMAGES = new LinkedHashMap<>();
6339
6440
  static {
65
    addUri( URI_PATH + ".png" );
66
    addUri( URI_PATH + ".jpg" );
67
    addUri( URI_PATH, getResource( URI_PATH + URI_IMAGE_EXT ) );
68
    addUri( "//" + URI_WEB );
69
    addUri( "http://" + URI_WEB );
70
    addUri( "https://" + URI_WEB );
71
  }
72
73
  @Start
74
  @SuppressWarnings( "unused" )
75
  private void start( final Stage stage ) {
76
  }
77
78
  private static void addUri( final String actualExpected ) {
79
    addUri( actualExpected, actualExpected );
41
    add( PATH_KITTEN_PNG, URI_FILE );
42
    add( PATH_KITTEN_PNG, URI_PATH );
43
    add( PATH_KITTEN_PNG, PATH_KITTEN_PNG );
44
    add( PATH_KITTEN_JPG, PATH_KITTEN_JPG );
45
    add( "//placekitten.com/200/200", "//placekitten.com/200/200" );
46
    add( "ftp://placekitten.com/200/200", "ftp://placekitten.com/200/200" );
47
    add( "http://placekitten.com/200/200", "http://placekitten.com/200/200" );
48
    add( "https://placekitten.com/200/200", "https://placekitten.com/200/200" );
8049
  }
8150
82
  private static void addUri( final String actual, final String expected ) {
51
  private static void add( final String expected, final String actual ) {
8352
    IMAGES.put( toMd( actual ), toHtml( expected ) );
8453
  }
8554
8655
  private static String toMd( final String resource ) {
8756
    return format( "![Tooltip](%s 'Title')", resource );
8857
  }
8958
9059
  private static String toHtml( final String url ) {
9160
    return format(
92
      "<p><img src=\"%s\" alt=\"Tooltip\" title=\"Title\" /></p>\n", url );
61
      "<p><img src=\"%s\" alt=\"Tooltip\" title=\"Title\" /></p>%n", url );
9362
  }
9463
9564
  /**
9665
   * Test that the key URIs present in the {@link #IMAGES} map are rendered
9766
   * as the value URIs present in the same map.
9867
   */
9968
  @Test
10069
  void test_ImageLookup_RelativePathWithExtension_ResolvedSuccessfully() {
101
    final var resource = getResourcePath( URI_IMAGE );
102
    final var imagePath = new File( URI_IMAGE ).toPath();
70
    final var resource = getResourcePath( PATH_KITTEN_PNG );
71
    final var imagePath = new File( PATH_KITTEN_PNG ).toPath();
10372
    final var subpaths = resource.getNameCount() - imagePath.getNameCount();
10473
    final var subpath = resource.subpath( 0, subpaths );
10574
10675
    // The root component isn't considered part of the path, so add it back.
10776
    final var documentPath = Path.of(
10877
      resource.getRoot().resolve( subpath ).toString(),
10978
      DOCUMENT_DEFAULT.getName() );
110
    final var context = createProcessorContext( documentPath );
79
    final var imagesDir = Path.of( "images" );
80
    final var context = createProcessorContext( documentPath, imagesDir );
11181
    final var extension = ImageLinkExtension.create( context );
11282
    final var extensions = List.of( extension );
...
12393
      final var node = parser.parse( key );
12494
      final var expectedHtml = entry.getValue();
125
      final var actualHtml = new StringBuilder( 128 );
126
127
      runLater( () -> actualHtml.append( renderer.render( node ) ) );
95
      final var actualHtml = renderer.render( node );
12896
129
      waitForFxEvents();
130
      assertEquals( expectedHtml, actualHtml.toString() );
97
      assertEquals( expectedHtml, actualHtml );
13198
    }
13299
  }
133100
134101
  /**
135102
   * Creates a new {@link ProcessorContext} for the given file name path.
136103
   *
137104
   * @param inputPath Fully qualified path to the file name.
138105
   * @return A context used for creating new {@link Processor} instances.
139106
   */
140
  private ProcessorContext createProcessorContext( final Path inputPath ) {
107
  private ProcessorContext createProcessorContext(
108
    final Path inputPath, final Path imagesDir ) {
141109
    return ProcessorContext
142110
      .builder()
143111
      .with( ProcessorContext.Mutator::setSourcePath, inputPath )
144112
      .with( ProcessorContext.Mutator::setExportFormat, XHTML_TEX )
145113
      .with( ProcessorContext.Mutator::setCaret, () -> Caret.builder().build() )
114
      .with( ProcessorContext.Mutator::setImagesDir, imagesDir::toFile )
146115
      .build();
147
  }
148
149
  private static URL toUrl( final String path ) {
150
    final var clazz = ImageLinkExtensionTest.class;
151
    final var packagePath = clazz.getPackageName().replace( '.', '/' );
152
    final var resourcePath = '/' + packagePath + '/' + path;
153
    return clazz.getResource( resourcePath );
154116
  }
155117
156118
  private static URI toUri( final String path ) {
157119
    try {
158
      return toUrl( path ).toURI();
120
      return Path.of( path ).toUri();
159121
    } catch( final Exception ex ) {
160122
      throw new RuntimeException( ex );
161123
    }
162124
  }
163125
164126
  private static Path getResourcePath( final String path ) {
165127
    return Paths.get( toUri( path ) );
166
  }
167
168
  private static String getResource( final String path ) {
169
    return toUri( path ).toString();
170128
  }
171129
}
M src/test/java/com/keenwrite/r/PluralizeTest.java
2020
public class PluralizeTest {
2121
  private static final ScriptEngine ENGINE =
22
      new ScriptEngineManager().getEngineByName( "Renjin" );
22
    new ScriptEngineManager().getEngineByName( "Renjin" );
2323
2424
  private static final Map<String, String> PLURAL_MAP = ofEntries(
25
      entry( "beef", "beefs" ),
26
      entry( "brother", "brothers" ),
27
      entry( "child", "children" ),
28
      entry( "cow", "cows" ),
29
      entry( "ephemeris", "ephemerides" ),
30
      entry( "genie", "genies" ),
31
      entry( "money", "moneys" ),
32
      entry( "mongoose", "mongooses" ),
33
      entry( "mythos", "mythoi" ),
34
      entry( "octopus", "octopuses" ),
35
      entry( "ox", "oxen" ),
36
      entry( "soliloquy", "soliloquies" ),
37
      entry( "trilby", "trilbys" ),
38
      entry( "wolf", "wolves" )
25
    entry( "beef", "beefs" ),
26
    entry( "brother", "brothers" ),
27
    entry( "child", "children" ),
28
    entry( "cow", "cows" ),
29
    entry( "ephemeris", "ephemerides" ),
30
    entry( "genie", "genies" ),
31
    entry( "money", "moneys" ),
32
    entry( "mongoose", "mongooses" ),
33
    entry( "mythos", "mythoi" ),
34
    entry( "octopus", "octopodes" ),
35
    entry( "ox", "oxen" ),
36
    entry( "soliloquy", "soliloquies" ),
37
    entry( "trilby", "trilbys" ),
38
    entry( "wolf", "wolves" )
3939
  );
4040
4141
  @BeforeAll
4242
  static void setup() throws ScriptException {
4343
    r( "setwd( 'R' );" );
4444
    r( "source( 'pluralize.R' );" );
4545
  }
4646
4747
  @Test
48
  @SuppressWarnings("UnnecessaryLocalVariable")
4948
  public void test_Pluralize_SingularForms_PluralForms()
50
      throws ScriptException {
51
    for( final var key : PLURAL_MAP.keySet() ) {
52
      final var expectedSingular = key;
53
      final var expectedPlural = PLURAL_MAP.get( key );
54
      final var actualSingular = pluralize( key, 1 );
55
      final var actualPlural = pluralize( key, 2 );
49
    throws ScriptException {
50
51
    for( final var entry : PLURAL_MAP.entrySet() ) {
52
      final var expectedSingular = entry.getKey();
53
      final var expectedPlural = entry.getValue();
54
      final var actualSingular = pluralize( expectedSingular, 1 );
55
      final var actualPlural = pluralize( expectedSingular, 2 );
5656
5757
      assertEquals( expectedSingular, actualSingular );
5858
      assertEquals( expectedPlural, actualPlural );
5959
    }
6060
  }
6161
6262
  private String pluralize( final String word, final int count )
63
      throws ScriptException {
64
    return r( format( "pluralize( '%s', %d );", word, count ) ).toString();
63
    throws ScriptException {
64
    final var stmt = format( "pluralize( word='%s', n=%d );", word, count );
65
    return r( stmt ).toString();
6566
  }
6667