| 198 | 198 | } |
| 199 | 199 | |
| 200 | cms.big <- function( n ) { | |
| 201 | } | |
| 202 | ||
| 203 | ||
| 204 | 200 | # ----------------------------------------------------------------------------- |
| 205 | 201 | # Returns a number as a comma-delimited string. This is a work-around |
| ... | ||
| 425 | 421 | round.up <- function( n, base = 5 ) { |
| 426 | 422 | 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 ); | |
| 427 | 435 | } |
| 428 | 436 | |
| 153 | 153 | |
| 154 | 154 | .irregular_patterns <- c( |
| 155 | "fish$", "ois$", "-sheep$", "deer$", "pox$", "[A-Z].*ese$", "itis$" | |
| 155 | "fish$", "ois$", "sheep$", "deer$", "pox$", "[A-Z].*ese$", "itis$" | |
| 156 | 156 | ) |
| 157 | 157 | |
| ... | ||
| 268 | 268 | .irregular_nouns <- list( |
| 269 | 269 | "beef" = c( "a" = "beefs", "c" = "beeves" ), |
| 270 | "biscotto" = c( "a" = "biscotti", "c" = NA_character_ ), | |
| 270 | 271 | "brother" = c( "a" = "brothers", "c" = "brethren" ), |
| 272 | "cactus" = c( "a" = NA_character_, "c" = "catci" ), | |
| 271 | 273 | "child" = c( "a" = NA_character_, "c" = "children" ), |
| 272 | 274 | "cherub" = c( "a" = "cherubim", "c" = NA_character_ ), |
| 273 | 275 | "cow" = c( "a" = "cows", "c" = "kine" ), |
| 276 | "crisis" = c( "a" = NA_character_, "c" = "crises" ), | |
| 277 | "data" = c( "a" = "data", "c" = "data" ), | |
| 274 | 278 | "ephemeris" = c( "a" = NA_character_, "c" = "ephemerides" ), |
| 275 | 279 | "genie" = c( "a" = "genies", "c" = "genii" ), |
| 280 | "graffito" = c( "a" = "graffiti", "c" = NA_character_ ), | |
| 276 | 281 | "matrix" = c( "a" = NA_character_, "c" = "matrices" ), |
| 277 | 282 | "money" = c( "a" = "moneys", "c" = "monies" ), |
| 278 | 283 | "mongoose" = c( "a" = "mongooses", "c" = NA_character_ ), |
| 284 | "minimum" = c( "a" = "minimums", "c" = "minima" ), | |
| 279 | 285 | "mythos" = c( "a" = NA_character_, "c" = "mythoi" ), |
| 280 | "octopus" = c( "a" = "octopuses", "c" = "octopodes" ), | |
| 286 | "octopus" = c( "a" = NA_character_, "c" = "octopodes" ), | |
| 281 | 287 | "ox" = c( "a" = NA_character_, "c" = "oxen" ), |
| 282 | 288 | "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" ), | |
| 283 | 294 | "soliloquy" = c( "a" = "soliloquies", "c" = NA_character_ ), |
| 284 | 295 | "seraph" = c( "a" = "seraphim", "c" = NA_character_ ), |
| 296 | "stadium" = c( "a" = "stadiums", "c" = "stadia" ), | |
| 285 | 297 | "trilby" = c( "a" = "trilbys", "c" = NA_character_ ), |
| 286 | 298 | "vertex" = c( "a" = NA_character_, "c" = "vertices" ), |
| 1 | import static org.gradle.api.JavaVersion.* | |
| 1 | //file:noinspection SpellCheckingInspection | |
| 2 | 2 | |
| 3 | 3 | buildscript { |
| 4 | 4 | repositories { |
| 5 | 5 | mavenCentral() |
| 6 | maven { | |
| 7 | url "https://plugins.gradle.org/m2/" | |
| 8 | } | |
| 6 | 9 | } |
| 7 | 10 | dependencies { |
| 8 | 11 | classpath 'org.owasp:dependency-check-gradle:7.4.3' |
| 12 | classpath "com.github.spotbugs.snom:spotbugs-gradle-plugin:5.0.13" | |
| 9 | 13 | } |
| 10 | 14 | } |
| 11 | 15 | |
| 12 | 16 | plugins { |
| 13 | 17 | id 'application' |
| 14 | 18 | id 'org.openjfx.javafxplugin' version '0.0.13' |
| 15 | 19 | id 'com.palantir.git-version' version '0.15.0' |
| 20 | //id "com.github.spotbugs" version "5.0.13" | |
| 16 | 21 | } |
| 17 | 22 | |
| ... | ||
| 63 | 68 | '--add-exports=javafx.graphics/com.sun.javafx.scene.traversal=ALL-UNNAMED', |
| 64 | 69 | ] |
| 70 | ||
| 71 | java { | |
| 72 | sourceCompatibility = 19 | |
| 73 | targetCompatibility = 19 | |
| 74 | } | |
| 65 | 75 | |
| 66 | 76 | javafx { |
| ... | ||
| 73 | 83 | def v_junit = '5.9.1' |
| 74 | 84 | def v_flexmark = '0.64.0' |
| 75 | def v_jackson = '2.14.0' | |
| 85 | def v_jackson = '2.14.2' | |
| 76 | 86 | def v_echosvg = '0.2.2' |
| 77 | 87 | def v_picocli = '4.7.0' |
| ... | ||
| 95 | 105 | |
| 96 | 106 | // YAML |
| 107 | implementation 'org.yaml:snakeyaml:2.0' | |
| 97 | 108 | implementation "com.fasterxml.jackson.core:jackson-core:${v_jackson}" |
| 98 | 109 | implementation "com.fasterxml.jackson.core:jackson-databind:${v_jackson}" |
| ... | ||
| 106 | 117 | |
| 107 | 118 | // R |
| 119 | implementation 'org.apache.commons:commons-compress:1.23.0' | |
| 120 | implementation 'org.codehaus.plexus:plexus-utils:3.5.1' | |
| 108 | 121 | implementation 'org.renjin:renjin-script-engine:3.5-beta76' |
| 109 | 122 | implementation 'org.renjin.cran:rjson:0.2.15-renjin-21' |
| ... | ||
| 126 | 139 | // Misc. |
| 127 | 140 | 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' | |
| 129 | 142 | implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3' |
| 130 | 143 | implementation 'javax.validation:validation-api:2.0.1.Final' |
| 131 | 144 | 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' | |
| 133 | 146 | |
| 134 | 147 | // Command-line parsing |
| ... | ||
| 163 | 176 | final String applicationPackage = "com.${applicationName}" |
| 164 | 177 | final String applicationClass = "${applicationPackage}.Launcher" |
| 165 | ||
| 166 | java { | |
| 167 | sourceCompatibility = VERSION_17 | |
| 168 | targetCompatibility = VERSION_17 | |
| 169 | } | |
| 170 | 178 | |
| 171 | 179 | compileJava { |
| 3 | 3 | org.gradle.parallel=true |
| 4 | 4 | |
| 5 |
| 1 | 1 |
| 1 | 1 |
| 31 | 31 | ARG_JAVA_OS="linux" |
| 32 | 32 | 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" | |
| 35 | 35 | ARG_JAVA_DIR="java" |
| 36 | 36 |
| 1 | sign.sh | |
| 1 | 2 |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 1 | 5 | package com.keenwrite; |
| 2 | 6 | |
| 3 | 7 | 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; | |
| 5 | 12 | |
| 6 | 13 | import java.io.IOException; |
| 7 | 14 | import java.nio.file.Path; |
| 8 | import java.util.ArrayList; | |
| 9 | 15 | import java.util.concurrent.Callable; |
| 10 | 16 | import java.util.concurrent.CompletableFuture; |
| 11 | 17 | import java.util.concurrent.ExecutorService; |
| 12 | 18 | import java.util.concurrent.atomic.AtomicInteger; |
| 13 | 19 | |
| 14 | 20 | import static com.keenwrite.Launcher.terminate; |
| 15 | 21 | import static com.keenwrite.events.StatusEvent.clue; |
| 22 | import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN; | |
| 16 | 23 | import static com.keenwrite.processors.ProcessorFactory.createProcessors; |
| 17 | import static com.keenwrite.util.FileWalker.walk; | |
| 18 | import static java.lang.System.lineSeparator; | |
| 19 | 24 | import static java.nio.file.Files.readString; |
| 20 | 25 | import static java.nio.file.Files.writeString; |
| ... | ||
| 28 | 33 | public class AppCommands { |
| 29 | 34 | 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; | |
| 37 | 35 | |
| 38 | 36 | private AppCommands() { |
| ... | ||
| 78 | 76 | try { |
| 79 | 77 | final var context = args.createProcessorContext(); |
| 80 | final var concat = context.getConcatenate(); | |
| 81 | final var inputPath = context.getSourcePath(); | |
| 82 | 78 | final var outputPath = context.getTargetPath(); |
| 83 | 79 | 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 ); | |
| 86 | 83 | |
| 87 | 84 | // Processors can export binary files. In such cases, processors will |
| ... | ||
| 100 | 97 | // Prevent the application from blocking while the processor executes. |
| 101 | 98 | 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; | |
| 102 | 107 | } |
| 103 | 108 | |
| ... | ||
| 116 | 121 | * </p> |
| 117 | 122 | * |
| 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. | |
| 121 | 125 | * @return All files in the same directory as the file being edited |
| 122 | 126 | * concatenated into a single string. |
| 123 | 127 | */ |
| 124 | private static String read( final Path inputPath, final boolean concat ) | |
| 128 | private static String read( final ProcessorContext context ) | |
| 125 | 129 | throws IOException { |
| 130 | final var concat = context.getConcatenate(); | |
| 131 | final var inputPath = context.getSourcePath(); | |
| 126 | 132 | final var parent = inputPath.getParent(); |
| 127 | 133 | final var filename = inputPath.getFileName().toString(); |
| 128 | 134 | final var extension = getExtension( filename ); |
| 129 | 135 | |
| 130 | 136 | // Short-circuit because: only one file was requested; there is no parent |
| 131 | 137 | // directory to scan for files; or there's no extension for globbing. |
| 132 | 138 | if( !concat || parent == null || extension.isBlank() ) { |
| 133 | 139 | 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 ); | |
| 147 | 140 | } |
| 148 | 141 | |
| 149 | return text.toString(); | |
| 142 | final var command = new ConcatenateCommand( | |
| 143 | parent, extension, context.getChapters() ); | |
| 144 | return command.call(); | |
| 150 | 145 | } |
| 151 | 146 | } |
| 95 | 95 | : HTML_TEX_DELIMITED; |
| 96 | 96 | case APP_PDF -> APPLICATION_PDF; |
| 97 | case TEXT_XML -> XHTML_TEX; | |
| 97 | 98 | default -> throw new IllegalArgumentException( format( |
| 98 | 99 | "Unrecognized format type and subtype: '%s' and '%s'", type, modifier |
| 1054 | 1054 | .with( Mutator::setLocale, w::getLocale ) |
| 1055 | 1055 | .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, | |
| 1058 | 1058 | () -> w.getFile( KEY_CACHES_DIR ) ) |
| 1059 | .with( Mutator::setImagesPath, | |
| 1059 | .with( Mutator::setImagesDir, | |
| 1060 | 1060 | () -> w.getFile( KEY_IMAGES_DIR ) ) |
| 1061 | 1061 | .with( Mutator::setImageOrder, |
| 1062 | 1062 | () -> w.getString( KEY_IMAGES_ORDER ) ) |
| 1063 | 1063 | .with( Mutator::setImageServer, |
| 1064 | 1064 | () -> w.getString( KEY_IMAGES_SERVER ) ) |
| 1065 | .with( Mutator::setFontsPath, | |
| 1065 | .with( Mutator::setFontsDir, | |
| 1066 | 1066 | () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) ) |
| 1067 | 1067 | .with( Mutator::setCaret, |
| ... | ||
| 1082 | 1082 | |
| 1083 | 1083 | 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 ); | |
| 1085 | 1095 | } |
| 1086 | 1096 | |
| ... | ||
| 1093 | 1103 | public ProcessorContext createProcessorContext( |
| 1094 | 1104 | 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; | |
| 1097 | 1107 | |
| 1098 | return processorContextBuilder() | |
| 1099 | .with( Mutator::setSourcePath, sourcePath ) | |
| 1108 | return createProcessorContextBuilder( format ) | |
| 1100 | 1109 | .with( Mutator::setTargetPath, targetPath ) |
| 1101 | .with( Mutator::setExportFormat, format ) | |
| 1102 | 1110 | .build(); |
| 1103 | 1111 | } |
| 1 | /* Copyright 2023 White Magic Software, Ltd. -- All rights reserved. | |
| 2 | * | |
| 3 | * SPDX-License-Identifier: MIT | |
| 4 | */ | |
| 1 | 5 | package com.keenwrite.cmdline; |
| 2 | 6 | |
| ... | ||
| 48 | 52 | ) |
| 49 | 53 | 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; | |
| 50 | 62 | |
| 51 | 63 | @CommandLine.Option( |
| 52 | 64 | names = {"--curl-quotes"}, |
| 53 | 65 | description = |
| 54 | 66 | "Replace straight quotes with curly quotes (${DEFAULT-VALUE})", |
| 55 | 67 | defaultValue = "true" |
| 56 | 68 | ) |
| 57 | private Boolean mCurlQuotes; | |
| 69 | private boolean mCurlQuotes; | |
| 58 | 70 | |
| 59 | 71 | @CommandLine.Option( |
| ... | ||
| 229 | 241 | .with( Mutator::setSourcePath, mSourcePath ) |
| 230 | 242 | .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 ) | |
| 234 | 246 | .with( Mutator::setImageServer, () -> mImageServer ) |
| 235 | 247 | .with( Mutator::setImageOrder, () -> mImageOrder ) |
| 236 | .with( Mutator::setFontsPath, () -> mFontDir ) | |
| 248 | .with( Mutator::setFontsDir, () -> mFontDir ) | |
| 237 | 249 | .with( Mutator::setExportFormat, format ) |
| 238 | 250 | .with( Mutator::setDefinitions, () -> definitions ) |
| 239 | 251 | .with( Mutator::setMetadata, () -> mMetadata ) |
| 240 | 252 | .with( Mutator::setLocale, () -> locale ) |
| 241 | .with( Mutator::setConcatenate, mConcatenate ) | |
| 253 | .with( Mutator::setConcatenate, () -> mConcatenate ) | |
| 254 | .with( Mutator::setChapters, () -> mChapters ) | |
| 242 | 255 | .with( Mutator::setSigilBegan, () -> mSigilBegan ) |
| 243 | 256 | .with( Mutator::setSigilEnded, () -> mSigilEnded ) |
| 244 | .with( Mutator::setRWorkingDir, () -> mRWorkingDir ) | |
| 245 | 257 | .with( Mutator::setRScript, () -> rScript ) |
| 258 | .with( Mutator::setRWorkingDir, () -> mRWorkingDir ) | |
| 246 | 259 | .with( Mutator::setCurlQuotes, () -> mCurlQuotes ) |
| 247 | 260 | .with( Mutator::setAutoRemove, () -> !mKeepFiles ) |
| 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 | } | |
| 1 | 76 |
| 102 | 102 | |
| 103 | 103 | return sDocumentBuilder.parse( input ); |
| 104 | } catch( final Exception ex ) { | |
| 105 | clue( ex ); | |
| 104 | } catch( final Throwable t ) { | |
| 105 | clue( t ); | |
| 106 | 106 | |
| 107 | 107 | return sDocumentBuilder.newDocument(); |
| 291 | 291 | } |
| 292 | 292 | |
| 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 | ||
| 293 | 302 | public boolean isUndefined() { |
| 294 | 303 | return equals( UNDEFINED ); |
| 5 | 5 | |
| 6 | 6 | import java.io.File; |
| 7 | import java.nio.file.Path; | |
| 7 | 8 | import java.util.List; |
| 8 | 9 | |
| ... | ||
| 99 | 100 | public static MediaType fromFile( final File file ) { |
| 100 | 101 | return fromExtension( FilenameUtils.getExtension( file.getName() ) ); |
| 102 | } | |
| 103 | ||
| 104 | public static MediaType fromPath( final Path path ) { | |
| 105 | return fromFile( path.toFile() ); | |
| 101 | 106 | } |
| 102 | 107 | |
| 8 | 8 | import static com.keenwrite.io.MediaType.*; |
| 9 | 9 | import static java.lang.System.arraycopy; |
| 10 | import static java.util.Arrays.fill; | |
| 10 | 11 | |
| 11 | 12 | /** |
| ... | ||
| 22 | 23 | */ |
| 23 | 24 | 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; | |
| 26 | 39 | |
| 27 | 40 | private static final Map<int[], MediaType> FORMAT = new LinkedHashMap<>(); |
| 28 | 41 | |
| 29 | 42 | private static void put( final int[] data, final MediaType mediaType ) { |
| 30 | 43 | FORMAT.put( data, mediaType ); |
| 31 | 44 | } |
| 32 | 45 | |
| 46 | /* The insertion order attempts to approximate the real-world likelihood of | |
| 47 | * encountering particular file formats in an application. | |
| 48 | */ | |
| 33 | 49 | static { |
| 34 | 50 | //@formatter:off |
| 35 | 51 | put( ints( 0x3C, 0x73, 0x76, 0x67, 0x20 ), IMAGE_SVG_XML ); |
| 36 | 52 | put( ints( 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ), IMAGE_PNG ); |
| 37 | 53 | put( ints( 0xFF, 0xD8, 0xFF, 0xE0 ), IMAGE_JPEG ); |
| 38 | 54 | 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 ); | |
| 52 | 56 | put( ints( 0x3C, 0x21 ), TEXT_HTML ); |
| 53 | 57 | put( ints( 0x3C, 0x68, 0x74, 0x6D, 0x6C ), TEXT_HTML ); |
| ... | ||
| 60 | 64 | put( ints( 0xFE, 0xFF, 0x00, 0x3C, 0x00, 0x3f, 0x00, 0x78 ), TEXT_XML ); |
| 61 | 65 | 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 ); | |
| 62 | 78 | put( ints( 0x23, 0x64, 0x65, 0x66 ), IMAGE_X_BITMAP ); |
| 63 | 79 | put( ints( 0x21, 0x20, 0x58, 0x50, 0x4D, 0x32 ), IMAGE_X_PIXMAP ); |
| 64 | 80 | put( ints( 0x2E, 0x73, 0x6E, 0x64 ), AUDIO_SIMPLE ); |
| 65 | 81 | put( ints( 0x64, 0x6E, 0x73, 0x2E ), AUDIO_SIMPLE ); |
| 66 | 82 | put( ints( 0x52, 0x49, 0x46, 0x46 ), AUDIO_WAV ); |
| 67 | 83 | 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 ); | |
| 69 | 85 | put( ints( 0xCA, 0xFE, 0xBA, 0xBE ), APP_JAVA ); |
| 70 | 86 | put( ints( 0xAC, 0xED ), APP_JAVA_OBJECT ); |
| ... | ||
| 81 | 97 | public static MediaType getMediaType( final byte[] data ) { |
| 82 | 98 | assert data != null; |
| 99 | assert data.length > 0; | |
| 83 | 100 | |
| 84 | 101 | 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 | |
| 86 | 105 | }; |
| 87 | 106 | |
| 88 | for( int i = 0; i < data.length; i++ ) { | |
| 107 | for( int i = 0; i < Math.min( data.length, source.length ); i++ ) { | |
| 89 | 108 | source[ i ] = data[ i ] & 0xFF; |
| 90 | 109 | } |
| 91 | 110 | |
| 92 | for( final var key : FORMAT.keySet() ) { | |
| 111 | for( final var entry : FORMAT.entrySet() ) { | |
| 112 | final var key = entry.getKey(); | |
| 113 | ||
| 93 | 114 | int i = -1; |
| 94 | 115 | boolean matches = true; |
| 95 | 116 | |
| 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; | |
| 98 | 119 | } |
| 99 | 120 | |
| 100 | 121 | if( matches ) { |
| 101 | return FORMAT.get( key ); | |
| 122 | return entry.getValue(); | |
| 102 | 123 | } |
| 103 | 124 | } |
| ... | ||
| 126 | 147 | * Convenience method to return the probed media type for the given |
| 127 | 148 | * {@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. | |
| 131 | 151 | * |
| 132 | 152 | * @param bis Data source to ascertain the {@link MediaType}. |
| 133 | 153 | * @return The IANA-defined {@link MediaType}, or |
| 134 | 154 | * {@link MediaType#UNDEFINED} if indeterminate. |
| 135 | 155 | * @throws IOException Could not read from the stream. |
| 136 | 156 | */ |
| 137 | 157 | public static MediaType getMediaType( final BufferedInputStream bis ) |
| 138 | 158 | throws IOException { |
| 139 | bis.mark( FORMAT_LENGTH ); | |
| 159 | bis.mark( BUFFER ); | |
| 140 | 160 | final var result = getMediaType( (InputStream) bis ); |
| 141 | 161 | bis.reset(); |
| ... | ||
| 157 | 177 | private static MediaType getMediaType( final InputStream is ) |
| 158 | 178 | 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 ); | |
| 161 | 181 | |
| 162 | 182 | if( count > 1 ) { |
| ... | ||
| 170 | 190 | |
| 171 | 191 | /** |
| 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. | |
| 174 | 194 | * |
| 175 | 195 | * @param data The input byte values to pad. |
| 176 | 196 | * @return The data with padding. |
| 177 | 197 | */ |
| 178 | 198 | private static int[] ints( final int... data ) { |
| 179 | final var magic = new int[ FORMAT_LENGTH + 1 ]; | |
| 180 | int i = -1; | |
| 199 | assert data != null; | |
| 181 | 200 | |
| 182 | while( ++i < data.length ) { | |
| 183 | magic[ i ] = data[ i ]; | |
| 184 | } | |
| 201 | final var magic = new int[ data.length + 1 ]; | |
| 185 | 202 | |
| 186 | while( i < FORMAT_LENGTH ) { | |
| 187 | magic[ i++ ] = END_OF_DATA; | |
| 188 | } | |
| 203 | fill( magic, EOS ); | |
| 204 | arraycopy( data, 0, magic, 0, data.length ); | |
| 189 | 205 | |
| 190 | 206 | return magic; |
| 8 | 8 | import java.util.function.Consumer; |
| 9 | 9 | |
| 10 | import static java.nio.charset.StandardCharsets.UTF_8; | |
| 10 | 11 | import static java.util.concurrent.Executors.newFixedThreadPool; |
| 11 | 12 | |
| ... | ||
| 51 | 52 | @Override |
| 52 | 53 | public Boolean call() throws IOException { |
| 53 | try( final var input = new InputStreamReader( mInput ); | |
| 54 | try( final var input = new InputStreamReader( mInput, UTF_8 ); | |
| 54 | 55 | final var buffer = new BufferedReader( input ) ) { |
| 55 | 56 | buffer.lines().forEach( mConsumer ); |
| 8 | 8 | import java.net.HttpURLConnection; |
| 9 | 9 | import java.net.URI; |
| 10 | import java.net.URISyntaxException; | |
| 10 | 11 | import java.net.URL; |
| 11 | 12 | import java.time.Duration; |
| 12 | 13 | import java.util.zip.GZIPInputStream; |
| 13 | 14 | |
| 14 | 15 | import static java.lang.Math.toIntExact; |
| 15 | 16 | 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; | |
| 17 | 19 | import static java.net.HttpURLConnection.HTTP_OK; |
| 18 | 20 | import static java.net.HttpURLConnection.setFollowRedirects; |
| ... | ||
| 167 | 169 | * Opens the input stream for the resource to download. |
| 168 | 170 | * |
| 169 | * @param url The {@link URL} resource to download. | |
| 171 | * @param uri The {@link URI} resource to download. | |
| 170 | 172 | * @return A token that can be used for downloading the content with |
| 171 | 173 | * 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. | |
| 173 | 176 | */ |
| 174 | public static DownloadToken open( final String url ) throws IOException { | |
| 177 | public static DownloadToken open( final String uri ) | |
| 178 | throws IOException, URISyntaxException { | |
| 175 | 179 | // 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 ) ); | |
| 177 | 181 | } |
| 178 | 182 | |
| ... | ||
| 199 | 203 | public static DownloadToken open( final URL url ) throws IOException { |
| 200 | 204 | final var conn = connect( url ); |
| 205 | final var contentType = conn.getContentType(); | |
| 201 | 206 | |
| 202 | MediaType contentType; | |
| 207 | MediaType remoteType; | |
| 203 | 208 | |
| 204 | 209 | try { |
| 205 | contentType = MediaType.valueFrom( conn.getContentType() ); | |
| 210 | remoteType = MediaType.valueFrom( contentType ); | |
| 206 | 211 | } catch( final Exception ex ) { |
| 207 | 212 | // If the media type couldn't be detected, try using the stream. |
| 208 | contentType = MediaType.UNDEFINED; | |
| 213 | remoteType = MediaType.UNDEFINED; | |
| 209 | 214 | } |
| 210 | 215 | |
| 211 | 216 | final var input = open( conn ); |
| 212 | 217 | |
| 213 | 218 | // Peek at the magic header bytes to determine the media type. |
| 214 | 219 | final var magicType = MediaTypeSniffer.getMediaType( input ); |
| 215 | 220 | |
| 216 | 221 | // 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; | |
| 222 | 231 | |
| 223 | 232 | return new DownloadToken( conn, input, mediaType ); |
| 25 | 25 | import static java.util.Arrays.asList; |
| 26 | 26 | 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; | |
| 28 | 28 | |
| 29 | 29 | public class SimpleTableControl<K, V, F extends TableField<Entry<K, V>>> |
| ... | ||
| 41 | 41 | final var table = new TableView<>( model ); |
| 42 | 42 | |
| 43 | table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY ); | |
| 43 | table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN ); | |
| 44 | 44 | table.setEditable( true ); |
| 45 | 45 | table.getColumns().addAll( |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| 4 | import com.keenwrite.ui.adapters.ReplacedElementAdapter; | |
| 5 | 4 | import com.keenwrite.collections.BoundedCache; |
| 5 | import com.keenwrite.ui.adapters.ReplacedElementAdapter; | |
| 6 | 6 | import org.w3c.dom.Element; |
| 7 | 7 | import org.xhtmlrenderer.extend.ReplacedElement; |
| ... | ||
| 16 | 16 | import java.util.Set; |
| 17 | 17 | |
| 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; | |
| 20 | 20 | import static com.keenwrite.processors.markdown.extensions.tex.TexNode.HTML_TEX; |
| 21 | 21 | import static java.lang.Math.min; |
| ... | ||
| 77 | 77 | } |
| 78 | 78 | |
| 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 | } ); | |
| 87 | 86 | |
| 88 | 87 | if( replaced != null ) { |
| ... | ||
| 106 | 105 | factory.remove( element ); |
| 107 | 106 | } |
| 108 | } | |
| 109 | ||
| 110 | public void addFactory( final ReplacedElementFactory factory ) { | |
| 111 | mFactories.add( factory ); | |
| 112 | 107 | } |
| 113 | 108 | |
| 93 | 93 | // content. Consequently, the chained factory must maintain insertion order. |
| 94 | 94 | mFactory = new ChainedReplacedElementFactory( |
| 95 | new SvgReplacedElementFactory(), | |
| 95 | new ImageReplacedElementFactory(), | |
| 96 | 96 | new SwingReplacedElementFactory() |
| 97 | 97 | ); |
| 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 | } | |
| 1 | 149 |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | if( newW <= 0 && newH <= 0 ) { | |
| 60 | newW = oldW; | |
| 61 | newH = oldH; | |
| 62 | } | |
| 63 | ||
| 59 | 64 | return new Dimension( newW, newH ); |
| 60 | 65 | } |
| 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 | } | |
| 107 | 1 |
| 2 | 2 | * Copyright 2013, Morten Nobel-Joergensen |
| 3 | 3 | * |
| 4 | * License: The BSD 3-Clause License | |
| 5 | * http://opensource.org/licenses/BSD-3-Clause | |
| 4 | * SPDX-License-Identifier: BSD-3-Clause | |
| 6 | 5 | */ |
| 7 | 6 | package com.keenwrite.preview.images; |
| 8 | 7 | |
| 9 | 8 | import java.awt.*; |
| 10 | 9 | |
| 11 | 10 | /** |
| 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. | |
| 13 | 12 | */ |
| 14 | 13 | public class ConstrainedDimension { |
| ... | ||
| 36 | 35 | public static ConstrainedDimension createAbsolutionDimension( |
| 37 | 36 | 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 | ||
| 39 | 40 | return new ConstrainedDimension() { |
| 40 | 41 | public Dimension getDimension( Dimension dimension ) { |
| 66 | 66 | |
| 67 | 67 | 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 ) ); | |
| 71 | 72 | } |
| 72 | 73 | |
| 73 | 74 | public ResampleOp( |
| 74 | 75 | final ResampleFilter filter, ConstrainedDimension dimensionConstrain ) { |
| 75 | 76 | super( dimensionConstrain ); |
| 76 | 77 | mFilter = filter; |
| 77 | 78 | } |
| 78 | 79 | |
| 79 | 80 | public BufferedImage doFilter( |
| 80 | BufferedImage srcImg, BufferedImage dest, int dstWidth, int dstHeight ) { | |
| 81 | BufferedImage srcImg, BufferedImage dst, int dstWidth, int dstHeight ) { | |
| 81 | 82 | this.dstWidth = dstWidth; |
| 82 | 83 | this.dstHeight = dstHeight; |
| ... | ||
| 147 | 148 | final BufferedImage out; |
| 148 | 149 | |
| 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 ); | |
| 154 | 155 | if( nrDestChannels != nrChannels ) { |
| 155 | 156 | final var errorMgs = format( |
| ... | ||
| 287 | 288 | for( int k = 0; k < max; k++ ) { tot += arrWeight[ subindex + k ]; } |
| 288 | 289 | 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; } | |
| 292 | 291 | } |
| 293 | 292 | } |
| 43 | 43 | .with( Mutator::setSourcePath, writeString( document, xhtml ) ) |
| 44 | 44 | .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() ) | |
| 47 | 47 | .with( Mutator::setCachesPath, context.getCachesPath() ) |
| 48 | .with( Mutator::setFontsPath, context.getFontsPath() ) | |
| 48 | .with( Mutator::setFontsPath, context.getFontsDir() ) | |
| 49 | 49 | .build(); |
| 50 | 50 |
| 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(); | |
| 396 | 419 | } |
| 397 | 420 |
| 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 | } | |
| 1 | 43 |
| 238 | 238 | |
| 239 | 239 | private Path getImagesPath() { |
| 240 | return mContext.getImagesPath(); | |
| 240 | return mContext.getImagesDir(); | |
| 241 | 241 | } |
| 242 | 242 |
| 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 | */ | |
| 2 | 5 | package com.keenwrite.processors.markdown; |
| 3 | 6 |
| 14 | 14 | import java.io.File; |
| 15 | 15 | import java.nio.file.Path; |
| 16 | import java.util.Optional; | |
| 16 | 17 | |
| 17 | import static com.keenwrite.ExportFormat.NONE; | |
| 18 | 18 | import static com.keenwrite.events.StatusEvent.clue; |
| 19 | 19 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| ... | ||
| 86 | 86 | */ |
| 87 | 87 | 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 ); | |
| 90 | 90 | |
| 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 | } | |
| 94 | 107 | |
| 108 | private ResolvedLink resolveUnqualifiedImageFile( | |
| 109 | final ResolvedLink link, | |
| 110 | final Node node, | |
| 111 | final String url ) { | |
| 95 | 112 | final var baseDir = getBaseDir(); |
| 113 | final var fqfn = baseDir.resolve( Path.of( url ) ); | |
| 96 | 114 | |
| 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 | } | |
| 99 | 121 | |
| 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(); | |
| 104 | 140 | |
| 105 | 141 | try { |
| 106 | 142 | // Compute the path to the image file. The base directory should |
| 107 | 143 | // be an absolute path to the file being edited, without an extension. |
| 108 | 144 | 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() ); | |
| 116 | 148 | |
| 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() ); | |
| 121 | 153 | } |
| 122 | 154 | |
| 123 | 155 | clue( "Main.status.error.file.missing.near", |
| 124 | imageFile + ".*", node.getLineNumber() | |
| 156 | imagePath + ".*", node.getLineNumber() | |
| 125 | 157 | ); |
| 126 | 158 | } catch( final Exception ex ) { |
| 127 | 159 | clue( ex ); |
| 128 | 160 | } |
| 129 | 161 | |
| 130 | 162 | 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(); | |
| 131 | 175 | } |
| 132 | 176 | |
| 133 | 177 | private ResolvedLink valid( final ResolvedLink link, final String url ) { |
| 134 | 178 | return link.withStatus( VALID ).withUrl( url ); |
| 135 | 179 | } |
| 136 | 180 | |
| 137 | 181 | private Path getImageDir() { |
| 138 | return mContext.getImagesPath(); | |
| 182 | return mContext.getImagesDir(); | |
| 139 | 183 | } |
| 140 | 184 | |
| 141 | 185 | private Iterable<String> getImageOrder() { |
| 142 | 186 | return mContext.getImageOrder(); |
| 143 | 187 | } |
| 144 | 188 | |
| 145 | 189 | private Path getBaseDir() { |
| 146 | 190 | return mContext.getBaseDir(); |
| 147 | 191 | } |
| 192 | } | |
| 193 | ||
| 194 | private static boolean readable( final File file ) { | |
| 195 | return file.isFile() && file.canRead(); | |
| 148 | 196 | } |
| 149 | 197 | } |
| 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 | */ | |
| 2 | 5 | package com.keenwrite.processors.r; |
| 3 | 6 | |
| ... | ||
| 48 | 51 | final var dir = getRWorkingDirectory(); |
| 49 | 52 | 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() ) { | |
| 50 | 67 | final var map = new HashMap<String, String>( definitions.size() + 1 ); |
| 51 | 68 | |
| 52 | 69 | definitions.forEach( |
| 53 | 70 | ( k, v ) -> map.put( KEY_OPERATOR.apply( k ), escape( v ) ) |
| 54 | 71 | ); |
| 55 | 72 | map.put( |
| 56 | 73 | KEY_OPERATOR.apply( "application.r.working.directory" ), |
| 57 | escape( dir ) | |
| 74 | escape( workingDir ) | |
| 58 | 75 | ); |
| 59 | 76 | |
| 60 | 77 | try { |
| 61 | 78 | Engine.eval( replace( bootstrap, map ) ); |
| 62 | 79 | } catch( final Exception ex ) { |
| 63 | 80 | 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. | |
| 67 | 81 | } |
| 68 | 82 | } |
| 74 | 74 | addAction( "file.export.pdf.dir", e -> actions.file_export_pdf_dir() ), |
| 75 | 75 | addAction( "file.export.pdf.repeat", e -> actions.file_export_repeat() ), |
| 76 | addAction( "file.export.html.dir", e -> actions.file_export_html_dir() ), | |
| 76 | 77 | addAction( "file.export.html_svg", e -> actions.file_export_html_svg() ), |
| 77 | 78 | addAction( "file.export.html_tex", e -> actions.file_export_html_tex() ), |
| 5 | 5 | import com.keenwrite.MainPane; |
| 6 | 6 | 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(); | |
| 678 | 645 | } catch( final Throwable t ) { |
| 679 | 646 | clue( t ); |
| 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 | } | |
| 1 | 26 |
| 2 | 2 | package com.keenwrite.ui.clipboard; |
| 3 | 3 | |
| 4 | import javafx.scene.control.TableView; | |
| 4 | 5 | import javafx.scene.input.ClipboardContent; |
| 6 | ||
| 7 | import java.util.TreeSet; | |
| 5 | 8 | |
| 6 | 9 | import static javafx.scene.input.Clipboard.getSystemClipboard; |
| ... | ||
| 28 | 31 | public static void write( final StringBuilder text ) { |
| 29 | 32 | 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 ); | |
| 30 | 70 | } |
| 31 | 71 | } |
| 190 | 190 | if( result.isPresent() ) { |
| 191 | 191 | 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 ); | |
| 194 | 195 | |
| 195 | 196 | return true; |
| 108 | 108 | //---- urlField ---- |
| 109 | 109 | 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" ); | |
| 112 | 112 | pane.add( urlField, "cell 1 0" ); |
| 113 | 113 | pane.add( linkBrowseFileButton, "cell 2 0" ); |
| 33 | 33 | import static java.util.Comparator.comparing; |
| 34 | 34 | 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; | |
| 36 | 36 | import static javafx.scene.input.KeyCode.ENTER; |
| 37 | 37 | import static javafx.scene.layout.Priority.ALWAYS; |
| ... | ||
| 133 | 133 | |
| 134 | 134 | mDirectory.addListener( ( c, o, n ) -> { |
| 135 | if( n != null ) {field.setText( n.getAbsolutePath() );} | |
| 135 | if( n != null ) { field.setText( n.getAbsolutePath() ); } | |
| 136 | 136 | } ); |
| 137 | 137 | |
| ... | ||
| 156 | 156 | final var style = "-fx-alignment: BASELINE_LEFT;"; |
| 157 | 157 | final var table = new TableView<FilesView.PathEntry>(); |
| 158 | table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY ); | |
| 158 | table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN ); | |
| 159 | 159 | |
| 160 | 160 | table.setRowFactory( tv -> { |
| 5 | 5 | import com.keenwrite.events.WordCountEvent; |
| 6 | 6 | import com.keenwrite.preferences.Workspace; |
| 7 | import com.keenwrite.ui.actions.Keyboard; | |
| 8 | import com.keenwrite.ui.clipboard.Clipboard; | |
| 7 | 9 | import com.whitemagicsoftware.wordcount.TokenizerException; |
| 8 | 10 | import javafx.beans.property.IntegerProperty; |
| ... | ||
| 24 | 26 | import static javafx.application.Platform.runLater; |
| 25 | 27 | import static javafx.collections.FXCollections.observableArrayList; |
| 28 | import static javafx.scene.control.SelectionMode.MULTIPLE; | |
| 26 | 29 | |
| 27 | 30 | /** |
| ... | ||
| 50 | 53 | initListeners( workspace ); |
| 51 | 54 | 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() ); | |
| 64 | 55 | } |
| 65 | 56 | |
| ... | ||
| 82 | 73 | final var document = event.getDocument(); |
| 83 | 74 | 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 ) ) | |
| 90 | 77 | ); |
| 91 | 78 | |
| ... | ||
| 112 | 99 | setMaxWidth( Double.MAX_VALUE ); |
| 113 | 100 | setPrefWidth( 128 ); |
| 114 | setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY ); | |
| 101 | setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN ); | |
| 115 | 102 | getSortOrder().setAll( colCount, colWord ); |
| 116 | 103 | |
| 117 | 104 | getStyleClass().add( "" ); |
| 118 | 105 | } |
| 119 | 106 | |
| 120 | 107 | private void initListeners( final Workspace workspace ) { |
| 108 | initLocaleListener( workspace ); | |
| 109 | initFontListener( workspace ); | |
| 110 | initKeyboardListener(); | |
| 111 | } | |
| 112 | ||
| 113 | private void initLocaleListener( final Workspace workspace ) { | |
| 121 | 114 | final var property = workspace.localeProperty( KEY_LANGUAGE_LOCALE ); |
| 122 | 115 | property.addListener( |
| 123 | 116 | ( 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 | } | |
| 124 | 129 | ); |
| 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 | } ); | |
| 125 | 141 | } |
| 126 | 142 | |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.events.StatusEvent; |
| 5 | import com.keenwrite.ui.actions.Keyboard; | |
| 5 | 6 | import com.keenwrite.ui.clipboard.Clipboard; |
| 6 | 7 | import javafx.beans.property.SimpleStringProperty; |
| 7 | 8 | import javafx.beans.property.StringProperty; |
| 8 | 9 | import javafx.collections.ObservableList; |
| 9 | 10 | import javafx.scene.control.*; |
| 10 | import javafx.scene.input.KeyCodeCombination; | |
| 11 | 11 | import javafx.stage.Stage; |
| 12 | 12 | import org.greenrobot.eventbus.Subscribe; |
| 13 | 13 | |
| 14 | 14 | import java.time.LocalDateTime; |
| 15 | 15 | import java.util.Objects; |
| 16 | import java.util.TreeSet; | |
| 17 | 16 | |
| 18 | 17 | import static com.keenwrite.Messages.get; |
| ... | ||
| 29 | 28 | import static javafx.scene.control.ButtonType.OK; |
| 30 | 29 | 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; | |
| 34 | 30 | import static javafx.stage.Modality.NONE; |
| 35 | 31 | |
| ... | ||
| 94 | 90 | |
| 95 | 91 | private void initTableView() { |
| 96 | final var ctrlC = new KeyCodeCombination( C, CONTROL_ANY ); | |
| 97 | final var ctrlInsert = new KeyCodeCombination( INSERT, CONTROL_ANY ); | |
| 98 | ||
| 99 | 92 | final var colDate = new TableColumn<LogEntry, String>( "Timestamp" ); |
| 100 | 93 | final var colMessage = new TableColumn<LogEntry, String>( "Message" ); |
| ... | ||
| 114 | 107 | mTable.getSelectionModel().setSelectionMode( MULTIPLE ); |
| 115 | 108 | mTable.setOnKeyPressed( event -> { |
| 116 | if( ctrlC.match( event ) || ctrlInsert.match( event ) ) { | |
| 117 | copyToClipboard( mTable ); | |
| 109 | if( Keyboard.isCopy( event ) ) { | |
| 110 | Clipboard.write( mTable ); | |
| 118 | 111 | } |
| 119 | 112 | } ); |
| ... | ||
| 202 | 195 | final var trace = mTrace == null ? "" : mTrace.get(); |
| 203 | 196 | |
| 204 | return "LogEntry{" + | |
| 197 | return getClass().getSimpleName() + "{" + | |
| 205 | 198 | "mDate=" + (date == null ? "''" : date) + |
| 206 | 199 | ", mMessage=" + (message == null ? "''" : message) + |
| 207 | 200 | ", mTrace=" + (trace == null ? "''" : trace) + |
| 208 | 201 | '}'; |
| 209 | 202 | } |
| 210 | 203 | |
| 211 | 204 | private String toString( final LocalDateTime date ) { |
| 212 | 205 | 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 | } | |
| 248 | 206 | } |
| 249 | ||
| 250 | Clipboard.write( sb ); | |
| 251 | 207 | } |
| 252 | 208 | } |
| 20 | 20 | * Denotes a local file. |
| 21 | 21 | */ |
| 22 | FILE, | |
| 22 | FILE( "file" ), | |
| 23 | 23 | /** |
| 24 | 24 | * Denotes either HTTP or HTTPS. |
| 25 | 25 | */ |
| 26 | HTTP, | |
| 26 | HTTP( "http" ), | |
| 27 | 27 | /** |
| 28 | 28 | * Denotes the File Transfer Protocol. |
| 29 | 29 | */ |
| 30 | FTP, | |
| 30 | FTP( "ftp" ), | |
| 31 | 31 | /** |
| 32 | 32 | * Denotes Java archive file. |
| 33 | 33 | */ |
| 34 | JAR, | |
| 34 | JAR( "jar" ), | |
| 35 | 35 | /** |
| 36 | * Could not determine schema (or is not supported by the application). | |
| 36 | * Could not determine scheme (or is not supported by the application). | |
| 37 | 37 | */ |
| 38 | UNKNOWN; | |
| 38 | UNKNOWN( "unknown" ); | |
| 39 | ||
| 40 | private final String mPrefix; | |
| 41 | ||
| 42 | ProtocolScheme( final String prefix ) { | |
| 43 | mPrefix = prefix; | |
| 44 | } | |
| 39 | 45 | |
| 40 | 46 | /** |
| ... | ||
| 132 | 138 | * @return {@link true} if the protocol must be fetched via HTTP or FTP. |
| 133 | 139 | */ |
| 140 | @SuppressWarnings( "unused" ) | |
| 134 | 141 | public static boolean isRemote( final URL url ) { |
| 135 | 142 | return valueFrom( url ).isRemote(); |
| ... | ||
| 161 | 168 | */ |
| 162 | 169 | public boolean isFtp() { |
| 163 | return this == HTTP; | |
| 170 | return this == FTP; | |
| 164 | 171 | } |
| 165 | 172 | |
| ... | ||
| 180 | 187 | public boolean isJar() { |
| 181 | 188 | 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; | |
| 182 | 203 | } |
| 183 | 204 | } |
| 530 | 530 | Action.file.export.pdf.repeat.icon=FILE_PDF_ALT |
| 531 | 531 | |
| 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 | ||
| 532 | 537 | Action.file.export.html_svg.description=Export the current document as HTML + SVG |
| 533 | 538 | Action.file.export.text=_Export As |
| 7 | 7 | import java.io.File; |
| 8 | 8 | import java.io.IOException; |
| 9 | import java.nio.file.Files; | |
| 9 | 10 | import java.util.concurrent.Semaphore; |
| 10 | 11 | import java.util.function.Consumer; |
| 11 | 12 | |
| 12 | 13 | import static java.io.File.createTempFile; |
| 13 | import static java.nio.file.Files.write; | |
| 14 | 14 | import static java.nio.file.StandardOpenOption.APPEND; |
| 15 | 15 | import static java.nio.file.StandardOpenOption.CREATE; |
| ... | ||
| 44 | 44 | thread.start(); |
| 45 | 45 | service.addListener( listener ); |
| 46 | write( file.toPath(), text.getBytes(), CREATE, APPEND ); | |
| 46 | Files.writeString( file.toPath(), text, CREATE, APPEND ); | |
| 47 | 47 | semaphor.acquire(); |
| 48 | 48 | service.stop(); |
| 47 | 47 | .with( ProcessorContext.Mutator::setLocale, () -> ENGLISH ) |
| 48 | 48 | .with( ProcessorContext.Mutator::setMetadata, HashMap::new ) |
| 49 | .with( ProcessorContext.Mutator::setThemesPath, () -> Path.of( "b" ) ) | |
| 49 | .with( ProcessorContext.Mutator::setThemesDir, () -> Path.of( "b" ) ) | |
| 50 | 50 | .with( ProcessorContext.Mutator::setCaret, () -> caret ) |
| 51 | .with( ProcessorContext.Mutator::setImagesPath, () -> new File( "i" ) ) | |
| 51 | .with( ProcessorContext.Mutator::setImagesDir, () -> new File( "i" ) ) | |
| 52 | 52 | .with( ProcessorContext.Mutator::setImageOrder, () -> "" ) |
| 53 | 53 | .with( ProcessorContext.Mutator::setImageServer, () -> "" ) |
| ... | ||
| 65 | 65 | Arguments.of( |
| 66 | 66 | 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>""" | |
| 68 | 70 | ), |
| 69 | 71 | Arguments.of( |
| 70 | 72 | XHTML_TEX, |
| 71 | 73 | """ |
| 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>""" | |
| 83 | 76 | ) |
| 84 | 77 | ); |
| 2 | 2 | package com.keenwrite.processors.markdown; |
| 3 | 3 | |
| 4 | import com.keenwrite.AwaitFxExtension; | |
| 5 | 4 | import com.keenwrite.editors.common.Caret; |
| 6 | 5 | import com.keenwrite.processors.Processor; |
| 7 | 6 | import com.keenwrite.processors.ProcessorContext; |
| 8 | 7 | import com.keenwrite.processors.markdown.extensions.ImageLinkExtension; |
| 9 | 8 | import com.vladsch.flexmark.html.HtmlRenderer; |
| 10 | 9 | import com.vladsch.flexmark.parser.Parser; |
| 11 | import javafx.stage.Stage; | |
| 12 | 10 | 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; | |
| 16 | 11 | |
| 17 | 12 | import java.io.File; |
| 18 | 13 | import java.net.URI; |
| 19 | import java.net.URL; | |
| 20 | 14 | import java.nio.file.Path; |
| 21 | 15 | import java.nio.file.Paths; |
| 22 | import java.util.HashMap; | |
| 16 | import java.util.LinkedHashMap; | |
| 23 | 17 | import java.util.List; |
| 24 | 18 | import java.util.Map; |
| 25 | 19 | |
| 26 | 20 | import static com.keenwrite.ExportFormat.XHTML_TEX; |
| 27 | 21 | import static com.keenwrite.constants.Constants.DOCUMENT_DEFAULT; |
| 28 | 22 | import static java.lang.String.format; |
| 29 | import static javafx.application.Platform.runLater; | |
| 30 | 23 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 31 | 24 | import static org.junit.jupiter.api.Assertions.assertNotNull; |
| 32 | import static org.testfx.util.WaitForAsyncUtils.waitForFxEvents; | |
| 33 | 25 | |
| 34 | 26 | /** |
| 35 | 27 | * Responsible for testing that linked images render into HTML according to |
| 36 | 28 | * the {@link ImageLinkExtension} rules. |
| 37 | 29 | */ |
| 38 | @ExtendWith( {ApplicationExtension.class, AwaitFxExtension.class} ) | |
| 39 | 30 | @SuppressWarnings( "SameParameterValue" ) |
| 40 | 31 | 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"; | |
| 58 | 37 | |
| 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<>(); | |
| 63 | 39 | |
| 64 | 40 | 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" ); | |
| 80 | 49 | } |
| 81 | 50 | |
| 82 | private static void addUri( final String actual, final String expected ) { | |
| 51 | private static void add( final String expected, final String actual ) { | |
| 83 | 52 | IMAGES.put( toMd( actual ), toHtml( expected ) ); |
| 84 | 53 | } |
| 85 | 54 | |
| 86 | 55 | private static String toMd( final String resource ) { |
| 87 | 56 | return format( "", resource ); |
| 88 | 57 | } |
| 89 | 58 | |
| 90 | 59 | private static String toHtml( final String url ) { |
| 91 | 60 | 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 ); | |
| 93 | 62 | } |
| 94 | 63 | |
| 95 | 64 | /** |
| 96 | 65 | * Test that the key URIs present in the {@link #IMAGES} map are rendered |
| 97 | 66 | * as the value URIs present in the same map. |
| 98 | 67 | */ |
| 99 | 68 | @Test |
| 100 | 69 | 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(); | |
| 103 | 72 | final var subpaths = resource.getNameCount() - imagePath.getNameCount(); |
| 104 | 73 | final var subpath = resource.subpath( 0, subpaths ); |
| 105 | 74 | |
| 106 | 75 | // The root component isn't considered part of the path, so add it back. |
| 107 | 76 | final var documentPath = Path.of( |
| 108 | 77 | resource.getRoot().resolve( subpath ).toString(), |
| 109 | 78 | DOCUMENT_DEFAULT.getName() ); |
| 110 | final var context = createProcessorContext( documentPath ); | |
| 79 | final var imagesDir = Path.of( "images" ); | |
| 80 | final var context = createProcessorContext( documentPath, imagesDir ); | |
| 111 | 81 | final var extension = ImageLinkExtension.create( context ); |
| 112 | 82 | final var extensions = List.of( extension ); |
| ... | ||
| 123 | 93 | final var node = parser.parse( key ); |
| 124 | 94 | 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 ); | |
| 128 | 96 | |
| 129 | waitForFxEvents(); | |
| 130 | assertEquals( expectedHtml, actualHtml.toString() ); | |
| 97 | assertEquals( expectedHtml, actualHtml ); | |
| 131 | 98 | } |
| 132 | 99 | } |
| 133 | 100 | |
| 134 | 101 | /** |
| 135 | 102 | * Creates a new {@link ProcessorContext} for the given file name path. |
| 136 | 103 | * |
| 137 | 104 | * @param inputPath Fully qualified path to the file name. |
| 138 | 105 | * @return A context used for creating new {@link Processor} instances. |
| 139 | 106 | */ |
| 140 | private ProcessorContext createProcessorContext( final Path inputPath ) { | |
| 107 | private ProcessorContext createProcessorContext( | |
| 108 | final Path inputPath, final Path imagesDir ) { | |
| 141 | 109 | return ProcessorContext |
| 142 | 110 | .builder() |
| 143 | 111 | .with( ProcessorContext.Mutator::setSourcePath, inputPath ) |
| 144 | 112 | .with( ProcessorContext.Mutator::setExportFormat, XHTML_TEX ) |
| 145 | 113 | .with( ProcessorContext.Mutator::setCaret, () -> Caret.builder().build() ) |
| 114 | .with( ProcessorContext.Mutator::setImagesDir, imagesDir::toFile ) | |
| 146 | 115 | .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 ); | |
| 154 | 116 | } |
| 155 | 117 | |
| 156 | 118 | private static URI toUri( final String path ) { |
| 157 | 119 | try { |
| 158 | return toUrl( path ).toURI(); | |
| 120 | return Path.of( path ).toUri(); | |
| 159 | 121 | } catch( final Exception ex ) { |
| 160 | 122 | throw new RuntimeException( ex ); |
| 161 | 123 | } |
| 162 | 124 | } |
| 163 | 125 | |
| 164 | 126 | private static Path getResourcePath( final String path ) { |
| 165 | 127 | return Paths.get( toUri( path ) ); |
| 166 | } | |
| 167 | ||
| 168 | private static String getResource( final String path ) { | |
| 169 | return toUri( path ).toString(); | |
| 170 | 128 | } |
| 171 | 129 | } |
| 20 | 20 | public class PluralizeTest { |
| 21 | 21 | private static final ScriptEngine ENGINE = |
| 22 | new ScriptEngineManager().getEngineByName( "Renjin" ); | |
| 22 | new ScriptEngineManager().getEngineByName( "Renjin" ); | |
| 23 | 23 | |
| 24 | 24 | 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" ) | |
| 39 | 39 | ); |
| 40 | 40 | |
| 41 | 41 | @BeforeAll |
| 42 | 42 | static void setup() throws ScriptException { |
| 43 | 43 | r( "setwd( 'R' );" ); |
| 44 | 44 | r( "source( 'pluralize.R' );" ); |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | 47 | @Test |
| 48 | @SuppressWarnings("UnnecessaryLocalVariable") | |
| 49 | 48 | 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 ); | |
| 56 | 56 | |
| 57 | 57 | assertEquals( expectedSingular, actualSingular ); |
| 58 | 58 | assertEquals( expectedPlural, actualPlural ); |
| 59 | 59 | } |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | 62 | 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(); | |
| 65 | 66 | } |
| 66 | 67 |