";
+
+ public static final String ZERO_WIDTH_SPACE_ENTITY_ENCODING = "";
+
+ /**
+ * , . / \ | - % ) & > < "
+ */
+ private static final Pattern NON_SPACE_BREAK_OPPORTUNITIES =
+ Pattern.compile("([,./\\\\|\\-%)]|&(amp|gt|lt|quot);)(\\S)");
+
+ /**
+ * Applies the minimal amount of entity-encoding necessary for the given plain text to appear in literal form in an
+ * HTML document. This requires entity-encoding greater than, less than, and ampersand characters.
+ *
+ * @param text
+ * some plain text that will be appearing in an HTML document.
+ * @return a version of the given plain text with any tag delimiters and ampersands entity-encoded; or null if the
+ * given text is null.
+ */
+ public static final String getLiteralText(CharSequence text) {
+ if (text == null) {
+ return null;
+ }
+ if (text.isEmpty()) {
+ return "";
+ }
+ return CharBasedFilteringTextMapper.replace(text.toString(), SimpleHtmlEntity::encodeForLiteral);
+ }
+
+ /**
+ * Converts text to HTML in the same manner as {@link #getLiteralText(CharSequence)}, but including BR tags in place
+ * of line breaks. The line breaks are identified via {@link StringUtil#getLines(CharSequence)}, and the conversion
+ * of the individual lines' text is does with getLiteralText.
+ *
+ * @param text
+ * the text to be converted.
+ * @return some HTML representing the given text converted to HTML-safe text, plus substituting BR tags for line
+ * breaks.
+ */
+ public static final String getLiteralTextWithLineBreaks(CharSequence text) {
+ if (StringUtils.isEmpty(text)) {
+ return "";
+ }
+ return StringUtil.join(StringUtil.getLines(getLiteralText(text)).iterator(), TAG_BR);
+ }
+
+ public static final void printLiteralTextWithLineBreaks(Appendable out, CharSequence text) {
+ if (StringUtils.isEmpty(text)) {
+ return;
+ }
+ try {
+ StringUtil.getNullSkippingJoiner(TAG_BR).appendTo(
+ out, StringUtil.getLines(getLiteralText(text)).iterator()
+ );
+ }
+ catch (IOException e) {
+ // This is not possible.
+ LOGGER.error("This exception should not happen", e);
+ }
+ }
+
+ /**
+ * Returns a version of the given text that is safe for an HTML attribute value. This requires entity-encoding
+ * greater than, less than, double quotation mark, and ampersand characters.
+ *
+ * @param text
+ * the text to be encoded in an HTML-safe manner.
+ * @return a version of the given text that is safe for an HTML attribute value.
+ */
+ public static final String getTagAttributeValue(CharSequence text) {
+ if (text == null) {
+ return null;
+ }
+ if (text.isEmpty()) {
+ return text.toString();
+ }
+ return Objects.toString(
+ CharBasedFilteringTextMapper.replace(text, SimpleHtmlEntity::encodeForTagAttributeValue), null
+ );
+ }
+
+ /**
+ * Converts the given text to HTML, including the non-literal and usually unnecessary conversion spaces to
+ * non-breaking spaces.
+ *
+ *
+ * This method exists to support a handful of classic forms and a few other old use cases. It should not be used for
+ * new code.
+ *
+ * @param text
+ * the text to be converted to HTML.
+ * @return a conversion of the given text to HTML, including the non-literal and usually unnecessary conversion
+ * spaces to non-breaking spaces.
+ */
+ public static final String getClassicHtmlText(String text) {
+ if (StringUtils.isBlank(text)) {
+ return text;
+ }
+ return Objects.toString(
+ CharBasedFilteringTextMapper.replace(text, SimpleHtmlEntity::encodeForClassicHtmlText), null
+ );
+ }
+
+ /**
+ * Reverses the encoding that is performed by {@link #getClassicHtmlText(String)}, translating the given HTML to
+ * text, including the non-literal conversion of non-breaking space entities to ordinary spaces.
+ *
+ * @param html
+ * the HTML to be converted to text.
+ * @return the decoded version of the string.
+ */
+ public static final String getClassicTextHtml(String html) {
+
+ if (StringUtils.isEmpty(html)) {
+ return html;
+ }
+
+ int len = html.length();
+ StringBuilder buff = null;
+ for (int i = 0; i < len; i++) {
+ SimpleHtmlEntity entity = SimpleHtmlEntity.get(html, i);
+ if (entity == null) {
+ if (buff != null) {
+ buff.append(html.charAt(i));
+ }
+ }
+ else {
+ if (buff == null) {
+ buff = new StringBuilder(len);
+ buff.append(html, 0, i);
+ }
+ entity.appendValue(buff);
+ i += entity.nameLength() + 1;
+ }
+ }
+
+ if (buff == null) {
+ return html;
+ }
+ return buff.toString();
+
+ }
+
+ /**
+ * Returns the preferred non-space optional break HTML sequence for the UserAgent being used for the current
+ * request, defaulting to "".
+ *
+ * @return the preferred non-space optional break HTML sequence for the UserAgent being used for the current
+ * request, defaulting to "".
+ */
+ public static final String getNonSpaceBreaksHtml() {
+ return System.getProperty(
+ "com.tractionsoftware.commons.codec.non_space_break_html", DEFAULT_NON_SPACE_BREAK_HTML
+ );
+ }
+
+ /**
+ * Inserts the preferred non-space optional break HTML sequence for the UserAgent being used for the current request
+ * where appropriate in the given text.
+ *
+ * @param text
+ * the text into which the non-space optional break HTML should be inserted. This must really be pure text and
+ * not contain any markup, since markup might be corrupted by this insertion process.
+ * @return the given text with the preferred non-space optional break HTML sequence for the UserAgent being used for
+ * the current request inserted where appropriate.
+ */
+ public static final String getHtmlWithNonSpaceBreaks(String text) {
+ if (text == null) {
+ return null;
+ }
+ return NON_SPACE_BREAK_OPPORTUNITIES.matcher(text).replaceAll("$1" + getNonSpaceBreaksHtml() + "$3");
+ }
+
+ public static final Appendable getLiteralAppendable(Appendable out, String preferredZeroWidthSpace) {
+ Objects.requireNonNull(out, "output");
+ if (StringUtils.isBlank(preferredZeroWidthSpace)) {
+ preferredZeroWidthSpace = TextWrapUtil.DEFAULT_ZERO_WIDTH_SPACE;
+ }
+ if (out instanceof HtmlLiteralAppendableWrapper literal &&
+ Objects.equals(literal.preferredZeroWidthSpace, preferredZeroWidthSpace)) {
+ return literal;
+ }
+ return new HtmlLiteralAppendableWrapper(out, preferredZeroWidthSpace);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/codec/JavaScriptEncodingUtil.java b/src/main/java/com/tractionsoftware/commons/codec/JavaScriptEncodingUtil.java
new file mode 100644
index 0000000..30c1014
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/codec/JavaScriptEncodingUtil.java
@@ -0,0 +1,247 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.codec;
+
+import com.tractionsoftware.commons.text.CharBasedFilteringTextMapper;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Provides a handful of helpers related to JavaScript encoding.
+ *
+ * @author Andy Keller, Dave Shepperton
+ */
+public final class JavaScriptEncodingUtil {
+
+ public static final String JAVASCRIPT_FILE_EXTENSION = ".js";
+
+ public enum CharacterRequiringEscaping {
+
+ APOSTROPHE('\'', true),
+
+ QUOTATION_MARK('"', true),
+
+ BACKSLASH('\\'),
+
+ SLASH('/'),
+
+ NEW_LINE('n'),
+
+ CARRIAGE_RETURN('r'),
+
+ TAB('t'),
+
+ FORM_FEED('f'),
+
+ BACKSPACE('b');
+
+ public static CharacterRequiringEscaping get(char c) {
+ return switch (c) {
+ case '\'' -> APOSTROPHE;
+ case '"' -> QUOTATION_MARK;
+ case '\\' -> BACKSLASH;
+ case '/' -> SLASH;
+ case '\n' -> NEW_LINE;
+ case '\r' -> CARRIAGE_RETURN;
+ case '\t' -> TAB;
+ case '\f' -> FORM_FEED;
+ default -> null;
+ };
+ }
+
+ public static String getRequiredEscapeSequence(char c) {
+ CharacterRequiringEscaping value = get(c);
+ if (value == null) {
+ return null;
+ }
+ return value.escapeSequence;
+ }
+
+ private final String escapeSequence;
+
+ private final boolean quotationMark;
+
+ CharacterRequiringEscaping(char escapingChar) {
+ this(escapingChar, false);
+ }
+
+ CharacterRequiringEscaping(char escapingChar, boolean quotationMark) {
+ this.escapeSequence = "\\" + escapingChar;
+ this.quotationMark = quotationMark;
+ }
+
+ @Override
+ public final String toString() {
+ return name() + " (" + escapeSequence + ")";
+ }
+
+ public final String getEscapeSequence() {
+ return escapeSequence;
+ }
+
+ public final boolean isQuotationMark() {
+ return quotationMark;
+ }
+
+ }
+
+ private JavaScriptEncodingUtil() {
+ }
+
+ /**
+ * Returns a version of the given text with all occurrences of certain characters escaped to ensure that the result
+ * is a valid JavaScript literal. Specifically, the following characters are escaped:
+ *
+ *
+ * apostrophe (a.k.a., single quotation mark)
+ * quotation mark (i.e., double quotation mark)
+ * backslash
+ * slash
+ * new line
+ * carriage return
+ * tab
+ * form feed
+ * backspace
+ *
+ *
+ * @param text
+ * the input text.
+ * @return a version of the given text with a transformation applied escaping any appearances of the following
+ * characters to make the result a valid JavaScript literal.
+ */
+ public static String getJavascriptLiteral(CharSequence text) {
+ return getJavascriptLiteral(text, false);
+ }
+
+ /**
+ * Returns a version of the given text with all occurrences of certain characters escaped or entity encoded to
+ * ensure that the result is both a valid JavaScript literal and suitable for inclusion in an HTML document.
+ *
+ *
+ * Specifically, the following characters are escaped:
+ *
+ *
+ * apostrophe (a.k.a., single quotation mark)
+ * quotation mark (i.e., double quotation mark)
+ * backslash
+ * slash
+ * new line
+ * carriage return
+ * tab
+ * form feed
+ * backspace
+ *
+ *
+ * And the following characters are entity encoded:
+ *
+ *
+ * less than
+ * greater than
+ * ampersand
+ *
+ *
+ * @param text
+ * the input text.
+ * @return a version of the given text with all occurrences of certain characters escaped or entity encoded the
+ * following characters to make the result both a valid JavaScript literal and suitable for inclusion in an HTML
+ * document.
+ */
+ public static String getHtmlCompatibleJavascriptLiteral(CharSequence text) {
+ return getJavascriptLiteral(text, true);
+ }
+
+ /**
+ * Returns a version of the given text with all occurrences of certain characters escaped to ensure that the result
+ * is a valid JSON literal; and, if an optional HTML-compatible version is requested, entity encoded to ensure that
+ * the result is also suitable for inclusion in an HTML document.
+ *
+ *
+ * Specifically, the following characters are escaped:
+ *
+ *
+ * apostrophe (a.k.a., single quotation mark)
+ * quotation mark (i.e., double quotation mark)
+ * backslash
+ * slash
+ * new line
+ * carriage return
+ * tab
+ * form feed
+ * backspace
+ *
+ *
+ * And the following characters are entity encoded if an HTML-compatible version is requested:
+ *
+ *
+ * less than
+ * greater than
+ * ampersand
+ *
+ *
+ * @param text
+ * the input text.
+ * @param htmlCompatible
+ * indicating whether the
+ * @return a version of the given text with all occurrences of certain characters escaped or entity encoded the
+ * following characters to make the result both a valid JSON literal and suitable for inclusion in an HTML
+ * document.
+ */
+ public static String getJavascriptLiteral(CharSequence text, boolean htmlCompatible) {
+ if (text == null) {
+ return null;
+ }
+ if (text.isEmpty()) {
+ return "";
+ }
+ if (htmlCompatible) {
+ return CharBasedFilteringTextMapper.replace(text, JavaScriptEncodingUtil::getHtmlCompatibleLiteralReplacement);
+ }
+ return CharBasedFilteringTextMapper.replace(text, CharacterRequiringEscaping::getRequiredEscapeSequence);
+ }
+
+ public static void printJavascriptLiteral(Appendable out, CharSequence text) {
+ printJavascriptLiteral(out, text, false);
+ }
+
+ public static void printHtmlCompatibleJavascriptLiteral(Appendable out, CharSequence text) {
+ printJavascriptLiteral(out, text, true);
+ }
+
+ public static void printJavascriptLiteral(Appendable out, CharSequence text, boolean htmlCompatible) {
+ if (StringUtils.isNotEmpty(text)) {
+ if (htmlCompatible) {
+ CharBasedFilteringTextMapper.replace(text, out, JavaScriptEncodingUtil::getHtmlCompatibleLiteralReplacement);
+ }
+ else {
+ CharBasedFilteringTextMapper.replace(text, out, CharacterRequiringEscaping::getRequiredEscapeSequence);
+ }
+ }
+ }
+
+ private static String getHtmlCompatibleLiteralReplacement(char c) {
+ return switch (c) {
+ case '<' -> HtmlEncodingUtil.SimpleHtmlEntity.LESS_THAN.getEncoding();
+ case '>' -> HtmlEncodingUtil.SimpleHtmlEntity.GREATER_THAN.getEncoding();
+ case '&' -> HtmlEncodingUtil.SimpleHtmlEntity.AMPERSAND.getEncoding();
+ default -> CharacterRequiringEscaping.getRequiredEscapeSequence(c);
+ };
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/codec/MD5Util.java b/src/main/java/com/tractionsoftware/commons/codec/MD5Util.java
new file mode 100644
index 0000000..a132ebd
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/codec/MD5Util.java
@@ -0,0 +1,295 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.codec;
+
+import com.tractionsoftware.commons.io.FileResource;
+import com.tractionsoftware.commons.io.IOUtil;
+import com.tractionsoftware.commons.io.LocalFileResource;
+import com.tractionsoftware.commons.lang.ObjectUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.math.BigInteger;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+public final class MD5Util {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(MD5Util.class);
+
+ /*
+ * Not instantiable.
+ */
+ private MD5Util() {
+ }
+
+ public static final String ALGORITHM_NAME = "MD5";
+
+ public interface DigestResult {
+
+ public boolean wasSuccessful();
+
+ public boolean isEmpty();
+
+ @Nullable
+ public byte[] hashBytes();
+
+ @Nullable
+ public String hashString();
+
+ @Nullable
+ public String paddedHashString();
+
+ }
+
+ private static final DigestResult DIGEST_RESULT_EMPTY_SUCCESS = new DigestResult() {
+
+ @Override
+ public boolean wasSuccessful() {
+ return true;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return true;
+ }
+
+ @Nonnull
+ @Override
+ public byte[] hashBytes() {
+ return ArrayUtils.EMPTY_BYTE_ARRAY;
+ }
+
+ @Nonnull
+ @Override
+ public String hashString() {
+ return "";
+ }
+
+ @Nonnull
+ @Override
+ public String paddedHashString() {
+ return "";
+ }
+
+ };
+
+ private static final DigestResult DIGEST_RESULT_EMPTY_FAILURE = new DigestResult() {
+
+ @Override
+ public boolean wasSuccessful() {
+ return false;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return true;
+ }
+
+ @Override
+ public byte[] hashBytes() {
+ return null;
+ }
+
+ @Override
+ public String hashString() {
+ return null;
+ }
+
+ @Override
+ public String paddedHashString() {
+ return null;
+ }
+
+ };
+
+ private static DigestResult getResult(byte[] bytes) {
+ if (bytes == null) {
+ return DIGEST_RESULT_EMPTY_FAILURE;
+ }
+ if (bytes.length == 0) {
+ return DIGEST_RESULT_EMPTY_SUCCESS;
+ }
+ return new NormalDigestResult(bytes);
+ }
+
+ private static final class NormalDigestResult implements DigestResult {
+
+ private final byte[] bytes;
+
+ private String hash;
+
+ private String paddedHash;
+
+ private NormalDigestResult(byte[] bytes) {
+ this(bytes, null, null);
+ }
+
+ private NormalDigestResult(byte[] bytes, String hash, String paddedHash) {
+ this.bytes = bytes;
+ this.hash = hash;
+ this.paddedHash = paddedHash;
+ }
+
+ @Override
+ public boolean wasSuccessful() {
+ return true;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return false;
+ }
+
+ @Override
+ public byte[] hashBytes() {
+ return bytes;
+ }
+
+ @Override
+ public String hashString() {
+ if (hash == null) {
+ hash = MD5Util.getHashString(bytes);
+ }
+ return hash;
+ }
+
+ @Override
+ public String paddedHashString() {
+ if (paddedHash == null) {
+ paddedHash = getPaddedHashString(bytes);
+ }
+ return paddedHash;
+ }
+
+ }
+
+ public static MessageDigest getMessageDigest() {
+ try {
+ return MessageDigest.getInstance(ALGORITHM_NAME);
+ }
+ catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("This server is missing a required component.");
+ }
+ }
+
+ public static final DigestInputStream createDigestInputStream(FileResource file) throws IOException {
+ return createDigestInputStream(file.getInputStream());
+ }
+
+ public static final DigestInputStream createDigestInputStream(InputStream input) throws IOException {
+ return new DigestInputStream(input, getMessageDigest());
+ }
+
+ public static final DigestResult digest(FileResource file) {
+ if (file.isDirectory()) {
+ return DIGEST_RESULT_EMPTY_SUCCESS;
+ }
+ return digestNonDirectoryFile(file);
+ }
+
+ public static final DigestResult digest(File file) {
+ if (file.isDirectory()) {
+ return DIGEST_RESULT_EMPTY_SUCCESS;
+ }
+ return digestNonDirectoryFile(LocalFileResource.createInstance(file));
+ }
+
+ public static final DigestResult digest(byte[] data) {
+ try (DigestInputStream input = createDigestInputStream(new ByteArrayInputStream(data))) {
+ return digestImpl(input);
+ }
+ catch (IOException e) {
+ LOGGER.warn("Unexpected error attempting to compute an MD5 hash for bytes.", e);
+ return DIGEST_RESULT_EMPTY_FAILURE;
+ }
+ }
+
+ public static final DigestResult digest(InputStream input) {
+ try {
+ if (input instanceof DigestInputStream digestStream &&
+ digestStream.getMessageDigest().getAlgorithm().equalsIgnoreCase(ALGORITHM_NAME)) {
+ return digestImpl(digestStream);
+ }
+ return digestImpl(createDigestInputStream(input));
+ }
+ catch (IOException e) {
+ LOGGER.warn(
+ "Unexpected error attempting to compute an MD5 hash for stream {}",
+ ObjectUtil.safeToStringObject(input),
+ e
+ );
+ return DIGEST_RESULT_EMPTY_FAILURE;
+ }
+ }
+
+ public static final String getHashString(byte[] digestedBytes) {
+ return HexFormat.of().formatHex(digestedBytes);
+ }
+
+ public static final String getPaddedHashString(byte[] digestedBytes) {
+ if (digestedBytes == null) {
+ return null;
+ }
+ return String.format("%1$032x", new BigInteger(1, digestedBytes));
+ }
+
+ private static final DigestResult digestNonDirectoryFile(FileResource file) {
+
+ if (file.isEmpty()) {
+ try {
+ return getInstanceForEmptyFile(file.getContentType());
+ }
+ catch (IOException e) {
+ LOGGER.warn("Failed to determine MD5 for empty file {}", file, e);
+ return DIGEST_RESULT_EMPTY_FAILURE;
+ }
+ }
+
+ try (DigestInputStream input = file.getDigestInputStream()) {
+ return digestImpl(input);
+ }
+ catch (IOException e) {
+ LOGGER.warn("Failed to compute MD5 for file {}", file, e);
+ return DIGEST_RESULT_EMPTY_FAILURE;
+ }
+
+ }
+
+ public static final DigestResult getInstanceForEmptyFile(String contentType) throws IOException {
+ return digest(IOUtil.getStringAsUtf8InputStream(StringUtils.trimToEmpty(contentType).toLowerCase()));
+ }
+
+ private static DigestResult digestImpl(DigestInputStream digestInput) throws IOException {
+ try (OutputStream out = OutputStream.nullOutputStream()) {
+ digestInput.transferTo(out);
+ }
+ return getResult(digestInput.getMessageDigest().digest());
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/codegen/java/JavaGeneratorStringUtil.java b/src/main/java/com/tractionsoftware/commons/codegen/java/JavaGeneratorStringUtil.java
new file mode 100644
index 0000000..274bc02
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/codegen/java/JavaGeneratorStringUtil.java
@@ -0,0 +1,95 @@
+package com.tractionsoftware.commons.codegen.java;
+
+import com.google.common.annotations.Beta;
+import com.tractionsoftware.commons.text.CharBasedFilteringTextMapper;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.PrintWriter;
+
+/**
+ * Helper methods for code that generates .java source files.
+ *
+ * @author Dave Shepperton
+ */
+@Beta
+public final class JavaGeneratorStringUtil {
+
+ private JavaGeneratorStringUtil() {
+ }
+
+ public static enum CharacterRequiringEscaping {
+
+ LINE_FEED('n'),
+
+ CARRIAGE_RETURN('r'),
+
+ BACKSLASH('\\'),
+
+ DOUBLE_QUOTATION_MARK('"', true);
+
+ @Nullable
+ public static final CharacterRequiringEscaping get(char c) {
+ return switch (c) {
+ case '\n' -> LINE_FEED;
+ case '\r' -> CARRIAGE_RETURN;
+ case '\\' -> BACKSLASH;
+ case '"' -> DOUBLE_QUOTATION_MARK;
+ default -> null;
+ };
+ }
+
+ @Nullable
+ public static final String getReplacement(char c) {
+ CharacterRequiringEscaping value = get(c);
+ if (value == null) {
+ return null;
+ }
+ return value.getEscapeSequence();
+ }
+
+ private final String escapeSequence;
+
+ private final boolean isQuotationMark;
+
+ CharacterRequiringEscaping(char escapingChar) {
+ this(escapingChar, false);
+ }
+
+ CharacterRequiringEscaping(char escapingChar, boolean isQuotationMark) {
+ this.escapeSequence = "\\" + escapingChar;
+ this.isQuotationMark = isQuotationMark;
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return name() + " (" + escapeSequence + ")";
+ }
+
+ @Nonnull
+ public final String getEscapeSequence() {
+ return escapeSequence;
+ }
+
+ public final boolean isQuotationMark() {
+ return isQuotationMark;
+ }
+
+ }
+
+ public static final String getStringLiteral(@Nullable String str) {
+ if (StringUtils.isEmpty(str)) {
+ return str;
+ }
+ return CharBasedFilteringTextMapper.replace(str, CharacterRequiringEscaping::getReplacement);
+ }
+
+ public static final void printStringLiteral(PrintWriter out, String str) {
+ if (StringUtils.isNotEmpty(str)) {
+ CharBasedFilteringTextMapper.replace(str, out, CharacterRequiringEscaping::getReplacement);
+ }
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/config/Configuration.java b/src/main/java/com/tractionsoftware/commons/config/Configuration.java
new file mode 100644
index 0000000..0d750c2
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/config/Configuration.java
@@ -0,0 +1,162 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.config;
+
+import com.tractionsoftware.commons.properties.GetProperty;
+
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * An interface representing an object type along with an associated set of configuration properties.
+ *
+ *
+ * The properties may determine the specific type itself, and may modify various behaviors of the instances to which
+ * they are applied.
+ *
+ * @author Dave Shepperton
+ */
+public interface Configuration extends GetProperty {
+
+ /**
+ * Returns the logical name for this configuration.
+ *
+ * @return the logical name for this configuration.
+ */
+ public String getName();
+
+ /**
+ * Returns the path to the file from which the Configuration properties were loaded, if applicable.
+ */
+ public String getPath();
+
+ /**
+ * Returns the templated setting values stored in the configuration, if present.
+ *
+ * @return the templated setting values stored in the configuration, if present; null otherwise.
+ */
+ public Map getTemplateSettings();
+
+ /**
+ * Returns a Configuration object which is identical to this one, but whose {@link #getName()} method will return
+ * the given name instead of this Configuration's name.
+ *
+ *
+ * If the requested name matches this Configuration's existing name, this method should generally return this
+ * instance. Otherwise, a new instance may be created.
+ *
+ *
+ * This default implementation uses {@link ForwardingConfiguration#withNewName(Configuration, String)}, which should
+ * be suitable for almost all cases.
+ *
+ * @param newName
+ * the requested new name.
+ * @return a new Configuration object which is a copy of this one, using the given name in place of this instance's
+ * name.
+ */
+ public default Configuration withNewName(String newName) {
+ if (Objects.equals(getName(), newName)) {
+ return this;
+ }
+ return ForwardingConfiguration.withNewName(this, newName);
+ }
+
+ /**
+ * This default implementation creates a {@link ConfigurationLocator}.
+ */
+ @Override
+ public default Configuration withDefaults(GetProperty defaults) {
+ return ConfigurationLocator.getSingleConfigurationOrLocator(this, defaults);
+ }
+
+ /**
+ * Returns a view of this Configuration that is guaranteed to supply access to the Configuration API methods only,
+ * or at least to methods that are deemed safe for general read-only access. For example, to pass a subclass of
+ * Configuration to an alien method that accepts a Configuration, and to ensure that that method cannot cast the
+ * argument back to the subtype in order to a to gain access to other sensitive methods, a client may invoke
+ * toReadOnly on that object before passing it along to that method.
+ *
+ *
+ * This default implementation returns this Configuration itself, which is a suitable implementation for subclasses
+ * that do not have any public methods beyond those declared in Configuration. Implementations that do have public
+ * methods that should not be accessible to clients that arbitrary clients when a Configuration would do should
+ * override it, perhaps using {@link ForwardingConfiguration#wrap(Configuration)}. Likewise, this method may be
+ * re-declared in subclasses using a different return type if there is a more specific safe read-only subtype that
+ * could be offered in place of Configuration, as this method itself overrides the declaration from
+ * {@link GetProperty#toReadOnly()}.
+ *
+ * @return a view of this Configuration that is guaranteed to supply access to the Configuration API methods only,
+ * or at least to methods that are deemed safe for general read-only access.
+ */
+ @Override
+ public default Configuration toReadOnly() {
+ return this;
+ }
+
+ /**
+ * This default implementation returns this Configuration instance itself, which should be suitable for all cases.
+ */
+ @Override
+ public default Configuration asConfiguration() {
+ return this;
+ }
+
+ /**
+ * This implementation uses {@link PropertyNameMappingConfiguration#wrapInNamespace(Configuration, String)} , which
+ * should be adequate for all Configuration implementations that do not need to return another specific sub-type of
+ * Configuration.
+ */
+ @Override
+ public default Configuration getNamespace(String space) {
+ return PropertyNameMappingConfiguration.wrapInNamespace(this, space);
+ }
+
+ /**
+ * This implementation uses {@link PropertyNameMappingConfiguration#wrapInNamespace(Configuration, String, char)} ,
+ * which should be adequate for all Configuration implementations that do not need to return another specific
+ * sub-type of Configuration.
+ */
+ @Override
+ public default Configuration getNamespace(String space, char separator) {
+ return PropertyNameMappingConfiguration.wrapInNamespace(this, space, separator);
+ }
+
+ /**
+ * This implementation uses {@link PropertyNameMappingConfiguration#wrapInPrefix(Configuration, String)} , which
+ * should be adequate for all Configuration implementations that do not need to return another specific sub-type of
+ * Configuration.
+ */
+ @Override
+ public default Configuration getPrefix(String prefix) {
+ return PropertyNameMappingConfiguration.wrapInPrefix(this, prefix);
+ }
+
+ /**
+ * This implementation uses {@link PropertyNameMappingConfiguration#wrapInPrefix(Configuration, String, char)} ,
+ * which should be adequate for all GetProperty implementations that do not need to return another specific sub-type
+ * of GetProperty.
+ */
+ @Override
+ public default Configuration getPrefix(String prefix, char separator) {
+ return PropertyNameMappingConfiguration.wrapInPrefix(this, prefix, separator);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/config/ConfigurationException.java b/src/main/java/com/tractionsoftware/commons/config/ConfigurationException.java
new file mode 100644
index 0000000..7a7748f
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/config/ConfigurationException.java
@@ -0,0 +1,83 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.config;
+
+import java.io.Serial;
+
+public class ConfigurationException extends RuntimeException {
+
+ @Serial
+ private static final long serialVersionUID = 3006693526768433933L;
+
+ public static final ConfigurationException forInvalidPropertyValue(String name, String value) {
+ throw new ConfigurationException(
+ "The " +
+ name +
+ " property value (\"" +
+ value +
+ "\") does not appear to be a valid content-type specification."
+ );
+ }
+
+ /**
+ * Constructs a new ConfigurationException with no detail message.
+ */
+ public ConfigurationException() {
+ super();
+ }
+
+ /**
+ * Constructs a new ConfigurationException with the specified detail message.
+ *
+ * @param message
+ * the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method.
+ */
+ public ConfigurationException(String message) {
+ super(message);
+ }
+
+ /**
+ * Constructs a new ConfigurationException with the specified cause and a detail message of
+ * (cause==null ? null : cause.toString()) (which
+ * typically contains the class and detail message of
+ * cause ). This constructor is useful for exceptions that
+ * are little more than wrappers for other throwables.
+ *
+ * @param cause
+ * the underlying cause.
+ */
+ public ConfigurationException(Throwable cause) {
+ super(cause);
+ }
+
+ /**
+ * Constructs a new ConfigurationException with the specified detail message and cause.
+ *
+ * @param message
+ * the detail message.
+ * @param cause
+ * the underlying cause.
+ */
+ public ConfigurationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/config/ConfigurationLocator.java b/src/main/java/com/tractionsoftware/commons/config/ConfigurationLocator.java
new file mode 100644
index 0000000..7f64839
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/config/ConfigurationLocator.java
@@ -0,0 +1,66 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.config;
+
+import java.util.Map;
+import java.util.Objects;
+
+import com.tractionsoftware.commons.properties.AbstractGetPropertyLocator;
+import com.tractionsoftware.commons.properties.GetProperty;
+import jakarta.annotation.Nonnull;
+
+/**
+ * Like a PropLocator, a ConfigurationLocator allows property lookups via the getProperty method to fall back from one
+ * store to another. This class provides an implementation of both the Configuration interface as well as an
+ * implementation of PropLocator that in which a Configuration is either the primary source or the fall back source,
+ * depending upon which constructor is used.
+ */
+public final class ConfigurationLocator extends AbstractGetPropertyLocator implements Configuration {
+
+ public static final Configuration getSingleConfigurationOrLocator(Configuration locals, GetProperty defaults) {
+ Objects.requireNonNull(locals, "main Configuration");
+ if (defaults == null) {
+ return locals;
+ }
+ return new ConfigurationLocator(locals, defaults);
+ }
+
+ public ConfigurationLocator(Configuration locals, GetProperty defaults) {
+ super(locals, defaults);
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return "Configuration: " + super.toString();
+ }
+
+ @Override
+ public final String getPath() {
+ return locals.getPath();
+ }
+
+ @Override
+ public final Map getTemplateSettings() {
+ return locals.getTemplateSettings();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/config/ConfiguredObject.java b/src/main/java/com/tractionsoftware/commons/config/ConfiguredObject.java
new file mode 100644
index 0000000..3cd9635
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/config/ConfiguredObject.java
@@ -0,0 +1,529 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.config;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.tractionsoftware.commons.lang.EnumUtil;
+import com.tractionsoftware.commons.lang.StringUtil;
+import com.tractionsoftware.commons.properties.AbstractGetPropertyLocator;
+import com.tractionsoftware.commons.properties.GetProperty;
+import com.tractionsoftware.commons.properties.SimpleProperties;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+
+import java.text.MessageFormat;
+import java.util.*;
+
+/**
+ * Something that is associated with a configuration, usually in the form of a configuration file. Access is provided to
+ * its {@link Configuration}, and to other properties.
+ *
+ * @property display_name=
+ *
+ * This optional property is used by the default implementation of the {@link #getDisplayName()} method to provide
+ * the display name for this Configurable as it appears in various contexts.
+ * @property display_name_short=
+ *
+ * This optional property is used by the default implementation of the {@link #getShortDisplayName()} method to
+ * provide a short version of the display name for this Configurable to be used in certain contexts.
+ * @property description= and description_arg=
+ *
+ * These optional properties are used by the default implementation of the {@link #getDescription()} method to
+ * provide the description text and arguments for any formatting parameters that may appear in the text.
+ */
+public interface ConfiguredObject extends Comparable {
+
+ /**
+ * The default name of the property that appears in a configuration (usually an individual .properties file, or an
+ * object from a .json file) to indicate the specification for the class for which the file represents a
+ * configuration.
+ */
+ public static final String PROP_NAME_CLASS = "class";
+
+ public static final String DISPLAY_PROP_NAME_NAME = "name";
+
+ public static final String PROP_NAME_DISPLAY_NAME = "display_name";
+
+ public static final String PROP_NAME_DISPLAY_NAME_SHORT = "display_name_short";
+
+ public static final String PROP_NAME_DESCRIPTION = "description";
+
+ public static final String DESCRIPTION_ARG = "description_arg";
+
+ /**
+ * When supported, this property may be set to a comma separated list of names representing aliases for this
+ * Configurables.
+ */
+ public static final String PROP_NAME_ALIASES = "aliases";
+
+ public static final String PROP_NAME_PRIORITY = "priority";
+
+ public static final int DEFAULT_PRIORITY = 0;
+
+ public static final String PROP_NAMESPACE_PERFORMANCE_CONCERN_THRESHOLD = "performance_concern_threshold";
+
+ public static interface DisplayProperties extends GetProperty {
+
+ public String getDisplayName();
+
+ public String getShortDisplayName();
+
+ public String getDescription();
+
+ /**
+ * Returns a SequencedSet of aliases -- alternative logical names -- from the given {@link Configuration}.
+ *
+ * @return a SequencedSet of aliases -- alternative logical names -- for the given {@link Configuration}.
+ */
+ @Nonnull
+ public SequencedSet getAliases();
+
+ }
+
+ public static class LocalDisplayProperties implements DisplayProperties {
+
+ protected final ConfiguredObject element;
+
+ public LocalDisplayProperties(@Nonnull ConfiguredObject element) {
+ Objects.requireNonNull(element, "Configurable");
+ this.element = element;
+ }
+
+ @Nonnull
+ @Override
+ public String toString() {
+ return "DisplayProperties for " + element;
+ }
+
+ @Override
+ public String getProperty(String propName) {
+ return switch (propName) {
+ case DISPLAY_PROP_NAME_NAME -> element.getName();
+ case PROP_NAME_DISPLAY_NAME -> getDisplayName();
+ case PROP_NAME_DISPLAY_NAME_SHORT -> getShortDisplayName();
+ case PROP_NAME_DESCRIPTION -> getDescription();
+ case PROP_NAME_ALIASES -> StringUtil.join(getAliases(), ',');
+ case null, default -> null;
+ };
+ }
+
+ @Override
+ public final Set getPropertyNames() {
+ return ImmutableSet.of(
+ DISPLAY_PROP_NAME_NAME, PROP_NAME_DISPLAY_NAME, PROP_NAME_DISPLAY_NAME_SHORT, PROP_NAME_DESCRIPTION
+ );
+ }
+
+ @Override
+ public String getDisplayName() {
+ return Objects.requireNonNullElseGet(
+ SimpleProperties.loadString(element.getConfiguration(), PROP_NAME_DISPLAY_NAME), element::getName
+ );
+ }
+
+ @Override
+ public String getShortDisplayName() {
+ return Objects.requireNonNullElseGet(
+ SimpleProperties.loadString(element.getConfiguration(), PROP_NAME_DISPLAY_NAME_SHORT),
+ this::getDisplayName
+ );
+ }
+
+ @Override
+ public String getDescription() {
+ String description = SimpleProperties.loadString(element.getConfiguration(), PROP_NAME_DESCRIPTION);
+ if (description != null && hasProperty(DESCRIPTION_ARG + "0")) {
+ List args = SimpleProperties.loadListSeparateProperties(
+ element.getConfiguration(), DESCRIPTION_ARG
+ );
+ return (new MessageFormat(description)).format(args.toArray());
+ }
+ return description;
+ }
+
+ @Nonnull
+ @Override
+ public SequencedSet getAliases() {
+ return SimpleProperties.loadSetSingleProperty(element.getConfiguration(), PROP_NAME_ALIASES);
+ }
+
+ }
+
+ public static class DisplayPropertiesAll extends AbstractGetPropertyLocator
+ implements DisplayProperties {
+
+ public static DisplayPropertiesAll createInstance(ConfiguredObject configured) {
+ return new DisplayPropertiesAll(
+ new LocalDisplayProperties(configured),
+ configured.getConfiguration()
+ );
+ }
+
+ public DisplayPropertiesAll(LocalDisplayProperties locals, Configuration all) {
+ super(locals, all);
+ }
+
+ @Override
+ public String getDisplayName() {
+ return locals.getDisplayName();
+ }
+
+ @Override
+ public String getShortDisplayName() {
+ return locals.getShortDisplayName();
+ }
+
+ @Override
+ public String getDescription() {
+ return locals.getDescription();
+ }
+
+ @Nonnull
+ @Override
+ public SequencedSet getAliases() {
+ return locals.getAliases();
+ }
+
+ }
+
+ public static final class ByIntegerProperty implements Comparator {
+
+ private final String propName;
+
+ private final int defaultValue;
+
+ private final boolean descending;
+
+ public ByIntegerProperty(String propName, int defaultValue, boolean descending) {
+ this.propName = propName;
+ this.defaultValue = defaultValue;
+ this.descending = descending;
+ }
+
+ @Override
+ public final int compare(ConfiguredObject c1, ConfiguredObject c2) {
+ int ret = compareByIntPropValue(c1, c2);
+ if (descending) {
+ return -ret;
+ }
+ return ret;
+ }
+
+ private final int compareByIntPropValue(ConfiguredObject c1, ConfiguredObject c2) {
+ return Integer.compare(
+ SimpleProperties.loadInt(c1.getConfiguration(), propName, defaultValue),
+ SimpleProperties.loadInt(c2.getConfiguration(), propName, defaultValue)
+ );
+ }
+
+ @Override
+ public final boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (other instanceof ByIntegerProperty otherComparator) {
+ if (propName == null) {
+ if (otherComparator.propName == null) {
+ return true;
+ }
+ return false;
+ }
+ if (otherComparator.propName == null) {
+ return false;
+ }
+ return propName.equals(otherComparator.propName);
+ }
+ return false;
+ }
+
+ @Override
+ public final int hashCode() {
+ return Objects.hash(propName);
+ }
+
+ }
+
+ /**
+ * Returns the elements in the map that are instances of the given test type. Since the test type can be an
+ * interface, therefore not extending the main type, the test type is not bound.
+ *
+ * @param
+ * narrows the allowable types for the {@link ConfiguredObject}s.
+ * @param name2element
+ * the name-to-Configurable mappings to be filtered.
+ * @param mainType
+ * the super-type for all the Configurables.
+ * @param testType
+ * the sub-type for the desired Configurables.
+ * @return the elements in the map that are instances of the given test type.
+ */
+ public static List getFilteredElementsByType(Map name2element, Class mainType, Class> testType) {
+ List ret = new ArrayList<>();
+ for (C e : name2element.values()) {
+ if (testType.isInstance(e)) {
+ ret.add(e);
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Returns the value of the named property from the given {@link Configuration} if one is defined, and otherwise
+ * throws a {@link ConfigurationException}.
+ *
+ * @param config
+ * the {@link Configuration} from which to load the property value.
+ * @param name
+ * the name of the property to retrieve.
+ * @return the value of the named property from the given {@link Configuration} if one is defined.
+ * @throws ConfigurationException
+ * if the named property is not defined in the given {@link Configuration}.
+ */
+ public static String getRequiredProperty(Configuration config, String name) {
+ String value = config.getProperty(name);
+ if (value == null) {
+ throw missingRequiredPropertyException(config, name);
+ }
+ return value;
+ }
+
+ /**
+ * Returns the value of the named property from the given {@link Configuration} if one is defined and has a
+ * non-blank value, and otherwise throws a {@link ConfigurationException}.
+ *
+ * @param config
+ * the {@link Configuration} from which to load the property value.
+ * @param name
+ * the name of the property to retrieve.
+ * @return the value of the named property from the given {@link Configuration} if one is defined and not blank.
+ * @throws ConfigurationException
+ * if the named property is not defined in the given {@link Configuration}, or if its value is blank.
+ */
+ public static String getRequiredNonBlankProperty(Configuration config, String name) {
+ String value = config.getProperty(name);
+ if (StringUtils.isBlank(value)) {
+ throw missingOrBlankRequiredPropertyException(config, name);
+ }
+ return value;
+ }
+
+ /**
+ * Returns an Enum object of the given type, whose name corresponds to the named property from the given
+ * {@link Configuration}, as long as the property is defined with a non-blank value, and a matching enum value can
+ * be identified. Otherwise, it throws a {@link ConfigurationException}.
+ *
+ * @param config
+ * the {@link Configuration} from which to load the property value.
+ * @param name
+ * the name of the property to retrieve.
+ * @param type
+ * the type of Enum to be loaded.
+ * @return an Enum object of the given type, whose name corresponds to the named property from the given
+ * {@link Configuration}, as long as the property is defined with a non-blank value, and a matching enum value
+ * can be identified
+ * @throws ConfigurationException
+ * if the named property is not defined in the given {@link Configuration}, or if its value is blank or does not
+ * correspond to any of the values of the given enum.
+ */
+ public static > E getRequiredEnumPropertyValue(Configuration config, String name, Class type) {
+ String rawValue = getRequiredNonBlankProperty(config, name);
+ E value = EnumUtil.enumFromString(type, rawValue, null);
+ if (value == null) {
+ throw noRecognizedEnumValueException(config, name, type, rawValue);
+ }
+ return value;
+ }
+
+ public static List getRequiredNonEmptyListProperty(Configuration config, String name) {
+ List list = SimpleProperties.loadListSingleProperty(config, name);
+ if (list.isEmpty()) {
+ throw missingOrBlankRequiredPropertyException(config, name);
+ }
+ return ImmutableList.copyOf(list);
+ }
+
+ public static ConfigurationException missingRequiredPropertyException(Configuration config, String name) {
+ return new ConfigurationException("The required property '" + config.fullyQualify(name) + "' is missing.");
+ }
+
+ public static ConfigurationException missingOrBlankRequiredPropertyException(Configuration config, String name) {
+ return new ConfigurationException("The required property '" +
+ config.fullyQualify(name) +
+ "' is missing or empty/whitespace.");
+ }
+
+ public static ConfigurationException noRecognizedEnumValueException(Configuration config, String name, Class extends Enum>> type, String rawValue) {
+ throw new ConfigurationException("No " +
+ type +
+ " value could be determined for the raw property " +
+ config.fullyQualify(name) +
+ "=" +
+ rawValue +
+ ".");
+ }
+
+ public static Comparator getIntegerPropertyComparator(String propName, int defaultValue, boolean descending) {
+ return new ByIntegerProperty(propName, defaultValue, descending);
+ }
+
+ public static Comparator getAscendingPriorityOrderComparator() {
+ return Comparator.comparingInt(ConfiguredObject::getPriority);
+ }
+
+ public static Comparator getDescendingPriorityOrderComparator() {
+ return getAscendingPriorityOrderComparator().reversed();
+ }
+
+ /**
+ * Returns a priority, with a higher value representing a higher priority, for this Configurable. The scale with
+ * respect to which a priority value is produced will generally be dependent upon what type of Configurable is
+ * involved, and is outside the specification of this method. Not all types of Configurables will include a built-in
+ * concept of priority. But for those that do, the value should generally indicate a relative importance level so
+ * that a family of Configurables of a particular type may be sorted (in ascending or descending order, as may be
+ * appropriate) by priority.
+ *
+ * @return a priority, with a higher value representing a higher priority, for this Configurable.
+ */
+ public default int getPriority() {
+ return SimpleProperties.loadInt(getConfiguration(), PROP_NAME_PRIORITY, DEFAULT_PRIORITY);
+ }
+
+ /**
+ * Retrieves the {@link Configuration} associated with this Configurable, providing access to its configuration
+ * properties.
+ *
+ *
+ * This implementation simply returns the Configuration object supplied to the constructor. Subclasses may override
+ * this method if necessary, but it must never return null.
+ *
+ * @return the {@link Configuration} associated with this Configurable.
+ */
+ @Nonnull
+ public Configuration getConfiguration();
+
+ /**
+ * Retrieves the name associated with this Configurable, which is usually the name of a configuration file (usually
+ * a .properties file) in which the Configuration corresponding to this Configurable is stored (without the file
+ * extension). Although Configurations can exist in different namespaces with the same name -- e.g., there can be a
+ * skin whose configuration is named "simple" and a digest skin whose configuration is also named "simple" -- the
+ * name retrieved here is not namespace qualified, as the namespace is usually known to the caller, and the name is
+ * merely retrieved to identify the Configurable's Configuration within whatever namespace it is known to inhabit.
+ *
+ * @return the name to be used in configuration files to identify this Configurable.
+ */
+ public default String getName() {
+ return getConfiguration().getName();
+ }
+
+ /**
+ * Retrieves the user-friendly name that can be displayed to identify this Configurable. It should be sufficiently
+ * specific that a user viewing the name somewhere in a user interface can unambiguously differentiate it from all
+ * the others in the same collection.
+ *
+ *
+ * This default implementation returns uses the value of the display_name= configuration property, defaulting to
+ * {@link #getName() the logical name of this Configurable}. Subclasses should override it as necessary.
+ *
+ * @return a user-friendly name that can be displayed to identify this Configurable.
+ */
+ public default String getDisplayName() {
+ return getDisplayProperties().getDisplayName();
+ }
+
+ /**
+ * Retrieves the short version of the user-friendly name that can be displayed to identify this Configurable. In
+ * contrast to {@link #getDisplayName()}, this name does not necessarily need to be completely unique across all
+ * Configurables in the collection to which this instance belongs. Some types of Configurables are only likely to
+ * appear as part of a set of other elements in which a shorter display name is enough to uniquely identify it to
+ * the viewing user. For types that only appear in, e.g., lists of all the elements in the collection, the short
+ * display name would generally be identical to the full display name.
+ *
+ *
+ * This default implementation uses the value of the display_name_short= configuration property, defaulting to
+ * {@link #getDisplayName()}. Subclasses should override it as necessary.
+ *
+ * @return the short version of a user-friendly name that can be displayed to identify this Configurable.
+ */
+ public default String getShortDisplayName() {
+ return getDisplayProperties().getShortDisplayName();
+ }
+
+ /**
+ * Returns a description of the Configurable, if one is available.
+ *
+ * @return a description of the Configurable, if one is available; null otherwise.
+ */
+ public default String getDescription() {
+ return getDisplayProperties().getDescription();
+ }
+
+ /**
+ * Represents the default ordering for Configurables in the same namespace, which is based upon a case-insensitive
+ * alphabetical ordering of their display names. In case of display names with the same order, the plain names are
+ * compared instead, which will not be equal unless either the Configurables really are the same, or the
+ * Configurables come from two different namespaces and happen to have the same Configuration name. (Note that
+ * Configurables from different namespaces are not generally -- nor should they be -- part of a single collection or
+ * grouping for the reason that there may be two completely different Configurables from different namespaces that
+ * have the same name, which would then be indistinguishable.)
+ *
+ * @param other
+ * the other {@link ConfiguredObject} to compare this to.
+ */
+ @Override
+ public default int compareTo(@Nullable ConfiguredObject other) {
+ if (other == null) {
+ return -1;
+ }
+ int testCompare = String.CASE_INSENSITIVE_ORDER.compare(getDisplayName(), other.getDisplayName());
+ if (testCompare != 0) {
+ return testCompare;
+ }
+ testCompare = String.CASE_INSENSITIVE_ORDER.compare(getName(), other.getName());
+ if (testCompare != 0) {
+ return testCompare;
+ }
+ return String.CASE_INSENSITIVE_ORDER.compare(getClass().getName(), other.getClass().getName());
+ }
+
+ public default String defaultToString() {
+ return getClass().getName() + ":" + getName();
+ }
+
+ /**
+ * Returns a {@link DisplayProperties} that reflects display properties rather than raw properties.
+ *
+ *
+ * This default implementation returns a GetProperty which handles "name", "display_name", "description" and
+ * "configuration_file_path" by deferring to {@link #getName()}, {@link #getDisplayName()},
+ * {@link #getDescription()}, and {@link Configuration#getPath()}, respectively; and otherwise, which defers to the
+ * {@link Configuration} provided by {@link #getConfiguration()}. Subclasses that offer more display properties
+ * should override this method, most likely with an implementation that uses this implementation for its default
+ * values (see {@link GetProperty#withDefaults(GetProperty)}).
+ *
+ * @return a {@link DisplayProperties} that reflects display properties rather than raw properties.
+ */
+ public default DisplayProperties getDisplayProperties() {
+ return new DisplayPropertiesAll(new LocalDisplayProperties(this), getConfiguration());
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/config/DynamicForwardingConfiguration.java b/src/main/java/com/tractionsoftware/commons/config/DynamicForwardingConfiguration.java
new file mode 100644
index 0000000..6fc088a
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/config/DynamicForwardingConfiguration.java
@@ -0,0 +1,50 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.config;
+
+import jakarta.annotation.Nonnull;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+public class DynamicForwardingConfiguration extends ForwardingConfiguration {
+
+ protected final Supplier extends Configuration> provider;
+
+ public DynamicForwardingConfiguration(Supplier extends Configuration> provider) {
+ this.provider = provider;
+ }
+
+ @Nonnull
+ @Override
+ public String toString() {
+ return "Configuration dyn fwd {" + provider + " -> " + provider.get() + "}";
+ }
+
+ @Nonnull
+ @Override
+ protected final Configuration delegate() {
+ Configuration delegate = provider.get();
+ Objects.requireNonNull(delegate, "dynamic delegate Configuration");
+ return delegate;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/config/ForwardingConfiguration.java b/src/main/java/com/tractionsoftware/commons/config/ForwardingConfiguration.java
new file mode 100644
index 0000000..3e1dd8a
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/config/ForwardingConfiguration.java
@@ -0,0 +1,129 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.config;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import com.tractionsoftware.commons.properties.ForwardingGetProperty;
+import com.tractionsoftware.commons.properties.GetProperty;
+import jakarta.annotation.Nonnull;
+
+/**
+ * A base {@link Configuration} implementation for implementing the decorator pattern. The {@link #delegate()} method
+ * returns the Configuration being decorated.
+ *
+ *
+ * Methods that offer basic Configuration functionality, such as {@link #getProperty(String)},
+ * {@link #getPropertyNames()} and {@link #getName()}, defer directly the corresponding method of the backing
+ * Configuration.
+ *
+ *
+ * Methods that return Configuration instances are guaranteed to never directly expose either the delegate or any object
+ * obtained directly from the delegate. They are also guaranteed not to retrieve and use the delegate instance until one
+ * of the basic methods is invoked. Each of these guarantees imply that such methods cannot be implemented via direct
+ * deference to the delegate. The resulting implementations fall into two categories:
+ *
+ *
+ * methods that involve some sort of decoration of a Configuration
+ * instance, such as {@link #withDefaults(GetProperty)} and
+ * {@link #getNamespace(String)}. ForwardingConfiguration does not
+ * override these at all, instead deferring to the default
+ * implementations from Configuration, which already use wrappers, and
+ * by definition do not have access to the delegate.
+ *
+ * methods that must ultimately defer to the delegate. There are
+ * exactly two such methods: {@link #getDefaults()} and
+ * {@link #getLocals()}. These are implemented using
+ * {@link #wrap(Supplier)} to wrap a Supplier that defers to the
+ * corresponding method of the delegate to dynamically supply its own
+ * delegate.
+ *
+ * @author Dave Shepperton
+ */
+public abstract class ForwardingConfiguration extends ForwardingGetProperty implements Configuration {
+
+ /**
+ * Creates a {@link Configuration} that defers to the given statically specified instance. This is perfect for
+ * wrapping an object to ensure that only the Configuration functionality is exposed.
+ *
+ * @param props
+ * the {@link Configuration} to which the Configuration will defer.
+ * @return a {@link Configuration} that defers to the given statically specified instance.
+ */
+ public static Configuration wrap(Configuration props) {
+ return new StaticForwardingConfiguration(props);
+ }
+
+ /**
+ * Creates a {@link Configuration} that defers to the Configuration provided by the given {@link Supplier}. This is
+ * perfect for wrapping an object to ensure that only the Configuration functionality is exposed.
+ *
+ * @param provider
+ * the {@link Supplier} for the {@link Configuration} to which the returned Configuration will defer.
+ * @return a {@link Configuration} that defers to the Configuration provided by the given {@link Supplier}.
+ */
+ public static final Configuration wrap(Supplier extends Configuration> provider) {
+ return new DynamicForwardingConfiguration(provider);
+ }
+
+ public static final Configuration withNewName(Configuration config, final String newName) {
+ if (config == null) {
+ return null;
+ }
+ if (Objects.equals(config.getName(), newName)) {
+ return config;
+ }
+ return new StaticForwardingConfiguration(config) {
+ @Override
+ public final String getName() {
+ return newName;
+ }
+ };
+ }
+
+ /**
+ * Returns the {@link Configuration} that should be used for the implementation of any Configuration methods.
+ *
+ * @return the {@link Configuration} that should be used for the implementation of any Configuration methods.
+ */
+ @Nonnull
+ @Override
+ protected abstract Configuration delegate();
+
+ @Nonnull
+ @Override
+ public String toString() {
+ return "Configuration: fwd {" + delegate() + "}";
+ }
+
+ @Override
+ public String getPath() {
+ return delegate().getPath();
+ }
+
+ @Override
+ public Map getTemplateSettings() {
+ return delegate().getTemplateSettings();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/config/NamedConfiguration.java b/src/main/java/com/tractionsoftware/commons/config/NamedConfiguration.java
deleted file mode 100644
index 1e89cc9..0000000
--- a/src/main/java/com/tractionsoftware/commons/config/NamedConfiguration.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- *
- * Copyright 1996-2025 Traction Software, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-
-// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
-
-package com.tractionsoftware.commons.config;
-
-import com.tractionsoftware.commons.properties.ReadOnlyPropertyMap;
-
-/**
- * An interface representing an object type along with an associated set of configuration properties.
- *
- *
- * The properties may determine the specific type itself, and may modify various behaviors of the instances to which
- * they are applied.
- *
- * @author Dave Shepperton
- */
-public interface NamedConfiguration {
-
- /**
- * Returns the logical name for this configuration.
- *
- * @return the logical name for this configuration.
- */
- public String getName();
-
- /**
- * Returns a display name for this configuration.
- *
- *
- * This may be the same as {@link #getName()}; or it may come from some configuration property; or it may be
- * specified some other way. But this method should never return null.
- *
- * @return a display name for this configuration.
- */
- public String getDisplayName();
-
- /**
- * Returns an {@link ReadOnlyPropertyMap} providing raw access to this configuration's properties.
- *
- * @return an {@link ReadOnlyPropertyMap} providing raw access to this configuration's properties.
- */
- public ReadOnlyPropertyMap getProperties();
-
-}
diff --git a/src/main/java/com/tractionsoftware/commons/config/PropertyNameMappingConfiguration.java b/src/main/java/com/tractionsoftware/commons/config/PropertyNameMappingConfiguration.java
new file mode 100644
index 0000000..070e80f
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/config/PropertyNameMappingConfiguration.java
@@ -0,0 +1,100 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.config;
+
+import com.tractionsoftware.commons.properties.AbstractPropertyNameMappingGetProperty;
+import com.tractionsoftware.commons.properties.PropertyNameMapper;
+import com.tractionsoftware.commons.properties.SimplePropertyNameMapper;
+import jakarta.annotation.Nonnull;
+
+import java.util.Map;
+import java.util.Objects;
+
+public final class PropertyNameMappingConfiguration extends AbstractPropertyNameMappingGetProperty
+ implements Configuration {
+
+ public static final Configuration wrapInNamespace(Configuration props, String space) {
+ return applyPropertyNameMapper(props, SimplePropertyNameMapper.getNamespaceInstanceWithDefaultSeparator(space));
+ }
+
+ public static final Configuration wrapInNamespace(Configuration props, String space, char separator) {
+ return applyPropertyNameMapper(
+ props,
+ SimplePropertyNameMapper.getNamespaceInstanceWithSeparator(space, separator)
+ );
+ }
+
+ public static final Configuration wrapInPrefix(Configuration props, String space) {
+ return applyPropertyNameMapper(props, SimplePropertyNameMapper.getPrefixInstanceWithDefaultSeparator(space));
+ }
+
+ public static final Configuration wrapInPrefix(Configuration props, String space, char separator) {
+ return applyPropertyNameMapper(
+ props,
+ SimplePropertyNameMapper.getPrefixInstanceWithSeparator(space, separator)
+ );
+ }
+
+ public static final Configuration applyPropertyNameMapper(Configuration config, PropertyNameMapper nameMapper) {
+ if (config == null || nameMapper == null) {
+ return config;
+ }
+ if (config instanceof PropertyNameMappingConfiguration nameMappedProps) {
+ if (nameMappedProps.nameMapper.isInverseOf(nameMapper)) {
+ return nameMappedProps.props;
+ }
+ return new PropertyNameMappingConfiguration(
+ nameMappedProps.props,
+ nameMappedProps.nameMapper.compose(nameMapper)
+ );
+ }
+ return new PropertyNameMappingConfiguration(config, nameMapper);
+ }
+
+ public PropertyNameMappingConfiguration(Configuration config, PropertyNameMapper nameMapper) {
+ super(config, nameMapper);
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return "Configuration: " + super.toString();
+ }
+
+ @Override
+ public final String getPath() {
+ return props.getPath();
+ }
+
+ @Override
+ public final Map getTemplateSettings() {
+ return props.getTemplateSettings();
+ }
+
+ @Override
+ public final Configuration withNewName(String newName) {
+ if (Objects.equals(getName(), newName)) {
+ return this;
+ }
+ return applyPropertyNameMapper(delegate().withNewName(newName), nameMapper);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/config/StaticForwardingConfiguration.java b/src/main/java/com/tractionsoftware/commons/config/StaticForwardingConfiguration.java
new file mode 100644
index 0000000..95c1ade
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/config/StaticForwardingConfiguration.java
@@ -0,0 +1,51 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+
+package com.tractionsoftware.commons.config;
+
+import jakarta.annotation.Nonnull;
+
+public class StaticForwardingConfiguration extends ForwardingConfiguration {
+
+ protected final Configuration config;
+
+ public StaticForwardingConfiguration(Configuration config) {
+ this.config = config;
+ }
+
+ @Nonnull
+ @Override
+ public String toString() {
+ return "Configuration: stat fwd {" + config + "}";
+ }
+
+ @Nonnull
+ @Override
+ protected final Configuration delegate() {
+ return config;
+ }
+
+ @Override
+ protected final boolean isStaticallySpecifiedDelegate() {
+ return true;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/html/HtmlCleanerAdapter.java b/src/main/java/com/tractionsoftware/commons/html/HtmlCleanerAdapter.java
new file mode 100644
index 0000000..862738f
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/html/HtmlCleanerAdapter.java
@@ -0,0 +1,693 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.html;
+
+import com.tractionsoftware.commons.config.ConfigurationException;
+import com.tractionsoftware.commons.io.IOUtil;
+import com.tractionsoftware.commons.lang.*;
+import com.tractionsoftware.commons.properties.GetProperty;
+import com.tractionsoftware.commons.properties.SimpleProperties;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.text.StringEscapeUtils;
+import org.htmlcleaner.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.function.BiFunction;
+
+/**
+ * A wrapper class to bridge the HtmlCleaner API for generating syntactically valid HTML or XML documents from HTML.
+ *
+ *
+ * Use {@link #getInstance(GetProperty)} method to retrieve an instance with a given set of properties.
+ *
+ * @property CleanerMode=[Document]/Fragment
+ *
+ * Indicates what mode this instance should use. If the mode is "Document", the input should be a complete document,
+ * and the output will be a complete and valid document. If the mode is "Fragment", the input should be an HTML
+ * fragment only -- something that would normally appear inside of a BODY tag -- and the output will be the cleaned
+ * version of that same markup.
+ * @property SerializationMode=SIMPLE_HTML|SIMPLE_XML|PRETTY_XML|COMPACT_XML
+ * |BROWSER_COMPACT_XML|TEXT_ONLY|TEXT_ONLY_WITH_HTML_ENTITIES|CUSTOM
+ *
+ * Indicates the manner in which the output will be serialized.
+ * @property custom_serializer_class=
+ *
+ * If the SerializationMode= property is set to "CUSTOM", this property must be set to the specification of a Class
+ * to be used as the custom {@link Serializer} for any operations performed by the instance being configured.
+ *
+ *
+ * The class must extend Serializer; if it has a constructor that accepts a {@link CleanerProperties} and a
+ * {@link GetProperty}, that must be visible; or if it does not have such a constructor, it must have a visible
+ * constructor that accepts just a CleanerProperties. If any of these conditions is not met, the configuration will
+ * be considered invalid, and a {@link ConfigurationException} will be raised.
+ * @property serializer_custom_normalizeSpaces=[true]|false
+ *
+ * If the SerializationMode property is set to "TEXT_ONLY" or "TEXT_ONLY_WITH_HTML_ENTITIES", this optional property
+ * may be used to indicate whether space-like characters should be "normalized" via
+ * {@link StringUtil#normalizeAlternativeWhitespace(String)}. Since this is usually a desirable behavior for
+ * HTML-to-text transformation, the default value of this property is "true". This behavior is applied before the
+ * "collapse" behavior described below, so that the spaces that are normalized can also be collapsed.
+ * @property serializer_custom_collapseWhitespace=true|false
+ *
+ * If the SerializationMode property is set to "TEXT_ONLY" or "TEXT_ONLY_WITH_HTML_ENTITIES", this optional property
+ * may be used to indicate whether whitespace should be "collapsed" via
+ * {@link StringUtil#collapseAndNormalizeWhitespace(String, boolean)}, which transforms runs of one or more
+ * whitespace characters to single ordinary spaces. The default value of this property is "false" for the
+ * "TEXT_ONLY" mode and "true" for the "TEXT_ONLY_WITH_HTML_ENTITIES" mode. This is so that pure text-only
+ * renderings will, by default, be left as true to their original form as possible, but text-only renderings
+ * intended for HTML will not contain what is usually redundant whitespace, particularly after entities are
+ * collapsed and normalized (see serializer_custom_normalizeSpaces=true|false above).
+ * @property serializer_custom_*
+ *
+ * If the SerializationMode property is set to "CUSTOM", any properties in the "serializer_custom" namespace will be
+ * passed as {@link GetProperty} to the {@link Serializer}'s constructor, if there is a visible constructor that
+ * accepts both a {@link CleanerProperties} and a GetProperty.
+ * @property AdvancedXmlEscape=[true]/false
+ * @property UseCdataForScriptAndStyle=[true]/false
+ * @property TranslateSpecialEntities=[true]/false
+ * @property RecognizeUnicodeChars=[true]/false
+ * @property OmitUnknownTags=true/[false]
+ * @property TreatUnknownTagsAsContent=true/[false]
+ * @property OmitDeprecatedTags=true/[false]
+ * @property TreatDeprecatedTagsAsContent=true/[false]
+ * @property OmitComments=true/[false]
+ * @property OmitXmlDeclaration=true/[false]
+ * @property OmitDoctypeDeclaration=[true]/false
+ * @property OmitHtmlEnvelope=[true]/false
+ * @property UseEmptyElementTags=[true]/false
+ * @property AllowMultiWordAttributes=[true]/false
+ * @property AllowHtmlInsideAttributes=[true]/false
+ * @property IgnoreQuestAndExclam=[true]/false
+ * @property NamespacesAware=[true]/false
+ * @property KeepHeadWhitespace=[true]/false
+ * @property AddNewlineToHeadAndBody=[true]/false
+ * @property HyphenReplacementInComment=
+ * @property PruneTags=
+ *
+ * A list of tags to be pruned from the output.
+ * @property AllowTags=
+ *
+ * A list of tags to be allowed in the output.
+ * @property BooleanAttributeValues=
+ * @property DeserializeEntities=true|false
+ *
+ * @author Dave Shepperton
+ */
+public final class HtmlCleanerAdapter {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(HtmlCleanerAdapter.class);
+
+ private static final String PROP_NAMESPACE_SERIALIZER_CUSTOM = "serializer_custom";
+
+ private static final String PROP_NAME_SERIALIZER_NORMALIZE_SPACES = "normalizeSpaces";
+
+ private static final String PROP_NAME_SERIALIZER_COLLAPSE_WHITESPACE = "collapseWhitespace";
+
+ private static final String FRAGMENT_PREFIX = "
";
+
+ private static final String FRAGMENT_SUFFIX = "
";
+
+ public static enum SerializationMode {
+
+ SIMPLE_HTML((props, _) -> new SimpleHtmlSerializer(props)),
+
+ SIMPLE_XML((props, _) -> new SimpleXmlSerializer(props)),
+
+ PRETTY_XML((props, _) -> new PrettyXmlSerializer(props)),
+
+ COMPACT_XML((props, _) -> new CompactXmlSerializer(props)),
+
+ BROWSER_COMPACT_XML((props, _) -> new BrowserCompactXmlSerializer(props)),
+
+ TEXT_ONLY((props, otherProperties) -> new TextOnlySerializer(
+ props, getTextOnlySerializationParametersForPureText(otherProperties))
+ ),
+
+ TEXT_ONLY_WITH_HTML_ENTITIES(
+ (props, otherProperties) ->
+ new TextOnlySerializer(props, getTextOnlySerializationParametersForHtml(otherProperties))
+ ),
+
+ CUSTOM((props, otherProperties) -> {
+ String customTypeName = SimpleProperties.loadString(otherProperties, "class");
+ if (StringUtils.isBlank(customTypeName)) {
+ throw new ConfigurationException("Missing class= property for custom Serializer.");
+ }
+ try {
+ Class extends Serializer> customType = Class.forName(customTypeName).asSubclass(Serializer.class);
+ try {
+ return customType.getConstructor(CleanerProperties.class, GetProperty.class)
+ .newInstance(props, otherProperties);
+ }
+ catch (NoSuchMethodException ignore) {
+ }
+ return customType.getConstructor(CleanerProperties.class).newInstance(props);
+ }
+ catch (Exception e) {
+ throw new ConfigurationException(
+ "There was a problem loading or instantiating the class '" +
+ customTypeName +
+ "'", e
+ );
+ }
+ });
+
+ private final BiFunction serializerCreator;
+
+ private SerializationMode(BiFunction serializerCreator) {
+ this.serializerCreator = serializerCreator;
+ }
+
+ public static final SerializationMode get(String str, SerializationMode defaultMode) {
+ return EnumUtil.enumFromString(SerializationMode.class, str, defaultMode);
+ }
+
+ public final Serializer getSerializer(CleanerProperties props, GetProperty otherProperties) {
+ return serializerCreator.apply(props, otherProperties);
+ }
+
+ }
+
+ public static enum CleanMode {
+
+ DOCUMENT,
+
+ FRAGMENT;
+
+ public static final CleanMode get(String str, CleanMode defaultMode) {
+ return EnumUtil.enumFromString(CleanMode.class, str, defaultMode);
+ }
+
+ }
+
+ private static enum CleanerPropertySetter {
+
+ AdvancedXmlEscape(),
+ UseCdataForScriptAndStyle(),
+ TranslateSpecialEntities(),
+ RecognizeUnicodeChars(),
+ OmitUnknownTags(),
+ TreatUnknownTagsAsContent(),
+ OmitDeprecatedTags(),
+ TreatDeprecatedTagsAsContent(),
+ OmitComments(),
+ OmitXmlDeclaration(),
+ OmitDoctypeDeclaration(),
+ OmitHtmlEnvelope(),
+ UseEmptyElementTags(),
+ AllowMultiWordAttributes(),
+ AllowHtmlInsideAttributes(),
+ IgnoreQuestAndExclam(),
+ NamespacesAware(),
+ KeepHeadWhitespace(),
+ AddNewlineToHeadAndBody(),
+ HyphenReplacementInComment(),
+ PruneTags(),
+ AllowTags(),
+ BooleanAttributeValues(),
+ DeserializeEntities();
+
+ public final void apply(CleanerProperties props, String value) {
+ switch (this) {
+ case AdvancedXmlEscape -> props.setAdvancedXmlEscape(NativeTypeConversion.stringToBoolean(value, true));
+ case UseCdataForScriptAndStyle ->
+ props.setUseCdataForScriptAndStyle(NativeTypeConversion.stringToBoolean(value, true));
+ case TranslateSpecialEntities ->
+ props.setTranslateSpecialEntities(NativeTypeConversion.stringToBoolean(value, true));
+ case RecognizeUnicodeChars ->
+ props.setRecognizeUnicodeChars(NativeTypeConversion.stringToBoolean(value, true));
+ case OmitUnknownTags -> props.setOmitUnknownTags(NativeTypeConversion.stringToBoolean(value, false));
+ case TreatUnknownTagsAsContent ->
+ props.setTreatUnknownTagsAsContent(NativeTypeConversion.stringToBoolean(value, false));
+ case OmitDeprecatedTags -> props.setOmitDeprecatedTags(NativeTypeConversion.stringToBoolean(value, false));
+ case TreatDeprecatedTagsAsContent ->
+ props.setTreatDeprecatedTagsAsContent(NativeTypeConversion.stringToBoolean(value, false));
+ case OmitComments -> props.setOmitComments(NativeTypeConversion.stringToBoolean(value, false));
+ case OmitXmlDeclaration -> props.setOmitXmlDeclaration(NativeTypeConversion.stringToBoolean(value, false));
+ case OmitDoctypeDeclaration ->
+ props.setOmitDoctypeDeclaration(NativeTypeConversion.stringToBoolean(value, true));
+ case OmitHtmlEnvelope -> props.setOmitHtmlEnvelope(NativeTypeConversion.stringToBoolean(value, true));
+ case UseEmptyElementTags -> props.setUseEmptyElementTags(NativeTypeConversion.stringToBoolean(value, true));
+ case AllowMultiWordAttributes ->
+ props.setAllowMultiWordAttributes(NativeTypeConversion.stringToBoolean(value, true));
+ case AllowHtmlInsideAttributes ->
+ props.setAllowHtmlInsideAttributes(NativeTypeConversion.stringToBoolean(value, true));
+ case IgnoreQuestAndExclam ->
+ props.setIgnoreQuestAndExclam(NativeTypeConversion.stringToBoolean(value, true));
+ case NamespacesAware -> props.setNamespacesAware(NativeTypeConversion.stringToBoolean(value, true));
+ case KeepHeadWhitespace ->
+ props.setKeepWhitespaceAndCommentsInHead(NativeTypeConversion.stringToBoolean(value, true));
+ case AddNewlineToHeadAndBody ->
+ props.setAddNewlineToHeadAndBody(NativeTypeConversion.stringToBoolean(value, true));
+ case HyphenReplacementInComment -> props.setHyphenReplacementInComment(value);
+ case PruneTags -> props.setPruneTags(value);
+ case AllowTags -> props.setAllowTags(value);
+ case BooleanAttributeValues -> props.setBooleanAttributeValues(value);
+ case DeserializeEntities -> props.setDeserializeEntities(NativeTypeConversion.stringToBoolean(value, true));
+ default -> {
+ }
+ }
+ }
+
+ }
+
+ private static final HtmlCleanerAdapter HTML2XHTML;
+
+ static {
+
+ CleanerProperties props = new CleanerProperties();
+ props.setTranslateSpecialEntities(true);
+ props.setTransResCharsToNCR(true);
+ props.setTranslateSpecialEntities(true);
+ props.setOmitComments(true);
+ props.setAdvancedXmlEscape(true);
+ props.setOmitDoctypeDeclaration(false);
+
+ try {
+ HTML2XHTML = createInstance(SerializationMode.SIMPLE_XML, props, CleanMode.DOCUMENT, null);
+ }
+ catch (ConfigurationException e) {
+ System.err.println("Failed to set up HTML-to-XHTML transformer:");
+ e.printStackTrace(System.err);
+ System.err.flush();
+ // We can't really run without this.
+ throw new RuntimeException(e);
+ }
+
+ }
+
+ private static enum TextOnlyTagSerializationMode {
+ NONE,
+ LINE_BREAK,
+ CHILDREN,
+ CHILDREN_WITH_SPACE_AFTER,
+ CHILDREN_WITH_SPACES_SURROUNDING,
+ IMG_ALT
+ }
+
+ private static final class TextOnlySerializationParameters {
+
+ /**
+ * Indicates whether this "text-only" rendering actually has to be HTML-safe. If so, tags will still be omitted,
+ * but runs of text that may contain tag delimiters will be entity-encoded Conversely, if the rendering is not
+ * supposed to be HTML-safe (i.e., must actually be text-only), we must decode any entities that come from the
+ * HTML.
+ */
+ private final boolean outputHtml;
+
+ /**
+ * Indicates whether {@link StringUtil#normalizeAlternativeWhitespace(String)} will be applied to the text
+ * before it is rendered in the output, replacing any occurrences of certain "alternative" space characters with
+ * normal spaces.
+ */
+ private final boolean normalizeSpaces;
+
+ /**
+ * Indicates whether {@link StringUtil#collapseAndNormalizeWhitespace(String, boolean)} will be applied to the
+ * text before it is rendered in the output, replacing any run of one or more whitespace characters with a
+ * single normal space.
+ */
+ private final boolean collapseWhitespace;
+
+ private boolean wasPadded = false;
+
+ private TextOnlySerializationParameters(boolean outputHtml, boolean normalizeSpaces, boolean collapseWhitespace) {
+ this.outputHtml = outputHtml;
+ this.normalizeSpaces = normalizeSpaces;
+ this.collapseWhitespace = collapseWhitespace;
+ }
+
+ public final boolean shouldOutputHtml() {
+ return outputHtml;
+ }
+
+ public final boolean shouldNormalizeSpaces() {
+ return normalizeSpaces;
+ }
+
+ public final boolean shouldCollapseWhitespace() {
+ return collapseWhitespace;
+ }
+
+ }
+
+ private static final TextOnlySerializationParameters getTextOnlySerializationParametersForPureText(GetProperty otherProperties) {
+ return new TextOnlySerializationParameters(
+ false,
+ SimpleProperties.loadBoolean(otherProperties, PROP_NAME_SERIALIZER_NORMALIZE_SPACES, true),
+ SimpleProperties.loadBoolean(otherProperties, PROP_NAME_SERIALIZER_COLLAPSE_WHITESPACE, false)
+ );
+ }
+
+ private static final TextOnlySerializationParameters getTextOnlySerializationParametersForHtml(GetProperty otherProperties) {
+ return new TextOnlySerializationParameters(
+ true,
+ SimpleProperties.loadBoolean(otherProperties, PROP_NAME_SERIALIZER_NORMALIZE_SPACES, true),
+ SimpleProperties.loadBoolean(otherProperties, PROP_NAME_SERIALIZER_COLLAPSE_WHITESPACE, true)
+ );
+ }
+
+ private static final class TextOnlySerializer extends Serializer {
+
+ private final TextOnlySerializationParameters params;
+
+ private TextOnlySerializer(CleanerProperties props, TextOnlySerializationParameters params) {
+ super(props);
+ this.params = params;
+ }
+
+ @Override
+ protected final void serialize(TagNode tagNode, Writer writer) throws IOException {
+
+ List extends BaseToken> tagChildren = tagNode.getAllChildren();
+ if (isMinimizedTagSyntax(tagNode)) {
+ return;
+ }
+
+ for (BaseToken item : tagChildren) {
+
+ if (item instanceof ContentNode contentNode) {
+ serializeContent(writer, contentNode.getContent());
+ continue;
+ }
+
+ if (!(item instanceof TagNode childTag)) {
+ continue;
+ }
+
+ TextOnlyTagSerializationMode tagMode = getTagMode(childTag);
+
+ switch (tagMode) {
+ case NONE -> {
+ }
+ case LINE_BREAK -> writer.write('\n');
+ case CHILDREN -> serialize(childTag, writer);
+ case CHILDREN_WITH_SPACE_AFTER -> {
+ serialize(childTag, writer);
+ writer.write(' ');
+ params.wasPadded = true;
+ }
+ case CHILDREN_WITH_SPACES_SURROUNDING -> {
+ if (params.wasPadded) {
+ writer.write(' ');
+ }
+ serialize(childTag, writer);
+ writer.write(' ');
+ params.wasPadded = true;
+ }
+ case IMG_ALT -> {
+ String alt = getAltText(item);
+ if (StringUtils.isNotBlank(alt)) {
+ writer.write(alt);
+ params.wasPadded = false;
+ }
+ }
+ }
+
+ }
+
+ }
+
+ private final TextOnlyTagSerializationMode getTagMode(TagNode tag) {
+
+ switch (StringUtils.defaultString(tag.getName()).toLowerCase()) {
+ case "style", "script", "input", "select" -> {
+ return TextOnlyTagSerializationMode.NONE;
+ }
+ case "br" -> {
+ if (params.shouldCollapseWhitespace()) {
+ return TextOnlyTagSerializationMode.CHILDREN_WITH_SPACE_AFTER;
+ }
+ if (params.shouldOutputHtml()) {
+ return TextOnlyTagSerializationMode.CHILDREN;
+ }
+ return TextOnlyTagSerializationMode.LINE_BREAK;
+ }
+ case "title" -> {
+ if (params.shouldCollapseWhitespace()) {
+ return TextOnlyTagSerializationMode.CHILDREN_WITH_SPACE_AFTER;
+ }
+ return TextOnlyTagSerializationMode.CHILDREN;
+ }
+ case "li", "td", "dt", "dd", "p", "div", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "address",
+ "center", "dir", "menu" -> {
+ if (params.shouldCollapseWhitespace()) {
+ return TextOnlyTagSerializationMode.CHILDREN_WITH_SPACES_SURROUNDING;
+ }
+ return TextOnlyTagSerializationMode.CHILDREN_WITH_SPACE_AFTER;
+ }
+ case "img" -> {
+ return TextOnlyTagSerializationMode.IMG_ALT;
+ }
+ default -> {
+ return TextOnlyTagSerializationMode.CHILDREN;
+ }
+ }
+
+ }
+
+ private final String getAltText(BaseToken item) {
+ if (item instanceof TagNode tag && "img".equals(tag.getName())) {
+ return tag.getAttributeByName("alt");
+ }
+ return null;
+ }
+
+ private final boolean isMinimizedTagSyntax(TagNode tagNode) {
+ TagInfo tagInfo = props.getTagInfoProvider().getTagInfo(tagNode.getName());
+ return tagInfo != null && !tagNode.hasChildren() && tagInfo.isEmptyTag();
+ }
+
+ private final void serializeContent(Writer writer, String content) throws IOException {
+
+ boolean normalize = params.shouldNormalizeSpaces();
+
+ if (params.shouldCollapseWhitespace()) {
+ content = StringUtil.collapseAndNormalizeWhitespace(content, normalize);
+ }
+ else if (normalize) {
+ content = StringUtil.normalizeAlternativeWhitespace(content);
+ }
+
+ if (content.isEmpty()) {
+ return;
+ }
+
+ if (params.shouldOutputHtml()) {
+ // If this is for an HTML document, we must assume
+ // that text node values that contain tag delimiters
+ // will have to be entity-encoded to be HTML-safe.
+ if (StringUtils.containsAny(content, '<', '>')) {
+ content = HtmlUtil.getLiteralText(content);
+ }
+ }
+ else {
+ // If this is not for HTML, we assume it's really
+ // supposed to be 100% text-only, and that HTML
+ // entities should be decoded.
+ content = StringEscapeUtils.unescapeHtml4(content);
+ }
+
+ writer.write(content);
+
+ }
+
+ }
+
+ /**
+ * Returns an HtmlCleanerAdapter instance that creates a valid simple XML serialization of an entire HTML document,
+ * including an XML declaration and an HTML DOCTYPE declaration, if one is present in the original.
+ *
+ * @return an HtmlCleanerAdapter instance that creates a valid simple XML serialization of an entire HTML document,
+ * including an XML declaration and an HTML DOCTYPE declaration, if one is present in the original.
+ */
+ public static final HtmlCleanerAdapter cleanHtml() {
+ return HTML2XHTML;
+ }
+
+ private final HtmlCleaner cleaner;
+
+ private final Serializer serializer;
+
+ private final CleanMode cleanMode;
+
+ /**
+ * Creates and returns a new HtmlCleanerAdapter instance with the given set of properties.
+ *
+ * @param props
+ * the configuration properties to use to configure this instance.
+ * @return the HtmlCleanerAdapter instance created.
+ * @throws ConfigurationException
+ * if one is raised while attempting to create or set up the {@link HtmlCleaner} instance or the
+ * {@link Serializer} to be used for
+ */
+ public static final HtmlCleanerAdapter getInstance(GetProperty props) {
+ CleanerProperties cleanerProps = new CleanerProperties();
+ for (CleanerPropertySetter setter : EnumSet.allOf(CleanerPropertySetter.class)) {
+ String value = props.getProperty(setter.name());
+ if (value != null) {
+ setter.apply(cleanerProps, value);
+ }
+ }
+ cleanerProps.setCharset(StandardCharsets.UTF_8.name());
+ SerializationMode serializationMode = SerializationMode.get(
+ props.getProperty("SerializationMode"), SerializationMode.SIMPLE_XML
+ );
+ CleanMode cleanMode = CleanMode.get(props.getProperty("CleanMode"), CleanMode.DOCUMENT);
+ return createInstance(serializationMode, cleanerProps, cleanMode, getOtherProperties(props));
+ }
+
+ private static final HtmlCleanerAdapter createInstance(SerializationMode serializationMode, CleanerProperties props, CleanMode cleanMode, GetProperty otherProperties)
+ throws ConfigurationException {
+ HtmlCleaner cleaner = new HtmlCleaner(props);
+ return new HtmlCleanerAdapter(
+ cleaner,
+ serializationMode.getSerializer(cleaner.getProperties(), otherProperties),
+ cleanMode
+ );
+ }
+
+ public static final HtmlCleanerAdapter createInstance(HtmlCleaner cleaner, Serializer serializer, CleanMode cleanMode) {
+ Objects.requireNonNull(cleaner, "HtmlCleaner");
+ Objects.requireNonNull(serializer, "Serializer");
+ Objects.requireNonNull(cleanMode, "CleanMode");
+ return new HtmlCleanerAdapter(cleaner, serializer, cleanMode);
+ }
+
+ private static final GetProperty getOtherProperties(GetProperty baseConfig) {
+ Map otherProperties = new HashMap<>();
+ baseConfig.getNamespace(PROP_NAMESPACE_SERIALIZER_CUSTOM).copyTo(otherProperties);
+ if (otherProperties.isEmpty()) {
+ return null;
+ }
+ return SimpleProperties.asGetProperty(otherProperties);
+ }
+
+ private HtmlCleanerAdapter(HtmlCleaner cleaner, Serializer serializer, CleanMode cleanMode) {
+ this.cleaner = cleaner;
+ this.serializer = serializer;
+ this.cleanMode = cleanMode;
+ }
+
+ private final TagNode getTagNode(InputStream input) throws IOException {
+ return cleaner.clean(input, StandardCharsets.UTF_8.name());
+ }
+
+ private final TagNode getTagNodeFragment(InputStream input) throws IOException {
+ return getTagNode(IOUtil.getSequenceInputStream(
+ new ByteArrayInputStream(FRAGMENT_PREFIX.getBytes(
+ StandardCharsets.UTF_8)),
+ input,
+ new ByteArrayInputStream(FRAGMENT_SUFFIX.getBytes(
+ StandardCharsets.UTF_8))
+ ));
+ }
+
+ /**
+ * Cleans the HTML document represented by the InputStream, writing the new document to the given OutputStream. Both
+ * reading and writing use the UTF-8 character set.
+ *
+ * @param input
+ * the bytes of a UTF-8 HTML document or document fragment to be cleaned.
+ * @param output
+ * the sink for the new UTF-8 HTML document or document fragment representing the clean version of the input
+ * document.
+ * @throws IOException
+ * if there is a problem reading or writing the document.
+ */
+ public final void clean(InputStream input, OutputStream output) throws IOException {
+ if (cleanMode == CleanMode.FRAGMENT) {
+ cleanFragment(input, output);
+ }
+ else {
+ cleanDocument(input, output);
+ }
+ }
+
+ /**
+ * Cleans the HTML document represented by the Reader, writing the new document to the given Writer.
+ *
+ * @param input
+ * a Reader that provides the characters for the HTML document or document fragment to be cleaned.
+ * @param output
+ * the sink for the new HTML document or document fragment representing the clean version of the input
+ * document.
+ * @throws IOException
+ * if there is a problem reading or writing the document.
+ */
+ public final void clean(Reader input, Writer output) throws IOException {
+ if (cleanMode == CleanMode.FRAGMENT) {
+ cleanFragment(input, output);
+ }
+ else {
+ cleanDocument(input, output);
+ }
+ }
+
+ private final void cleanDocument(InputStream input, OutputStream output) throws IOException {
+ TagNode doc = getTagNode(input);
+ if (doc == null) {
+ LOGGER.warn("HtmlCleaner did not return a document node for cleanup.");
+ return;
+ }
+ serializer.writeToStream(doc, IOUtil.getNoCloseOutputStream(output), StandardCharsets.UTF_8.name());
+ }
+
+ private final void cleanDocument(Reader input, Writer output) throws IOException {
+ TagNode doc = cleaner.clean(input);
+ if (doc == null) {
+ LOGGER.warn("HtmlCleaner did not return a document node for cleanup.");
+ return;
+ }
+ serializer.write(doc, IOUtil.getNoCloseWriter(output), StandardCharsets.UTF_8.name());
+ }
+
+ private final void cleanFragment(InputStream input, OutputStream output) throws IOException {
+ TagNode doc = getTagNodeFragment(input);
+ if (doc == null) {
+ LOGGER.warn("HtmlCleaner did not return a document node for fragment cleanup.");
+ return;
+ }
+ TagNode cleanTargetContents = doc.findElementByAttValue("id", "clean-target", true, false);
+ serializer.writeToStream(
+ cleanTargetContents,
+ IOUtil.getNoCloseOutputStream(output),
+ StandardCharsets.UTF_8.name(),
+ true
+ );
+ }
+
+ private final void cleanFragment(Reader input, Writer output) throws IOException {
+ TagNode doc = getTagNodeFragment(new ByteArrayInputStream(IOUtils.toByteArray(input, StandardCharsets.UTF_8)));
+ if (doc == null) {
+ LOGGER.warn("HtmlCleaner did not return a document node for fragment cleanup.");
+ return;
+ }
+ TagNode cleanTargetContents = doc.findElementByAttValue("id", "clean-target", true, false);
+ serializer.write(cleanTargetContents, IOUtil.getNoCloseWriter(output), StandardCharsets.UTF_8.name(), true);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/html/HtmlCleanerAdapterTransformer.java b/src/main/java/com/tractionsoftware/commons/html/HtmlCleanerAdapterTransformer.java
new file mode 100644
index 0000000..9356169
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/html/HtmlCleanerAdapterTransformer.java
@@ -0,0 +1,178 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.html;
+
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableMap;
+import com.tractionsoftware.commons.config.Configuration;
+import com.tractionsoftware.commons.config.ConfiguredObject;
+import com.tractionsoftware.commons.io.StringWriteUtil;
+import com.tractionsoftware.commons.lang.NativeTypeConversion;
+import com.tractionsoftware.commons.properties.SimpleProperties;
+import com.tractionsoftware.commons.text.TextTransformationException;
+import com.tractionsoftware.commons.text.TextTransformer;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.io.input.CharSequenceReader;
+import org.apache.commons.io.output.StringBuilderWriter;
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.*;
+import java.util.function.Supplier;
+
+/**
+ * A {@link TextTransformer} implementation that uses an {@link HtmlCleanerAdapter}.
+ *
+ * @author Dave Shepperton
+ */
+public final class HtmlCleanerAdapterTransformer implements TextTransformer, ConfiguredObject {
+
+ public static final HtmlCleanerAdapterTransformer getDefaultHtmlToTextInstance() {
+ return defaultHtmlToText.get();
+ }
+
+ public static final HtmlCleanerAdapterTransformer getDefaultHtmlToHtmlCompatibleTextInstance() {
+ return defaultHtmlToHtmlCompatibleText.get();
+ }
+
+ private static final HtmlCleanerAdapterTransformer loadDefaultHtmlToTextInstance() {
+ return new HtmlCleanerAdapterTransformer(defaultHtmlToTextConfig());
+ }
+
+ private static final HtmlCleanerAdapterTransformer loadDefaultHtmlToHtmlCompatibleTextInstance() {
+ return new HtmlCleanerAdapterTransformer(defaultHtmlToHtmlCompatibleTextConfig());
+ }
+
+ private static final Configuration defaultHtmlToTextConfig() {
+ String trueStr = NativeTypeConversion.booleanToString(true);
+ return SimpleProperties.asConfiguration(
+ ImmutableMap.of(
+ ConfiguredObject.DISPLAY_PROP_NAME_NAME,
+ "HTML Cleaner HTML to Text",
+ ConfiguredObject.PROP_NAME_DESCRIPTION,
+ "Renders text from an HTML document or fragment using htmlcleaner.",
+ "cleaner_TransResCharsToNCR", trueStr,
+ "cleaner_TranslateSpecialEntities", trueStr,
+ "cleaner_OmitComments", trueStr,
+ "cleaner_AdvancedXmlEscape", trueStr,
+ "cleaner_OmitXmlDeclaration", trueStr,
+ "cleaner_DeserializeEntities", trueStr,
+ "cleaner_CleanMode", HtmlCleanerAdapter.CleanMode.FRAGMENT.name(),
+ "cleaner_SerializationMode", HtmlCleanerAdapter.SerializationMode.TEXT_ONLY.name()
+ ),
+ "html_to_text"
+ );
+ }
+
+ private static final Configuration defaultHtmlToHtmlCompatibleTextConfig() {
+ return SimpleProperties.asConfiguration(
+ ImmutableMap.of(
+ ConfiguredObject.DISPLAY_PROP_NAME_NAME,
+ "HTML Cleaner HTML to Text with HTML Entities",
+ ConfiguredObject.PROP_NAME_DESCRIPTION,
+ "Renders the text from an HTML document or fragment cleaned using htmlcleaner, containing no HTML other than any entity-encoded runs of text necessary to represent special characters such as tag delimiters, ampersands, etc.",
+ "cleaner_SerializationMode",
+ HtmlCleanerAdapter.SerializationMode.TEXT_ONLY_WITH_HTML_ENTITIES.name()
+ ),
+ "html_to_html_text"
+ ).withDefaults(getDefaultHtmlToTextInstance().getConfiguration());
+ }
+
+ private static final Supplier defaultHtmlToText = Suppliers.memoize(
+ HtmlCleanerAdapterTransformer::loadDefaultHtmlToTextInstance
+ );
+
+ private static final Supplier defaultHtmlToHtmlCompatibleText = Suppliers.memoize(
+ HtmlCleanerAdapterTransformer::loadDefaultHtmlToHtmlCompatibleTextInstance
+ );
+
+ private final Configuration config;
+
+ private final HtmlCleanerAdapter cleaner;
+
+ public HtmlCleanerAdapterTransformer(Configuration config) {
+ this.config = config;
+ cleaner = HtmlCleanerAdapter.getInstance(config.getNamespace("cleaner"));
+ }
+
+ @Override
+ public final String transform(@Nullable CharSequence text) {
+ if (text == null) {
+ return "";
+ }
+ if (text.isEmpty()) {
+ return text.toString();
+ }
+ try (CharSequenceReader reader = new CharSequenceReader(text)) {
+ StringWriter writer = new StringWriter();
+ transform(reader, writer);
+ writer.flush();
+ return writer.toString();
+ }
+ catch (IOException e) {
+ // This should not happen.
+ throw new RuntimeException(e);
+ }
+ catch (TextTransformationException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public final void transform(@Nullable CharSequence text, @Nonnull Appendable out)
+ throws IOException, TextTransformationException {
+ if (StringUtils.isEmpty(text)) {
+ return;
+ }
+ try (StringReader reader = new StringReader(text.toString())) {
+ if (out instanceof Writer w) {
+ transform(reader, w);
+ }
+ else if (out instanceof StringBuilder buff) {
+ StringBuilderWriter w = new StringBuilderWriter(buff);
+ transform(reader, w);
+ w.flush();
+ }
+ else {
+ try (StringWriter sw = new StringWriter()) {
+ transform(reader, sw);
+ StringWriteUtil.safeAppend(out, sw.toString());
+ }
+ catch (IOException e) {
+ // For StringWriter close -- this should not happen.
+ }
+ }
+ }
+ }
+
+ @Override
+ public final void transform(@Nonnull Reader reader, @Nonnull Writer writer)
+ throws IOException, TextTransformationException {
+ cleaner.clean(reader, writer);
+ }
+
+ @Nonnull
+ @Override
+ public final Configuration getConfiguration() {
+ return config;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/html/HtmlTransformerService.java b/src/main/java/com/tractionsoftware/commons/html/HtmlTransformerService.java
new file mode 100644
index 0000000..8f2768d
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/html/HtmlTransformerService.java
@@ -0,0 +1,75 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.html;
+
+import com.tractionsoftware.commons.lang.JavaUtil;
+import com.tractionsoftware.commons.text.TextTransformer;
+import jakarta.annotation.Nonnull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.function.Supplier;
+
+public abstract class HtmlTransformerService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(HtmlTransformerService.class);
+
+ private static final Supplier extends HtmlTransformerService> instance =
+ JavaUtil.lazyServiceLoader(
+ HtmlTransformerService.class, HtmlTransformerService::defaultTextTransformerService, LOGGER
+ );
+
+ @Nonnull
+ public static final HtmlTransformerService get() {
+ return instance.get();
+ }
+
+ @Nonnull
+ private static final HtmlTransformerService defaultTextTransformerService() {
+ return new HtmlTransformerService() {
+
+ @Nonnull
+ @Override
+ public final TextTransformer textOnly() {
+ return HtmlCleanerAdapterTransformer.getDefaultHtmlToTextInstance();
+ }
+
+ @Nonnull
+ public final TextTransformer htmlCompatibleTextOnly() {
+ return HtmlCleanerAdapterTransformer.getDefaultHtmlToHtmlCompatibleTextInstance();
+ }
+
+ };
+
+ }
+
+ @Nonnull
+ public abstract TextTransformer textOnly();
+
+ @Nonnull
+ public abstract TextTransformer htmlCompatibleTextOnly();
+
+ @Nonnull
+ public TextTransformer textOnlyForSnippets() {
+ return textOnly();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/html/HtmlUtil.java b/src/main/java/com/tractionsoftware/commons/html/HtmlUtil.java
new file mode 100644
index 0000000..f58407f
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/html/HtmlUtil.java
@@ -0,0 +1,846 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.html;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.net.MediaType;
+import com.tractionsoftware.commons.io.StringWriteUtil;
+import com.tractionsoftware.commons.lang.NativeTypeConversion;
+import com.tractionsoftware.commons.lang.ObjectUtil;
+import com.tractionsoftware.commons.lang.StringUtil;
+import com.tractionsoftware.commons.text.CharBasedFilteringTextMapper;
+import com.tractionsoftware.commons.text.SnippetUtil;
+import com.tractionsoftware.commons.text.TextWrapUtil;
+import com.tractionsoftware.commons.util.CollectionUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.text.StringEscapeUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public final class HtmlUtil {
+
+ /**
+ * Not instantiable.
+ */
+ private HtmlUtil() {
+ }
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(HtmlUtil.class);
+
+ private static final Pattern HTML_HEADING_START_TAG = Pattern.compile(
+ "<([Hh])([123456])(\\s.+)?>"
+ );
+
+ public static enum SimpleHtmlEntity {
+
+ QUOTATION_MARK('"', "quot"),
+
+ GREATER_THAN('>', "gt"),
+
+ LESS_THAN('<', "lt"),
+
+ AMPERSAND('&', "amp"),
+
+ NON_BREAKING_SPACE(' ', "nbsp");
+
+ public static final SimpleHtmlEntity getForLiteral(char c) {
+ return switch (c) {
+ case '>' -> SimpleHtmlEntity.GREATER_THAN;
+ case '<' -> SimpleHtmlEntity.LESS_THAN;
+ case '&' -> SimpleHtmlEntity.AMPERSAND;
+ default -> null;
+ };
+ }
+
+ public static final SimpleHtmlEntity getForTagAttributeValue(char c) {
+ return switch (c) {
+ case '"' -> QUOTATION_MARK;
+ case '>' -> GREATER_THAN;
+ case '<' -> LESS_THAN;
+ case '&' -> AMPERSAND;
+ default -> null;
+ };
+ }
+
+ public static final SimpleHtmlEntity getForClassicConversion(char c) {
+ return switch (c) {
+ case '"' -> QUOTATION_MARK;
+ case '>' -> GREATER_THAN;
+ case '<' -> LESS_THAN;
+ case '&' -> AMPERSAND;
+ case ' ', StringUtil.CHAR_NON_BREAKING_SPACE -> NON_BREAKING_SPACE;
+ default -> null;
+ };
+ }
+
+ public static final SimpleHtmlEntity get(char c) {
+ return switch (c) {
+ case '"' -> QUOTATION_MARK;
+ case '>' -> GREATER_THAN;
+ case '<' -> LESS_THAN;
+ case '&' -> AMPERSAND;
+ case StringUtil.CHAR_NON_BREAKING_SPACE -> NON_BREAKING_SPACE;
+ default -> null;
+ };
+ }
+
+ public static final SimpleHtmlEntity get(String s, int index) {
+
+ char c = s.charAt(index);
+ if (Character.isSurrogate(c) || c != '&') {
+ return null;
+ }
+
+ int remaining = s.length() - index;
+ if (remaining < 4) {
+ return null;
+ }
+
+ int start = index + 1;
+ for (SimpleHtmlEntity entity : SimpleHtmlEntity.values()) {
+ int len = entity.name.length();
+ if (remaining > len &&
+ entity.name.equals(s.substring(start, start + len)) &&
+ s.charAt(start + len) == ';') {
+ return entity;
+ }
+ }
+ return null;
+
+ }
+
+ public static final String encodeForLiteral(char c) {
+ SimpleHtmlEntity entity = getForLiteral(c);
+ if (entity == null) {
+ return null;
+ }
+ return entity.encoding;
+ }
+
+ public static final String encodeForTagAttributeValue(char c) {
+ SimpleHtmlEntity entity = getForTagAttributeValue(c);
+ if (entity == null) {
+ return null;
+ }
+ return entity.encoding;
+ }
+
+ public static final String encodeForClassicHtmlText(char c) {
+ SimpleHtmlEntity entity = getForClassicConversion(c);
+ if (entity == null) {
+ return null;
+ }
+ return entity.encoding;
+ }
+
+ private final char value;
+
+ private final String name;
+
+ private final String encoding;
+
+ private SimpleHtmlEntity(char value, String name) {
+ this.value = value;
+ this.name = name;
+ this.encoding = "&" + name + ";";
+ }
+
+ public final void append(StringBuilder buff) {
+ buff.append(encoding);
+ }
+
+ public final void print(PrintWriter out) {
+ out.print(encoding);
+ }
+
+ public final void appendValue(StringBuilder buff) {
+ buff.append(value);
+ }
+
+ public final String getEncoding() {
+ return encoding;
+ }
+
+ public final int nameLength() {
+ return name.length();
+ }
+
+ public static final String escapeAmpersand(char c) {
+ if (c == '&') {
+ return AMPERSAND.encoding;
+ }
+ return null;
+ }
+
+ }
+
+ private static final class HtmlLiteralAppendableWrapper implements Appendable {
+
+ private final String preferredZeroWidthSpace;
+
+ private final Appendable out;
+
+ private HtmlLiteralAppendableWrapper(Appendable out, String preferredZeroWidthSpace) {
+ this.out = out;
+ this.preferredZeroWidthSpace = preferredZeroWidthSpace;
+ }
+
+ @Override
+ public final Appendable append(CharSequence csq) {
+ appendLiteralText(csq);
+ return this;
+ }
+
+ @Override
+ public final Appendable append(@Nonnull CharSequence csq, int start, int end) {
+ Objects.requireNonNull(csq, "sequence");
+ appendLiteralText(csq.subSequence(start, end));
+ return this;
+ }
+
+ @Override
+ public final Appendable append(char c) {
+ String literalReplacement = encodeForLiteral(c);
+ if (literalReplacement != null) {
+ StringWriteUtil.safeAppend(out, literalReplacement);
+ }
+ else {
+ StringWriteUtil.safeAppend(out, c);
+ }
+ return this;
+ }
+
+ private final void appendLiteralText(@Nullable CharSequence text) {
+ if (StringUtils.isNotEmpty(text)) {
+ CharBasedFilteringTextMapper.replace(text, out, this::encodeForLiteral);
+ }
+ }
+
+ private final String encodeForLiteral(char c) {
+ if (c == StringUtil.CHAR_ZERO_WIDTH_SPACE) {
+ return preferredZeroWidthSpace;
+ }
+ return SimpleHtmlEntity.encodeForLiteral(c);
+ }
+
+ }
+
+ public static final String TAG_NAME_SCRIPT = "script";
+
+ public static final String TAG_NAME_LINK = "link";
+
+ public static final String TAG_NAME_STYLE = "style";
+
+ public static final String TAG_NAME_A = "a";
+
+ public static final String TAG_NAME_IMG = "img";
+
+ public static final String INLINE_COMMENT_START = "";
+
+ public static final String INLINE_COMMENT_END_WITH_SCRIPT_OR_STYLE_COMMENT = "//" + INLINE_COMMENT_END;
+
+ public static final String ATTRIBUTE_NAME_TYPE = "type";
+
+ public static final String LINK_ATTRIBUTE_NAME_REL = "rel";
+
+ public static final String LINK_ATTRIBUTE_NAME_HREF = "href";
+
+ public static final String STYLE_ATTRIBUTE_NAME_MEDIA = "media";
+
+ public static final String ATTRIBUTE_NAME_SRC = "src";
+
+ public static final String ATTRIBUTE_NAME_WIDTH = "width";
+
+ public static final String ATTRIBUTE_NAME_HEIGHT = "height";
+
+ public static final String ATTRIBUTE_NAME_IMAGE_DIALOG_SRC = "data-image-src";
+
+ public static final String ATTRIBUTE_NAME_IMAGE_DIALOG_TITLE = "data-title";
+
+ public static final String TYPE_JAVASCRIPT = MediaType.TEXT_JAVASCRIPT_UTF_8.withoutParameters().toString();
+
+ public static final String LINK_REL_STYLESHEET = "stylesheet";
+
+ public static final String LINK_REL_ALTERNATE_STYLESHEET = "alternate " + LINK_REL_STYLESHEET;
+
+ public static final String TAG_BR = " ";
+
+ public static final String DEFAULT_NON_SPACE_BREAK_HTML = "";
+
+ public static final String ZERO_WIDTH_SPACE_ENTITY_ENCODING = "";
+
+ public static final String ATTRIBUTE_NAME_CLASS = "class";
+
+ public static final String ATTRIBUTE_NAME_TARGET = "target";
+
+ public static final String ATTRIBUTE_VALUE_TARGET_NEW_WINDOW_NAME = "_blank";
+
+ /**
+ * , . / \ | - % ) & > < "
+ */
+ private static final Pattern NON_SPACE_BREAK_OPPORTUNITIES =
+ Pattern.compile("([,./\\\\|\\-%)]|&(amp|gt|lt|quot);)(^\\s)");
+
+ private static final Splitter CLASS_ATTRIBUTE_SPLITTER = Splitter.on(Pattern.compile("\\s+"));
+
+ /**
+ * Applies the minimal amount of entity-encoding necessary for the given plain text to appear in literal form in an
+ * HTML document. This requires entity-encoding greater than, less than, and ampersand characters.
+ *
+ * @param text
+ * some plain text that will be appearing in an HTML document.
+ * @return a version of the given plain text with any tag delimiters and ampersands entity-encoded; or null if the
+ * given text is null.
+ */
+ public static final String getLiteralText(@Nullable CharSequence text) {
+ if (text == null) {
+ return null;
+ }
+ if (text.isEmpty()) {
+ return "";
+ }
+ return CharBasedFilteringTextMapper.replace(text.toString(), SimpleHtmlEntity::encodeForLiteral);
+ }
+
+ /**
+ * Converts text to HTML in the same manner as {@link #getLiteralText(CharSequence)}, but including BR tags in place
+ * of line breaks. The line breaks are identified via {@link StringUtil#getLines(CharSequence)}, and the conversion
+ * of the individual lines' text is does with getLiteralText.
+ *
+ * @param text
+ * the text to be converted.
+ * @return some HTML representing the given text converted to HTML-safe text, plus substituting BR tags for line
+ * breaks.
+ */
+ public static final String getLiteralTextWithLineBreaks(@Nullable CharSequence text) {
+ if (StringUtils.isEmpty(text)) {
+ return "";
+ }
+ return StringUtil.join(StringUtil.getLines(getLiteralText(text)).iterator(), TAG_BR);
+ }
+
+ public static final void printLiteralTextWithLineBreaks(@Nonnull Appendable out, @Nullable CharSequence text) {
+ Objects.requireNonNull(out, "output");
+ if (StringUtils.isEmpty(text)) {
+ return;
+ }
+ try {
+ StringUtil.getNullSkippingJoiner(TAG_BR).appendTo(
+ out, StringUtil.getLines(getLiteralText(text)).iterator()
+ );
+ }
+ catch (IOException e) {
+ // This is not possible.
+ LOGGER.error("This exception should not happen", e);
+ }
+ }
+
+ /**
+ * Returns a version of the given text that is safe for an HTML attribute value. This requires entity-encoding
+ * greater than, less than, double quotation mark, and ampersand characters.
+ *
+ * @param text
+ * the text to be encoded in an HTML-safe manner.
+ * @return a version of the given text that is safe for an HTML attribute value.
+ */
+ public static final String getTagAttributeValue(@Nullable CharSequence text) {
+ if (text == null) {
+ return "";
+ }
+ if (text.isEmpty()) {
+ return text.toString();
+ }
+ return Objects.toString(
+ CharBasedFilteringTextMapper.replace(text, SimpleHtmlEntity::encodeForTagAttributeValue), null
+ );
+ }
+
+ /**
+ * Converts the given text to HTML, including the non-literal and usually unnecessary conversion spaces to
+ * non-breaking spaces.
+ *
+ * @param text
+ * the text to be converted to HTML.
+ * @return a conversion of the given text to HTML, including the non-literal and usually unnecessary conversion
+ * spaces to non-breaking spaces.
+ */
+ public static String getClassicHtmlText(@Nullable String text) {
+ if (StringUtils.isBlank(text)) {
+ return text;
+ }
+ return Objects.toString(
+ CharBasedFilteringTextMapper.replace(text, SimpleHtmlEntity::encodeForClassicHtmlText), null
+ );
+ }
+
+ /**
+ * Reverses the encoding that is performed by {@link #getClassicHtmlText(String)}, translating the given HTML to
+ * text, including the non-literal conversion of non-breaking space entities to ordinary spaces.
+ *
+ * @param html
+ * the HTML to be converted to text.
+ * @return the decoded version of the string.
+ */
+ public static String getClassicTextHtml(@Nullable String html) {
+
+ if (StringUtils.isEmpty(html)) {
+ return html;
+ }
+
+ int len = html.length();
+ StringBuilder buff = null;
+ for (int i = 0; i < len; i++) {
+ SimpleHtmlEntity entity = SimpleHtmlEntity.get(html, i);
+ if (entity == null) {
+ if (buff != null) {
+ buff.append(html.charAt(i));
+ }
+ }
+ else {
+ if (buff == null) {
+ buff = new StringBuilder(len);
+ buff.append(html, 0, i);
+ }
+ entity.appendValue(buff);
+ i += entity.nameLength() + 1;
+ }
+ }
+
+ if (buff == null) {
+ return html;
+ }
+ return buff.toString();
+
+ }
+
+ /**
+ * Inserts the preferred non-space optional break HTML sequence where applicable in the given text.
+ *
+ * @param text
+ * the text into which the non-space optional break HTML should be inserted. This must really be pure text and
+ * not contain any markup, since markup might be corrupted by this insertion process.
+ * @param nonSpaceBreaksHtml
+ * to provide the desired non-space break HTML sequence.
+ * @return the given text with the preferred non-space optional break HTML sequence where applicable.
+ */
+ public static final String getHtmlWithNonSpaceBreaks(@Nonnull String text, @Nonnull Supplier nonSpaceBreaksHtml) {
+ Objects.requireNonNull(text, "text");
+ Objects.requireNonNull(nonSpaceBreaksHtml, "non-space break HTML");
+ return NON_SPACE_BREAK_OPPORTUNITIES.matcher(text).replaceAll("$1" + nonSpaceBreaksHtml.get() + "$3");
+ }
+
+ public static final String getTagAttribute(@Nonnull String name, @Nullable String value) {
+ Objects.requireNonNull(name, "name");
+ if (value == null) {
+ return name;
+ }
+ return name + "=\"" + getTagAttributeValue(value) + "\"";
+ }
+
+ public static final boolean containsClassName(@Nullable String classAttr, @Nullable String className) {
+
+ classAttr = StringUtils.trimToNull(classAttr);
+ if (classAttr == null) {
+ return false;
+ }
+ className = StringUtils.trimToNull(className);
+ if (className == null) {
+ return false;
+ }
+
+ for (String classAttrSub : CLASS_ATTRIBUTE_SPLITTER.split(classAttr)) {
+ if (className.equals(classAttrSub)) {
+ return true;
+ }
+ }
+ return false;
+
+ }
+
+ /**
+ * Converts all occurrences of the ampersand character to HTML ampersand entities.
+ */
+ public static final String escapeAmps(@Nullable String str) {
+ if (StringUtils.isEmpty(str)) {
+ return str;
+ }
+ return Objects.toString(
+ CharBasedFilteringTextMapper.replace(str, SimpleHtmlEntity::escapeAmpersand),
+ null
+ );
+ }
+
+ public static final void printLiteralText(@Nonnull Appendable out, @Nullable CharSequence text) {
+ Objects.requireNonNull(out, "output");
+ if (StringUtils.isNotEmpty(text)) {
+ CharBasedFilteringTextMapper.replace(text, out, SimpleHtmlEntity::encodeForLiteral);
+ }
+ }
+
+ public static final void printTagAttribute(@Nonnull Appendable out, @Nonnull String name, @Nullable String value) {
+ Objects.requireNonNull(out, "output");
+ Objects.requireNonNull(name, "attribute name");
+ try {
+ out.append(name);
+ if (value != null) {
+ out.append("=\"");
+ printTagAttributeValue(out, value);
+ out.append('"');
+ }
+ }
+ catch (IOException e) {
+ LOGGER.error("Failed to write to {}", ObjectUtil.safeToStringObject(out), e);
+ }
+ }
+
+ public static final void printTagAttributeValue(@Nonnull Appendable out, @Nullable String text) {
+ Objects.requireNonNull(out, "output");
+ if (StringUtils.isNotEmpty(text)) {
+ CharBasedFilteringTextMapper.replace(text, out, SimpleHtmlEntity::encodeForTagAttributeValue);
+ }
+ }
+
+ public static final void printBeginSelect(@Nonnull PrintWriter out, @Nonnull String name) {
+ Objects.requireNonNull(out, "output");
+ Objects.requireNonNull(name, "name");
+ out.print("");
+ }
+
+ public static final void printBeginSelect(@Nonnull PrintWriter out, @Nonnull String name, @Nullable String attributes) {
+ Objects.requireNonNull(out, "output");
+ Objects.requireNonNull(name, "name");
+ out.print("");
+ }
+
+ public static final void printBeginSelect(@Nonnull PrintWriter out, @Nonnull String name, @Nullable Map attributes) {
+ printStartTag(out, "select", attributes);
+ }
+
+ public static final void printBeginSelect(@Nonnull PrintWriter out, @Nonnull String name, @Nullable String attributes, boolean disabled) {
+ Objects.requireNonNull(out, "output");
+ out.print("");
+ }
+
+ public static final void printOption(@Nonnull PrintWriter out, @Nullable String text, @Nullable String value, boolean selected) {
+ Objects.requireNonNull(out, "output");
+ out.print("");
+ if (StringUtils.isNotEmpty(text)) {
+ out.print(text);
+ }
+ out.print(" ");
+ }
+
+ public static final void printOption(@Nonnull PrintWriter out, @Nullable String text, @Nullable String value, boolean selected, @Nullable String attributes) {
+ Objects.requireNonNull(out, "output");
+ out.print("");
+ printLiteralText(out, text);
+ out.print(" ");
+ }
+
+ public static final void printOption(@Nonnull PrintWriter out, String text, String value, boolean selected, Map attributes) {
+ if (selected || value != null) {
+ Map useAttributes = new LinkedHashMap<>();
+ if (value != null) {
+ useAttributes.put("value", value);
+ }
+ if (CollectionUtil.isNotEmpty(attributes)) {
+ useAttributes.putAll(attributes);
+ }
+ if (selected) {
+ useAttributes.put("selected", null);
+ }
+ attributes = useAttributes;
+ }
+ printStartTag(out, "option", attributes);
+ printLiteralText(out, text);
+ printEndTag(out, "option");
+ }
+
+ public static final void printOption(@Nonnull PrintWriter out, String name, boolean selected) {
+ printOption(out, name, null, selected, ImmutableMap.of());
+ }
+
+ public static final void printEndSelect(@Nonnull PrintWriter out) {
+ printEndTag(out, "select");
+ }
+
+ public static final void printBeginOptionGroup(@Nonnull PrintWriter out, String label, String value) {
+ printBeginOptionGroup(out, label, value, null);
+ }
+
+ public static final void printBeginOptionGroup(@Nonnull PrintWriter out, String label, String value, String className) {
+ out.print("");
+ }
+
+ public static final void printEndOptionGroup(@Nonnull PrintWriter out) {
+ Objects.requireNonNull(out, "output");
+ out.print(" ");
+ }
+
+ public static final void printBeginLink(@Nonnull PrintWriter out, @Nonnull String url, @Nullable Map otherAttributes) {
+ Objects.requireNonNull(out, "output");
+ out.print(" attr : otherAttributes.entrySet()) {
+ out.print(' ');
+ String attrName = attr.getKey();
+ if ("href".equals(attrName)) {
+ continue;
+ }
+ printTagAttribute(out, attrName, attr.getValue());
+ }
+ }
+ out.print(">");
+ }
+
+ public static final void printLink(PrintWriter out, String url, String linkText, @Nullable Map otherAttributes) {
+ printBeginLink(out, url, otherAttributes);
+ printLiteralText(out, linkText);
+ out.print(" ");
+ }
+
+ public static final void printStartTag(@Nonnull PrintWriter out, @Nonnull String tagName, @Nullable Map attributes) {
+ Objects.requireNonNull(out, "output");
+ StringUtil.checkNotBlankX(tagName, "tag name");
+ out.print('<');
+ out.print(tagName);
+ if (attributes != null) {
+ for (Map.Entry attr : attributes.entrySet()) {
+ out.print(' ');
+ printTagAttribute(out, attr.getKey(), attr.getValue());
+ }
+ }
+ out.print('>');
+ }
+
+ public static final void printEndTag(PrintWriter out, String tagName) {
+ Objects.requireNonNull(out, "output");
+ StringUtil.checkNotBlankX(tagName, "tag name");
+ out.print("");
+ out.print(tagName);
+ out.print('>');
+ }
+
+ public static final void printInputTag(@Nonnull PrintWriter out, String name, String value, String type) {
+ Objects.requireNonNull(out, "output");
+ Map attributes = new LinkedHashMap<>();
+ if (type != null) {
+ attributes.put("type", type);
+ }
+ if (name != null) {
+ attributes.put("name", name);
+ }
+ if (value != null) {
+ attributes.put("value", value);
+ }
+ printStartTag(out, "input", attributes);
+ }
+
+ public static final void printMetaTag(@Nonnull PrintWriter out, Map attributes) {
+ printStartTag(out, "META", attributes);
+ }
+
+ public static final void printOpenGraphMetaTag(PrintWriter out, String ogPropertyType, String content) {
+ printMetaTag(
+ out,
+ ImmutableMap.of(
+ "property", "og:" + ogPropertyType,
+ "content", content
+ )
+ );
+ }
+
+ public static final Appendable getLiteralAppendable(@Nonnull Appendable out, @Nullable String preferredZeroWidthSpace) {
+ Objects.requireNonNull(out, "output");
+ if (StringUtils.isBlank(preferredZeroWidthSpace)) {
+ preferredZeroWidthSpace = TextWrapUtil.DEFAULT_ZERO_WIDTH_SPACE;
+ }
+ if (out instanceof HtmlLiteralAppendableWrapper literal &&
+ Objects.equals(literal.preferredZeroWidthSpace, preferredZeroWidthSpace)) {
+ return literal;
+ }
+ return new HtmlLiteralAppendableWrapper(out, preferredZeroWidthSpace);
+ }
+
+ public static final String createComment(@Nonnull String text) {
+ Objects.requireNonNull(text, "text");
+ return INLINE_COMMENT_START + " " + text + " " + INLINE_COMMENT_END;
+ }
+
+ public static final int getApparentHeadingLevel(@Nonnull String tagText) {
+ Objects.requireNonNull(tagText, "tag text");
+ Matcher m = HTML_HEADING_START_TAG.matcher(tagText);
+ if (m.matches()) {
+ return NativeTypeConversion.stringToInt(m.group(2), 0);
+ }
+ return 0;
+ }
+
+ /**
+ * Computes a simple text snippet from the given HTML, no longer than the requested maximum length.
+ *
+ * @param html
+ * the HTML to be snippetized.
+ * @param maximumLength
+ * the maximum length of the snippet.
+ * @param ellipses
+ * the ellipses to use in the event the content is truncated.
+ * @return a simple text snippet from the given HTML, no longer than the requested maximum length.
+ */
+ public static final String getTextSnippetFromHtml(@Nullable String html, int maximumLength, @Nullable String ellipses) {
+ if (maximumLength <= 0 || StringUtils.isBlank(html)) {
+ return "";
+ }
+ return SnippetUtil.getSnippet(
+ getUnescapedHtmlForSnippet(html, maximumLength), maximumLength, ellipses
+ );
+ }
+
+ /**
+ * Applies an HTML-to-text transformation appropriate for preparing the given HTML content to be snippetized.
+ *
+ * @param html
+ * the HTML to be turned into a text-only equivalent for snippetizing.
+ * @param maximumLength
+ * the maximum length of the snippet.
+ * @return a text-only version of the input HTML suitable for snippetizing.
+ * @implNote We would like to avoid spending time applying HTML entity unescaping to text that is only going to
+ * be discarded. Since the unescape utility code we're using does not allow us to stop after we've got a desired
+ * amount of output, we take special pains to only unescape text if we know we need to, and then sending only as
+ * much HTML text as we think can possibly need to be unescaped to fulfill the requirement for the maximum
+ * snippet length.
+ */
+ private static final String getUnescapedHtmlForSnippet(@Nonnull String html, int maximumLength) {
+
+ // Extract text by removing HTML tags and any other text that should not be included here.
+ String htmlText = HtmlTransformerService.get().textOnlyForSnippets().transform(html).trim();
+ if (StringUtils.isBlank(htmlText)) {
+ return htmlText;
+ }
+
+ // Maximum expected amount of a text-only string we'll need
+ // for snippetizing.
+ int maximumLengthOfText = maximumLength + 50;
+
+ int firstAmpersand = htmlText.indexOf('&');
+ if (firstAmpersand == -1) {
+ if (htmlText.length() > maximumLengthOfText) {
+ return htmlText.substring(0, maximumLengthOfText);
+ }
+ return htmlText;
+ }
+
+ String textHead = htmlText.substring(0, firstAmpersand);
+ int textHeadLen = textHead.length();
+ if (textHeadLen >= maximumLengthOfText) {
+ return textHead.substring(0, maximumLengthOfText);
+ }
+
+ if (textHeadLen > 0) {
+ // Take into account the text-only part we took from the
+ // "head" of the string by removing that length from the
+ // maximum required text length, and by removing that head
+ // text from the remaining HTML text.
+ maximumLengthOfText -= textHeadLen;
+ htmlText = htmlText.substring(textHeadLen);
+ }
+
+ // We'd like to make sure that we supply enough possibly
+ // entity-encoded text to get a sufficient amount of plain
+ // text output. We go with a factor of 10 here, which is
+ // probably overkill in practice, but safe to cover weird
+ // corner cases.
+ int maximumLengthOfRemainingHtmlText = maximumLengthOfText * 10;
+ if (htmlText.length() > maximumLengthOfRemainingHtmlText) {
+ htmlText = htmlText.substring(0, maximumLengthOfRemainingHtmlText);
+ }
+
+ String unescapedTail = StringEscapeUtils.unescapeHtml4(htmlText);
+ if (unescapedTail.length() > maximumLengthOfText) {
+ unescapedTail = unescapedTail.substring(0, maximumLengthOfText);
+ }
+
+ if (textHeadLen == 0) {
+ return unescapedTail;
+ }
+ return textHead + unescapedTail;
+
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/AbstractIconFile.java b/src/main/java/com/tractionsoftware/commons/image/AbstractIconFile.java
new file mode 100644
index 0000000..07a58b7
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/AbstractIconFile.java
@@ -0,0 +1,213 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.tractionsoftware.commons.io.FileMetadata;
+import com.tractionsoftware.commons.io.SimpleMutableFileMetadata;
+import com.tractionsoftware.commons.text.NumberFormats;
+import com.tractionsoftware.commons.util.Dimensions;
+import jakarta.annotation.Nonnull;
+
+import java.io.BufferedReader;
+import java.nio.charset.Charset;
+
+/**
+ * Base implementation of {@link IconFileResource} that provides caching of {@link Dimensions} used for
+ * {@link #getOriginalDimensions()} and fairly universal implementations of various other IconFile methods.
+ *
+ * @author Dave Shepperton
+ */
+public abstract class AbstractIconFile implements IconFileResource {
+
+ /**
+ * Encapsulates a cached {@link Dimensions} object that can be initialized with a preferred value, and can be
+ * updated as necessary.
+ */
+ private final class CachedDimensions {
+
+ private Dimensions dimensions;
+
+ private boolean readOrSet = false;
+
+ private CachedDimensions(Dimensions preferredDimensions) {
+ update(preferredDimensions);
+ }
+
+ public final synchronized Dimensions get() {
+ if (!readOrSet) {
+ readOrSet = true;
+ dimensions = AbstractIconFile.this.getInitDimensions();
+ }
+ return dimensions;
+ }
+
+ public final synchronized void update(Dimensions newDimensions) {
+ if (newDimensions != null &&
+ newDimensions.getUnits() == Dimensions.Units.PIXELS &&
+ newDimensions.hasAtLeastOneValidDimension()) {
+ this.dimensions = newDimensions;
+ readOrSet = true;
+ }
+ else {
+ this.dimensions = null;
+ readOrSet = false;
+ }
+ }
+
+ }
+
+ private final CachedDimensions dimensions;
+
+ /**
+ * Constructs a new AbstractIconFile instance without any preferred dimensions.
+ */
+ public AbstractIconFile() {
+ this(null);
+ }
+
+ /**
+ * Constructs a new AbstractIconFile instance with the given preferred dimensions, if any, set as the original
+ * dimensions.
+ *
+ * @param preferredDimensions
+ * a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} object representing the preferred value for
+ * the original dimensions of the image.
+ */
+ public AbstractIconFile(Dimensions preferredDimensions) {
+ this.dimensions = new CachedDimensions(preferredDimensions);
+ }
+
+ @Override
+ public final boolean isValid() {
+ if (!isUnderlyingFileValid()) {
+ return false;
+ }
+ Dimensions originalDimensions = getOriginalDimensions();
+ if (originalDimensions == null || originalDimensions.hasAtLeastOneValidDimension()) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public final Charset getCharset() {
+ return null;
+ }
+
+ @Override
+ public final BufferedReader getReader() {
+ throw new UnsupportedOperationException("image data");
+ }
+
+ @Override
+ public final BufferedReader getReader(Charset charset) {
+ throw new UnsupportedOperationException("image data");
+ }
+
+ /**
+ * This default implementation returns null. Subclasses should override it if a display name is available.
+ */
+ @Override
+ public String getDisplayName() {
+ return null;
+ }
+
+ /**
+ * This default implementation returns null. Subclasses should override it if a description is available.
+ */
+ @Override
+ public String getDescription() {
+ return null;
+ }
+
+ /**
+ * This implementation returns a {@link Dimensions} object which is cached on demand in a thread-safe fashion. It
+ * may still return null if no dimensions could be determined or retrieved.
+ */
+ @Override
+ public final Dimensions getOriginalDimensions() {
+ return dimensions.get();
+ }
+
+ /**
+ * This default implementation defers to {@link SimpleMutableFileMetadata#createForIconFileInfo(IconFileResource)},
+ * which was specifically designed to provide a convenient implementation for this method. It should be suitable for
+ * all implementations unless the {@link FileMetadata} needs to carry special non-standard properties.
+ */
+ @Nonnull
+ @Override
+ public FileMetadata getMetadata() {
+ return SimpleMutableFileMetadata.createForIconFileInfo(this);
+ }
+
+ @Nonnull
+ @Override
+ public final String getFormattedSize() {
+ long byteSize = getByteSize();
+ if (byteSize < 0) {
+ return "?";
+ }
+ return NumberFormats.getFormattedByteSize(getByteSize());
+ }
+
+ /**
+ * Updates the dimensions to the new specified {@link Dimensions}. If the Dimensions is null, not
+ * {@link Dimensions.Units#PIXELS pixel}-denominated, or does not
+ * {@link Dimensions#hasAtLeastOneValidDimension() have at least one valid dimension}, the currently cached
+ * Dimensions, if any, will be cleared. This will effectively require the dimensions to be recomputed from the
+ * backing image file the next time {@link #getOriginalDimensions()} is invoked, unless this method is invoked again
+ * with valid Dimensions.
+ *
+ * @param newDimensions
+ * the new {@link Dimensions} to use.
+ */
+ protected final void updateDimensions(Dimensions newDimensions) {
+ dimensions.update(newDimensions);
+ }
+
+ /**
+ * Returns true if this AbstractIconFile fulfills
+ * {@link com.tractionsoftware.commons.io.FileResource#isValid() FileResource's basic definition for validity}.
+ *
+ * @return true if this AbstractIconFile fulfills
+ * {@link com.tractionsoftware.commons.io.FileResource#isValid() FileResource's basic definition for validity}.
+ */
+ protected abstract boolean isUnderlyingFileValid();
+
+ /**
+ * Reads or otherwise retrieves the {@link Dimensions} for the image encapsulated by this icon file resource. It is
+ * used to initialize the Dimensions cached on this instance.
+ *
+ *
+ * This default implementation uses
+ * {@link Dimensions#getImageDimensionsInPixels(com.tractionsoftware.commons.io.FileResource)}, passing this object
+ * itself, which will use {@link #getInputStream() the InputStream} for the underlying resource. It should be
+ * applicable to most implementations, but subclasses should override it as necessary, e.g., if the dimensions are
+ * already known -- or to return null if they definitely cannot be known.
+ *
+ * @return the freshly retrieved {@link Dimensions} for the image encapsulated by this icon file resource, if they
+ * were already known or could be read or otherwise retrieved; null otherwise.
+ */
+ protected Dimensions getInitDimensions() {
+ return Dimensions.getImageDimensionsInPixels(this);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/ForwardingIcon.java b/src/main/java/com/tractionsoftware/commons/image/ForwardingIcon.java
new file mode 100644
index 0000000..973d13b
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/ForwardingIcon.java
@@ -0,0 +1,132 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.tractionsoftware.commons.util.Dimensions;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+public abstract class ForwardingIcon implements Icon {
+
+ private static abstract class ForcedDimensionsIcon extends ForwardingIcon {
+
+ private final Dimensions forcedDimensions;
+
+ private ForcedDimensionsIcon(Dimensions forcedDimensions) {
+ this.forcedDimensions = forcedDimensions;
+ }
+
+ @Override
+ public final Dimensions getDimensions() {
+ return forcedDimensions;
+ }
+
+ @Override
+ public final Icon withDimensions(Dimensions nestedNewDimensions) {
+ if (Objects.equals(forcedDimensions, nestedNewDimensions)) {
+ return this;
+ }
+ return delegate().withDimensions(nestedNewDimensions);
+ }
+
+ }
+
+ public static final Icon wrap(Icon icon) {
+ return new ForwardingIcon() {
+ @Override
+ protected final Icon delegate() {
+ return icon;
+ }
+ };
+ }
+
+ public static final Icon wrap(Supplier extends Icon> iconSupplier) {
+ return new ForwardingIcon() {
+ @Override
+ protected final Icon delegate() {
+ return iconSupplier.get();
+ }
+ };
+ }
+
+ public static final Icon wrapWithForcedDimensions(Icon icon, Dimensions forcedDimensions) {
+ return new ForcedDimensionsIcon(forcedDimensions) {
+ @Override
+ protected final Icon delegate() {
+ return icon;
+ }
+ };
+ }
+
+ protected abstract Icon delegate();
+
+ @Override
+ public boolean isValid() {
+ return delegate().isValid();
+ }
+
+ @Override
+ public String getFilename() {
+ return delegate().getFilename();
+ }
+
+ @Override
+ public Dimensions getDimensions() {
+ return delegate().getDimensions();
+ }
+
+ @Override
+ public String getContentId() {
+ return delegate().getContentId();
+ }
+
+ @Override
+ public String getDataUrl() {
+ return delegate().getDataUrl();
+ }
+
+ @Override
+ public String getWidthHTML() {
+ return delegate().getWidthHTML();
+ }
+
+ @Override
+ public String getHeightHTML() {
+ return delegate().getHeightHTML();
+ }
+
+ @Override
+ public Icon getScaled(Dimensions newMaxDimensions) {
+ return delegate().getScaled(newMaxDimensions);
+ }
+
+ @Override
+ public Icon withDimensions(Dimensions newDimensions) {
+ return delegate().getScaled(newDimensions);
+ }
+
+ @Override
+ public IconFileResource getImageFileResource() {
+ return delegate().getImageFileResource();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/ForwardingIconFile.java b/src/main/java/com/tractionsoftware/commons/image/ForwardingIconFile.java
new file mode 100644
index 0000000..1f17b8c
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/ForwardingIconFile.java
@@ -0,0 +1,63 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.tractionsoftware.commons.io.FileResourceType;
+import com.tractionsoftware.commons.io.ForwardingFileResource;
+import com.tractionsoftware.commons.util.Dimensions;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+
+public abstract class ForwardingIconFile extends ForwardingFileResource implements IconFileResource {
+
+ @Nonnull
+ @Override
+ protected abstract IconFileResource delegate();
+
+ @Nonnull
+ @Override
+ public Icon getImage(Dimensions maxDimensions) {
+ return delegate().getImage(maxDimensions);
+ }
+
+ @Nullable
+ @Override
+ public String getDisplayName() {
+ return delegate().getDisplayName();
+ }
+
+ @Override
+ public Dimensions getOriginalDimensions() {
+ return delegate().getOriginalDimensions();
+ }
+
+ @Override
+ public FileResourceType getImageResourceType() {
+ return delegate().getImageResourceType();
+ }
+
+ @Nonnull
+ @Override
+ public String toDebugString() {
+ return delegate().toDebugString();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/HEIFUtil.java b/src/main/java/com/tractionsoftware/commons/image/HEIFUtil.java
new file mode 100644
index 0000000..8dc48c7
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/HEIFUtil.java
@@ -0,0 +1,51 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.tractionsoftware.heif.HeicMetaReader;
+import com.tractionsoftware.heif.entity.ImageSimpleMeta;
+import com.tractionsoftware.commons.util.Dimensions;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Helpers for the HEIF/HEIC format. Currently this only covers metadata.
+ *
+ * @author Dave Shepperton
+ */
+public final class HEIFUtil {
+
+ private HEIFUtil() {
+ }
+
+ public static final Dimensions getDimensions(InputStream input) throws IOException {
+ ImageSimpleMeta meta = HeicMetaReader.readMetadata(input);
+ return Dimensions.getInstanceInPixels(meta.getWidth(), meta.getHeight());
+ }
+
+ public static final Dimensions getDimensions(File file) throws IOException {
+ ImageSimpleMeta meta = HeicMetaReader.readMetadata(file);
+ return Dimensions.getInstanceInPixels(meta.getWidth(), meta.getHeight());
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/Icon.java b/src/main/java/com/tractionsoftware/commons/image/Icon.java
new file mode 100644
index 0000000..aee16e9
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/Icon.java
@@ -0,0 +1,229 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.tractionsoftware.commons.io.CommonFileResourceType;
+import com.tractionsoftware.commons.io.FileResourceType;
+import com.tractionsoftware.commons.util.Dimensions;
+
+/**
+ * An Icon represents an image with particular display properties.
+ */
+public interface Icon {
+
+ /**
+ * Returns true if this Icon is "valid." The definition of validity as it applies here requires the icon image
+ * resource to be available, and to really represent an image.
+ *
+ * @return The definition of validity as it applies here requires the icon image resource to be available, and to
+ * really represent an image.
+ */
+ public boolean isValid();
+
+ /**
+ * Returns a file name for this icon image.
+ *
+ * @return a file name for this icon image.
+ */
+ public String getFilename();
+
+ /**
+ * Returns a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} object representing the dimensions that
+ * should be used to display this icon image, if the dimensions are known or can be determined (which should be the
+ * case if {@link #isValid() this Icon is valid}). These may or may not be the same as the image's intrinsic
+ * dimensions.
+ *
+ * @return a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} object representing the dimensions that
+ * should be used to display this icon image, if the dimensions are known or can be determined;
+ * {@link Dimensions#getInvalidInstanceInPixels() an invalid Dimensions instance otherwise}, with width and
+ * height both set to -1.
+ */
+ public Dimensions getDimensions();
+
+ /**
+ * Returns the value representing the pixel width that should be used to display this icon image, if the dimensions
+ * are known or can be determined (which should be the case if {@link #isValid() this Icon is valid}). This width
+ * may or may not be the same as the image's intrinsic width.
+ *
+ *
+ * This method will always be implemented to be the same as invoking {@link Dimensions#getWidth()} on the result of
+ * {@link #getDimensions()}. This default implementation should not generally be overridden.
+ *
+ * @return the value representing the pixel width that should be used to display this icon image, if the dimensions
+ * are known or can be determined; -1 otherwise.
+ */
+ public default int getWidth() {
+ return getDimensions().getWidth();
+ }
+
+ /**
+ * Returns the value representing the pixel height that should be used to display this icon image, if the dimensions
+ * are known or can be determined (which should be the case if {@link #isValid() this Icon is valid}). This height
+ * may or may not be the same as the image's intrinsic height.
+ *
+ *
+ * This method will always be implemented to be the same as invoking {@link Dimensions#getHeight()} on the result of
+ * {@link #getDimensions()}. This default implementation should not generally be overridden.
+ *
+ * @return the value representing the pixel height that should be used to display this icon image, if the dimensions
+ * are known or can be determined; -1 otherwise.
+ */
+ public default int getHeight() {
+ return getDimensions().getHeight();
+ }
+
+ /**
+ * Returns a Content-ID to use for this icon resource for at least the duration of the current request. Invocations
+ * on other requests for a FileInfo object referring to the same underlying resource may return different values.
+ *
+ * @return a Content-ID to use for this file resource for at least the duration of the current request.
+ */
+ public abstract String getContentId();
+
+ /**
+ * Returns a data: URL that encodes the icon image. This can be appropriate when the image needs to be embedded
+ * without referring to a URL that would require a separate request to retrieve.
+ *
+ * @return a data: URL that encodes the icon image.
+ */
+ public String getDataUrl();
+
+ /**
+ * Returns a String representing a width= HTML IMG tag attribute. For example,
+ *
+ *
+ * width="10"
+ *
+ *
+ *
+ * This can be useful for building HTML IMG elements.
+ *
+ * @return a String representing a width= HTML IMG tag attribute; or an empty string if the width is not known or
+ * otherwise not specified.
+ */
+ public default String getWidthHTML() {
+ return ImageUtil.getImgWidthAttributeHtml(getWidth());
+ }
+
+ /**
+ * Returns a String representing a height= HTML IMG tag attribute. For example,
+ *
+ *
+ * height="10"
+ *
+ *
+ *
+ * This can be useful for building HTML IMG elements.
+ *
+ * @return a String representing a width= HTML IMG tag attribute; or an empty string if the width is not known or
+ * otherwise not specified.
+ */
+ public default String getHeightHTML() {
+ return ImageUtil.getImgHeightAttributeHtml(getHeight());
+ }
+
+ /**
+ * Returns an Icon instance that represents a proportionally scaled resized version of this Icon which is as large
+ * as possible while still fitting within the given {@link Dimensions.Units#PIXELS pixel}-denominated
+ * {@link Dimensions}. If a more appropriately sized version of the same image is already available, that will be
+ * used by the new Icon instance, but the size of the new Icon's image will not be expanded beyond the original size
+ * of the largest available source image data.
+ *
+ * @param newMaxDimensions
+ * a {@link Dimensions.Units#PIXELS pixel}-denominated {@link Dimensions} representing the requested maximum
+ * dimensions. Passing null as the argument for this parameter is equivalent to requesting an Icon that has
+ * dimensions that are as large as possible (that is, no maximum).
+ * @return an Icon instance that represents a proportionally scaled resized version of this Icon which is as large
+ * as possible while still fitting within the given {@link Dimensions.Units#PIXELS pixel}-denominated
+ * {@link Dimensions}.
+ */
+ public Icon getScaled(Dimensions newMaxDimensions);
+
+ /**
+ * Returns an Icon instance that represents a version of this Icon whose {@link #getDimensions()} will return the
+ * given {@link Dimensions.Units#PIXELS pixel}-denominated {@link Dimensions}. This method is only intended to be
+ * used on an Icon whose the underlying image's dimensions are not known. Such an Icon instance
+ * {@link #isValid() will report that it is valid}, but its getDimensions() method will return null. This is most
+ * likely to happen in the case of an Icon created from an
+ * {@link CommonFileResourceType#EXTERNAL external resource}.
+ *
+ *
+ * Another case in which it might possibly be appropriate to use this method would be for some sort of place-holder
+ * image that can be stretched without regard to proportion, such as an image that is merely a single pixel.
+ *
+ *
+ * For all other resources, there should be no reason to invoke this method, but all implementations must handle it
+ * on a sort of best-efforts basis. At best, if a version of the underlying image resource that happens to exactly
+ * match the requested dimensions is available, that will be used by the new Icon instance. Otherwise, clients
+ * should expect that some modest effort may be made to identify and use an existing version with the
+ * closest partially or otherwise approximately matching dimensions and use that for the new Icon instance, and that
+ * even though getDimensions() will return the requested Dimensions, the image will not "natively" have those
+ * dimensions. This means that when it is displayed in the context of an HTML document via an IMG tag with
+ * corresponding width= and height= attributes, it will appear stretched or compressed in a non-proportional way. In
+ * those cases, unless there's some rare case in which the client is willing to use a non-proportionally stretched
+ * image, it is almost certainly preferable to choose one of these options: instead of trying to specify the
+ * dimensions of a static resource, load the Icon and use the dimensions that TeamPage reports; create an
+ * appropriately sized image and put it into a plug-in and reference that static Icon resource; or invoke
+ * {@link #getScaled(Dimensions)} to retrieve an instance that will not necessarily have the exact requested
+ * dimensions, but which will appear properly scaled.
+ *
+ * @param newDimensions
+ * a {@link Dimensions.Units#PIXELS pixel}-denominated {@link Dimensions} representing the requested dimensions
+ * for the new Icon instance. If null is passed as the argument for this parameter, the same Icon instance will
+ * be returned.
+ * @return an Icon instance that represents a version of this Icon whose {@link #getDimensions()} will return the
+ * given {@link Dimensions.Units#PIXELS pixel}-denominated {@link Dimensions}.
+ */
+ public Icon withDimensions(Dimensions newDimensions);
+
+ /**
+ * Returns a {@link IconFileResource} that can be used to provide direct access to this Icon image data, if
+ * possible. This can be useful if the image has to be attached to an email message, or in certain other cases.
+ *
+ * @return a {@link IconFileResource} that can be used to provide direct access to this Icon image data, if
+ * possible; null otherwise.
+ */
+ public IconFileResource getImageFileResource();
+
+ /**
+ * Returns the {@link FileResourceType} indicating the type of resource that this Icon instance represents. This
+ * method should never return null. For miscellaneous images, or anything otherwise uncategorized,
+ * {@link CommonFileResourceType#OTHER} should be returned.
+ *
+ *
+ * This implementation delegates to {@code getImageFileResource().getType()}. Subclasses should override it as
+ * necessary.
+ *
+ * @return the {@link FileResourceType} indicating the type of image resource that this Icon instance represents.
+ */
+ public default FileResourceType getImageResourceType() {
+ return getImageFileResource().getType();
+ }
+
+ public default String getTitle() {
+ return null;
+ }
+
+ public default String getAltText() {
+ return null;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/IconFileResource.java b/src/main/java/com/tractionsoftware/commons/image/IconFileResource.java
new file mode 100644
index 0000000..19b7e1e
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/IconFileResource.java
@@ -0,0 +1,279 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.tractionsoftware.commons.io.CommonFileResourceType;
+import com.tractionsoftware.commons.io.FileResource;
+import com.tractionsoftware.commons.io.FileResourceType;
+import com.tractionsoftware.commons.io.SizedInputStream;
+import com.tractionsoftware.commons.util.Dimensions;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.URI;
+import java.util.Date;
+import java.util.Objects;
+
+/**
+ * A special {@link FileResource} that is used to back {@link Icon} implementations. Since instances will frequently be
+ * cached and otherwise shared between multiple threads, implementations should almost always be thread-safe, and not
+ * carrying request-specific data. The main exception is {@link SimpleFileResourceIconFileAdapter}.
+ *
+ * @author Dave Shepperton
+ */
+public interface IconFileResource extends FileResource {
+
+ /**
+ * A simple type of IconFile representing an invalid instance, suitable for use as a placeholder.
+ */
+ public static final class InvalidIconFileResource extends AbstractIconFile {
+
+ private final FileResourceType resourceType;
+
+ public InvalidIconFileResource(@Nonnull FileResourceType resourceType) {
+ super();
+ Objects.requireNonNull(resourceType, "resource type");
+ this.resourceType = resourceType;
+ }
+
+ @Override
+ protected final boolean isUnderlyingFileValid() {
+ return false;
+ }
+
+ @Nonnull
+ @Override
+ public final SizedInputStream getInputStream() throws IOException {
+ throw new FileNotFoundException();
+ }
+
+ @Nonnull
+ @Override
+ public final FileResourceType getType() {
+ return resourceType;
+ }
+
+ @Nonnull
+ @Override
+ public final URI getURI() {
+ return URI.create("icon:invalid");
+ }
+
+ @Nonnull
+ @Override
+ public final String getPath() {
+ return "";
+ }
+
+ @Nonnull
+ @Override
+ public final String getFilename() {
+ return "";
+ }
+
+ @Nullable
+ @Override
+ public final String getContentType() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public final String getContentId() {
+ return null;
+ }
+
+ @Override
+ public final long getByteSize() {
+ return 0;
+ }
+
+ @Nonnull
+ @Override
+ public final Date getLastModified() {
+ return new Date(0);
+ }
+
+ @Nullable
+ @Override
+ public final String getDisplayName() {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public final String getDescription() {
+ return null;
+ }
+
+ @Nonnull
+ @Override
+ public final String toDebugString() {
+ return "NONE";
+ }
+
+ @Override
+ public final FileResourceType getImageResourceType() {
+ return resourceType;
+ }
+
+ }
+
+ /**
+ * Returns true if this icon file is valid. Validity for IconFiles generally requires that the basic
+ * {@link FileResource} validity check be fulfilled, and also that the underlying resource is a valid and supported
+ * type of image.
+ *
+ * @return true if this icon file instance is considered valid; false otherwise.
+ */
+ @Override
+ public boolean isValid();
+
+ /**
+ * Returns false because all IconFileResource instances must represent files, not directories.
+ */
+ @Override
+ public default boolean isDirectory() {
+ return false;
+ }
+
+ /**
+ * Returns an unscaled {@link Icon} representing an interpretation of this file as an image.
+ *
+ *
+ * This may require a request to the file system or a file repository service, which could be relatively slow
+ * compared with other methods in this class. Naturally, this method will not make sense for files that are not
+ * images, and it is very likely that if {@link #isImage()} returns true that this method will return an Icon that
+ * is not {@link Icon#isValid() valid}.
+ *
+ *
+ * This default implementation defers to {@link #getImage(Dimensions)}, passing null for the maximum
+ * {@link Dimensions}. It should be suitable for all IconFile implementations.
+ *
+ * @return an unscaled {@link Icon} representing an interpretation of this file as an image, if possible; null
+ * otherwise.
+ * @see #isImage()
+ */
+ @Nonnull
+ public default Icon getImage() {
+ return getImage(null);
+ }
+
+ @Nonnull
+ @Override
+ public default Icon getImage(Dimensions maxDimensions) {
+ return new SimpleIcon(this, maxDimensions);
+ }
+
+ /**
+ * Returns a display name for this IconFile. This may simply be {@link #getFilename() its file name}.
+ *
+ * @return a display name for this IconFile.
+ */
+ @Nullable
+ public String getDisplayName();
+
+ /**
+ * Returns a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} object representing the original
+ * dimensions of the image encapsulated by this IconFile. This should usually be cached on the IconFile instance,
+ * and therefore should not have to be cached on an {@link Icon} instance.
+ *
+ *
+ * If this instance is not {@link #isValid()}, this method will definitely return null, but can return null in
+ * certain other cases. This is most likely to happen in the case of an
+ * {@link CommonFileResourceType#EXTERNAL external resource} that cannot be retrieved by TeamPage in order to have
+ * its dimensions inspected. In that case, the Icon instance
+ * {@link Icon#isValid() will reflect that the resource should still be assumed to be valid}, and the missing
+ * {@link Icon#getDimensions() Dimensions}, in which case it would be appropriate for clients to use
+ * {@link Icon#withDimensions(Dimensions)} if appropriate display dimensions are known (e.g., from an SDL html.image
+ * tag's width= and height= attributes).
+ *
+ * @return a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} object representing the dimensions of
+ * the image encapsulated by this Icon, if available; null otherwise.
+ */
+ public Dimensions getOriginalDimensions();
+
+ /**
+ * Returns a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} object representing a proportionally
+ * scaled version of the original dimensions of the of the image encapsulated by this IconFile which fit into the
+ * given maximum dimensions.
+ *
+ *
+ * This default implementation uses {@link ImageUtil#getScaledDimensions(Dimensions, Dimensions)}, which should be
+ * suitable for all implementations.
+ *
+ * @param maxDimensions
+ * a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} object representing the requested maximum
+ * dimensions.
+ * @return a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} object representing a proportionally
+ * scaled version of the original dimensions of the of the image encapsulated by this IconFile which fit into
+ * the given maximum dimensions; or the original dimensions as-is if no maximum dimensions are specified, or if
+ * the original dimensions already fit into the maximum; or null if
+ * {@link #getOriginalDimensions() the original dimensions} are not available.
+ * @see ImageUtil#getScaledDimensions(Dimensions, Dimensions)
+ */
+ public default Dimensions getDimensions(Dimensions maxDimensions) {
+ return ImageUtil.getScaledDimensions(getOriginalDimensions(), maxDimensions);
+ }
+
+ /**
+ * Returns the {@link FileResourceType} indicating the type of image resource this represents. This is required for
+ * some {@link Icon} implementations.
+ *
+ * @return the {@link FileResourceType} indicating the type of image resource this represents.
+ */
+ public FileResourceType getImageResourceType();
+
+ /**
+ * Returns a more detailed descriptive String than {@code toString()}, suitable for debugging purposes.
+ *
+ * @return a more detailed descriptive String than {@code toString()}, suitable for debugging purposes.
+ */
+ @Nonnull
+ public String toDebugString();
+
+ /**
+ * This implementation always returns false because icon files are image resources, and will never be text.
+ */
+ @Override
+ public default boolean isText() {
+ return false;
+ }
+
+ /**
+ * This implementation always returns false because icon files are image resources, and will never be plain text.
+ */
+ @Override
+ public default boolean isPlainText() {
+ return false;
+ }
+
+ /**
+ * This implementation always returns false because icon files are image resources, and will never be HTML.
+ */
+ @Override
+ public default boolean isHtml() {
+ return false;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/ImageUtil.java b/src/main/java/com/tractionsoftware/commons/image/ImageUtil.java
new file mode 100644
index 0000000..d80d1b4
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/ImageUtil.java
@@ -0,0 +1,641 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.tractionsoftware.commons.html.HtmlUtil;
+import com.tractionsoftware.commons.io.FileResource;
+import com.tractionsoftware.commons.lang.NativeTypeConversion;
+import com.tractionsoftware.commons.io.FileUtil;
+import com.tractionsoftware.commons.lang.ObjectUtil;
+import com.tractionsoftware.commons.util.Dimensions;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.metadata.IIOMetadata;
+import javax.imageio.metadata.IIOMetadataNode;
+import javax.imageio.stream.ImageInputStream;
+import javax.swing.*;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.*;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.function.Supplier;
+
+/**
+ * Helper methods for images, including determining dimensions and generating scaled versions.
+ *
+ * @author Andy Keller, Dave Shepperton
+ */
+public final class ImageUtil {
+
+ private ImageUtil() {
+ }
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ImageUtil.class);
+
+ /**
+ * The set of image file name extensions that are generally supported on the web and by TeamPage: jpg (and jpeg),
+ * png, and gif. This doesn't mean that other images won't be supported, but only that TeamPage will generally only
+ * use or offer JPEG, PNG and GIF formatted images where web-facing resources are involved.
+ */
+ public static final Set SUPPORTED_IMAGE_FILE_EXTENSIONS = ImmutableSet.of("jpg", "jpeg", "png", "gif");
+
+ /**
+ * A {@link FileFilter} that only matches files that have one of the {@link #SUPPORTED_IMAGE_FILE_EXTENSIONS}.
+ */
+ public static final FileFilter SUPPORTED_IMAGE_FILES_FILE_FILTER =
+ FileUtil.getFileFilterByExtension(SUPPORTED_IMAGE_FILE_EXTENSIONS);
+
+ /**
+ * Represents the status of an attempt to create an image.
+ */
+ public static enum ImageCreationResultStatus {
+
+ /**
+ * The operation succeeded.
+ */
+ SUCCESS,
+
+ /**
+ * The operation was aborted because the target resource already exists.
+ */
+ ALREADY_EXISTS,
+
+ /**
+ * The operation failed.
+ */
+ FAILURE
+
+ }
+
+ /**
+ * Represents the result of an attempt to create an image.
+ */
+ public static interface ImageCreationResult {
+
+ /**
+ * Returns the {@link ImageCreationResultStatus} representing the status of the requested image creation
+ * operation.
+ *
+ * @return the {@link ImageCreationResultStatus} representing the status of the requested image creation
+ * operation.
+ */
+ public ImageCreationResultStatus getStatus();
+
+ /**
+ * Returns a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} if the image was successfully
+ * created.
+ *
+ * @return a {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} if the image was successfully
+ * created; {@link Dimensions#getInvalidInstanceInPixels() an invalid Dimensions object} otherwise.
+ */
+ public Dimensions dimensions();
+
+ /**
+ * Returns true if the operation failed. This should be the same as checking either whether {@link #getStatus()}
+ * return {@link ImageCreationResultStatus#FAILURE}.
+ *
+ * @return true if the operation failed; false otherwise.
+ */
+ public default boolean hadFailure() {
+ if (getStatus() == ImageCreationResultStatus.FAILURE) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns the Exception representing the error that caused the requested operation to fail, if any. This will
+ * always return null unless {@link #getStatus()} returns {@link ImageCreationResultStatus#FAILURE}.
+ *
+ * @return the Exception representing the error that caused the requested operation to fail, if any; null
+ * otherwise.
+ */
+ public Exception error();
+
+ }
+
+ /**
+ * Represents a result for a successful image creation attempt.
+ */
+ private record SuccessfulImageCreationResult(Dimensions dimensions) implements ImageCreationResult {
+
+ @Override
+ public final ImageCreationResultStatus getStatus() {
+ return ImageCreationResultStatus.SUCCESS;
+ }
+
+ @Override
+ public final Exception error() {
+ return null;
+ }
+
+ }
+
+ /**
+ * Represents the result for an image creation attempt that was aborted because the target resource seemed to
+ * already exist. Its {@link #dimensions()} method still provides access to the existing resource's dimensions in
+ * case they're still required.
+ */
+ private static final class AlreadyExistedImageCreationResult implements ImageCreationResult {
+
+ private final Supplier getInputStream;
+
+ private Dimensions dimensions = null;
+
+ private AlreadyExistedImageCreationResult(Supplier getInputStream) {
+ this.getInputStream = getInputStream;
+ }
+
+ @Override
+ public final ImageCreationResultStatus getStatus() {
+ return ImageCreationResultStatus.ALREADY_EXISTS;
+ }
+
+ @Override
+ public final Dimensions dimensions() {
+ if (dimensions == null) {
+ try (InputStream input = getInputStream.get()) {
+ dimensions = ImageUtil.getDimensions(input);
+ }
+ catch (Exception e) {
+ dimensions = Dimensions.getInvalidInstanceInPixels();
+ }
+ }
+ return dimensions;
+ }
+
+ @Override
+ public final Exception error() {
+ return null;
+ }
+
+ }
+
+ /**
+ * Represents a result for a failed image creation attempt.
+ */
+ private record FailedImageCreationResult(Exception error) implements ImageCreationResult {
+
+ @Override
+ public final ImageCreationResultStatus getStatus() {
+ return ImageCreationResultStatus.FAILURE;
+ }
+
+ @Override
+ public final Dimensions dimensions() {
+ return Dimensions.getInvalidInstanceInPixels();
+ }
+
+ }
+
+ /**
+ * Returns true if a file with the given extension or mime type identifier will be considered an image by the image
+ * format support in this JVM {@link ImageIO}.
+ */
+ public static final boolean isImage(String fileExtension, String mimeType) {
+ if (isImageExtension(fileExtension) || isImageMimeType(mimeType)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if a file with the given extension will be considered an image by the image format support in this
+ * JVM (via {@link ImageIO}).
+ */
+ public static final boolean isImageExtension(String fileExtension) {
+ if (StringUtils.isBlank(fileExtension)) {
+ return false;
+ }
+ try {
+ if (ImageIO.getImageReadersBySuffix(fileExtension).hasNext()) {
+ return true;
+ }
+ if ("webp".equalsIgnoreCase(fileExtension) ||
+ "heic".equalsIgnoreCase(fileExtension) ||
+ "heif".equalsIgnoreCase(fileExtension)) {
+ return true;
+ }
+ return false;
+ }
+ catch (Exception e) {
+ LOGGER.warn("Unable to find a decoder for file extension {}", fileExtension, e);
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if a file with the given mime type or content type will be considered an image by the image format
+ * support in this JVM (via {@link ImageIO}).
+ */
+ public static final boolean isImageMimeType(String mimeType) {
+ if (StringUtils.isBlank(mimeType)) {
+ return false;
+ }
+ try {
+ if (ImageIO.getImageReadersByMIMEType(mimeType).hasNext()) {
+ return true;
+ }
+ if ("image/webp".equalsIgnoreCase(mimeType) ||
+ "image/heic".equalsIgnoreCase(mimeType) ||
+ "image/heif".equalsIgnoreCase(mimeType)) {
+ return true;
+ }
+ return false;
+ }
+ catch (Exception e) {
+ LOGGER.warn("Unable to find a decoder for mime/content type {}", mimeType, e);
+ }
+ return false;
+ }
+
+ /**
+ * Returns the {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} representing the size of the image
+ * from the given {@link InputStream}.
+ *
+ * @param input
+ * from which the image data be read.
+ * @return the {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} representing the size of the image
+ * from the given {@link InputStream}, if a decoder can be found for the image format, and the dimensions can be
+ * determined successfully.
+ * @throws IOException
+ * if one is raised while attempting to use the given {@link InputStream}, or if no decoder
+ */
+ public static final Dimensions getDimensions(InputStream input) throws IOException {
+
+ try (ImageInputStream iis = ImageIO.createImageInputStream(input)) {
+
+ Iterator decoders = ImageIO.getImageReaders(iis);
+
+ if (decoders.hasNext()) {
+
+ ImageReader r = decoders.next();
+ r.setInput(iis);
+ int index = r.getMinIndex();
+ int width = r.getWidth(index);
+ int height = r.getHeight(index);
+ int[] dimensions = new int[] { width, height };
+ // special handling for retina PNG images
+ IIOMetadata imageMetadata = r.getImageMetadata(0);
+ if ("png".equals(r.getFormatName())) {
+ handleHiDpiPngDimensions(dimensions, imageMetadata);
+ }
+ return Dimensions.getInstanceInPixels(dimensions[0], dimensions[1]);
+
+ }
+
+ throw new IOException("Unsupported format: Failed to find an image decoder for " + input);
+
+ }
+
+ }
+
+ private static final void handleHiDpiPngDimensions(int[] dimensions, IIOMetadata imageMetadata) {
+
+ Node node = imageMetadata.getAsTree(imageMetadata.getNativeMetadataFormatName());
+ NodeList children = node.getChildNodes();
+ int len = children.getLength();
+
+ for (int i = 0; i < len; i++) {
+ Node child = children.item(i);
+ if (child instanceof IIOMetadataNode iioMetadataNode && "pHYs".equals(child.getNodeName())) {
+ handleHiDpiPngDimensions(dimensions, iioMetadataNode);
+ return;
+ }
+ }
+
+ }
+
+ private static final void handleHiDpiPngDimensions(int[] dimensions, IIOMetadataNode pHYsNode) {
+
+ // To understand how HiDPI PNGs are encoded, I used to following reference.
+ //
+ // http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html
+ //
+ // convert numbers to inches and check > 140
+ //
+ // [andy]
+
+ if (!"meter".equals(pHYsNode.getAttribute("unitSpecifier"))) {
+ return;
+ }
+
+ int pHYs_pixelsPerUnitXAxis = NativeTypeConversion.stringToInt(pHYsNode.getAttribute("pixelsPerUnitXAxis"), -1);
+ int pHYs_pixelsPerUnitYAxis = NativeTypeConversion.stringToInt(pHYsNode.getAttribute("pixelsPerUnitYAxis"), -1);
+
+ float dpiX = pHYs_pixelsPerUnitXAxis * 0.0254f;
+ float dpiY = pHYs_pixelsPerUnitYAxis * 0.0254f;
+
+ if (dpiX > 140 && dpiY > 140) {
+ dimensions[0] /= 2;
+ dimensions[1] /= 2;
+ }
+
+ }
+
+ /**
+ * Attempts to determine the appropriate file extension that would be used for the image that can be read from the
+ * given {@link InputStream}. Specifically, if the data from the InputStream seem to represent a supported image
+ * format, the value returned will be the result of invoking {@link ImageReader#getFormatName()} on an
+ * {@link ImageReader} from {@link ImageIO#getImageReaders(Object)} (as long as it returns a non-blank name).
+ *
+ * @param input
+ * an {@link InputStream} from which an image can be read.
+ * @return the appropriate file extension that would be used for the image that can be read from the given
+ * {@link InputStream}, if the data represent a valid image that uses a supported format; null otherwise.
+ */
+ public static final String getImageFileExtensionFromContents(InputStream input) {
+ try (ImageInputStream iis = ImageIO.createImageInputStream(input)) {
+ Iterator decoders = ImageIO.getImageReaders(iis);
+ if (decoders.hasNext()) {
+ ImageReader r = decoders.next();
+ r.setInput(iis);
+ String formatName = r.getFormatName();
+ if (StringUtils.isNotBlank(formatName)) {
+ return formatName;
+ }
+ }
+ }
+ catch (IOException e) {
+ LOGGER.warn(
+ "Unexpected failure reading image data from input {}", ObjectUtil.safeToStringObject(input), e
+ );
+ }
+ return null;
+ }
+
+ /**
+ * Determines the {@link Dimensions} representing the largest width and height no larger than the given original
+ * Dimensions, and proportional to the original Dimensions, but still fitting within the given maximum dimensions.
+ *
+ * @param original
+ * the {@link Dimensions} representing the original dimensions of an image.
+ * @param max
+ * the maximum dimensions for the proportional scaling, if any are required. If the argument for this parameter
+ * is null, the original {@link Dimensions} will be returned as-is.
+ * @return the {@link Dimensions} representing the largest width and height no larger than the given original
+ * Dimensions, and proportional to the original Dimensions, but still fitting within the given maximum
+ * dimensions, if maximum dimensions were requested; otherwise, the original {@link Dimensions} as-is.
+ */
+ public static final Dimensions getScaledDimensions(Dimensions original, Dimensions max) {
+
+ if (original == null || !original.hasAtLeastOneValidDimension()) {
+ return null;
+ }
+ if (max == null) {
+ return original;
+ }
+
+ // Start at 100%
+ int sw = original.getWidth();
+ int sh = original.getHeight();
+
+ double fw = scalefactor(sw, max.getWidth());
+ double fh = scalefactor(sh, max.getHeight());
+
+ // take the smaller
+ double f = Math.min(fw, fh);
+
+ if (f < 1) { // don't make images bigger
+ sw = (int) (sw * f);
+ sh = (int) (sh * f);
+ return Dimensions.getInstanceInPixels(sw, sh);
+ }
+
+ return original;
+
+ }
+
+ private static final double scalefactor(double l, double max) {
+ return (max == -1 || l == 0) ? 1.0 : (max / l);
+ }
+
+ /**
+ * Attempts to create a new PNG formatted image representing a scaled version of the image in the given original
+ * image {@link File}, constrained by the given maximum {@link Dimensions}, and storing the result in the given
+ * destination scaled image File.
+ *
+ * @param originalImageFile
+ * the {@link File} containing the original image file.
+ * @param scaledImageFile
+ * the destination {@link File} for the newly created scaled PNG formatted version of the original image.
+ * @param maxDimensions
+ * the {@link Dimensions}, if any, representing the dimensions for the proportional scaling of the original
+ * image, if any are required. If the argument for this parameter is null, the new image's dimensions will be
+ * the same as those of the original. See {@link #getScaledDimensions(Dimensions, Dimensions)}.
+ * @return an {@link ImageCreationResult} representing the status of the attempt.
+ */
+ public static final ImageCreationResult createScaledPNG(File originalImageFile, File scaledImageFile, Dimensions maxDimensions) {
+
+ // It's up to us to make sure the directory structure and
+ // destination file exist before we try to start using it.
+ File scaledImageFileDir = scaledImageFile.getParentFile();
+
+ if (!scaledImageFileDir.exists()) {
+ if (scaledImageFileDir.mkdirs()) {
+ LOGGER.debug("Created directory for scaled PNG {}", scaledImageFileDir);
+ }
+ }
+
+ boolean createdFile;
+ try {
+ createdFile = scaledImageFile.createNewFile();
+ }
+ catch (Exception e) {
+ LOGGER.error("Unable to create the file {}", scaledImageFile, e);
+ return new FailedImageCreationResult(e);
+ }
+
+ if (!createdFile) {
+ LOGGER.warn("The file {} already exists.", scaledImageFile);
+ return new AlreadyExistedImageCreationResult(() -> {
+ try {
+ return FileUtil.getBufferedInputStream(scaledImageFile);
+ }
+ catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ try (InputStream originalImageStream = FileUtil.getBufferedInputStream(originalImageFile);
+ OutputStream scaledImageStream = FileUtil.getBufferedOutputStream(scaledImageFile)) {
+ return createScaledPNG(originalImageStream, scaledImageStream, maxDimensions);
+ }
+ catch (Exception e) {
+ LOGGER.error("Unable to create a scaled PNG from the image file {}", originalImageFile, e);
+ return new FailedImageCreationResult(e);
+ }
+
+ }
+
+ /**
+ * Attempts to create a new PNG formatted image representing a scaled version of the image read from the given image
+ * {@link InputStream}, constrained by the given maximum {@link Dimensions}, and writing the result to the given
+ * {@link OutputStream}.
+ *
+ * @param originalImageInput
+ * the {@link InputStream} from which the original image can be read.
+ * @param scaledImageOutput
+ * the {@link OutputStream} to which the for the newly created scaled PNG formatted version of the original
+ * image will be written.
+ * @param maxDimensions
+ * the {@link Dimensions}, if any, representing the dimensions for the proportional scaling of the original
+ * image, if any are required. If the argument for this parameter is null, the new image's dimensions will be
+ * the same as those of the original. See {@link #getScaledDimensions(Dimensions, Dimensions)}.
+ * @return an {@link ImageCreationResult} representing the status of the attempt.
+ */
+ public static final ImageCreationResult createScaledPNG(InputStream originalImageInput, OutputStream scaledImageOutput, Dimensions maxDimensions) {
+
+ try {
+
+ BufferedImage originalImage = ImageIO.read(originalImageInput);
+
+ originalImage = cropToSquare(originalImage);
+
+ Dimensions originalDimensions =
+ Dimensions.getInstanceInPixels(originalImage.getWidth(), originalImage.getHeight());
+
+ // We use ImageIcon as a hack to make sure the image is completely loaded; if it isn't, the drawImage
+ // operation will fail.
+ loadImageAsIcon(originalImage);
+ ImageIcon scaledImageIcon;
+
+ // If the image is already small enough, we don't need
+ // to scale it.
+ Dimensions scaledDimensions = getScaledDimensions(originalDimensions, maxDimensions);
+ if (scaledDimensions != null) {
+ scaledImageIcon = new ImageIcon(
+ originalImage.getScaledInstance(
+ scaledDimensions.getWidth(), scaledDimensions.getHeight(), Image.SCALE_SMOOTH
+ )
+ );
+ }
+ // -1 -1 uses the original dimensions.
+ else {
+ scaledImageIcon = new ImageIcon(originalImage.getScaledInstance(-1, -1, Image.SCALE_SMOOTH));
+ }
+
+ int finalWidth = scaledImageIcon.getIconWidth();
+ int finalHeight = scaledImageIcon.getIconHeight();
+ BufferedImage renderedImage = new BufferedImage(finalWidth, finalHeight, BufferedImage.TYPE_INT_ARGB);
+ Graphics g = renderedImage.getGraphics();
+ g.drawImage(scaledImageIcon.getImage(), 0, 0, null);
+ // JavaDoc says this is a good idea, even though gc
+ // will do it automatically.
+ g.dispose();
+ ImageIO.write(renderedImage, "png", scaledImageOutput);
+ scaledImageOutput.flush();
+ return new SuccessfulImageCreationResult(Dimensions.getInstanceInPixels(finalWidth, finalHeight));
+
+ }
+ catch (IOException | RuntimeException e) {
+ LOGGER.error("A problem was encountered while attempting to create a scaled PNG image", e);
+ return new FailedImageCreationResult(e);
+ }
+
+ }
+
+ /**
+ * Gets a {@link BufferedImage} corresponding to a cropped square version of the given BufferedImage.
+ *
+ * @param img
+ * the {@link BufferedImage} representing the existing image data.
+ * @return a {@link BufferedImage} corresponding to a cropped square version of the given BufferedImage.
+ */
+ public static final BufferedImage cropToSquare(BufferedImage img) {
+
+ // original width and height
+ int ow = img.getWidth();
+ int oh = img.getHeight();
+
+ if (ow == oh) {
+ return img;
+ }
+
+ // new width and height
+ int w = ow;
+ int h = oh;
+
+ // delta/offset where we should start crop
+ int dw = 0;
+ int dh = 0;
+
+ if (ow < oh) {
+ h = w;
+ dh = (oh - h) / 2;
+ }
+ else {
+ w = h;
+ dw = (ow - w) / 2;
+ }
+
+ try {
+ return img.getSubimage(dw, dh, w, h);
+ }
+ catch (RuntimeException e) {
+ LOGGER.warn("Unable to crop {} to {}x{} dw={} dh={}", ObjectUtil.safeToStringObject(img), w, h, dw, dh, e);
+ }
+ return img;
+
+ }
+
+ public static final String getImgWidthAttributeHtml(int width) {
+ if (width < 0) {
+ return "";
+ }
+ return HtmlUtil.getTagAttribute(HtmlUtil.ATTRIBUTE_NAME_WIDTH, String.valueOf(width));
+ }
+
+ public static final String getImgHeightAttributeHtml(int height) {
+ if (height < 0) {
+ return "";
+ }
+ return HtmlUtil.getTagAttribute(HtmlUtil.ATTRIBUTE_NAME_HEIGHT, String.valueOf(height));
+ }
+
+ public static final Dimensions getDimensionsForImageFile(FileResource file, Dimensions maximumDimensions) {
+ if (file.isDirectory()) {
+ return Dimensions.getInvalidInstanceInPixels();
+ }
+ return Dimensions.getImageDimensionsInPixels(file, maximumDimensions);
+ }
+
+ public static final Supplier> getDimensionsSupplier(final FileResource file, final Dimensions maxDimensions) {
+ return Suppliers.memoize(() -> ImageUtil.getDimensionsForImageFile(file, maxDimensions));
+ }
+
+ @CanIgnoreReturnValue
+ private static final ImageIcon loadImageAsIcon(Image image) {
+ return new ImageIcon(image);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/SimpleFileResourceIconFileAdapter.java b/src/main/java/com/tractionsoftware/commons/image/SimpleFileResourceIconFileAdapter.java
new file mode 100644
index 0000000..b7c3eee
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/SimpleFileResourceIconFileAdapter.java
@@ -0,0 +1,165 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.tractionsoftware.commons.io.FileResource;
+import com.tractionsoftware.commons.io.FileResourceType;
+import com.tractionsoftware.commons.io.SizedInputStream;
+import com.tractionsoftware.commons.util.Dimensions;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Date;
+import java.util.Objects;
+
+/**
+ * An {@link IconFileResource} implementation in terms of a generic
+ * {@link com.tractionsoftware.commons.io.FileResource}. Note that since instance carries a FileInfo, unlike other
+ * IconFile implementations, these are not safe to be shared between threads.
+ *
+ * @author Dave Shepperton
+ */
+public final class SimpleFileResourceIconFileAdapter extends AbstractIconFile {
+
+ public static final SimpleFileResourceIconFileAdapter createInstance(FileResource file, FileResourceType fileResourceType) {
+ Objects.requireNonNull(file, "FileResource");
+ Objects.requireNonNull(fileResourceType, "file resource type");
+ return new SimpleFileResourceIconFileAdapter(file, fileResourceType);
+ }
+
+ private final FileResource file;
+
+ private final FileResourceType fileResourceType;
+
+ private SimpleFileResourceIconFileAdapter(FileResource file, FileResourceType fileResourceType) {
+ this.file = file;
+ this.fileResourceType = fileResourceType;
+ }
+
+ @Override
+ public final boolean equals(Object other) {
+ if (other instanceof SimpleFileResourceIconFileAdapter otherAdapter &&
+ file.equals(otherAdapter.file)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public final int hashCode() {
+ return Objects.hash(file);
+ }
+
+ @Override
+ public final String toString() {
+ return getClass().getSimpleName() + ":{" + file + "}";
+ }
+
+ @Nonnull
+ @Override
+ public final FileResourceType getType() {
+ return file.getType();
+ }
+
+ @Nonnull
+ @Override
+ public final String toDebugString() {
+ StringBuilder ret = new StringBuilder();
+ ret.append(getClass().getSimpleName());
+ ret.append(":{");
+ ret.append(file);
+ Dimensions originalDimensions = getOriginalDimensions();
+ if (originalDimensions != null) {
+ ret.append(" (");
+ ret.append(originalDimensions);
+ ret.append(")");
+ }
+ if (!isValid()) {
+ ret.append(" [INVALID]");
+ }
+ ret.append("}");
+ return ret.toString();
+ }
+
+ @Override
+ protected final boolean isUnderlyingFileValid() {
+ return file.isValid();
+ }
+
+ @Nonnull
+ @Override
+ public final URI getURI() {
+ return file.getURI();
+ }
+
+ @Nonnull
+ @Override
+ public final String getPath() {
+ return file.getPath();
+ }
+
+ @Nonnull
+ @Override
+ public final String getFilename() {
+ return file.getFilename();
+ }
+
+ @Nullable
+ @Override
+ public final String getDataUrl() {
+ return file.getDataUrl();
+ }
+
+ @Nonnull
+ @Override
+ public final SizedInputStream getInputStream() throws IOException {
+ return file.getInputStream();
+ }
+
+ @Override
+ public final String getContentType() {
+ return file.getContentType();
+ }
+
+ @Override
+ public final String getContentId() {
+ return file.getContentId();
+ }
+
+ @Override
+ public final long getByteSize() {
+ return file.getByteSize();
+ }
+
+ @Nonnull
+ @Override
+ public final Date getLastModified() {
+ return file.getLastModified();
+ }
+
+ @Override
+ public final FileResourceType getImageResourceType() {
+ return fileResourceType;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/SimpleIcon.java b/src/main/java/com/tractionsoftware/commons/image/SimpleIcon.java
new file mode 100644
index 0000000..e00b03a
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/SimpleIcon.java
@@ -0,0 +1,129 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.tractionsoftware.commons.util.Dimensions;
+
+import java.util.Objects;
+
+/**
+ * A complete implementation of an {@link Icon} based upon an {@link IconFileResource} instance.
+ *
+ * @author Dave Shepperton
+ */
+public final class SimpleIcon implements Icon {
+
+ private final IconFileResource iconFile;
+
+ private final Dimensions maxDimensions;
+
+ public SimpleIcon(IconFileResource iconFile) {
+ this(iconFile, null);
+ }
+
+ public SimpleIcon(IconFileResource iconFile, Dimensions maxDimensions) {
+ this.iconFile = iconFile;
+ this.maxDimensions = maxDimensions;
+ }
+
+ @Override
+ public final String toString() {
+ StringBuilder ret = new StringBuilder();
+ ret.append(getClass().getSimpleName());
+ ret.append(":{");
+ ret.append(iconFile);
+ if (maxDimensions != null) {
+ ret.append(", ");
+ ret.append(maxDimensions);
+ }
+ ret.append("}");
+ return ret.toString();
+ }
+
+ @Override
+ public final boolean isValid() {
+ return iconFile.isValid();
+ }
+
+ @Override
+ public final String getFilename() {
+ return iconFile.getFilename();
+ }
+
+
+ @Override
+ public final String getContentId() {
+ return iconFile.getContentId();
+ }
+
+ @Override
+ public final String getDataUrl() {
+ return iconFile.getDataUrl();
+ }
+
+ @Override
+ public final Dimensions getDimensions() {
+ Dimensions dimensions = iconFile.getDimensions(maxDimensions);
+ if (dimensions == null) {
+ return Dimensions.getInvalidInstanceInPixels();
+ }
+ return dimensions;
+ }
+
+ @Override
+ public final IconFileResource getImageFileResource() {
+ return iconFile;
+ }
+
+ /**
+ * This implementation is somewhat intelligent in that it won't attempt to retrieve or create a new {@link Icon} if
+ * the requested maximum {@link Dimensions} are the same as the ones for this instance.
+ */
+ @Override
+ public final Icon getScaled(Dimensions newMaxDimensions) {
+ if (Objects.equals(this.maxDimensions, newMaxDimensions)) {
+ return this;
+ }
+ if (newMaxDimensions == null ||
+ newMaxDimensions.equals(iconFile.getOriginalDimensions())) {
+ return iconFile.getImage();
+ }
+ return iconFile.getImage(newMaxDimensions);
+ }
+
+ /**
+ * This implementation is somewhat intelligent in that it won't attempt to retrieve or create a new {@link Icon} if
+ * the requested {@link Dimensions} are the same as the ones for this instance, and otherwise makes a token attempt
+ * to identify the closest matching available version of the file to use with
+ * {@link ForwardingIcon#wrapWithForcedDimensions(Icon, Dimensions)}.
+ */
+ public final Icon withDimensions(Dimensions newDimensions) {
+ if (Objects.equals(getDimensions(), newDimensions)) {
+ return this;
+ }
+ if (newDimensions == null ||
+ newDimensions.equals(iconFile.getOriginalDimensions())) {
+ return iconFile.getImage();
+ }
+ return ForwardingIcon.wrapWithForcedDimensions(iconFile.getImage(newDimensions), newDimensions);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/image/WebPUtil.java b/src/main/java/com/tractionsoftware/commons/image/WebPUtil.java
new file mode 100644
index 0000000..7bd2655
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/image/WebPUtil.java
@@ -0,0 +1,481 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.image;
+
+import com.google.common.collect.ImmutableMap;
+import com.tractionsoftware.commons.util.Dimensions;
+import org.slf4j.LoggerFactory;
+import org.slf4j.Logger;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Helpers for the WebP format.
+ *
+ *
+ * References:
+ *
+ *
+ *
+ * @author Dave Shepperton
+ */
+public final class WebPUtil {
+
+ private WebPUtil() {
+ }
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(WebPUtil.class);
+
+ private static final int LENGTH_UINT32 = 4;
+
+ private static final int HEADER_LENGTH_WEBP = 8;
+
+ private static final byte[] HEADER_RIFF = new byte[] { 'R', 'I', 'F', 'F' };
+
+ private static final byte[] HEADER_WEBP = new byte[] { 'W', 'E', 'B', 'P' };
+
+ private static final byte[] HEADER_VP8 = new byte[] { 'V', 'P', '8' };
+
+ private static final int HEADER_VP8_OFFSET = 4;
+
+ public static final Dimensions getDimensions(InputStream input) throws IOException {
+
+ // "RIFF" block plus file size block
+ byte[] data = input.readNBytes(8);
+
+ checkSufficientBytesX("WebP header", HEADER_LENGTH_WEBP, data.length);
+ checkRequiredBytesX("RIFF header", HEADER_RIFF, data);
+
+ // "WEBP" block plus "VP8*" block
+ data = input.readNBytes(8);
+ checkSufficientBytesX("WEBP/VP8 header", HEADER_LENGTH_WEBP, data.length);
+ checkRequiredBytesX("WEBP header", HEADER_WEBP, data);
+ checkRequiredBytesX("WEBP header", HEADER_VP8, data, HEADER_VP8_OFFSET);
+
+ Format format = getFormat((char) data[7]);
+
+ int chunkSize = getUInt32("Chunk size", input.readNBytes(LENGTH_UINT32), 0);
+ Metadata metadata = format.readMetadata(input, chunkSize);
+ Dimensions result = metadata.getDimensions();
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("Decoded WebP image dimensions ({}): {}", metadata.getFormat().getName(), result);
+ }
+ return result;
+
+ }
+
+ public static final class Metadata {
+
+ private final Format format;
+
+ private final Dimensions dimensions;
+
+ private final ImmutableMap otherAttributes;
+
+ private Metadata(Format format, Dimensions dimensions, ImmutableMap otherAttributes) {
+ this.format = format;
+ this.dimensions = dimensions;
+ this.otherAttributes = otherAttributes;
+ }
+
+ public Format getFormat() {
+ return format;
+ }
+
+ public final Dimensions getDimensions() {
+ return dimensions;
+ }
+
+ public final ImmutableMap getOtherAttributes() {
+ return otherAttributes;
+ }
+
+ }
+
+ /**
+ * Represents a WebP format, of which there are three.
+ *
+ *
+ * The common header elements for all WebP formats are 20 bytes long, and are as follows:
+ *
+ *
+ * 1. String "RIFF"
+ * -> 4 bytes, 0 - 3
+ *
+ * 2. A little-endian 32 bit value of the block length, the whole size of the block controlled by the
+ * RIFF header. Normally this equals the payload size (file size minus 8 bytes: 4 bytes for the 'RIFF'
+ * identifier and 4 bytes for storing the value itself).
+ * -> 4 byte, 4 - 7
+ *
+ * 3. String "WEBP" (RIFF container name).
+ * -> 4 bytes, 8 - 11
+ *
+ * 4. String "VP8*" (chunk tag with one of ' ' [space], 'L' or 'X' in place of the '*').
+ * -> 4 bytes, 12 - 15
+ *
+ * 5. A little-endian 32 bit value representing the chunk size.
+ * -> 4 bytes, 16 - 19
+ *
+ *
+ *
+ * All of those elements are read in {@link #getDimensions(InputStream)} before invoking
+ * {@link Format#readMetadata(InputStream, int)}, so implementations should assume that those 20 bytes have already
+ * been read from the {@link InputStream}.
+ */
+ public static abstract class Format {
+
+ public String toString() {
+ return getName();
+ }
+
+ /**
+ * Returns the name of this format.
+ *
+ * @return the name of this format.
+ */
+ public abstract String getName();
+
+ /**
+ * Returns the character from the "VP8*" header that corresponds to this format.
+ *
+ * @return the character from the "VP8*" header that corresponds to this format.
+ */
+ public abstract char getCode();
+
+ /**
+ * Reads the metadata for a stream of data that is encoded in this format.
+ *
+ * @param input
+ * an {@link InputStream} that contains the data, which will have been advanced past the first 20 bytes, so
+ * that the next byte that will be read is byte 20 (0-indexed, or byte 21 1-indexed).
+ * @return the {@link Metadata} for the input interpreted according to this format's specification.
+ * @throws IOException
+ * if there is a problem reading from the {@link InputStream} or if the data are not compatible with this
+ * format.
+ */
+ public final Metadata readMetadata(InputStream input) throws IOException {
+ return readMetadata(input, -1);
+ }
+
+ /**
+ * Reads the metadata for a stream of data that is encoded in this format.
+ *
+ * @param input
+ * an {@link InputStream} that contains the data, which will have been advanced past the first 20 bytes, so
+ * that the next byte that will be read is byte 20 (0-indexed, or byte 21 1-indexed).
+ * @param chunkSize
+ * the chunk size read from the header, if any; -1 otherwise.
+ * @return the {@link Metadata} for the input interpreted according to this format's specification.
+ * @throws IOException
+ * if there is a problem reading from the {@link InputStream} or if the data are not compatible with this
+ * format.
+ */
+ public abstract Metadata readMetadata(InputStream input, int chunkSize) throws IOException;
+
+ }
+
+ /**
+ *
+ * Simple Format: Lossy
+ *
+ * After the common header, the lossy format uses the same encoding as a key frame in the VP8 data format as
+ * described in RFC 6386, with the assumption that the frame width and height refers to the image's canvas width and
+ * height. The VP8 data stream is formatted follows:
+ *
+ * 1. Common frame tag, with four fields:
+ * 1. A 1-bit frame type (0 for key frames, 1 for interframes).
+ * 2. A 3-bit version number (0 - 3 are defined as four different profiles with different decoding
+ * complexity; other values may be defined for future variants of the VP8 data format).
+ * 3. A 1-bit show_frame flag (0 when current frame is not for display, 1 when current frame is for
+ * display).
+ * 4. A 19-bit field containing the size of the first data partition in bytes.
+ * -> 3 bytes, 20 - 22
+ *
+ * 2. Start Code bytes: 0x9D, 0x01, 0x2A.
+ * -> 3 bytes, 23 - 25
+ *
+ * 8. Horizontal scale and width: (2 bits Horizontal Scale << 14) | Width (14 bits).
+ * -> 2 bytes, 26 - 27
+ *
+ * 9. Vertical scale and height: (2 bits Vertical Scale << 14) | Height (14 bits)
+ * -> 2 bytes, 28 - 29
+ *
+ */
+ static final Format LOSSY = new Format() {
+
+ private static final int HEADER_LENGTH = 10;
+
+ private static final byte[] START_CODE_BYTES = new byte[] { (byte) 0x9D, 0x01, 0x2A };
+
+ private static final int getWebPWidthFieldVP8Lossy(byte[] data) {
+ return getDimensionFieldVPC8KeyFrame(data, 6);
+ }
+
+ private static final int getWebPHeightFieldVP8Lossy(byte[] data) {
+ return getDimensionFieldVPC8KeyFrame(data, 8);
+ }
+
+ private static final int getDimensionFieldVPC8KeyFrame(byte[] data, int index) {
+ return (data[index + 1] & 0xFF) | ((data[index] & 0x3F) << 8);
+ }
+
+ @Override
+ public final String getName() {
+ return "Lossy";
+ }
+
+ @Override
+ public final char getCode() {
+ return ' ';
+ }
+
+ @Override
+ public final Metadata readMetadata(InputStream input, int chunkSize) throws IOException {
+
+ if (chunkSize >= 0) {
+ checkSufficientBytesX("WebP lossy format header", HEADER_LENGTH, chunkSize);
+ }
+
+ byte[] data = input.readNBytes(HEADER_LENGTH);
+
+ checkSufficientBytesX("WebP lossy format header", HEADER_LENGTH, data.length);
+ checkRequiredBytesX("WebP lossy format start code bytes", START_CODE_BYTES, data, 3);
+
+ int width = getWebPWidthFieldVP8Lossy(data);
+ int height = getWebPHeightFieldVP8Lossy(data);
+ Dimensions dimensions = Dimensions.getInstanceInPixels(width, height);
+
+ return new Metadata(this, dimensions, ImmutableMap.of());
+
+ }
+
+ };
+
+ /**
+ *
+ * Simple Format: Lossless
+ *
+ * After the common header, the data are formatted follows:
+ *
+ * 1. One byte signature 0x2f.
+ * -> 1 byte, 20
+ *
+ * 2. The VP8 bitstream data. The first 28 bits of the bitstream specify the width and height of the image. Width
+ * and height are decoded as 14-bit integers as follows:
+ * int image_width = ReadBits(14) + 1;
+ * int image_height = ReadBits(14) + 1;
+ * The 14-bit precision for image width and height limits the maximum size of a WebP lossless image to
+ * 16384✕16384 pixels.
+ *
+ */
+ static final Format LOSSLESS = new Format() {
+
+ private static final int getWebPWidthFieldVP8Lossless(byte[] data) {
+ return getIntFromUInt8(data, 1, 0xFF, 0) | getIntFromUInt8(data, 2, 0x3F, 8);
+ }
+
+ private static final int getWebPHeightFieldVP8Lossless(byte[] data) {
+ return getIntFromUInt8(data, 2, 0xC0, -6) |
+ getIntFromUInt8(data, 3, 0xFF, 2) |
+ getIntFromUInt8(data, 4, 0x0F, 10);
+ }
+
+ @Override
+ public final String getName() {
+ return "Lossless";
+ }
+
+ @Override
+ public final char getCode() {
+ return 'L';
+ }
+
+ @Override
+ public final Metadata readMetadata(InputStream input, int chunkSize) throws IOException {
+
+ // 1 B for signature, 4 bytes for width and height info.
+ byte[] data = input.readNBytes(5);
+ if (data[0] != 0x2F) {
+ throw new IOException("Missing signature byte for lossless WebP format.");
+ }
+
+ Dimensions dimensions = Dimensions.getInstanceInPixels(
+ 1 + getWebPWidthFieldVP8Lossless(data),
+ 1 + getWebPHeightFieldVP8Lossless(data)
+ );
+ return new Metadata(this, dimensions, ImmutableMap.of());
+
+ }
+
+ };
+
+ /**
+ *
+ * Extended Format
+ *
+ * After the common header, the data are formatted follows:
+ *
+ * 1. One byte with bit flags.
+ * -> 1 byte, 20
+ *
+ * 2. Three bytes, reserved.
+ * -> 3 bytes, 21 - 23
+ *
+ * 3. Canvas Width Minus One: 24 bits. 1-based width of the canvas in pixels. The actual canvas width is
+ * 1 + Canvas Width Minus One
+ * -> 3 bytes, 24 - 26
+ *
+ * 4. Canvas Height Minus One: 24 bits. 1-based height of the canvas in pixels. The actual canvas height
+ * is 1 + Canvas Height Minus One.
+ * -> 3 bytes, 27 - 29
+ *
+ */
+ static final Format EXTENDED = new Format() {
+
+ private static final int CHUNK_SIZE_METADATA = 6;
+
+ private static final int getWebPWidthFieldVP8Extended(byte[] data) throws IOException {
+ // byte 4 in the chunk is where the width field starts
+ return getUInt24("WebP width field (VP8 extended)", data, 4);
+ }
+
+ private static final int getWebPHeightFieldVP8Extended(byte[] data) throws IOException {
+ // byte 7 in the chunk is where the height field starts
+ return getUInt24("WebP height field (VP8 extended)", data, 7);
+ }
+
+ @Override
+ public final String getName() {
+ return "Extended";
+ }
+
+ @Override
+ public final char getCode() {
+ return 'X';
+ }
+
+ @Override
+ public final Metadata readMetadata(InputStream input, int chunkSize) throws IOException {
+ if (chunkSize >= 0) {
+ checkSufficientBytesX("WebP extended format metadata", CHUNK_SIZE_METADATA, chunkSize);
+ }
+ byte[] data = input.readNBytes(chunkSize);
+ checkSufficientBytesX("WebP extended format metadata", CHUNK_SIZE_METADATA, data.length);
+ int width = 1 + getWebPWidthFieldVP8Extended(data);
+ int height = 1 + getWebPHeightFieldVP8Extended(data);
+ if (((long) width) * ((long) height) >= 0x100000000L) {
+ throw new IOException(
+ "WebP extended format dimensions (" + width + "x" + height + ") invalid (too large)."
+ );
+ }
+ return new Metadata(this, Dimensions.getInstanceInPixels(width, height), ImmutableMap.of());
+ }
+
+ };
+
+ static Format getFormat(char code) throws IOException {
+ return switch (code) {
+ case ' ' -> LOSSY;
+ case 'L' -> LOSSLESS;
+ case 'X' -> EXTENDED;
+ default -> throw new IOException(
+ "Unrecognized WebP VP8 format code '" + code + "' (" + Integer.toHexString(code) + ")."
+ );
+ };
+ }
+
+ private static final short getUInt8(String desc, byte[] data, int index) throws IOException {
+ if (data.length < index) {
+ throw new IOException(String.format("Insufficient data for %s.", desc));
+ }
+ return (short) (data[index] & 0xFF);
+ }
+
+ private static final int getUInt24(String desc, byte[] data, int offset) throws IOException {
+ if (data.length < offset + 2) {
+ throw new IOException(String.format("Insufficient data for %s.", desc));
+ }
+ return (((int) data[offset + 2]) << 16 & 0xFF0000) |
+ (((int) data[offset + 1]) << 8 & 0xFF00) |
+ (((int) data[offset]) & 0xFF);
+ }
+
+ private static final int getUInt32(String desc, byte[] data, int index) throws IOException {
+ if (data.length < index + 3) {
+ throw new IOException(String.format("Insufficient data for %s.", desc));
+ }
+ return (((int) data[index + 3]) << 24 & 0xFF000000) |
+ (((int) data[index + 2]) << 16 & 0xFF0000) |
+ (((int) data[index + 1]) << 8 & 0xFF00) |
+ (((int) data[index]) & 0xFF);
+ }
+
+ private static final int getIntFromUInt8(byte[] data, int index, int mask, int lShift) {
+ int v = (data[index] & mask);
+ if (lShift == 0) {
+ return v;
+ }
+ if (lShift > 1) {
+ return v << lShift;
+ }
+ return v >> -lShift;
+ }
+
+ private static final void checkSufficientBytesX(String desc, int requiredLength, int actualLength)
+ throws IOException {
+ if (actualLength < requiredLength) {
+ throw new IOException(
+ String.format("Insufficient bytes for %s (%d < %d).", desc, actualLength, requiredLength)
+ );
+ }
+ }
+
+ private static final void checkRequiredBytesX(String desc, byte[] requiredData, byte[] actualData)
+ throws IOException {
+ checkRequiredBytesX(desc, requiredData, actualData, 0);
+ }
+
+ private static final void checkRequiredBytesX(String desc, byte[] requiredData, byte[] actualData, int offset)
+ throws IOException {
+ int len = requiredData.length;
+ for (int i = 0; i < len; i++) {
+ if (requiredData[i] != actualData[i + offset]) {
+ throw new IOException(String.format("Missing/invalid %s.", desc));
+ }
+ }
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/AbstractCustomPrintWriter.java b/src/main/java/com/tractionsoftware/commons/io/AbstractCustomPrintWriter.java
new file mode 100644
index 0000000..19007b5
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/AbstractCustomPrintWriter.java
@@ -0,0 +1,142 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.annotations.Beta;
+import com.tractionsoftware.commons.util.DefaultFormatterProvider;
+import com.tractionsoftware.commons.util.FormatterProvider;
+import org.apache.commons.io.function.IORunnable;
+
+import java.io.*;
+import java.util.Formatter;
+import java.util.Locale;
+
+/**
+ * A skeleton class for a customized {@link PrintWriter}. This is necessary at least in part because some of
+ * PrintWriter's internals that would be useful to access to modify its default behaviors are private.
+ *
+ * @author Dave Shepperton
+ */
+@Beta
+public abstract class AbstractCustomPrintWriter extends PrintWriter {
+
+ private FormatterProvider formatterProvider;
+
+ protected final boolean autoFlush;
+
+ public AbstractCustomPrintWriter(Writer out, boolean autoFlush) {
+ super(out, autoFlush);
+ this.autoFlush = autoFlush;
+ }
+
+ public AbstractCustomPrintWriter(OutputStream out, boolean autoFlush) {
+ super(out, autoFlush);
+ this.autoFlush = autoFlush;
+ }
+
+ @Override
+ public void flush() {
+ try {
+ checkOpenX();
+ out.flush();
+ }
+ catch (IOException e) {
+ setError();
+ }
+ }
+
+ @Override
+ public void close() {
+ if (out != null) {
+ try {
+ out.close();
+ out = null;
+ }
+ catch (IOException e) {
+ setError();
+ }
+ }
+ }
+
+ /**
+ * Flushes if auto flush is enabled.
+ */
+ protected final void autoFlush() {
+ if (autoFlush) {
+ flush();
+ }
+ }
+
+ /**
+ * Checks whether this AbstractCustomPrintWriter is still open, {@link #setError() setting the error condition} if
+ * not
+ *
+ * @return true if this AbstractCustomPrintWriter is still open; false otherwise.
+ */
+ protected final boolean checkOpenQ() {
+ if (out == null) {
+ setError();
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Checks whether this AbstractCustomPrintWriter is still open.
+ *
+ * @throws IOException
+ * if this AbstractCustomPrintWriter is no longer open.
+ */
+ protected final void checkOpenX() throws IOException {
+ if (out == null) {
+ throw new IOException("Stream closed");
+ }
+ }
+
+ protected final Formatter getDefaultFormatter() {
+ return formatterProvider().getDefault();
+ }
+
+ protected final Formatter getFormatter(Locale l) {
+ return formatterProvider().get(l);
+ }
+
+ protected final void doOperation(IORunnable operation) {
+ try {
+ checkOpenX();
+ operation.run();
+ }
+ catch (InterruptedIOException e) {
+ Thread.currentThread().interrupt();
+ }
+ catch (IOException e) {
+ setError();
+ }
+ }
+
+ private final FormatterProvider formatterProvider() {
+ if (formatterProvider == null) {
+ formatterProvider = DefaultFormatterProvider.getInstance(this);
+ }
+ return formatterProvider;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/AbstractTempFileResource.java b/src/main/java/com/tractionsoftware/commons/io/AbstractTempFileResource.java
new file mode 100644
index 0000000..a8994bb
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/AbstractTempFileResource.java
@@ -0,0 +1,338 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.lang.JavaUtil;
+import com.tractionsoftware.commons.lang.Resource;
+import com.tractionsoftware.commons.lang.ResourceUtil;
+import jakarta.annotation.Nonnull;
+
+import java.io.*;
+import java.nio.charset.Charset;
+import java.util.Objects;
+
+/**
+ * Common super-class for {@link TempFileResource} implementations.
+ *
+ * @author Dave Shepperton
+ */
+public abstract class AbstractTempFileResource extends FileMetadataBasedFileResource implements
+ TempFileResource {
+
+ private static final IllegalStateException alreadyReturned(String name) {
+ throw new IllegalStateException(name + " already returned.");
+ }
+
+ private JavaUtil.CleanupTargetWrapper currentOutput;
+
+ private JavaUtil.CleanupTargetWrapper currentPrintWriter;
+
+ private JavaUtil.CleanupTargetWrapper inputStreams;
+
+ private final long sizeLimit;
+
+ protected AbstractTempFileResource(MutableFileMetadata metadata) {
+ this(metadata, -1L);
+ }
+
+ protected AbstractTempFileResource(MutableFileMetadata metadata, long sizeLimit) {
+ super(metadata);
+ this.sizeLimit = sizeLimit;
+ }
+
+ @Override
+ public final String toString() {
+ return getClass().getSimpleName() +
+ ":{" +
+ getFilename() +
+ " - " +
+ getToStringIdentifier() +
+ "}";
+ }
+
+ @Override
+ public final void flush() throws IOException {
+ if (currentPrintWriter != null) {
+ currentPrintWriter.get().flush();
+ }
+ else if (currentOutput != null) {
+ currentOutput.get().flush();
+ }
+ }
+
+ @Override
+ public final void close() throws IOException {
+
+ RuntimeException firstError = null;
+ try {
+ closeInput();
+ }
+ catch (RuntimeException e) {
+ firstError = e;
+ }
+
+ flushOutputQuietly();
+ try {
+ closeOutput();
+ }
+ catch (IOException e) {
+ if (firstError != null) {
+ e.addSuppressed(firstError);
+ }
+ throw e;
+ }
+ catch (RuntimeException e) {
+ if (firstError != null) {
+ firstError.addSuppressed(e);
+ throw firstError;
+ }
+ throw e;
+ }
+
+ }
+
+ private final void closeInput() {
+ if (inputStreams != null) {
+ inputStreams.close();
+ inputStreams = null;
+ }
+ }
+
+ private final void closeOutput() throws IOException {
+ if (currentPrintWriter != null) {
+ currentPrintWriter.close();
+ }
+ else if (currentOutput != null) {
+ currentOutput.close();
+ }
+ }
+
+ private final void flushOutputQuietly() {
+ try {
+ flush();
+ }
+ catch (IOException | RuntimeException e) {
+ LOGGER.warn("Unexpected issue flushing output for {}", this, e);
+ }
+ }
+
+ private final void closeOutputQuietly() {
+ try {
+ closeOutput();
+ }
+ catch (IOException | RuntimeException e) {
+ LOGGER.warn("Unexpected issue closing output for {}", this, e);
+ }
+ }
+
+ @Override
+ public String getErrorMessage() {
+ return null;
+ }
+
+ @Nonnull
+ @Override
+ public final SizedInputStream getInputStream() throws IOException {
+ if (hasAnyInputStreams()) {
+ checkNoOutputStreamX();
+ }
+ return new SizedInputStream(createInputStream()) {
+ @Override
+ public final long size() {
+ return AbstractTempFileResource.this.getByteSize();
+ }
+ };
+ }
+
+ @Override
+ public final BufferedReader getReader(Charset charset) throws IOException {
+ return IOUtil.getBufferedReader(getInputStream(), Objects.requireNonNullElseGet(charset, this::getCharset));
+ }
+
+ @Override
+ public final OutputStream getOutputStream() throws IOException {
+ if (currentOutput == null) {
+ checkNoPrintWriterX();
+ checkNoOutputStreamX();
+ checkNoInputStreamX();
+ currentOutput = JavaUtil.CleanupTargetWrapper.create(this, createOutputStream());
+ }
+ return currentOutput.get();
+ }
+
+ @Override
+ public final PrintWriter getUtf8PrintWriter() throws IOException {
+ if (currentPrintWriter == null) {
+ checkNoOutputStreamX();
+ currentPrintWriter = JavaUtil.CleanupTargetWrapper.create(
+ this, SingleThreadPrintWriter.createUtf8Instance(getOutputStream())
+ );
+ }
+ return currentPrintWriter.get();
+ }
+
+ @Override
+ public final boolean delete() {
+
+ closeInput();
+ closeOutputQuietly();
+
+ try {
+ doDelete();
+ return true;
+ }
+ catch (IOException e) {
+ LOGGER.warn("Failed to delete temporary file {}", this, e);
+ return false;
+ }
+
+ }
+
+ @Override
+ public final void save() throws IOException {
+ flush();
+ closeOutputQuietly();
+ doSave();
+ }
+
+ protected String getToStringIdentifier() {
+ return getURI().toString();
+ }
+
+ protected abstract InputStream createRawInputStream() throws IOException;
+
+ protected abstract OutputStream createRawOutputStream() throws IOException;
+
+ protected abstract void doSave() throws IOException;
+
+ /**
+ * Actually delete the underlying resource if it still exists.
+ *
+ * @throws IOException
+ * if the operation was attempted but failed, but not when the operation was a no-op (e.g., if the underlying
+ * resource does not exist, possibly because it has already been deleted).
+ */
+ protected abstract void doDelete() throws IOException;
+
+ /**
+ * This is for the implementation to apply whatever changes have now been committed to the {@link OutputStream}
+ * created by {@link #createRawOutputStream()}.
+ */
+ protected abstract void onRealOutputStreamClosed();
+
+ protected Resource createTracker() {
+ return ResourceUtil.NO_OP_RESOURCE;
+ }
+
+ private final InputStream createInputStream() throws IOException {
+ if (inputStreams == null) {
+ inputStreams = JavaUtil.CleanupTargetWrapper.create(
+ this, new InputStreamTracker(toString(), this::createTracker)
+ );
+ }
+ return inputStreams.get().createTrackedStream(this::createRawInputStream);
+ }
+
+ private final OutputStream createOutputStream() throws IOException {
+
+ OutputStream output = createRawOutputStream();
+
+ try {
+ output = IOUtil.getSizeLimitingOutputStream(output, getSizeLimit(), true);
+ LOGGER.debug("Opened OutputStream for {}", this);
+ return IOUtil.getCloseNotifyingOutputStream(
+ output, this::onBeforeCloseOutput, this::onAfterCloseOutput
+ );
+ }
+ catch (RuntimeException | Error e) {
+ IOUtil.close(output);
+ throw e;
+ }
+
+ }
+
+ private final void onBeforeCloseOutput() {
+ if (currentPrintWriter != null) {
+ currentPrintWriter.get().flush();
+ currentPrintWriter = null;
+ }
+ currentOutput = null;
+ }
+
+ private final void onAfterCloseOutput() {
+ LOGGER.debug("Closed OutputStream for {}", this);
+ onRealOutputStreamClosed();
+ }
+
+ public final long getSizeLimit() {
+ return sizeLimit;
+ }
+
+ protected final boolean hasAnyInputStreams() {
+ if (inputStreams == null) {
+ return false;
+ }
+ return inputStreams.get().hasAnyStreams();
+ }
+
+ protected final void checkNoInputStreamX() throws IllegalStateException {
+ if (hasAnyInputStreams()) {
+ throw alreadyReturned("InputStream");
+ }
+ }
+
+ protected final void checkNoOutputStreamX() throws IllegalStateException {
+ if (currentOutput != null) {
+ throw alreadyReturned("OutputStream");
+ }
+ }
+
+ protected final void checkNoPrintWriterX() throws IllegalStateException {
+ if (currentPrintWriter != null) {
+ throw alreadyReturned("PrintWriter");
+ }
+ }
+
+ protected final boolean checkNoInputStreamQ() {
+ if (hasAnyInputStreams()) {
+ LOGGER.warn("InputStream still for {} open.", this, alreadyReturned("OutputStream"));
+ return false;
+ }
+ return true;
+ }
+
+ protected final boolean checkNoOutputStreamQ() {
+ if (currentOutput != null) {
+ LOGGER.warn("OutputStream still for {} open.", this, alreadyReturned("OutputStream"));
+ return false;
+ }
+ return true;
+ }
+
+ protected final boolean checkNoPrintWriterQ() {
+ if (currentPrintWriter != null) {
+ LOGGER.warn("PrintWriter still for {} open.", this, alreadyReturned("PrintWriter"));
+ return false;
+ }
+ return true;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/ByteBufferInputStream.java b/src/main/java/com/tractionsoftware/commons/io/ByteBufferInputStream.java
new file mode 100644
index 0000000..456bca6
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/ByteBufferInputStream.java
@@ -0,0 +1,109 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.annotations.Beta;
+import jakarta.annotation.Nonnull;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.util.Objects;
+
+/**
+ * An {@link InputStream} that wraps a {@link ByteBuffer}, which is created or retrieved on demand.
+ *
+ * @author Dave Shepperton
+ */
+@Beta
+public final class ByteBufferInputStream extends InputStream {
+
+ @FunctionalInterface
+ public static interface ByteBufferCreator {
+
+ public ByteBuffer create() throws IOException;
+
+ }
+
+ public static final ByteBufferInputStream createInstance(String text, Charset charset) {
+ Objects.requireNonNull(text, "text");
+ Objects.requireNonNull(charset, "Charset");
+ return new ByteBufferInputStream(() -> charset.encode(text));
+ }
+
+ public static final ByteBufferInputStream createInstance(byte[] data) {
+ return new ByteBufferInputStream(() -> ByteBuffer.wrap(data));
+ }
+
+ public static final ByteBufferInputStream createInstance(ByteBuffer data) {
+ return new ByteBufferInputStream(() -> data);
+ }
+
+ private final ByteBufferCreator buffCreator;
+
+ private ByteBuffer buff;
+
+ private boolean closed = false;
+
+ public ByteBufferInputStream(ByteBufferCreator buffCreator) {
+ this.buffCreator = buffCreator;
+ }
+
+ @Override
+ public final int read() throws IOException {
+ if (!buff().hasRemaining()) {
+ return -1;
+ }
+ return buff.get() & 0xFF;
+ }
+
+ @Override
+ public final int read(@Nonnull byte[] bytes, int off, int len) throws IOException {
+ if (!buff().hasRemaining()) {
+ return -1;
+ }
+ len = Math.min(len, buff.remaining());
+ buff.get(bytes, off, len);
+ return len;
+ }
+
+ @Override
+ public final void close() {
+ if (!closed) {
+ if (buff != null) {
+ buff = null;
+ }
+ closed = true;
+ }
+ }
+
+ private final ByteBuffer buff() throws IOException {
+ if (buff == null) {
+ if (closed) {
+ throw new IOException("closed");
+ }
+ buff = buffCreator.create();
+ }
+ return buff;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/CommonFileResourceType.java b/src/main/java/com/tractionsoftware/commons/io/CommonFileResourceType.java
new file mode 100644
index 0000000..775ffe4
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/CommonFileResourceType.java
@@ -0,0 +1,87 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+/**
+ * Some common file resource types.
+ *
+ * @author Dave Shepperton
+ */
+public enum CommonFileResourceType implements FileResourceType {
+
+ /**
+ * Content, such as an image embedded in a document.
+ */
+ CONTENT(true),
+
+ /**
+ * An icon for a type of file.
+ */
+ ICON_FILE_TYPE(true),
+
+ ICON_OTHER(true),
+
+ /**
+ * A logo or other banner image.
+ */
+ LOGO(true),
+
+ /**
+ * A user profile picture image.
+ */
+ PROFILE_PICTURE(true),
+
+ /**
+ * Inline data, usually from a data: URI.
+ */
+ DATA(false),
+
+ /**
+ * Referencing an external source, but which has been retrieved and persisted locally.
+ */
+ EXTERNAL_RETRIEVED(true),
+
+ /**
+ * Referencing an external source.
+ */
+ EXTERNAL(false),
+
+ /**
+ * A thumbnail of a content image.
+ */
+ CONTENT_THUMBNAIL(false),
+
+ /**
+ * Something else.
+ */
+ OTHER(false);
+
+ private final boolean supportsContentId;
+
+ private CommonFileResourceType(boolean supportsContentId) {
+ this.supportsContentId = supportsContentId;
+ }
+
+ public final boolean supportsContentId() {
+ return supportsContentId;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/ErrorTempFileResource.java b/src/main/java/com/tractionsoftware/commons/io/ErrorTempFileResource.java
new file mode 100644
index 0000000..4119503
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/ErrorTempFileResource.java
@@ -0,0 +1,136 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import jakarta.annotation.Nonnull;
+
+import java.io.*;
+import java.net.URI;
+import java.util.Date;
+import java.util.Objects;
+
+public final class ErrorTempFileResource extends FileMetadataBasedFileResource
+ implements TempFileResource {
+
+ public final static ErrorTempFileResource createInstance(MutableFileMetadata metadata, Exception error) {
+ Objects.requireNonNull(metadata, "metadata");
+ Objects.requireNonNull(metadata.getFilename(), "file name from metadata");
+ return new ErrorTempFileResource(metadata, error);
+ }
+
+ private final Exception error;
+
+ private final Date date;
+
+ private ErrorTempFileResource(MutableFileMetadata metadata, Exception error) {
+ super(metadata);
+ this.error = error;
+ this.date = new Date();
+ }
+
+ @Override
+ public final boolean delete() {
+ return true;
+ }
+
+ @Override
+ public final void save() throws IOException {
+ throw fileNotFoundException();
+ }
+
+ @Override
+ public final boolean exists() {
+ return false;
+ }
+
+ @Override
+ public final String getErrorMessage() {
+ return error.getMessage();
+ }
+
+ @Override
+ public final boolean hadError() {
+ return true;
+ }
+
+ @Override
+ public final OutputStream getOutputStream() throws IOException {
+ throw fileNotFoundException();
+ }
+
+ @Override
+ public final PrintWriter getUtf8PrintWriter() throws IOException {
+ throw fileNotFoundException();
+ }
+
+ @Override
+ public final void flush() {
+ }
+
+ @Override
+ public final void close() {
+ }
+
+ @Override
+ public final boolean isValid() {
+ return false;
+ }
+
+ @Nonnull
+ @Override
+ public final URI getURI() {
+ return URI.create(getURISpec());
+ }
+
+ @Nonnull
+ @Override
+ public final SizedInputStream getInputStream() throws IOException {
+ throw fileNotFoundException();
+ }
+
+ @Override
+ public final long getByteSize() {
+ return 0;
+ }
+
+ @Nonnull
+ @Override
+ public final Date getLastModified() {
+ return date;
+ }
+
+ private final String getURISpec() {
+ String ext = getExtension();
+ if (ext == null) {
+ return URI_ID_ERROR;
+ }
+ return URI_ID_ERROR + "." + ext;
+ }
+
+ /**
+ * Throws a {@link FileNotFoundException} shared by certain methods as a way of asserting that the underlying file
+ * doesn't actually exist.
+ */
+ private final FileNotFoundException fileNotFoundException() {
+ return new FileNotFoundException("This temporary file doesn't exist.");
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/FileIconService.java b/src/main/java/com/tractionsoftware/commons/io/FileIconService.java
new file mode 100644
index 0000000..1ba8000
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/FileIconService.java
@@ -0,0 +1,130 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.image.Icon;
+import com.tractionsoftware.commons.image.IconFileResource;
+import com.tractionsoftware.commons.image.SimpleIcon;
+import com.tractionsoftware.commons.lang.JavaUtil;
+import com.tractionsoftware.commons.lang.ObjectUtil;
+import com.tractionsoftware.commons.util.Dimensions;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URL;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+public abstract class FileIconService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FileIconService.class);
+
+ public static final Icon NO_ICON = new SimpleIcon(
+ new IconFileResource.InvalidIconFileResource(CommonFileResourceType.ICON_FILE_TYPE)
+ );
+
+ public static final FileIconService NONE = new FileIconService() {
+
+ @Nonnull
+ @Override
+ protected final Icon getIconImpl(@Nonnull FileResource file, @Nullable Dimensions maxDimensions) {
+ return NO_ICON.getScaled(maxDimensions);
+ }
+
+ @Override
+ protected String getIconStyleNameImpl(FileResource file) {
+ return null;
+ }
+
+ @Override
+ protected final URL getURLImpl(@Nonnull Icon icon) {
+ return null;
+ }
+
+ };
+
+ private static final Supplier extends FileIconService> instance = JavaUtil.lazyServiceLoader(
+ FileIconService.class, NONE, LOGGER
+ );
+
+ @Nonnull
+ public static final FileIconService get() {
+ return instance.get();
+ }
+
+ @Nonnull
+ public final Icon getIcon(@Nonnull FileResource file) {
+ return getIcon(file, null);
+ }
+
+ @Nonnull
+ public final Icon getIcon(@Nonnull FileResource file, Dimensions maxDimensions) {
+ Icon icon;
+ try {
+ icon = getIconImpl(file, maxDimensions);
+ }
+ catch (RuntimeException e) {
+ LOGGER.warn("Failed to retrieve a file icon for {}", ObjectUtil.safeToString(file), e);
+ icon = null;
+ }
+ return Objects.requireNonNullElse(icon, NO_ICON);
+ }
+
+ @Nullable
+ public final String getIconStyleName(@Nonnull FileResource file) {
+ try {
+ return getIconStyleNameImpl(file);
+ }
+ catch (RuntimeException e) {
+ LOGGER.warn("Failed to retrieve a style name for the icon for {}", ObjectUtil.safeToString(file), e);
+ return null;
+ }
+ }
+
+ @Nullable
+ public URL getURL(@Nonnull Icon icon) {
+ FileResourceType type = icon.getImageResourceType();
+ if (type != CommonFileResourceType.ICON_FILE_TYPE) {
+ throw new IllegalArgumentException(
+ "Expected type " + CommonFileResourceType.ICON_FILE_TYPE + ", got " + type + "."
+ );
+ }
+ try {
+ return getURLImpl(icon);
+ }
+ catch (RuntimeException e) {
+ LOGGER.warn("Failed to construct a URL for icon {} ", ObjectUtil.safeToStringObject(icon), e);
+ return null;
+ }
+ }
+
+ @Nullable
+ protected abstract Icon getIconImpl(FileResource file, @Nullable Dimensions maxDimensions);
+
+ @Nullable
+ protected abstract String getIconStyleNameImpl(FileResource file);
+
+ @Nullable
+ protected abstract URL getURLImpl(@Nonnull Icon icon);
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/FileMetadata.java b/src/main/java/com/tractionsoftware/commons/io/FileMetadata.java
new file mode 100644
index 0000000..de47aaa
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/FileMetadata.java
@@ -0,0 +1,255 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.image.ImageUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+
+import java.net.URI;
+import java.util.Objects;
+
+/**
+ * Represents a collection of logical metadata for a file resource. This includes properties such as the file's publicly
+ * advertised name, its media type (i.e., "Content-Type" or "mime type"), various "Content-*" headers that are
+ * associated with an original MIME part (i.e., email attachment) from which the file was created, whether it has been
+ * "persisted" to its final storage location or is a temporary file, and other properties. The underlying file may be a
+ * file on disk, or in any other repository.
+ *
+ *
+ * Contrast with {@link FileResource} which provides access to the actual file (i.e., the file's actual data, wherever
+ * that may be stored).
+ *
+ * @author Andy Keller, Dave Shepperton
+ * @see MutableFileMetadata
+ * @see SimpleMutableFileMetadata
+ */
+public interface FileMetadata {
+
+ /**
+ * Returns the public name for the file resource. It must be a valid cross-platform file name, even if it does not
+ * correspond to the name of a real file on disk.
+ *
+ * @return the public name for the file resource.
+ */
+ @Nullable
+ public String getFilename();
+
+ /**
+ * Returns the {@link URI} that defines the location of the file in a store of some sort. This may be a file: URI or
+ * something else.
+ *
+ * @return the {@link URI} that defines the location of the file in a store of some sort. This may be a file: URI or
+ * something else.
+ */
+ @Nullable
+ public URI getURI();
+
+ /**
+ * Returns the specification for the {@link URI} that defines the location of the file in a store of some sort. This
+ * may be a file: URI or something else.
+ *
+ * @return the specification {@link URI} that defines the location of the file in a store of some sort. This may be
+ * a file: URI or something else.
+ */
+ @Nullable
+ public default String getURISpec() {
+ return Objects.toString(getURI(), null);
+ }
+
+ /**
+ * Returns a text description of the file.
+ *
+ * @return a text description of the file, if one is available; null otherwise.
+ */
+ @Nullable
+ public String getDescription();
+
+ /**
+ * Returns the media type designation -- "Content-Type" or "mime type" -- that was associated with this file as it
+ * was originally created. This may come from the "Content-Type" header in a MIME message part (i.e., an email
+ * attachment), the "Content-Type" HTTP request header, or some other source. If no media type was specified at the
+ * time the file was being created, this method may still return a value if a media type was automatically
+ * determined when the file was being persisted. See:
+ *
+ *
+ *
+ * @return the "Content-Type" for the file if one is known or can be determined for this FileMetadata; null
+ * otherwise.
+ */
+ @Nullable
+ public String getContentType();
+
+ /**
+ * Returns the serial number for the file in the context of a list of files. This is generally used for an assigned
+ * and immutable numeric identifier for a file; more specifically, it usually corresponds to an attachment's ID.
+ *
+ * @return the serial number for the file in the context of a list of files; -1 otherwise.
+ */
+ public int getNumber();
+
+ /**
+ * Returns true if this FileMetadata represents a reference to a "persisted" file, such as an attachment or shared
+ * file that has been stored in the appropriate repository, as opposed to a temporary file.
+ *
+ * @return true if this FileMetadata represents a reference to a "persisted" file; false otherwise.
+ */
+ public boolean isReferenceToPersistedFile();
+
+ /**
+ * Returns true if this FileMetadata represents a reference to a temp file. This is as opposed to a "persisted"
+ * file, such as an attachment or shared file that has been stored in the appropriate repository.
+ *
+ *
+ * This default implementation returns {@code !isReferenceToPersistedFile()}, which must always be true by
+ * definition. Subclasses should not generally override it.
+ *
+ * @return true if this FileMetadata represents a reference to a temp file; false otherwise.
+ */
+ public default boolean isReferenceToTempFile() {
+ return !isReferenceToPersistedFile();
+ }
+
+ /**
+ * If this file came from a MIME message part (i.e., an email attachment), this method returns the value of the
+ * "Content-ID" header for that part. See RFC 2392 .
+ *
+ * @return the value of the "Content-Id" header associated with this file, if one has been specified; null
+ * otherwise.
+ */
+ @Nullable
+ public String getContentId();
+
+ /**
+ * If this file came from a MIME message part (i.e., an email attachment), this method returns the value of the
+ * "Content-Location" header for that part. See RFC 2557 .
+ *
+ * @return the value of the "Content-Location" header associated with this file, if one has been specified; null
+ * otherwise.
+ */
+ @Nullable
+ public String getContentLocation();
+
+ /**
+ * Returns the "Content-Base" header that was associated with this file as it was originally created from a MIME
+ * message part (i.e., an email attachment). The "Content-Base" header is meant to serve as a base URL so that
+ * relative URLs in the "Content-Location" header can be fully qualified. See RFC 2110 .
+ *
+ * @return the "Content-Base" header that was associated with this file as it was originally created from a MIME
+ * message part (i.e., an email attachment).
+ */
+ @Nullable
+ public String getContentBase();
+
+ /**
+ * Returns true if this {@link FileMetadata} has a file name with no problems, containing no illegal or discouraged
+ * characters.
+ *
+ * @return true if this {@link FileMetadata} has a minimally legal file name, containing no illegal or discouraged
+ * characters.
+ * @see FileNameUtil#checkFileName(String)
+ */
+ public default boolean hasGoodFileName() {
+ return FileNameUtil.checkFileName(getFilename()).noProblem();
+ }
+
+ /**
+ * Returns true if this {@link FileMetadata} has a minimally legal file name.
+ *
+ * @return true if this {@link FileMetadata} has a minimally legal file name.
+ * @see FileNameUtil#checkFileName(String)
+ */
+ public default boolean hasLegalFileName() {
+ return FileNameUtil.checkFileName(getFilename()).legal();
+ }
+
+ /**
+ * Returns the file name extension, if any, based upon {@link #getFilename() the currently set file name}.
+ *
+ *
+ * This default implementation defers to {@link FileNameUtil#getExtension(String, String)}, which should be suitable
+ * for all implementations.
+ *
+ * @return the file name extension, if any, based upon {@link #getFilename() the currently set file name}, if any;
+ * null or blank otherwise.
+ */
+ public default String getExtension() {
+ return FileNameUtil.getExtension(getFilename(), null);
+ }
+
+ public default boolean appearsToBeImage() {
+ if (ImageUtil.isImage(getExtension(), getContentType())) {
+ return true;
+ }
+ return false;
+ }
+
+ @Nullable
+ public FileResourceType getResourceType();
+
+ /**
+ * Returns a view of this FileMetadata that offers read-only access to properties.
+ *
+ * @return a FileMetadata that provides a view of this FileMetadata that offers read-only access to properties.
+ */
+ @Nonnull
+ public FileMetadata toReadOnly();
+
+ /**
+ * Creates a new {@link MutableFileMetadata} populated by copying all properties from this FileMetadata.
+ *
+ * @return a new {@link MutableFileMetadata} populated by copying all properties from this FileMetadata.
+ */
+ @Nonnull
+ public default MutableFileMetadata mutableCopy() {
+ return SimpleMutableFileMetadata.createCopy(this);
+ }
+
+ /**
+ * Copies the source {@link FileMetadata} to an {@link MutableFileMetadata}.
+ *
+ * @param destination
+ * a {@link MutableFileMetadata} to which the metadata from this FileMetadata will be copied.
+ */
+ public default void copyTo(@Nullable MutableFileMetadata destination) {
+
+ if (destination == null) {
+ return;
+ }
+
+ destination.setFilename(getFilename());
+ destination.setDescription(getDescription());
+ destination.setContentType(getContentType());
+ destination.setURI(getURI());
+ destination.setReferenceToPersistedFile(isReferenceToPersistedFile());
+ destination.setNumber(getNumber());
+ destination.setContentId(getContentId());
+ destination.setContentLocation(getContentLocation());
+ destination.setContentBase(getContentBase());
+
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/FileMetadataBasedFileResource.java b/src/main/java/com/tractionsoftware/commons/io/FileMetadataBasedFileResource.java
new file mode 100644
index 0000000..b5040e4
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/FileMetadataBasedFileResource.java
@@ -0,0 +1,84 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+
+import java.net.URI;
+import java.util.Objects;
+
+public abstract class FileMetadataBasedFileResource implements FileResource {
+
+ protected final F metadata;
+
+ public FileMetadataBasedFileResource(@Nonnull F metadata) {
+ Objects.requireNonNull(metadata, "metadata");
+ this.metadata = metadata;
+ }
+
+ @Nonnull
+ @Override
+ public URI getURI() {
+ URI uri = metadata.getURI();
+ Objects.requireNonNull(uri, "metadata URI");
+ return uri;
+ }
+
+ @Nonnull
+ @Override
+ public String getFilename() {
+ String fileName = metadata.getFilename();
+ Objects.requireNonNull(fileName, "metadata file name");
+ return fileName;
+ }
+
+ @Nullable
+ @Override
+ public String getDescription() {
+ return metadata.getDescription();
+ }
+
+ @Nullable
+ @Override
+ public String getContentType() {
+ return metadata.getContentType();
+ }
+
+ @Nullable
+ @Override
+ public String getContentId() {
+ return metadata.getContentId();
+ }
+
+ @Nonnull
+ @Override
+ public FileMetadata getMetadata() {
+ return metadata.toReadOnly();
+ }
+
+ @Nonnull
+ @Override
+ public FileResourceType getType() {
+ return Objects.requireNonNullElse(metadata.getResourceType(), CommonFileResourceType.OTHER);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/FileNameUtil.java b/src/main/java/com/tractionsoftware/commons/io/FileNameUtil.java
new file mode 100644
index 0000000..694dece
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/FileNameUtil.java
@@ -0,0 +1,908 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.annotations.Beta;
+import com.google.common.base.Ascii;
+import com.google.common.base.CharMatcher;
+import com.tractionsoftware.commons.net.MediaTypeUtil;
+import com.tractionsoftware.commons.text.CharBasedFilteringTextMapper;
+import com.tractionsoftware.commons.net.URLUtil;
+import com.tractionsoftware.commons.lang.EnhancedCharSequence;
+import com.tractionsoftware.commons.lang.StringUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.File;
+import java.util.Objects;
+import java.util.function.IntPredicate;
+import java.util.function.Supplier;
+
+public final class FileNameUtil {
+
+ private FileNameUtil() {
+ }
+
+ /**
+ * The usual separator in a file name that appears before the file extension.
+ */
+ public static final char EXTENSION_SEPARATOR_CHAR = '.';
+
+ /**
+ * String version of {@link #EXTENSION_SEPARATOR_CHAR}.
+ */
+ public static final String EXTENSION_SEPARATOR = ".";
+
+ public static final char CURRENT_PATH_INDICATOR_CHAR = '.';
+
+ public static final String CURRENT_PATH_INDICATOR = ".";
+
+ /**
+ * The default path separator used in Windows (backslash).
+ *
+ *
+ * C:\Program Files\Foo\bar
+ *
+ */
+ public static final char WINDOWS_PATH_SEPARATOR_CHAR = '\\';
+
+ /**
+ * The default generic path separator used in UNIX, Linux, macOS, etc. (forward slash).
+ *
+ *
+ * /Users/bob/foo/bar
+ *
+ */
+ public static final char GENERIC_PATH_SEPARATOR_CHAR = '/';
+
+ /**
+ * String version of {@link #GENERIC_PATH_SEPARATOR_CHAR}.
+ */
+ public static final String GENERIC_PATH_SEPARATOR = "/";
+
+ /**
+ * Consecutive dot characters (.), usually denoting the parent container in a path specification.
+ *
+ *
+ * ../foo/bar
+ *
+ */
+ public static final String CONSECUTIVE_DOTS = "..";
+
+ private static final char INVALID_FILE_NAME_CHARACTER_REPLACEMENT_CHAR = '_';
+
+ public static final int FILE_NAME_LENGTH_LIMIT = 255;
+
+ /**
+ * On Windows, these are considered reserved, and are explicitly disallowed in file names:
+ *
+ *
+ * < (less than)
+ * > (greater than)
+ * : (colon)
+ * ; (semi-colon - not usually illegal, but can cause problems in various contexts)
+ * " (double quotation mark)
+ * / (slash)
+ * / (backslash)
+ * | (vertical bar or pipe)
+ * ? (question mark)
+ * * (asterisk)
+ *
+ *
+ *
+ * See Naming Files, Paths, and Namespaces
+ */
+ public static final CharMatcher RESERVED_FILENAME_CHARACTERS = CharMatcher.anyOf(
+ EnhancedCharSequence.getInstance(
+ new char[] {
+ '<', '>', ':', '"', GENERIC_PATH_SEPARATOR_CHAR, WINDOWS_PATH_SEPARATOR_CHAR, '|', '*', '?'
+ }
+ )
+ );
+
+ /**
+ * These characters are dangerous because they are often part of commands on UNIX-like systems.
+ *
+ *
+ * Java ISO control characters, including 0x0 through 0x1f (such as NUL, BEL and ESC), as well as 0x7f (DEL)
+ * through 0x9f ()
+ * ; (semi-colon)
+ * ` (backtick)
+ *
+ */
+ public static final CharMatcher DISCOURAGED_FILENAME_CHARACTERS = CharMatcher.anyOf(
+ EnhancedCharSequence.getInstance(new char[] { ';', '&', '`', Ascii.HT, Ascii.NL, Ascii.CR, Ascii.FF })
+ );
+
+ /**
+ * The following reserved characters are not allowed:
+ *
+ *
+ * Java ISO control characters, including 0x0 through 0x1f (such as NUL, BEL and ESC), as well as 0x7f through
+ * 0x9f (such as DEL and C1 controls)
+ * < (less than)
+ * > (greater than)
+ * : (colon)
+ * " (double quotation mark)
+ * / (slash)
+ * / (backslash)
+ * | (vertical bar or pipe)
+ * ? (question mark)
+ * * (asterisk)
+ *
+ *
+ *
+ * See Naming Files, Paths, and Namespaces
+ */
+ public static final CharMatcher ILLEGAL_FILENAME_CHARACTERS = CharMatcher.javaIsoControl()
+ .or(RESERVED_FILENAME_CHARACTERS);
+
+ private static final IntPredicate INVALID_CODE_POINT = cp -> !Character.isValidCodePoint(cp);
+
+ public static final IntPredicate ILLEGAL_FILENAME_CODEPOINTS = INVALID_CODE_POINT;
+
+ public static final IntPredicate DISCOURAGED_FILENAME_CODEPOINTS = StringUtil::isAlternativeWhitespaceCodePoint;
+
+ public static final StringUtil.CharMapper ILLEGAL_OR_DISCOURAGED_FILENAME_CHAR_REPLACER =
+ new StringUtil.CharMapper() {
+
+ @Override
+ public final String toString() {
+ return "Illegal/Discouraged file name character replacer";
+ }
+
+ @Override
+ public final char getReplacement(char c) {
+ if (ILLEGAL_FILENAME_CHARACTERS.matches(c) ||
+ DISCOURAGED_FILENAME_CHARACTERS.matches(c)) {
+ if (Character.isWhitespace(c)) {
+ return ' ';
+ }
+ return INVALID_FILE_NAME_CHARACTER_REPLACEMENT_CHAR;
+ }
+ return c;
+ }
+
+ };
+
+ private static enum ProblemClassificationLevel {
+ NONE,
+ DISCOURAGED,
+ ILLEGAL
+ }
+
+ @Beta
+ public static enum FileNameCheckResult {
+
+ /**
+ * No illegal or discouraged characters, sequences, or names were found.
+ */
+ NO_PROBLEMS(ProblemClassificationLevel.NONE),
+
+ /**
+ * The file name was empty or blank (or null).
+ */
+ EMPTY_OR_BLANK(ProblemClassificationLevel.ILLEGAL),
+
+ /**
+ * The file name is too long to be allowed in modern operating environments.
+ */
+ TOO_LONG(ProblemClassificationLevel.ILLEGAL),
+
+ /**
+ * The file name contains at least one character that is illegal in a modern operating environment.
+ */
+ ILLEGAL_CHARACTERS(ProblemClassificationLevel.ILLEGAL),
+
+ /**
+ * The file name contains a sequence of at least {@link #CONSECUTIVE_DOTS two consecutive dots}, which is
+ * illegal in most modern operating environments.
+ */
+ ILLEGAL_DOT_SEQUENCE(ProblemClassificationLevel.ILLEGAL),
+
+ /**
+ * The file name is an illegal name in one or more modern operating environments, such as a name consisting of a
+ * single dot.
+ */
+ ILLEGAL_FILENAME(ProblemClassificationLevel.ILLEGAL),
+
+ /**
+ * The file name is a
+ * {@link #isSpecialIllegalWindowsFileName(String) special reserved and illegal file name on Windows}.
+ */
+ ILLEGAL_SPECIAL_WINDOWS_FILENAME(ProblemClassificationLevel.ILLEGAL),
+
+ /**
+ * The file name contains at least one discouraged character, such as as semi-colon or backtick.
+ */
+ DISCOURAGED_CHARACTERS(ProblemClassificationLevel.DISCOURAGED),
+
+ /**
+ * The file name contains a discouraged sequence of whitespace characters, such as consecutive space
+ * characters.
+ */
+ DISCOURAGED_WHITESPACE_SEQUENCE(ProblemClassificationLevel.DISCOURAGED),
+
+ /**
+ * The file name ends with the "." extension separator character.
+ */
+ DISCOURAGED_ENDS_WITH_EXTENSION_SEPARATOR(ProblemClassificationLevel.DISCOURAGED),
+
+ /**
+ * The file name is discouraged in UNIX environments because of its tendency to accidentally become part of a
+ * dangerous command, or some other similar reason. See {@link #isDangerousUnixFileName(String)}.
+ */
+ DISCOURAGED_UNIX_FILENAME(ProblemClassificationLevel.DISCOURAGED);
+
+ private final ProblemClassificationLevel level;
+
+ private FileNameCheckResult(ProblemClassificationLevel level) {
+ this.level = level;
+ }
+
+ public final boolean noProblem() {
+ if (level == ProblemClassificationLevel.NONE) {
+ return true;
+ }
+ return false;
+ }
+
+ public final boolean hasProblem() {
+ if (level == ProblemClassificationLevel.NONE) {
+ return false;
+ }
+ return true;
+ }
+
+ public final boolean legal() {
+ if (level == ProblemClassificationLevel.ILLEGAL) {
+ return false;
+ }
+ return true;
+ }
+
+ public final boolean discouraged() {
+ if (level == ProblemClassificationLevel.DISCOURAGED) {
+ return true;
+ }
+ return false;
+ }
+
+ public final boolean illegal() {
+ if (level == ProblemClassificationLevel.ILLEGAL) {
+ return true;
+ }
+ return false;
+ }
+
+ }
+
+ /**
+ * A {@link StringUtil.CharMapper} that can be used to replace the generic or UNIX platform file separator character
+ * with the platform-specific file separator char.
+ */
+ public static final StringUtil.CharMapper GENERIC_TO_WINDOWS_SEPARATOR_REPLACEMENT = c -> {
+ if (GENERIC_PATH_SEPARATOR_CHAR == c) {
+ return File.separatorChar;
+ }
+ return c;
+ };
+
+ /**
+ * Returns true if the given file name is outright illegal, even when followed by extensions, in Windows.
+ *
+ *
+ * See Naming Files, Paths, and Namespaces
+ *
+ * @param fileName
+ * the file name to check.
+ * @return true if the given file name is outright illegal, even when followed by extensions, in Windows.
+ * @throws NullPointerException
+ * if the given file name is null.
+ */
+ public static final boolean isSpecialIllegalWindowsFileName(@Nonnull String fileName) {
+ String name = stripExtension(fileName);
+ return switch (name.toUpperCase()) {
+ case "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
+ "COM9", "COM¹", "COM²", "COM³", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
+ "LPT9", "LPT¹", "LPT²", "LPT" -> true;
+ default -> false;
+ };
+ }
+
+ /**
+ * Checks whether the given file name is a dangerous file name in a UNIX-like environment.
+ *
+ * @param fileName
+ * the file name to check.
+ * @return true the given file name is a dangerous file name in a UNIX-like environment according to the heuristic
+ * used here; false otherwise.
+ */
+ public static final boolean isDangerousUnixFileName(String fileName) {
+ if (StringUtil.startsWith(fileName, '-')) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Strips the path from the beginning of a full path specification, leaving just the file name.
+ *
+ * @param fullPath
+ * the full path of the file.
+ * @return the file name from the full path.
+ */
+ public static final String stripPath(String fullPath) {
+ if (fullPath == null) {
+ return null;
+ }
+ String pi = platformIndependentPath(fullPath);
+ while (StringUtil.endsWith(pi, GENERIC_PATH_SEPARATOR_CHAR)) {
+ pi = pi.substring(0, pi.length() - 1);
+ }
+ int s = pi.lastIndexOf(GENERIC_PATH_SEPARATOR_CHAR);
+ if (s == -1) {
+ return fullPath;
+ }
+ return pi.substring(s + 1);
+ }
+
+ /**
+ * Strips the file name from the end of a full path specification, leaving just the directory path to the file, if
+ * any.
+ *
+ * @param fullPath
+ * the full path of the file.
+ * @return the path component, if any.
+ */
+ public static final String stripFile(String fullPath) {
+ if (fullPath == null) {
+ return null;
+ }
+ String pi = platformIndependentPath(fullPath);
+ while (StringUtil.endsWith(pi, GENERIC_PATH_SEPARATOR_CHAR)) {
+ pi = pi.substring(0, pi.length() - 1);
+ }
+ int s = pi.lastIndexOf(GENERIC_PATH_SEPARATOR_CHAR);
+ if (s == -1) {
+ return "";
+ }
+ return fullPath.substring(0, s + 1);
+ }
+
+ /**
+ * Returns true if the character represents a file separator for any known platforms or situations. This means
+ * either a slash or backslash.
+ *
+ * @param c
+ * the character to check.
+ * @return true if the character represents a file separator for any known platforms or situations; false otherwise.
+ */
+ public static final boolean isSeparator(char c) {
+ return switch (c) {
+ case GENERIC_PATH_SEPARATOR_CHAR, WINDOWS_PATH_SEPARATOR_CHAR -> true;
+ default -> false;
+ };
+ }
+
+ /**
+ * Joins a name with a folder to create a complete path using the separator. If the folder name already ends in
+ * separator, another separator will not be added.
+ */
+ public static final String joinPath(String folder, String name, String sep) {
+ return folder.endsWith(sep) ? folder + name : folder + sep + name;
+ }
+
+ public static final String getExtension(String fileNameOrPath) {
+ return getExtension(fileNameOrPath, null);
+ }
+
+ /**
+ * Returns the file extension for the given file name or path, not including the period, or the given default if no
+ * extension is identified.
+ *
+ *
+ * This method will return anything after the final "." in the filename portion of the given file name or path
+ * (including returning the empty String if the file name or path ends in a dot). If no extension is found, the
+ * given default value is returned instead.
+ *
+ *
+ * If the given name or path String is a path ending with a trailing slash, that will be treated as a directory, and
+ * thus any "extension" in that directory name -- e.g., "baz" in the path "/foo/bar.baz/" -- will not be returned.
+ * The extension must be identified before the last file path separator.
+ *
+ *
+ * In general, no validation is performed on the given file name or path. Identification of a file path separator is
+ * platform-independent (i.e., both "/" and "\" are handled).
+ *
+ * @param fileNameOrPath
+ * the file name, possibly in the form of a path, whose extension is to be identified.
+ * @param defaultValue
+ * the value to be returned if there is no extension found.
+ * @return the file extension for the given file name or path if there is one, including empty String for a name or
+ * path ending in "."; the given default value otherwise.
+ * @see #stripExtension(String)
+ */
+ public static final String getExtension(String fileNameOrPath, String defaultValue) {
+
+ if (fileNameOrPath == null) {
+ return defaultValue;
+ }
+
+ int lastDot = fileNameOrPath.lastIndexOf(EXTENSION_SEPARATOR_CHAR);
+ if (lastDot == -1) {
+ // No extension.
+ return defaultValue;
+ }
+
+ int lastSep = StringUtils.lastIndexOfAny(fileNameOrPath, URLUtil.PATH_SEPARATOR, File.separator);
+ if (lastSep != -1 && lastDot < lastSep) {
+ // no extension after last separator.
+ return defaultValue;
+ }
+ if (lastDot == fileNameOrPath.length() - 1) {
+ return "";
+ }
+ return fileNameOrPath.substring(lastDot + 1);
+
+ }
+
+ /**
+ * Returns the given file path, with the file extension, if any (including the period), removed.
+ *
+ *
+ * This method has the same logic as {@link #getExtension(String, String)} for what constitutes an extension.
+ *
+ * @param fileNameOrPath
+ * the file name, possibly in the form of a path, whose extension is to be identified.
+ * @return the given file path, with the file extension, if any (including the period), removed; the given file path
+ * as-is otherwise.
+ * @see #getExtension(String, String)
+ */
+ public static final String stripExtension(String fileNameOrPath) {
+
+ if (fileNameOrPath == null) {
+ return null;
+ }
+
+ int lastDot = fileNameOrPath.lastIndexOf(EXTENSION_SEPARATOR_CHAR);
+ if (lastDot == -1) {
+ // No extension.
+ return fileNameOrPath;
+ }
+
+ int lastSep = StringUtils.lastIndexOfAny(fileNameOrPath, URLUtil.PATH_SEPARATOR, File.separator);
+ if (lastSep != -1 && lastDot < lastSep) {
+ // no extension after last separator.
+ return fileNameOrPath;
+ }
+ return fileNameOrPath.substring(0, lastDot);
+
+ }
+
+ /**
+ * Returns the given path with any leading separator removed if it's present. "Separator" here covers either the
+ * slash or backslash.
+ *
+ * @param path
+ * the path to transform.
+ * @return the given path with a leading separator removed if it's present; otherwise, the given path as-is.
+ */
+ public static final String removeLeadingSeparator(String path) {
+ int len = StringUtils.length(path);
+ if (len == 0) {
+ return path;
+ }
+ if (isSeparator(path.charAt(0))) {
+ return path.substring(1, len);
+ }
+ return path;
+ }
+
+ /**
+ * Returns the given path with any trailing separator removed if it's present. "Separator" here covers either the
+ * slash or backslash.
+ *
+ * @param path
+ * the path to transform.
+ * @return the given path with a trailing separator removed if it's present; otherwise, the given path as-is.
+ */
+ public static final String removeTrailingSeparator(String path) {
+ int len = StringUtils.length(path);
+ if (len == 0) {
+ return path;
+ }
+ boolean removeTrailing = isSeparator(path.charAt(len - 1));
+ if (removeTrailing) {
+ return path.substring(0, len - 1);
+ }
+ return path;
+ }
+
+ /**
+ * Returns the given path with any leading and trailing separators removed if they're present. "Separator" here
+ * covers either the slash or backslash.
+ *
+ * @param path
+ * the path to transform.
+ * @return the given path with any leading and trailing separators removed if they're present; otherwise, the given
+ * path as-is.
+ */
+ public static final String removeLeadingAndTrailingSeparators(String path) {
+
+ int len = StringUtils.length(path);
+ if (len == 0) {
+ return path;
+ }
+
+ boolean removeLeading = isSeparator(path.charAt(0));
+ if (len == 1) {
+ if (removeLeading) {
+ return "";
+ }
+ return path;
+ }
+
+ boolean removeTrailing = isSeparator(path.charAt(len - 1));
+ if (removeLeading || removeTrailing) {
+ return path.substring(removeLeading ? 1 : 0, removeTrailing ? len - 1 : len);
+ }
+ return path;
+
+ }
+
+ public static final String platformSpecificPath(@Nullable String path) {
+ if (GENERIC_PATH_SEPARATOR_CHAR == File.separatorChar) {
+ return path;
+ }
+ return CharBasedFilteringTextMapper.replace(path, GENERIC_TO_WINDOWS_SEPARATOR_REPLACEMENT);
+ }
+
+ public static final void appendPlatformSpecificPath(@Nonnull Appendable out, @Nullable CharSequence path) {
+ Objects.requireNonNull(out, "output");
+ if (GENERIC_PATH_SEPARATOR_CHAR == File.separatorChar) {
+ StringWriteUtil.safeAppend(out, path);
+ }
+ else {
+ CharBasedFilteringTextMapper.replace(path, out, GENERIC_TO_WINDOWS_SEPARATOR_REPLACEMENT);
+ }
+ }
+
+ public static final String platformIndependentPath(@Nullable String fileNameOrPath) {
+
+ if (fileNameOrPath == null) {
+ return null;
+ }
+
+ int l = fileNameOrPath.length();
+
+ StringBuilder ret = null;
+ for (int i = 0; i < l; i++) {
+ char c = fileNameOrPath.charAt(i);
+ switch (c) {
+ case ':':
+ // either a mac directory separator or a DOS "c:\" drive specifier
+ if (i + 1 < l &&
+ (fileNameOrPath.charAt(i + 1) == WINDOWS_PATH_SEPARATOR_CHAR ||
+ fileNameOrPath.charAt(i + 1) == GENERIC_PATH_SEPARATOR_CHAR)) {
+ if (ret != null) {
+ ret.append(':'); // keep the colon
+ }
+ }
+ else {
+ if (ret == null) {
+ ret = new StringBuilder(l);
+ ret.append(fileNameOrPath, 0, i);
+ }
+ ret.append(GENERIC_PATH_SEPARATOR_CHAR); // mac separator
+ }
+ break;
+ case WINDOWS_PATH_SEPARATOR_CHAR: // windows
+ if (ret == null) {
+ ret = new StringBuilder(l);
+ ret.append(fileNameOrPath, 0, i);
+ }
+ ret.append(GENERIC_PATH_SEPARATOR_CHAR);
+ break;
+ case GENERIC_PATH_SEPARATOR_CHAR: // Unix (no change)
+ if (ret != null) {
+ ret.append(GENERIC_PATH_SEPARATOR_CHAR);
+ }
+ break;
+ default:
+ if (ret != null) {
+ ret.append(c);
+ }
+ break;
+ }
+ }
+ if (ret == null) {
+ return fileNameOrPath;
+ }
+ return ret.toString();
+ }
+
+ /**
+ * Checks whether the given file name is illegal or discouraged. Although the
+ *
+ * @param fileName
+ * the file name to check.
+ * @return a FileNameValidityCheckResult representing the result of examining the given file name.
+ */
+ @Beta
+ @Nonnull
+ public static final FileNameCheckResult checkFileName(@Nullable String fileName) {
+
+ if (StringUtils.isBlank(fileName)) {
+ return FileNameCheckResult.EMPTY_OR_BLANK;
+ }
+
+ if (fileName.length() > FILE_NAME_LENGTH_LIMIT) {
+ return FileNameCheckResult.TOO_LONG;
+ }
+
+ if (ILLEGAL_FILENAME_CHARACTERS.matchesAnyOf(fileName) ||
+ fileName.chars().anyMatch(ILLEGAL_FILENAME_CODEPOINTS)) {
+ return FileNameCheckResult.ILLEGAL_CHARACTERS;
+ }
+
+ if (fileName.equals(CURRENT_PATH_INDICATOR) || fileName.equals(CONSECUTIVE_DOTS)) {
+ return FileNameCheckResult.ILLEGAL_FILENAME;
+ }
+
+ if (isSpecialIllegalWindowsFileName(fileName)) {
+ return FileNameCheckResult.ILLEGAL_SPECIAL_WINDOWS_FILENAME;
+ }
+
+ if (fileName.contains(CONSECUTIVE_DOTS)) {
+ return FileNameCheckResult.ILLEGAL_DOT_SEQUENCE;
+ }
+
+ if (DISCOURAGED_FILENAME_CHARACTERS.matchesAnyOf(fileName) ||
+ fileName.chars().anyMatch(DISCOURAGED_FILENAME_CODEPOINTS)) {
+ return FileNameCheckResult.DISCOURAGED_CHARACTERS;
+ }
+
+ if (StringUtil.hasCollapsableOrNormalizableWhitespace(fileName, false)) {
+ return FileNameCheckResult.DISCOURAGED_WHITESPACE_SEQUENCE;
+ }
+
+ int lastCodePoint = fileName.codePointBefore(fileName.length());
+ if (lastCodePoint == EXTENSION_SEPARATOR_CHAR || Character.isWhitespace(lastCodePoint)) {
+ return FileNameCheckResult.DISCOURAGED_ENDS_WITH_EXTENSION_SEPARATOR;
+ }
+
+ if (isDangerousUnixFileName(fileName)) {
+ return FileNameCheckResult.DISCOURAGED_UNIX_FILENAME;
+ }
+
+ return FileNameCheckResult.NO_PROBLEMS;
+
+ }
+
+ /**
+ * Returns a "good" file name for the given name. Attempts are made to remove or replace illegal portions of the
+ * name so that the result can be as close to the originally supplied version as possible, but as a last result, a
+ * default name will be used based on the given Content-Type specification, if any (generally "Untitled" followed by
+ * a suitable extension if one can be determined).
+ *
+ *
+ * Specifically, the file name is guaranteed to be a valid name for a file stored in a file system, or in some other
+ * sort of server side repository.
+ *
+ *
+ * At the time of writing, the following characters are illegal:
+ *
+ *
+ * < (less than)
+ * > (greater than)
+ * : (colon)
+ * ; (semicolon)
+ * double quotation mark
+ * forward slash
+ * backward slash
+ * vertical pipe
+ * question mark
+ * asterisk
+ * characters 0 through 31 (0x0 through 0x1f), including various control characters and other whitespace
+ * characters, such as new line and carriage return
+ *
+ *
+ *
+ * Besides individual characters, the following are also illegal:
+ *
+ *
+ * consecutive file extension separators (.)
+ * alternative whitespace (see {@link StringUtil#isAlternativeWhitespaceCodePoint(int)})
+ * ending with a file extension separator (.) or whitespace
+ * case-sensitive matches for any of the proscribed file names for Windows operating systems, such as "CON",
+ * "PRN" or "NUL", with or without a file extension (e.g., "nul", "NUL", or "NUL.txt")
+ *
+ *
+ *
+ * See Windows > Apps > Win32
+ * > Desktop > Technologies > Data Access and Storage > Local File Systems > Naming Files, Paths, and
+ * Namespaces .
+ *
+ * @param fileName
+ * the file name from which a "good" file name will be determined.
+ * @param getDefaultBaseName
+ * an optional Supplier for a preferred base file name in case the supplied name cannot be repaired. The
+ * argument for this parameter may be null, or it may return a null or empty value; in either case, a localized
+ * default base file name will be used (generally, "Untitled").
+ * @param contentType
+ * the supplied Content-Type or MIME type of the file.
+ * @return a file name guaranteed to be a valid name for a file stored in a file system, or in some other sort of
+ * sort of server side repository, and which has been otherwise normalized as applicable.
+ * @see #getGoodNormalizedFileName(String, Supplier, String, boolean)
+ */
+ public static final String getGoodFileName(@Nullable String fileName, @Nullable Supplier getDefaultBaseName, @Nullable String contentType) {
+ String validFileName = getGoodFileNameImpl(fileName);
+ if (validFileName == null) {
+ return getDefaultGoodFileName(getDefaultBaseName, contentType);
+ }
+ return validFileName;
+ }
+
+ /**
+ * A more aggressive alternative to {@link #getGoodFileName(String, Supplier, String)} that will apply additional
+ * normalizations, such as collapsing consecutive space characters, removing whitespace before the file extension
+ * and optionally ensuring the presence of some sort of file extension.
+ *
+ * @param fileNameOrPath
+ * the file name or path from which a validated file name will be determined.
+ * @param getDefaultBaseName
+ * an optional Supplier for a preferred base file name in case the supplied name cannot be repaired. The
+ * argument for this parameter may be null, or it may return a null or empty value; in either case, a localized
+ * default base file name will be used (generally, "Untitled").
+ * @param contentType
+ * the supplied Content-Type or MIME type of the file.
+ * @param ensureExtension
+ * indicates whether the name should definitely have a suitable file extension, possibly replacing one that may
+ * already be present. This will be ignored for "dot files" (i.e., those whose names start with '.').
+ * @return a valid and normalized file name extracted from the given file name or path.
+ */
+ public static final String getGoodNormalizedFileName(@Nullable String fileNameOrPath, @Nullable Supplier getDefaultBaseName, @Nullable String contentType, boolean ensureExtension) {
+
+ String fileName = getGoodFileName(fileNameOrPath, getDefaultBaseName, contentType);
+ fileName = StringUtils.trimToNull(StringUtil.collapseAndNormalizeWhitespace(fileName, true));
+
+ // Check again for a blank (null/empty/whitespace) file name.
+ if (StringUtils.isBlank(fileName)) {
+ return getDefaultGoodFileName(contentType);
+ }
+
+ String ext = getExtension(fileName, null);
+ if (StringUtils.isNotBlank(ext)) {
+ return stripExtension(fileName).trim() + EXTENSION_SEPARATOR_CHAR + ext;
+ }
+
+ // For file names that are not "dot files", try to ensure they have a reasonable file extension, if requested.
+ if (ensureExtension && fileName.charAt(0) != EXTENSION_SEPARATOR_CHAR) {
+ if (StringUtils.isBlank(ext)) {
+ // Guess the file extension based upon the supplied Content-Type.
+ ext = MediaTypeUtil.getExtensionFromContentType(contentType);
+ if (StringUtils.isNotBlank(ext)) {
+ return fileName + EXTENSION_SEPARATOR_CHAR + ext;
+ }
+ }
+ }
+
+ return fileName;
+
+ }
+
+ public static final String getDefaultGoodFileName(@Nullable String contentType) {
+ return getDefaultGoodFileName(null, contentType);
+ }
+
+ public static final String getDefaultGoodFileName(@Nullable Supplier getDefaultBaseName, @Nullable String contentType) {
+ String ext = MediaTypeUtil.getExtensionFromContentType(contentType);
+ String baseName = getDefaultBaseFileName(getDefaultBaseName);
+ if (StringUtils.isBlank(ext)) {
+ return baseName;
+ }
+ return baseName + EXTENSION_SEPARATOR_CHAR + ext;
+ }
+
+ public static final String getDescendantPathSpec(@Nullable String directoryPath, String... pathComponents) {
+
+ if (directoryPath == null) {
+ return StringUtil.join(pathComponents, File.separator);
+ }
+
+ if (ArrayUtils.isEmpty(pathComponents)) {
+ if (directoryPath.isEmpty()) {
+ return CURRENT_PATH_INDICATOR;
+ }
+ return platformSpecificPath(directoryPath);
+ }
+
+ StringBuilder spec = new StringBuilder(50);
+ appendPlatformSpecificPath(spec, directoryPath);
+ if (!StringUtil.endsWith(spec, File.separatorChar)) {
+ spec.append(File.separatorChar);
+ }
+ StringUtil.getNullSkippingJoiner(File.separatorChar).appendTo(spec, pathComponents);
+ return spec.toString();
+
+ }
+
+ private static final String getDefaultBaseFileName() {
+ return System.getProperty("com.tractionsoftware.commons.io.default_base_file_name", "Untitled");
+ }
+
+ private static final String getDefaultBaseFileName(@Nullable Supplier getDefaultBaseName) {
+ if (getDefaultBaseName == null) {
+ return getDefaultBaseFileName();
+ }
+ String baseName = StringUtils.trimToNull(getDefaultBaseName.get());
+ if (baseName == null) {
+ return getDefaultBaseFileName();
+ }
+ return baseName;
+ }
+
+ private static final String getGoodFileNameImpl(String fileName) {
+
+ if (StringUtils.isBlank(fileName)) {
+ return null;
+ }
+
+ fileName = StringUtil.normalizeAlternativeWhitespace(fileName);
+ fileName = CharBasedFilteringTextMapper.replace(fileName, ILLEGAL_OR_DISCOURAGED_FILENAME_CHAR_REPLACER);
+ fileName = StringUtil.collapseConsecutiveCharacters(fileName, EXTENSION_SEPARATOR_CHAR);
+ fileName = fileName.trim();
+ if (fileName.isEmpty()) {
+ return null;
+ }
+
+ int[] codePoints = fileName.codePoints().toArray();
+ int removeCharsFromEnd = 0;
+ for (int index = codePoints.length - 1; index >= 0; index--) {
+ int codePoint = codePoints[index];
+ if (codePoint == EXTENSION_SEPARATOR_CHAR || Character.isWhitespace(codePoint)) {
+ removeCharsFromEnd += Character.charCount(codePoint);
+ }
+ else {
+ break;
+ }
+ }
+
+ int len = fileName.length();
+ if (removeCharsFromEnd > 0) {
+ if (removeCharsFromEnd == len) {
+ return null;
+ }
+ fileName = fileName.substring(0, len - removeCharsFromEnd);
+ }
+
+ if (isSpecialIllegalWindowsFileName(fileName)) {
+ return null;
+ }
+
+ return fileName;
+
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/FileResource.java b/src/main/java/com/tractionsoftware/commons/io/FileResource.java
new file mode 100644
index 0000000..d695d73
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/FileResource.java
@@ -0,0 +1,720 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.annotations.Beta;
+import com.google.common.net.MediaType;
+import com.tractionsoftware.commons.codec.MD5Util;
+import com.tractionsoftware.commons.image.Icon;
+import com.tractionsoftware.commons.image.ImageUtil;
+import com.tractionsoftware.commons.image.SimpleFileResourceIconFileAdapter;
+import com.tractionsoftware.commons.net.MediaTypeUtil;
+import com.tractionsoftware.commons.net.URLUtil;
+import com.tractionsoftware.commons.text.NumberFormats;
+import com.tractionsoftware.commons.util.Dimensions;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.io.LineIterator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.net.URI;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.security.DigestInputStream;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.Objects;
+
+/**
+ * An abstraction for a file, including access to an {@link InputStream} that can be used to access the file's contents.
+ * (Contrast this with {@link FileMetadata}, which encapsulates file metadata only.) The underlying file may be stored
+ * in any medium or repository, and is not limited an ordinary file system.
+ *
+ *
+ * FileInfo objects are not intended to represent a dynamically generated resource, but there is nothing in this API
+ * that rules that out. (The same may not be true of sub-interfaces.)
+ *
+ *
+ * FileInfo objects, like most other SDK objects, are only created on demand contingent upon the requesting user's
+ * permission to access the underlying file, and should never be shared across request threads.
+ *
+ * @author Andy Keller, Dave Shepperton
+ */
+public interface FileResource {
+
+ static final Logger LOGGER = LoggerFactory.getLogger(FileResource.class);
+
+ /**
+ * Returns the {@link FileResourceType} indicating the type of resource that this instance represents. This method
+ * should never return null. For miscellaneous files, or anything otherwise uncategorized,
+ * {@link CommonFileResourceType#OTHER} should be returned.
+ *
+ * @return the {@link FileResourceType} indicating the type of image resource that this Icon instance represents.
+ */
+ @Nonnull
+ public FileResourceType getType();
+
+ /**
+ * Returns true if this FileInfo is "valid." If a FileInfo instance is not valid, any method whose implementation
+ * requires read or write access to the file's data or metadata, such as {@link #getInputStream()} or
+ * {@link #getByteSize()}, may throw an Exception, or may return an value that would otherwise be invalid.
+ *
+ *
+ * The basic notion of validity requires that the file exists and its data and metadata are available. This may
+ * involve checking whether the underlying file in a file system or file repository exists and can be read, or some
+ * other implementation-dependent test. Subclasses representing special types of resources may override not just
+ * this method but also this definition of validity.
+ *
+ * @return true if this FileInfo is "valid"; false otherwise.
+ */
+ public boolean isValid();
+
+ /**
+ * Returns the {@link URI} that defines the location of the file in a store of some sort. This may be a file: URI or
+ * something else.
+ *
+ * @return the {@link URI} that defines the location of the file in a store of some sort. This may be a file: URI or
+ * something else.
+ */
+ @Nonnull
+ public URI getURI();
+
+ /**
+ * Returns a path that can be used to uniquely refer to this file resource. It may or may not reflect the details of
+ * the underlying storage location, such as a path to an actual file.
+ *
+ * @return a path that can be used to uniquely refer to this file resource.
+ */
+ @Nonnull
+ public default String getPath() {
+ return getURI().getPath();
+ }
+
+ /**
+ * Returns the published name of the file. This is not necessarily the same as the final path component that appears
+ * in {@link #getPath()} or {@link #getURI()}.
+ *
+ * @return the published name of the file.
+ */
+ @Nonnull
+ public String getFilename();
+
+ /**
+ * Returns true if this file is a directory.
+ */
+ public boolean isDirectory();
+
+ /**
+ * Returns an {@link InputStream} that can be used to access the contents of the underlying file.
+ *
+ *
+ * This should be available for all files, and implementations are responsible for wrapping any "raw" InputStream
+ * objects as appropriate (e.g., with a {@link java.io.BufferedInputStream}) as may be necessary to optimize
+ * performance.
+ *
+ *
+ * Although clients are responsible for closing all InputStreams retrieved via this method, in order to avoid
+ * leaking file descriptors, implementations should take appropriate precautions to ensure that those instances are
+ * still cleaned up when the FileInfo instance is being finalized.
+ *
+ *
+ * If the FileInfo implementation also supports write operations as well as read operations (e.g., via methods that
+ * return {@link java.io.OutputStream}s or {@link java.io.Writer}s that can be used to modify the contents of the
+ * underlying file resource), clients must dispose of the resources required for that write access before invoking
+ * this method.
+ *
+ * @throws IOException
+ * if a problem is encountered while attempting to open the underlying file resource, or if the resource is a
+ * directory or other collection rather than a single file.
+ * @throws IllegalStateException
+ * implementations must throw this Exception if the client has already obtained a {@link java.io.OutputStream},
+ * {@link java.io.Writer}, or other object that provides write access to the underlying file resource, and has
+ * not yet closed it.
+ * @throws UnsupportedOperationException
+ * in some rare cases, if the resource may be {@link #isValid() valid}, but the implementation does not support
+ * directly reading the underlying data, possibly because it was never intended that should be required.
+ */
+ @Nonnull
+ public SizedInputStream getInputStream() throws IOException;
+
+ /**
+ * Returns a {@link Reader} to read the underlying file's contents as text decoded using the {@link Charset} from
+ * {@link #getCharset()}.
+ *
+ *
+ * This default implementation delegates to {@link #getReader(Charset)}, passing null. Subclasses should not need to
+ * override it.
+ *
+ *
+ * Although clients are responsible for closing all Readers retrieved via this method, in order to avoid leaking
+ * file descriptors, implementations should take appropriate precautions to ensure that those instances are still
+ * cleaned up when the FileInfo instance is being finalized.
+ *
+ * @return a {@link Reader} to read underlying file's contents as text.
+ * @throws IOException
+ * if one is raised while creating any necessary {@link InputStream} or {@link Reader} objects.
+ * @throws IllegalStateException
+ * optionally if another method providing read/write access to the underlying file's contents has already been
+ * invoked and the resulting object has not yet been closed.
+ * @throws UnsupportedOperationException
+ * optionally if this FileInfo is known to represent a non-text resource. Also in some rare cases, if the
+ * resource may be {@link #isValid() valid}, but the implementation does not support directly reading the
+ * underlying data, possibly because it was never intended that should be required. See
+ * {@link #getInputStream()}.
+ */
+ public default BufferedReader getReader() throws IOException {
+ return getReader(null);
+ }
+
+ /**
+ * Returns a {@link Reader} to read the underlying file's contents as text as decoded using the given
+ * {@link Charset}.
+ *
+ *
+ * This default implementation delegates to {@link IOUtil#getBufferedReader(InputStream, Charset)}, passing
+ * {@link #getInputStream() the InputStream}; and passing either the given Charset, or if that is null, the Charset
+ * returned by {@link #getCharset() Charset} for this file resource. Subclasses should override it as necessary.
+ *
+ *
+ * Although clients are responsible for closing all Readers retrieved via this method, in order to avoid leaking
+ * file descriptors, implementations should take appropriate precautions to ensure that those instances are still
+ * cleaned up when the FileInfo instance is being finalized.
+ *
+ * @param charset
+ * a {@link Charset} to use in place of the one returned by {@link #getCharset()}. If the client knows the
+ * correct Charset to use to decode the text in this file, and suspects that {@link #getCharset()} may return
+ * the wrong value, it may be useful to supply the Charset, but most clients should use {@link #getReader()}.
+ * @return a {@link Reader} to read underlying file's contents as text.
+ * @throws IOException
+ * if one is raised while creating any necessary {@link InputStream} or {@link Reader} objects.
+ * @throws IllegalStateException
+ * optionally if another method providing read/write access to the underlying file's contents has already been
+ * invoked and the resulting object has not yet been closed.
+ * @throws UnsupportedOperationException
+ * optionally if this FileInfo is known to represent a non-text resource. Also, in some rare cases, if the
+ * resource may be {@link #isValid() valid}, but the implementation does not support directly reading the
+ * underlying data, possibly because it was never intended that should be required. See
+ * {@link #getInputStream()}.
+ */
+ public default BufferedReader getReader(Charset charset) throws IOException {
+ return IOUtil.getBufferedReader(getInputStream(), Objects.requireNonNullElseGet(charset, this::getCharset));
+ }
+
+ /**
+ * Returns the text from the underlying file, decoded using the file's {@link Charset} as per
+ * {@link #getCharset()}.
+ *
+ *
+ * This default implementation delegates to {@link #readString(Charset)}, passing null for the Charset. Subclasses
+ * should not need to override it.
+ *
+ * @return the text from the underlying file, decoded using the file's {@link Charset} as per {@link #getCharset()}.
+ * @throws IOException
+ * if one is raised while attempting to read the file's contents.
+ * @throws IllegalStateException
+ * optionally if another method providing read/write access to the underlying file's contents has already been
+ * invoked and the resulting object has not yet been closed.
+ * @throws UnsupportedOperationException
+ * optionally if this FileInfo is known to represent a non-text resource. Also, in some rare cases, if the
+ * resource may be {@link #isValid() valid}, but the implementation does not support directly reading the
+ * underlying data, possibly because it was never intended that should be required. See
+ * {@link #getInputStream()}.
+ */
+ public default String readString() throws IOException {
+ return readString(null);
+ }
+
+ /**
+ * Returns the text from the underlying file, decoded using the given {@link Charset}.
+ *
+ *
+ * This default implementation gets a Reader from {@link #getReader(Charset)}, passing either the given Charset, or
+ * if that is null, the Charset returned by {@link #getCharset() Charset} for this file resource. Subclasses should
+ * override it as necessary.
+ *
+ * @param charset
+ * a {@link Charset} to use in place of the one returned by {@link #getCharset()}. If the client knows the
+ * correct Charset to use to decode the text in this file, and suspects that {@link #getCharset()} may return
+ * the wrong value, it may be useful to supply the Charset, but most clients should pass null for this argument
+ * or simply use {@link #readString()}.
+ * @return the text from the underlying file, decoded using the given {@link Charset}.
+ * @throws IOException
+ * if one is raised while attempting to read the file's contents.
+ * @throws IllegalStateException
+ * optionally if another method providing read/write access to the underlying file's contents has already been
+ * invoked and the resulting object has not yet been closed.
+ * @throws UnsupportedOperationException
+ * optionally if this FileInfo is known to represent a non-text resource. Also, in some rare cases, if the
+ * resource may be {@link #isValid() valid}, but the implementation does not support directly reading the
+ * underlying data, possibly because it was never intended that should be required. See
+ * {@link #getInputStream()}.
+ */
+ public default String readString(Charset charset) throws IOException {
+ try (Reader reader = getReader(Objects.requireNonNullElseGet(charset, this::getCharset))) {
+ return IOUtil.readContent(reader);
+ }
+ }
+
+ /**
+ * Returns a {@link DigestInputStream} that can be used to compute a digest for the contents of this file while
+ * reading the contents.
+ *
+ * @return a {@link DigestInputStream} that can be used to compute a digest for the contents of this file while *
+ * reading it, if this file does not represent a directory; null otherwise.
+ * @throws IOException
+ * if a problem is encountered while attempting to open the underlying file resource, or if the resource is a
+ * directory or other collection rather than a single file.
+ * @throws IllegalStateException
+ * implementations must throw this Exception if the client has already obtained a {@link java.io.OutputStream},
+ * {@link java.io.Writer}, or other object that provides write access to the underlying file resource, and has
+ * not yet closed it.
+ * @throws UnsupportedOperationException
+ * in some rare cases, if the resource may be {@link #isValid() valid}, but the implementation does not support
+ * directly reading the underlying data, possibly because it was never intended that should be required.
+ * @see #getMD5()
+ * @see #getMD5Hash()
+ * @see #getPaddedMD5Hash()
+ */
+ public default DigestInputStream getDigestInputStream() throws IOException {
+ return MD5Util.createDigestInputStream(this);
+ }
+
+ /**
+ * Returns the MD5 hash bytes for the contents of this file; or, if the file is empty, its
+ * {@link #getContentType() content type specification}.
+ *
+ *
+ * This default implementation uses {@link MD5Util#digest(FileResource)} and
+ * {@link MD5Util.DigestResult#hashBytes()} on the result. Subclasses that can provide a more efficient
+ * implementation should override it.
+ *
+ * @return if this File does not represent a directory, the MD5 hash bytes based on the contents of this file; or,
+ * if the file is empty, the MD5 hash bytes based on its {@link #getContentType() content type specification};
+ * null otherwise.
+ * @see #getMD5Hash()
+ * @see #getPaddedMD5Hash()
+ */
+ public default byte[] getMD5() {
+ return MD5Util.digest(this).hashBytes();
+ }
+
+ /**
+ * Computes a hash based on the contents of this file; or, if the file is empty, its
+ * {@link #getContentType() content type specification}.
+ *
+ *
+ * This default implementation uses {@link MD5Util#digest(FileResource)} and
+ * {@link MD5Util.DigestResult#hashString()} on the result. Subclasses that can provide a more efficient
+ * implementation should override it.
+ *
+ * @return if this File does not represent a directory, a hash based on the contents of this file; or, if the file
+ * is empty, the {@link #getContentType() content type specification}; null otherwise.
+ * @see #getMD5()
+ * @see #getPaddedMD5Hash()
+ */
+ @Nullable
+ public default String getMD5Hash() {
+ return MD5Util.digest(this).hashString();
+ }
+
+ /**
+ * Identical to {@link #getMD5Hash()} except that the resulting string is formatted to match the formatting used by
+ * most command line md5hash commands and similar utilities.
+ *
+ *
+ * This default implementation uses {@link MD5Util#digest(FileResource)} and
+ * {@link MD5Util.DigestResult#paddedHashString()} on the result. Subclasses that can provide a more efficient
+ * implementation should override it.
+ *
+ * @return the padded MD5 hash, similar to {@link #getMD5Hash()}, if available; null otherwise.
+ * @see #getMD5()
+ * @see #getMD5Hash()
+ */
+ @Nullable
+ public default String getPaddedMD5Hash() {
+ return MD5Util.digest(this).paddedHashString();
+ }
+
+ /**
+ * Returns the {@link Charset} representing the encoding that should be used to decode the contents of this file as
+ * text. The Charset returned by this method must be used by the {@link Reader} returned by {@link #getReader()}.
+ *
+ *
+ * This default implementation uses the "charset" parameter, if any, retrieved from
+ * {@link #getContentType() this file resource's content type}, or {@link StandardCharsets#UTF_8 UTF-8} if no
+ * charset is specified.
+ *
+ * @return the {@link Charset} representing the encoding that should be used to decode the contents of this file as
+ * text, if any; or null if the file resource's contents are known not to be textual.
+ */
+ public default Charset getCharset() {
+ String contentTypeSpec = getContentType();
+ if (contentTypeSpec != null) {
+ try {
+ return MediaType.parse(contentTypeSpec).charset().toJavaUtil().orElse(StandardCharsets.UTF_8);
+ }
+ catch (IllegalArgumentException e) {
+ LOGGER.info("Can't parse content-type spec {}", contentTypeSpec);
+ }
+ }
+ return StandardCharsets.UTF_8;
+ }
+
+ /**
+ * Returns the extension from this file's file name (not including the dot).
+ *
+ * @return the extension from this file's file name (not including the dot); null otherwise.
+ */
+ @Nullable
+ public default String getExtension() {
+ return FileNameUtil.getExtension(getFilename(), null);
+ }
+
+ /**
+ * Returns a description of the file.
+ *
+ * @return a description of the file resource, if one is present; null otherwise.
+ */
+ @Nullable
+ public String getDescription();
+
+ /**
+ * Returns the content-type of the file, if available.
+ *
+ * @return the content-type of the file, if available; null otherwise.
+ */
+ @Nullable
+ public String getContentType();
+
+ /**
+ * Returns a Content-ID to use for this file resource for at least the duration of the current request. Invocations
+ * on other requests for a FileInfo object referring to the same underlying resource may return different values.
+ *
+ * @return a Content-ID to use for this file resource for at least the duration of the current request.
+ * @throws UnsupportedOperationException
+ * in some rare cases, if the resource is {@link #isValid() valid}, but should not be included by CID.
+ */
+ @Nullable
+ public String getContentId();
+
+ /**
+ * Returns the size of the file in bytes, much like {@link java.io.File#length()}.
+ *
+ *
+ * This may require a request to the file system or a file repository service, which could be relatively slow
+ * compared with other methods in this class.
+ *
+ * @return the size of the file in bytes, if it can be determined; or -1 (or some other negative number) otherwise.
+ */
+ public long getByteSize();
+
+ /**
+ * Returns an appropriately formatted representation of the size of this file in bytes as returned by
+ * {@link #getByteSize()}.
+ *
+ *
+ *
+ * If X is the size and X < 1024 bytes, it is returned with the size in bytes (B).
+ *
+ * If 1024 bytes < X < 1024 kilobytes, it is returned with the size in kilobytes (KB).
+ *
+ * If 1024 kilobytes < X, it is returned with the size in megabytes (MB).
+ *
+ * If 1024 megabytes < X, it is returned with the size in gigabytes (GB).
+ *
+ *
+ * @return an appropriately formatted representation of the size of this file in bytes as returned by
+ * {@link #getByteSize()}.
+ */
+ @Nonnull
+ public default String getFormattedSize() {
+ return NumberFormats.getFormattedByteSize(getByteSize());
+ }
+
+ /**
+ * Returns a representation of the size of this file in bytes as returned by {@link #getByteSize()}, rendered
+ * according to the number formatting rules for the current locale.
+ *
+ *
+ * For example, in English, there is a "," after every 3 digits; and in French, there is a " " (single space)
+ * separator after every 3 digits.
+ *
+ * @return a representation of the size of this file in bytes as returned by {@link #getByteSize()}, rendered
+ * according to the number formatting rules for the current locale.
+ * @see NumberFormats#getFormattedWholeNumber(long)
+ */
+ @Nonnull
+ public default String getFormattedByteSize() {
+ return NumberFormats.getFormattedWholeNumber(getByteSize());
+ }
+
+ public default boolean isEmpty() {
+ if (getByteSize() == 0) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns the last modified or creation date of this file, much like {@link java.io.File#lastModified()}.
+ *
+ * @return the last modified or creation date of this file
+ */
+ @Nonnull
+ public Date getLastModified();
+
+ /**
+ * Returns a {@link SimpleMutableFileMetadata} encapsulating the metadata for the file represented by this
+ * FileInfo.
+ *
+ * @return a {@link SimpleMutableFileMetadata} encapsulating the metadata for the file represented by this FileInfo.
+ */
+ @Nonnull
+ public FileMetadata getMetadata();
+
+ /**
+ * Returns a data: URI containing the content-type and Base64 encoded data representing the bytes contained in this
+ * file resource, if available.
+ *
+ * @return a data: URI containing the content-type and Base64 encoded data representing the bytes contained in this
+ * file resource, if available; null otherwise.
+ * @throws UnsupportedOperationException
+ * in some rare cases, if the resource may be {@link #isValid() valid}, but the implementation does not support
+ * directly reading the underlying data, possibly because it was never intended that should be required. See
+ * {@link #getInputStream()}.
+ */
+ @Nullable
+ public default String getDataUrl() {
+ try {
+ return URLUtil.getDataUrl(this);
+ }
+ catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns true if this FileInfo represents a file that is backed by a persistent store, whether or not the resource
+ * actually exists.
+ *
+ *
+ * The most common example of when this method would return false would be the case of a {@link TempFileResource},
+ * but there are other cases, such as the case of an external resource, whether or not TeamPage may have temporarily
+ * retrieved it and cached it in memory.
+ *
+ *
+ * This default implementation returns true. Subclasses should override it if they represent file resources not
+ * backed by data in a persistent store.
+ *
+ * @return true if this FileInfo represents a file that is backed by a persistent store; false otherwise.
+ */
+ public default boolean isPersistent() {
+ return true;
+ }
+
+ /**
+ * Returns a String representing the Base64 encoded bytes of this file's content.
+ *
+ *
+ * This implementation delegates to {@link URLUtil#getBase64EncodedStringForDataUrl(java.io.File)} using the
+ * {@link InputStream} returned by {@link #getInputStream()}. Subclasses should not need to override it.
+ *
+ * @return a String representing the Base64 encoded bytes of this file's content, if this File instance represents a
+ * file and the file's contents can be read successfully; null otherwise.
+ */
+ @Nullable
+ public default String getBase64() {
+ if (isDirectory()) {
+ return null;
+ }
+ try (InputStream input = getInputStream()) {
+ return URLUtil.getBase64EncodedStringForDataUrl(input);
+ }
+ catch (Exception e) {
+ LOGGER.warn("Unable to Base64 encode the file {}", this, e);
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if this file appears to be an image.
+ *
+ *
+ * The default implementation is based upon whether either the file extension or content-type for this file are
+ * known to be associated with images. Subclasses that can provide a more definite answer should override it.
+ *
+ * @return true if the file seems to be an image; false otherwise.
+ */
+ public default boolean isImage() {
+ if (isDirectory()) {
+ return false;
+ }
+ if (ImageUtil.isImage(getExtension(), getContentType())) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns an unscaled {@link Icon} representing an interpretation of this file as an image.
+ *
+ *
+ * This may require a request to the file system or a file repository service, which could be relatively slow
+ * compared with other methods in this class. Naturally, this method will not make sense for files that are not
+ * images, and it is very likely that if {@link #isImage()} returns true that this method will return an Icon that
+ * is not {@link Icon#isValid() valid}.
+ *
+ *
+ * This default implementation defers to {@link #getImage(Dimensions)}, passing null for the maximum
+ * {@link Dimensions}. It should be suitable for all IconFile implementations.
+ *
+ * @return an unscaled {@link Icon} representing an interpretation of this file as an image, if possible; null
+ * otherwise.
+ * @see #isImage()
+ */
+ @Nullable
+ public default Icon getImage() {
+ return getImage(null);
+ }
+
+ /**
+ * Returns a scaled {@link Icon} based on this IconFile, scaled if necessary to fit into the given
+ * {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions}, if any.
+ *
+ * @param maxDimensions
+ * an optional {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions} that should limit the size of the
+ * returned {@link Icon}.
+ * @return an {@link Icon} based on this IconFile, scaled if necessary to fit into the given
+ * {@link Dimensions.Units#PIXELS}-denominated {@link Dimensions}, if any; or null if no Icon can be directly
+ * created based upon this IconFile.
+ */
+ @Nullable
+ public default Icon getImage(Dimensions maxDimensions) {
+ if (isImage()) {
+ return SimpleFileResourceIconFileAdapter.createInstance(this, getType()).getImage(maxDimensions);
+ }
+ return null;
+ }
+
+ @Nullable
+ public default String getIconStyleName() {
+ return FileIconService.get().getIconStyleName(this);
+ }
+
+ @Nonnull
+ public default Icon getIcon() {
+ return getIcon(null);
+ }
+
+ /**
+ * Returns an {@link Icon} representing a file icon for this file resource.
+ *
+ *
+ * This implementation simply delegates to {@code FileIconService.get().getIcon(this, maxDimensions)}. This should
+ * be sufficient for most implementations, but can be overridden by subclasses as necessary.
+ *
+ * @param maxDimensions
+ * optional preferred maximum dimensions for the file icon.
+ * @return an {@link Icon} for an image representing an icon for this file, if one can be determined; null
+ * otherwise.
+ */
+ @Nonnull
+ public default Icon getIcon(Dimensions maxDimensions) {
+ return FileIconService.get().getIcon(this, maxDimensions);
+ }
+
+ /**
+ * Returns true if this file's {@link #getContentType() content type} is a text type, ignoring any charset or other
+ * optional parameters.
+ *
+ *
+ * The intention is that if this method returns true, methods like {@link #getReader()} can be used to obtain
+ * character data from the file. This does not necessarily mean that the file is exactly a plain text document, but
+ * rather just that it is expected to contain valid character data. Contrast with #isPlainText()
+ *
+ *
+ * This default implementation delegates to {@link MediaTypeUtil#isTextContentType(String)}, but subclasses may
+ * override it as appropriate.
+ *
+ * @return true if this file's {@link #getContentType() content type} is a text type; false otherwise.
+ */
+ public default boolean isText() {
+ return MediaTypeUtil.isTextContentType(getContentType());
+ }
+
+ /**
+ * Returns true if this file's {@link #getContentType() content type} is plain text ("text/plain") ignoring any
+ * charset or other optional parameters.
+ *
+ *
+ * Contrast this with {@link #isText()}.
+ *
+ *
+ * This default implementation delegates to {@link MediaTypeUtil#isTextPlainContentType(String)}, but subclasses may
+ * override it as appropriate.
+ *
+ * @return true if this file's {@link #getContentType() content type} is a text type; false otherwise.
+ */
+ @Beta
+ public default boolean isPlainText() {
+ return MediaTypeUtil.isTextPlainContentType(getContentType());
+ }
+
+ /**
+ * Returns true if this file's {@link #getContentType() content type} is HTML ("text/html"), ignoring any charset or
+ * other optional parameters.
+ *
+ *
+ * This default implementation delegates to {@link MediaTypeUtil#isTextHtmlContentType(String)}, but subclasses may
+ * override it as appropriate.
+ *
+ * @return true if this file's {@link #getContentType() content type} is HTML; false otherwise.
+ */
+ @Beta
+ public default boolean isHtml() {
+ return MediaTypeUtil.isTextHtmlContentType(getContentType());
+ }
+
+ /**
+ * Returns an {@link Iterator} of Strings, each representing successive lines of text in this file. Clients should
+ * invoke {@link #isText()} before invoking this method, or else risk getting invalid character data just like they
+ * would in any other case when, e.g., attempting to interpret arbitrary binary data as character data.
+ *
+ *
+ * This default implementation returns a {@link LineIterator} backed by the {@link Reader} from
+ * {@link #getReader()}. Subclasses may override it as necessary.
+ *
+ * @return an {@link Iterator} of Strings, each representing successive lines of text in this file. Some lines may
+ * be blank.
+ * @throws IOException
+ * if one is raised attempting to access the contents of the file.
+ */
+ @Beta
+ public default Iterator getLineIterator() throws IOException {
+ return new LineIterator(getReader());
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/FileResourceType.java b/src/main/java/com/tractionsoftware/commons/io/FileResourceType.java
new file mode 100644
index 0000000..6bbb648
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/FileResourceType.java
@@ -0,0 +1,32 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+/**
+ * Represents a file's resource type.
+ *
+ * @author Dave Shepperton
+ */
+public interface FileResourceType {
+
+ public boolean supportsContentId();
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/FileUtil.java b/src/main/java/com/tractionsoftware/commons/io/FileUtil.java
new file mode 100644
index 0000000..34b210d
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/FileUtil.java
@@ -0,0 +1,1222 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.tractionsoftware.commons.lang.Resource;
+import com.tractionsoftware.commons.lang.StringUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.filefilter.IOFileFilter;
+import org.apache.commons.io.filefilter.RegexFileFilter;
+import org.apache.commons.io.filefilter.WildcardFileFilter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Strings;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.*;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+/**
+ * Utilities related to java {@link File}s and paths.
+ *
+ * @author Andy Keller, Dave Shepperton
+ */
+public final class FileUtil {
+
+ private FileUtil() {
+ }
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FileUtil.class.getName());
+
+ private static final String SYSTEM_PROPERTY_NAME_TEMP_DIRECTORY = "java.io.tmpdir";
+
+ private static final String SYSTEM_PROPERTY_NAME_WORKING_DIRECTORY = "user.dir";
+
+ /**
+ * The different types of results of an attempt to delete a file or directory.
+ */
+ public static enum DeleteRequestStatus {
+
+ NO_FILE_REQUESTED(false),
+
+ NO_SUCH_FILE(false),
+
+ SUCCESS_IMMEDIATE(false),
+
+ SUCCESS_ON_EXIT(false),
+
+ FAILURE_SECURITY(true),
+
+ FAILURE_NON_EMPTY_DIRECTORY(true),
+
+ FAILURE_OTHER(true);
+
+ private final boolean representsFailure;
+
+ DeleteRequestStatus(boolean representsFailure) {
+ this.representsFailure = representsFailure;
+ }
+
+ public final boolean failed() {
+ return representsFailure;
+ }
+
+ }
+
+ /**
+ * A {@link FileFilter} for including files that have a given file extension. It will never match directories.
+ */
+ private static final class ExtFileFilter implements FileFilter {
+
+ private final Set extensions;
+
+ private ExtFileFilter(Set extensions) {
+ Objects.requireNonNull(extensions, "extensions Set");
+ if (extensions.isEmpty()) {
+ LOGGER.warn(
+ "This FileFilter will not match any files because there are no extensions specified",
+ new IllegalArgumentException()
+ );
+ }
+ this.extensions = ImmutableSet.copyOf(extensions);
+ }
+
+ @Override
+ public final boolean accept(File pathname) {
+ if (pathname.isDirectory()) {
+ return false;
+ }
+ return hasIncludedExtension(pathname.getName());
+ }
+
+ private final boolean hasIncludedExtension(String name) {
+ String ext = FileNameUtil.getExtension(name, null);
+ if (ext == null) {
+ return false;
+ }
+ if (extensions.contains(ext)) {
+ return true;
+ }
+ return false;
+ }
+
+ }
+
+ public static final Comparator LAST_MODIFIED_ORDER_ASCENDING =
+ Comparator.comparing(File::lastModified);
+
+ public static final Comparator LAST_MODIFIED_ORDER_DESCENDING =
+ LAST_MODIFIED_ORDER_ASCENDING.reversed();
+
+ /**
+ * Returns true if the target {@link File} represents an {@link File#exists() existing}
+ * {@link File#canRead() readable} {@link File#isFile() file} (not a {@link File#isDirectory() directory}), or a
+ * non-existent file location where a file would be readable if it did exist. Specifically, this returns true for a
+ * non-null argument in either of two cases:
+ *
+ *
+ * The file exists, is already a file (as opposed to a directory), and is readable.
+ * The file does not exist, but the parent directory does exist and is readable.
+ *
+ *
+ * @param file
+ * the {@link File} to test.
+ * @return true if the target {@link File} represents an {@link File#exists() existing}
+ * {@link File#canRead() readable} {@link File#isFile() file} (not a {@link File#isDirectory() directory}), or a
+ * non-existent file location where a file would be readable if it did exist; false otherwise.
+ */
+ public static final boolean fileIsOrWouldBeReadable(@Nullable File file) {
+ return isOrWouldBeAllowed(file, File::isFile, File::canRead);
+ }
+
+ /**
+ * Returns true if the target {@link File} represents an {@link File#exists() existing}
+ * {@link File#canRead() readable} {@link File#isDirectory() directory} (not a {@link File#isFile() file}), or a
+ * non-existent file location where a directory would be readable if it did exist. Specifically, this returns true
+ * for a non-null argument in either of two cases:
+ *
+ *
+ * The file exists, is already a directory (as opposed to a file), and is readable.
+ * The file does not exist, but the parent directory does exist and is readable.
+ *
+ *
+ * @param file
+ * the {@link File} to test.
+ * @return true if the target {@link File} represents an {@link File#exists() existing}
+ * {@link File#canRead() readable} {@link File#isDirectory() directory} (not a {@link File#isFile() file}), or a
+ * non-existent file location where a directory would be readable if it did exist; false otherwise.
+ */
+ public static final boolean directoryIsOrWouldBeReadable(@Nullable File file) {
+ return isOrWouldBeAllowed(file, File::isDirectory, File::canRead);
+ }
+
+ /**
+ * Returns true if the target {@link File} represents an {@link File#exists() existing}
+ * {@link File#canWrite() writable} {@link File#isFile() file} (not a {@link File#isDirectory() directory}), or a
+ * non-existent file location where a file would be writable if it did exist. This is a best-efforts basis check,
+ * because there is no way to be certain that a file system write operation will succeed without actually performing
+ * it. Specifically, this returns true for a non-null argument in either of two cases:
+ *
+ *
+ * The file exists, is already a file (as opposed to a directory), and is writable.
+ * The file does not exist, but the parent directory does exist and is writable.
+ *
+ *
+ * @param file
+ * the {@link File} to test.
+ * @return true if the target {@link File} represents an {@link File#exists() existing}
+ * {@link File#canWrite() writable} {@link File#isFile() file} (not a {@link File#isDirectory() directory}), or
+ * a non-existent file location where a file would be writable if it did exist; false otherwise.
+ */
+ public static final boolean fileIsOrWouldBeWritable(@Nullable File file) {
+ return isOrWouldBeAllowed(file, File::isFile, File::canWrite);
+ }
+
+ /**
+ * Returns true if the target {@link File} represents an {@link File#exists() existing}
+ * {@link File#canWrite() writable} {@link File#isDirectory() directory} (not a {@link File#isFile() file}), or a
+ * non-existent file location where a directory could be created and be writable if it did exist. This is a
+ * best-efforts basis check, because there is no way to be certain that a file system write operation will succeed
+ * without actually performing it. Specifically, this returns true for a non-null argument in either of two cases:
+ *
+ *
+ * The file exists, is already a directory (as opposed to a file), and is writable.
+ * The file does not exist, but the parent directory does exist and is writable.
+ *
+ *
+ * @param file
+ * the {@link File} to test.
+ * @return true if the target {@link File} represents an {@link File#exists() existing}
+ * {@link File#canWrite() writable} {@link File#isDirectory() directory} (not a {@link File#isFile() file}), or
+ * a non-existent file location where a directory could be created and be writable if it did exist; false
+ * otherwise.
+ */
+ public static final boolean directoryIsOrWouldBeWritable(@Nullable File file) {
+ return isOrWouldBeAllowed(file, File::isDirectory, File::canWrite);
+ }
+
+ /**
+ * Returns the relative path of the given file with respect to the given root, or the original given file path if it
+ * is not contained by the given root path.
+ *
+ *
+ * This method does not include any path canonicalization. It defers to {@link File#toURI()} to turn the given file
+ * paths into absolute paths; to {@link URI#relativize(URI)} to create a URI representing the relative path; and to
+ * {@link URI#getPath()} to return the (non-encoded) form of that path. If the file does not have a path that is
+ * relative to the given root, the original path will be returned as-is. null arguments are handled gracefully.
+ *
+ *
+ * The intention is to handle paths like this:
+ *
+ *
+ * root: /apps/traction/server
+ * file: /apps/traction/server/config/user/directories/ad.properties
+ * result: config/user/directories/ad.properties
+ *
+ *
+ * @param root
+ * representing the root path, with respect to which the given file's relative path should be determined.
+ * @param file
+ * representing the file path whose relative form should be determined.
+ * @return the relative path of the given file with respect to the given root, or the original given file path if it
+ * is not contained by the given root path.
+ */
+ public static final String getRelativePath(@Nullable File root, @Nullable File file) {
+ if (root == null) {
+ if (file == null) {
+ return null;
+ }
+ return file.getPath();
+ }
+ if (file == null) {
+ return null;
+ }
+ return root.toURI().relativize(file.toURI()).getPath();
+ }
+
+ /**
+ * Deletes the specified file or directory. If the directory is not empty, it removes all of its contents before
+ * removing the directory.
+ *
+ * @throws IOException
+ * if the delete operation fails. In the case of a directory, some files may have been deleted.
+ * @throws NullPointerException
+ * if the argument for the {@link File} is null.
+ * @see FileUtils#deleteDirectory(File)
+ * @see Files#delete(Path)
+ */
+ public static final void delete(@Nonnull File file) throws IOException {
+ Objects.requireNonNull(file, "file");
+ if (file.isDirectory()) {
+ FileUtils.deleteDirectory(file);
+ }
+ else {
+ Files.delete(file.toPath());
+ }
+ }
+
+ @Nonnull
+ public static final List delete(@Nonnull Iterable files) {
+ Objects.requireNonNull(files, "files");
+ ImmutableList.Builder notDeleted = ImmutableList.builder();
+ for (File file : files) {
+ try {
+ Files.delete(file.toPath());
+ }
+ catch (IOException | SecurityException e) {
+ notDeleted.add(file);
+ }
+ }
+ return notDeleted.build();
+ }
+
+ @Nonnull
+ public static final List listDirs(@Nonnull File file) {
+ Objects.requireNonNull(file, "file");
+ File[] directories = file.listFiles(File::isDirectory);
+ if (directories == null) {
+ return List.of();
+ }
+ return Arrays.asList(directories);
+ }
+
+ /**
+ * Returns all of the files in the specified folder with the specified extension.
+ */
+ public static final Collection findFilesByExtension(@Nonnull File directory, @Nonnull String matchExtension, boolean recurse) {
+ Objects.requireNonNull(directory, "directory");
+ Objects.requireNonNull(matchExtension, "extension");
+ return FileUtils.listFiles(directory, new String[] { matchExtension }, recurse);
+ }
+
+ /**
+ * Returns {@link File#getCanonicalFile() the canonicalized form} of the given {@link File} if possible.
+ *
+ * @param file
+ * the File to be canonicalized.
+ * @return {@link File#getCanonicalFile() the canonicalized form} of the given {@link File} if possible; the given
+ * File otherwise.
+ * @throws NullPointerException
+ * if the argument for the {@link File} is null.
+ */
+ public static final File getCanonicalFile(@Nonnull File file) {
+ Objects.requireNonNull(file, "file");
+ try {
+ return file.getCanonicalFile();
+ }
+ catch (IOException | SecurityException e) {
+ LOGGER.warn("Failed to canonicalize {} ", file, e);
+ }
+ return file;
+ }
+
+ /**
+ * Checks whether it can be determined that the given file represents a descendant file of the given directory,
+ * either using {@link File#getAbsoluteFile() absolute} and {@link Path#normalize() normalized} path forms or
+ * canonicalized forms.
+ *
+ *
+ * Note: the way this method works handles symlinks in a very particular way. For a symlink at /foo/bar linking to
+ * /foo/bar, this method will return true in all three of these cases:
+ *
+ *
+ * directory = /foo, file = /foo/bar: just by comparing the paths, this is a match.
+ * directory = /baz, file = /baz/bar: also just by comparing the paths, this is a match.
+ * directory = /baz, file = /foo/bar: because /foo/bar links to /baz, which contains the path /baz/bar, the
+ * comparison that uses canonicalized forms will consider this a match.
+ *
+ *
+ *
+ * If this behavior is not desirable, use {@link #isDescendantCanonical(File, File)}.
+ *
+ *
+ * This method will also return false in the unlikely event that the an IOException or SecurityException is raised
+ * while checking the canonical path forms.
+ *
+ * @param directory
+ * a {@link File} representing the directory.
+ * @param file
+ * a {@link File} representing the file whose path is to be checked against the directory.
+ * @return true if neither argument is null and if it can be determined that the given file represents a descendant
+ * file of the given directory.
+ */
+ public static final boolean isDescendant(@Nullable File directory, @Nullable File file) {
+
+ if (directory == null || file == null) {
+ return false;
+ }
+
+ Path absNormDirectoryPath = toAbsoluteNormalizedPath(directory);
+ Path absNormFilePath = toAbsoluteNormalizedPath(file);
+ if (absNormFilePath.startsWith(absNormDirectoryPath)) {
+ return true;
+ }
+
+ try {
+ return isDescendantForAlreadyCanonicalFilesImpl(
+ absNormDirectoryPath.toFile().getCanonicalFile(), absNormFilePath.toFile().getCanonicalFile()
+ );
+ }
+ catch (IOException | SecurityException e) {
+ LOGGER.warn("isDescendant encountered an error ({} vs {})", directory, file, e);
+ }
+ return false;
+
+ }
+
+ /**
+ * Checks whether it can be determined that the given file represents the same file or a descendant file of the
+ * given directory, either using {@link File#getAbsoluteFile() absolute} and {@link Path#normalize() normalized}
+ * path forms or canonicalized forms.
+ *
+ *
+ * Note: the way this method works handles symlinks in a very particular way. For a symlink at /foo/bar linking to
+ * /foo/bar, this method will return true in all three of these cases:
+ *
+ *
+ * directory = /foo, file = /foo/bar: just by comparing the paths, this is a match.
+ * directory = /baz, file = /baz/bar: also just by comparing the paths, this is a match.
+ * directory = /baz, file = /foo/bar: because /foo/bar links to /baz, which contains the path /baz/bar, the
+ * comparison that uses canonicalized forms will consider this a match.
+ *
+ *
+ *
+ * If this behavior is not desirable, use {@link #isOrIsDescendantCanonical(File, File)}.
+ *
+ *
+ * This method will also return false in the unlikely event that the an IOException or SecurityException is raised
+ * while checking the canonical path forms.
+ *
+ * @param directory
+ * a {@link File} representing the directory.
+ * @param file
+ * a {@link File} representing the file whose path is to be checked against the directory.
+ * @return true if if neither argument is null and it can be determined that the given file represents the same file
+ * or a descendant file of the given directory; false otherwise.
+ */
+ public static final boolean isOrIsDescendant(@Nullable File directory, @Nullable File file) {
+
+ if (directory == null || file == null) {
+ return false;
+ }
+
+ Path absNormDirectoryPath = toAbsoluteNormalizedPath(directory);
+ Path absNormFilePath = toAbsoluteNormalizedPath(file);
+ if (absNormFilePath.equals(absNormDirectoryPath) || absNormFilePath.startsWith(absNormDirectoryPath)) {
+ return true;
+ }
+
+ directory = absNormDirectoryPath.toFile();
+ file = absNormFilePath.toFile();
+
+ try {
+ return isOrIsDescendantForAlreadyCanonicalFilesImpl(
+ absNormDirectoryPath.toFile().getCanonicalFile(), absNormFilePath.toFile().getCanonicalFile()
+ );
+ }
+ catch (IOException | SecurityException e) {
+ LOGGER.warn("isOrIsDescendant encountered an error ({} vs {}) ", directory, file, e);
+ }
+ return false;
+
+ }
+
+ /**
+ * Checks whether it can be determined that the given file represents a descendant file of the given directory,
+ * using only the {@link #getFileFilterByExtension(String) canonicalized forms} based on the
+ * {@link File#getAbsoluteFile() absolute} {@link Path#normalize() normalized} forms.
+ *
+ *
+ * Note: the way this method works handles symlinks less permissively than {@link #isDescendant(File, File)}. For a
+ * symlink at /foo/bar linking to /foo/bar, this method will return true in only two of these three of these cases:
+ *
+ *
+ * directory = /foo, file = /foo/bar: just by comparing the paths, this would be a match, but since the
+ * canonicalized form of /foo/bar is /baz/bar, this is not a match.
+ * directory = /baz, file = /baz/bar: also just by comparing the paths, this is a match.
+ * directory = /baz, file = /foo/bar: because /foo/bar links to /baz, which contains the path /baz/bar, the
+ * comparison that uses canonicalized forms will consider this a match.
+ *
+ *
+ *
+ * This method will also return false in the unlikely event that the an IOException or SecurityException is raised
+ * while checking the canonical path forms.
+ *
+ * @param directory
+ * a {@link File} representing the directory.
+ * @param file
+ * a {@link File} representing the file whose path is to be checked against the directory.
+ * @return true if neither argument is null and if it can be determined that the given file represents a descendant
+ * file of the given directory.
+ * @see #isDescendant(File, File)
+ */
+ public static final boolean isDescendantCanonical(@Nullable File directory, @Nullable File file) {
+
+ if (directory == null || file == null) {
+ return false;
+ }
+
+ try {
+ return isDescendantForAlreadyCanonicalFilesImpl(
+ toAbsoluteNormalizedPath(directory).toFile().getCanonicalFile(),
+ toAbsoluteNormalizedPath(file).toFile().getCanonicalFile()
+ );
+ }
+ catch (IOException | SecurityException e) {
+ LOGGER.warn("isDescendantCanonical encountered an error ({} vs {})", directory, file, e);
+ }
+ return false;
+
+ }
+
+ /**
+ * Checks whether it can be determined that the given file represents a descendant file of the given directory,
+ * using only the {@link #getFileFilterByExtension(String) canonicalized forms} based on the
+ * {@link File#getAbsoluteFile() absolute} {@link Path#normalize() normalized} forms.
+ *
+ *
+ * Note: the way this method works handles symlinks less permissively than {@link #isOrIsDescendant(File, File)}.
+ * For a symlink at /foo/bar linking to /foo/bar, this method will return true in only two of these three of these
+ * cases:
+ *
+ *
+ * directory = /foo, file = /foo/bar: just by comparing the paths, this would be a match, but since the
+ * canonicalized form of /foo/bar is /baz/bar, this is not a match.
+ * directory = /baz, file = /baz/bar: also just by comparing the paths, this is a match.
+ * directory = /baz, file = /foo/bar: because /foo/bar links to /baz, which contains the path /baz/bar, the
+ * comparison that uses canonicalized forms will consider this a match.
+ *
+ *
+ *
+ * This method will also return false in the unlikely event that the an IOException or SecurityException is raised
+ * while checking the canonical path forms.
+ *
+ * @param directory
+ * a {@link File} representing the directory.
+ * @param file
+ * a {@link File} representing the file whose path is to be checked against the directory.
+ * @return true if neither argument is null and if it can be determined that the given file represents a descendant
+ * file of the given directory.
+ */
+ public static final boolean isOrIsDescendantCanonical(@Nullable File directory, @Nullable File file) {
+ if (directory == null || file == null) {
+ return false;
+ }
+
+ try {
+ return isOrIsDescendantForAlreadyCanonicalFilesImpl(
+ toAbsoluteNormalizedPath(directory).toFile().getCanonicalFile(),
+ toAbsoluteNormalizedPath(file).toFile().getCanonicalFile()
+ );
+ }
+ catch (IOException | SecurityException e) {
+ LOGGER.warn("isDescendant encountered an error ({} vs {})", directory, file, e);
+ }
+ return false;
+ }
+
+ /**
+ * Checks whether it can be determined that the given file represents or a descendant file of the given directory,
+ * assuming that both are already in canonicalized forms.
+ *
+ * @param directoryCanonical
+ * a canonicalized {@link File} representing the directory.
+ * @param fileCanonical
+ * a canonicalized {@link File} representing the file whose path is to be checked against the directory.
+ * @return true if neither argument is null and if it can be determined that the given file represents a descendant
+ * file of the given directory, assuming that both are already in canonicalized form; false otherwise.
+ * @throws IllegalArgumentException
+ * if one of the {@link File#getPath() files' paths} do not have {@link File#isAbsolute() absolute paths}, or
+ * contains any path segments matching "." or ".." indicating that it has not been canonicalized.
+ */
+ public static final boolean isDescendantForAlreadyCanonicalFiles(@Nullable File directoryCanonical, @Nullable File fileCanonical) {
+ if (directoryCanonical == null || fileCanonical == null) {
+ return false;
+ }
+ checkAbsoluteAndNoRelativeSegmentsX(directoryCanonical);
+ checkAbsoluteAndNoRelativeSegmentsX(fileCanonical);
+ if (fileCanonical.toPath().startsWith(directoryCanonical.toPath())) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Checks whether it can be determined that the given file represents the same file or a descendant file of the
+ * given directory, assuming that both are already in canonicalized forms.
+ *
+ * @param directoryCanonical
+ * a canonicalized {@link File} representing the directory.
+ * @param fileCanonical
+ * a canonicalized {@link File} representing the file whose path is to be checked against the directory.
+ * @return true if neither argument is null and if it can be determined that the given file represents the same file
+ * or a descendant file of the given directory, assuming that both are already in canonicalized form; false
+ * otherwise.
+ * @throws IllegalArgumentException
+ * if one of the {@link File#getPath() files' paths} do not have {@link File#isAbsolute() absolute paths}, or
+ * contains any path segments matching "." or ".." indicating that it has not been canonicalized.
+ */
+ public static final boolean isOrIsDescendantForAlreadyCanonicalFiles(@Nullable File directoryCanonical, @Nullable File fileCanonical) {
+ if (directoryCanonical == null || fileCanonical == null) {
+ return false;
+ }
+ checkAbsoluteAndNoRelativeSegmentsX(directoryCanonical);
+ checkAbsoluteAndNoRelativeSegmentsX(fileCanonical);
+ return isOrIsDescendantForAlreadyCanonicalFilesImpl(directoryCanonical, fileCanonical);
+ }
+
+ /**
+ * Checks whether it can be determined that the given {@link File}s represent the same actual file.
+ *
+ * @param file
+ * the first {@link File}.
+ * @param other
+ * the other {@link File}.
+ * @return neither {@link File} is null, and if it can be determined that the given {@link File}s represent the same
+ * actual file; false otherwise.
+ */
+ public static final boolean areSameFiles(@Nullable File file, @Nullable File other) {
+
+ if (file == null || other == null) {
+ return false;
+ }
+ if (file.equals(other)) {
+ return true;
+ }
+
+ Path absNormFilePath = toAbsoluteNormalizedPath(file);
+ Path absNormOtherPath = toAbsoluteNormalizedPath(other);
+ if (absNormFilePath.equals(absNormOtherPath)) {
+ return true;
+ }
+
+ try {
+ if (absNormFilePath.toFile().getCanonicalPath().equals(absNormOtherPath.toFile().getCanonicalPath())) {
+ return true;
+ }
+ return false;
+ }
+ catch (IOException | SecurityException e) {
+ LOGGER.warn("areSameFiles encountered an error", e);
+ }
+ return false;
+
+ }
+
+ public static final boolean isDescendantPath(@Nullable String directoryPath, @Nullable String filePath) {
+ if (directoryPath == null || filePath == null) {
+ return false;
+ }
+ return isDescendant(new File(directoryPath), new File(filePath));
+ }
+
+ public static final boolean isOrIsDescendantPath(@Nullable String directoryPath, @Nullable String filePath) {
+ if (directoryPath == null || filePath == null) {
+ return false;
+ }
+ return isOrIsDescendant(new File(directoryPath), new File(filePath));
+ }
+
+ public static final boolean isDescendantPathCanonical(@Nullable String directoryPath, @Nullable String filePath) {
+ if (directoryPath == null || filePath == null) {
+ return false;
+ }
+ return isDescendantCanonical(new File(directoryPath), new File(filePath));
+ }
+
+ public static final boolean isOrIsDescendantPathCanonical(@Nullable String directoryPath, @Nullable String filePath) {
+ if (directoryPath == null || filePath == null) {
+ return false;
+ }
+ return isOrIsDescendantCanonical(new File(directoryPath), new File(filePath));
+ }
+
+ public static final boolean existsAndReadable(@Nullable File file) {
+ if (file != null) {
+ try {
+ return file.exists();
+ }
+ catch (SecurityException e) {
+ LOGGER.warn("File {} may or may not exist, but is apparently not readable", file, e);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Moves a file using {@link Files#move(Path, Path, CopyOption...)}, replacing any existing file, as long as the
+ * given source and destination Files are not already the same.
+ *
+ * @return true if the file was actually moved; false if the source and destination files were already the same.
+ * @throws IOException
+ * if one is raised while attempting to perform the move operation.
+ */
+ public static final boolean move(@Nonnull File source, @Nonnull File dest) throws IOException {
+ Objects.requireNonNull(source, "source file");
+ Objects.requireNonNull(dest, "destination file");
+ if (areSameFiles(source, dest)) {
+ return false;
+ }
+ Files.move(source.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ return true;
+ }
+
+ public static final void checkAndCreateDirectory(String directoryPath) throws IOException {
+ checkAndCreateDirectory(directoryPath, IOException.class);
+ }
+
+ public static void checkAndCreateDirectory(String directoryPath, Class xcpType)
+ throws X {
+ if (directoryPath == null) {
+ return;
+ }
+ checkAndCreateDirectory(new File(directoryPath), xcpType);
+ }
+
+ public static final void checkAndCreateDirectory(File directory) throws IOException {
+ checkAndCreateDirectory(directory, IOException.class);
+ }
+
+ public static void checkAndCreateDirectory(File directory, Class xcpType)
+ throws X {
+
+ if (directory.exists()) {
+ if (directory.isDirectory()) {
+ return;
+ }
+ if (xcpType != null) {
+ throwException(
+ xcpType,
+ "The specified location (" + directory.getPath() + ") exists, but is not a directory."
+ );
+ }
+ return;
+ }
+
+ if (directory.mkdirs()) {
+ return;
+ }
+
+ if (xcpType != null) {
+ throwException(
+ xcpType,
+ "Unable to create directory \"" + directory.getPath() + "\"."
+ );
+ }
+
+ }
+
+ public static final File createSystemTempFile(String prefix, String extension) throws IOException {
+ if (StringUtils.isBlank(extension)) {
+ return File.createTempFile(prefix, null);
+ }
+ return File.createTempFile(prefix, Strings.CS.prependIfMissing(extension, FileNameUtil.EXTENSION_SEPARATOR));
+ }
+
+ public static final File getCurrentDirectory() {
+ return new File(FileNameUtil.CURRENT_PATH_INDICATOR);
+ }
+
+ /**
+ * Returns a File for the directory that is determined to be the working directory for the current Java process.
+ *
+ * @return a File for the directory that is determined to be the working directory for the current Java process.
+ */
+ public static final File getWorkingDirectory() {
+ String pathFromProperty = System.getProperty(SYSTEM_PROPERTY_NAME_WORKING_DIRECTORY);
+ return new File(StringUtils.defaultIfBlank(pathFromProperty, "."));
+ }
+
+ public static final void setTempDirectory(@Nullable String directoryPath) {
+ setTempDirectory(new File(StringUtils.defaultString(directoryPath)));
+ }
+
+ /**
+ * Sets the temp directory by modifying the
+ * {@link #SYSTEM_PROPERTY_NAME_TEMP_DIRECTORY java.io.tmpdir system property}.
+ *
+ * @param directory
+ * representing the new temp directory.
+ * @throws IllegalArgumentException
+ * if the given {@link File} is not suitable for a temp directory.
+ * @throws NullPointerException
+ * if the argument for the {@link File} is null.
+ */
+ public static final void setTempDirectory(@Nonnull File directory) {
+ checkTempDirectoryX(directory);
+ System.setProperty(SYSTEM_PROPERTY_NAME_TEMP_DIRECTORY, directory.getPath());
+ }
+
+ /**
+ * Returns the {@link File} representing the specified location of the current temp directory, as read from the
+ * {@link #SYSTEM_PROPERTY_NAME_TEMP_DIRECTORY java.io.tmpdir system property}.
+ *
+ * @return the {@link File} representing the specified location of the current temp directory, as read from the
+ * {@link #SYSTEM_PROPERTY_NAME_TEMP_DIRECTORY java.io.tmpdir system property}.
+ * @throws Error
+ * if the temporary directory is not set.
+ */
+ public static final File getTempDirectory() {
+ String defaultTempDirectoryPath = System.getProperty(SYSTEM_PROPERTY_NAME_TEMP_DIRECTORY);
+ if (StringUtils.isNotBlank(defaultTempDirectoryPath)) {
+ return new File(defaultTempDirectoryPath);
+ }
+ throw new Error("No temp directory has been set.");
+ }
+
+ /**
+ * Attempts to delete a {@link File}, falling back to set it to be deleted on exit if that attempt fails.
+ *
+ * @param deleteTarget
+ * the {@link File} to delete. It must not be a directory.
+ * @return a {@link DeleteRequestStatus} representing the result of the attempt.
+ */
+ @CanIgnoreReturnValue
+ public static final DeleteRequestStatus deleteOrDeleteOnExit(File deleteTarget) {
+
+ if (deleteTarget == null) {
+ return DeleteRequestStatus.NO_FILE_REQUESTED;
+ }
+
+ try {
+ // We check whether the file exists or not because NoSuchFileException is /optional/ for the Files.delete
+ // method. This is not atomic now, because it's check-then-act, but in most cases, a file is only being
+ // deleted by one caller at a time, and this is better than returning success when no delete operation
+ // actually happened.
+ if (deleteTarget.exists()) {
+ Files.delete(deleteTarget.toPath());
+ return DeleteRequestStatus.SUCCESS_IMMEDIATE;
+ }
+ return DeleteRequestStatus.NO_SUCH_FILE;
+ }
+ catch (SecurityException e) {
+ LOGGER.warn("Failed to delete {}", deleteTarget, e);
+ return DeleteRequestStatus.FAILURE_SECURITY;
+ }
+ catch (NoSuchFileException e) {
+ return DeleteRequestStatus.NO_SUCH_FILE;
+ }
+ catch (DirectoryNotEmptyException e) {
+ return deleteOnExit(deleteTarget, e, DeleteRequestStatus.FAILURE_NON_EMPTY_DIRECTORY);
+ }
+ catch (IOException e) {
+ return deleteOnExit(deleteTarget, e, DeleteRequestStatus.FAILURE_OTHER);
+ }
+
+ }
+
+ public static final FileFilter getFileFilterByExtension(@Nonnull String ext) {
+ Objects.requireNonNull(ext, "extension");
+ return getFileFilterByExtension(Collections.singleton(ext));
+ }
+
+ public static final FileFilter getFileFilterByExtension(@Nonnull Set extensions) {
+ Objects.requireNonNull(extensions, "extensions");
+ return new ExtFileFilter(getExtensionsForExtensionFileFilter(extensions));
+ }
+
+ @Nonnull
+ public static final IOFileFilter getFileNameBlobFilter(@Nonnull String fileNameMatcherBlob) {
+ StringUtil.checkNotBlankX(fileNameMatcherBlob, "file name matcher blob");
+ if (StringUtils.containsAny(
+ fileNameMatcherBlob,
+ FileNameUtil.GENERIC_PATH_SEPARATOR_CHAR,
+ FileNameUtil.WINDOWS_PATH_SEPARATOR_CHAR,
+ ':'
+ )) {
+ throw new IllegalArgumentException("Unsupported character in file name matcher blob.");
+ }
+ return WildcardFileFilter.builder().setWildcards(fileNameMatcherBlob).get();
+ }
+
+ @Nonnull
+ public static final IOFileFilter getFileNamePatternFilter(@Nonnull String patternSpec) {
+ StringUtil.checkNotBlankX(patternSpec, "file name pattern spec");
+ return new RegexFileFilter(patternSpec);
+ }
+
+ /**
+ * Returns a {@link BufferedInputStream} for the given {@link File}.
+ *
+ * @param file
+ * the {@link File}.
+ * @return a {@link BufferedInputStream} for the given {@link File}.
+ * @throws IOException
+ * if there is a problem opening the {@link InputStream} for the given {@link File}.
+ * @throws NullPointerException
+ * if the argument for the {@link File} is null.
+ */
+ @Nonnull
+ public static final BufferedInputStream getBufferedInputStream(@Nonnull File file) throws IOException {
+ Objects.requireNonNull(file, "File");
+ return new BufferedInputStream(Files.newInputStream(file.toPath()));
+ }
+
+ /**
+ * Returns a {@link BufferedReader} for the given {@link File} using the UTF-8 charset.
+ *
+ * @param file
+ * the {@link File}.
+ * @return a {@link BufferedReader} for the given {@link File} using the UTF-8 charset.
+ * @throws IOException
+ * if there is a problem opening the underlying {@link InputStream} for the given {@link File}.
+ * @throws NullPointerException
+ * if the argument for the {@link File} is null.
+ */
+ @Nonnull
+ public static final BufferedReader getBufferedUtf8Reader(@Nonnull File file) throws IOException {
+ Objects.requireNonNull(file, "File");
+ return Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Returns a {@link BufferedOutputStream} for the given {@link File}.
+ *
+ * @param file
+ * the {@link File}.
+ * @return a {@link BufferedOutputStream} for the given {@link File}.
+ * @throws IOException
+ * if there is a problem opening the {@link OutputStream} for the given {@link File}.
+ * @throws NullPointerException
+ * if the argument for the {@link File} is null.
+ */
+ @Nonnull
+ public static final BufferedOutputStream getBufferedOutputStream(@Nonnull File file) throws IOException {
+ Objects.requireNonNull(file, "File");
+ return new BufferedOutputStream(Files.newOutputStream(file.toPath()));
+ }
+
+ /**
+ * Returns a {@link BufferedWriter} that writes UTF-8 character data to the given {@link File}.
+ *
+ * @param file
+ * the target file.
+ * @return a {@link BufferedWriter} that writes UTF-8 character data to the given {@link File}.
+ * @throws IOException
+ * if there is a problem opening the file for writing.
+ * @throws NullPointerException
+ * if the given file is null.
+ */
+ @Nonnull
+ public static final BufferedWriter getBufferedUtf8Writer(@Nonnull File file) throws IOException {
+ Objects.requireNonNull(file, "File");
+ return Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Returns a wrapped version of the given {@link File}'s {@link FileOutputStream} which incorporates buffering and
+ * tracking.
+ *
+ * @param file
+ * the {@link File} to be read.
+ * @return a wrapped version of the given {@link File}'s {@link FileOutputStream} which incorporates buffering and
+ * tracking.
+ * @throws IOException
+ * if one is raised while attempting to create a {@link FileOutputStream}.
+ * @throws NullPointerException
+ * if either argument is null.
+ * @see #getBufferedOutputStream(File)
+ * @see IOUtil#getTrackedOutputStream(OutputStream, Supplier)
+ */
+ @Nonnull
+ public static final OutputStream getBufferedTrackedOutputStream(@Nonnull File file, @Nonnull Supplier extends Resource> getTracker)
+ throws IOException {
+ return IOUtil.getTrackedOutputStream(getBufferedOutputStream(file), getTracker, file.toString());
+ }
+
+ /**
+ * Returns a {@link PrintWriter} that writes to the given {@link File}, wrapping a {@link BufferedWriter} that
+ * writes UTF-8 character data. The returned object is NOT thread safe.
+ *
+ * @param file
+ * the target file.
+ * @return a {@link PrintWriter} that writes to the given File, wrapping a {@link BufferedWriter} that writes UTF-8
+ * character data.
+ * @throws IOException
+ * if there is a problem opening the file for writing.
+ * @throws NullPointerException
+ * if the given file is null.
+ */
+ @Nonnull
+ public static final PrintWriter getBufferedUtf8PrintWriter(@Nonnull File file) throws IOException {
+ Writer w = getBufferedUtf8Writer(file);
+ try {
+ return SingleThreadPrintWriter.createInstance(w);
+ }
+ catch (RuntimeException | Error e) {
+ IOUtils.closeQuietly(w);
+ throw e;
+ }
+ }
+
+ /**
+ * Returns a {@link File} representing the effective target destination for an operation to move a file or
+ * directory. The result will depend upon whether the requested destination is an existing directory, in order to
+ * implement the mv command-line style logic: if the destination is a directory, the file or directory
+ * being moved will be moved so that it is inside of the target directory; and if the destination is a file or does
+ * not exist, the file or directory being moved will take that exact name and location.
+ *
+ * @param requestedDestination
+ * the {@link File} representing the requested destination path for the move operation.
+ * @param fileName
+ * the existing name (not path) of the file to be moved.
+ * @return a {@link File} representing the effective target destination for an operation to move a file or
+ * directory.
+ * @throws NullPointerException
+ * if either argument is null.
+ * @throws IllegalArgumentException
+ * if the given file name is not {@link FileNameUtil#checkFileName(String) minimally valid} as a normal file
+ * name.
+ * @throws SecurityException
+ * if there is a security manager installed that prevents the requested destination file from being read.
+ */
+ @Nonnull
+ public static final File getMoveDestination(@Nonnull File requestedDestination, @Nonnull String fileName)
+ throws SecurityException {
+ Objects.requireNonNull(requestedDestination, "destination");
+ Objects.requireNonNull(fileName, "file name");
+ FileNameUtil.FileNameCheckResult result = FileNameUtil.checkFileName(fileName);
+ if (result.hasProblem()) {
+ LOGGER.debug("move destination file name {} => {}", fileName, result);
+ throw new IllegalArgumentException(String.format("The file name '%s' is not valid.", fileName));
+ }
+ if (requestedDestination.exists() && requestedDestination.isDirectory()) {
+ return new File(requestedDestination, fileName);
+ }
+ return requestedDestination;
+ }
+
+ @Nonnull
+ public static final File getDescendant(@Nonnull File directory, @Nonnull String... pathComponents) {
+ Objects.requireNonNull(directory, "directory");
+ Objects.requireNonNull(pathComponents, "path components");
+ File file = directory;
+ for (String pathComponent : pathComponents) {
+ FileNameUtil.FileNameCheckResult result = FileNameUtil.checkFileName(pathComponent);
+ if (result.hasProblem()) {
+ LOGGER.debug("Path component {} => {}", pathComponent, result);
+ throw new IllegalArgumentException(String.format(
+ "The path component '%s' is not allowed.",
+ pathComponent
+ ));
+ }
+ file = new File(file, pathComponent);
+ }
+ return file;
+ }
+
+ @Nonnull
+ public static final FileFilter getFindFilesByExtensionFilter(@Nonnull String matchExtension, boolean recurse) {
+ FileFilter extFilter = getFileFilterByExtension(matchExtension);
+ if (recurse) {
+ return (file) -> file.isDirectory() || extFilter.accept(file);
+ }
+ return extFilter;
+ }
+
+ /**
+ * Returns an absolute normalized {@link Path} for the given {@link File}. This ensures safe and consistent
+ * comparisons.
+ *
+ *
+ * Methods like {@link Path#startsWith(Path)} compare raw, unresolved path segments - it does not resolve "." or
+ * ".." components. Without normalizing first, a path like "[dir]/../escaped.txt" would incorrectly be reported as a
+ * descendant of [dir], because the literal segments of [dir] are a prefix of the literal segments of the file, even
+ * though the path - once resolved - actually refers to a location outside [dir]. We resolve to an absolute path
+ * before normalizing because {@link Path#normalize()} on a lone "." segment yields a Path with a single
+ * empty-string name element (not an empty/zero-element path), which would otherwise make relative paths like "."
+ * fail to match their own children (e.g. "./foo.bar").
+ *
+ *
+ * Note that "absolute and normalized" is not the same as canonicalized.
+ *
+ * @param file
+ * the {@link File} to be transformed.
+ * @return an absolute normalized {@link Path} for the given {@link File}. This ensures safe and consistent
+ * comparisons.
+ * @throws NullPointerException
+ * if the argument for the file is null.
+ */
+ @Nonnull
+ public static final Path toAbsoluteNormalizedPath(@Nonnull File file) {
+ Objects.requireNonNull(file, "file");
+ return file.toPath().toAbsolutePath().normalize();
+ }
+
+ @Nonnull
+ private static final DeleteRequestStatus deleteOnExit(@Nonnull File deleteTarget, @Nonnull Exception deleteError, @Nonnull DeleteRequestStatus onFailure) {
+ try {
+ deleteTarget.deleteOnExit();
+ LOGGER.info(
+ "Unable to immediately delete file {}; marked to be deleted on exit.", deleteTarget, deleteError
+ );
+ return DeleteRequestStatus.SUCCESS_ON_EXIT;
+ }
+ catch (Exception e) {
+ e.addSuppressed(deleteError);
+ LOGGER.info(
+ "Unable to immediately delete file {}, or mark it for deletion on exit", deleteTarget, e
+ );
+ }
+ return onFailure;
+ }
+
+ @Nonnull
+ private static final Set getExtensionsForExtensionFileFilter(@Nonnull Set extensions) {
+ Objects.requireNonNull(extensions, "extensions");
+ Set modifiedExtensions = new HashSet<>();
+ for (String ext : extensions) {
+ if (StringUtils.isBlank(ext)) {
+ continue;
+ }
+ if (StringUtil.startsWith(ext, FileNameUtil.EXTENSION_SEPARATOR_CHAR)) {
+ ext = ext.substring(1);
+ }
+ modifiedExtensions.add(ext);
+ }
+ return modifiedExtensions;
+ }
+
+ private static final void throwException(@Nonnull Class xcpType, String message) throws X {
+ Objects.requireNonNull(xcpType, "Exception type");
+ X xcp;
+ try {
+ xcp = xcpType.getConstructor(String.class).newInstance(message);
+ }
+ catch (Exception instantiationXcp) {
+ throw new RuntimeException(message);
+ }
+ throw xcp;
+ }
+
+ /**
+ * Applies tests for requirements for a valid temp file directory path.
+ *
+ * @param candidateTempDirectory
+ * the directory to test.
+ * @throws IllegalArgumentException
+ * if the given path is found to be invalid for some reason.
+ */
+ private static final void checkTempDirectoryX(@Nonnull File candidateTempDirectory)
+ throws IllegalArgumentException {
+
+ Objects.requireNonNull(candidateTempDirectory, "candidate temp directory");
+
+ if (StringUtils.isBlank(candidateTempDirectory.getPath())) {
+ throw new IllegalArgumentException("Missing or blank temp directory path.");
+ }
+
+ try {
+
+ if (!candidateTempDirectory.exists()) {
+ throw new IllegalArgumentException("Temp directory " + candidateTempDirectory + " does not exist.");
+ }
+
+ if (!candidateTempDirectory.isDirectory()) {
+ throw new IllegalArgumentException(
+ "Temp directory " + candidateTempDirectory + " exists, but is a file."
+ );
+ }
+
+ if (!candidateTempDirectory.canRead()) {
+ throw new IllegalArgumentException("Can't read the temp directory " + candidateTempDirectory + ".");
+ }
+
+ if (!candidateTempDirectory.canWrite()) {
+ throw new IllegalArgumentException("Can't write in the temp directory " + candidateTempDirectory + ".");
+ }
+
+ }
+ catch (SecurityException e) {
+ throw new IllegalArgumentException("Not allowed to use this directory for temp files.", e);
+ }
+
+ }
+
+ private static final boolean isOrWouldBeAllowed(@Nullable File file, Predicate super File> existingTypeTest, Predicate super File> permissionTest) {
+ if (file == null) {
+ return false;
+ }
+ try {
+ if (file.exists()) {
+ if (existingTypeTest.test(file) && permissionTest.test(file)) {
+ return true;
+ }
+ return false;
+ }
+ File parent = file.getParentFile();
+ if (parent != null && parent.isDirectory() && permissionTest.test(parent)) {
+ return true;
+ }
+ return false;
+ }
+ catch (RuntimeException e) {
+ LOGGER.warn("File location validation encountered an error while performing a test for {}", file, e);
+ return false;
+ }
+ }
+
+ private static final void checkAbsoluteAndNoRelativeSegmentsX(@Nonnull File file) {
+ if (!file.isAbsolute()) {
+ throw new IllegalArgumentException(String.format("The file %s does not use an absolute path.", file));
+ }
+ file.toPath().forEach(segment -> checkNonRelativeSegmentX(file, segment));
+ }
+
+ private static final void checkNonRelativeSegmentX(@Nonnull File file, @Nonnull Path segment) {
+ if (Strings.CS.equalsAny(
+ segment.toString(),
+ FileNameUtil.CONSECUTIVE_DOTS,
+ FileNameUtil.CURRENT_PATH_INDICATOR
+ )) {
+ throw new IllegalArgumentException(
+ String.format("The file %s contains an unexpected path segment %s.", file, segment)
+ );
+ }
+ }
+
+ private static final boolean isDescendantForAlreadyCanonicalFilesImpl(@Nonnull File directoryCanonical, @Nonnull File fileCanonical) {
+ if (fileCanonical.toPath().startsWith(directoryCanonical.toPath())) {
+ return true;
+ }
+ return false;
+ }
+
+ private static final boolean isOrIsDescendantForAlreadyCanonicalFilesImpl(@Nonnull File directoryCanonical, @Nonnull File fileCanonical) {
+ if (fileCanonical.equals(directoryCanonical) ||
+ isDescendantForAlreadyCanonicalFilesImpl(directoryCanonical, fileCanonical)) {
+ return true;
+ }
+ return false;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/ForwardingFileResource.java b/src/main/java/com/tractionsoftware/commons/io/ForwardingFileResource.java
new file mode 100644
index 0000000..f282904
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/ForwardingFileResource.java
@@ -0,0 +1,123 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.collect.ForwardingObject;
+import com.tractionsoftware.commons.image.Icon;
+import com.tractionsoftware.commons.util.Dimensions;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Date;
+
+/**
+ * A base {@link FileResource} implementation for implementing the decorator pattern. The {@link #delegate()} method returns
+ * the FileInfo being decorated.
+ *
+ * @author Dave Shepperton
+ */
+public abstract class ForwardingFileResource extends ForwardingObject implements FileResource {
+
+ @Nonnull
+ @Override
+ protected abstract FileResource delegate();
+
+ @Override
+ public boolean isValid() {
+ return delegate().isValid();
+ }
+
+ @Nonnull
+ @Override
+ public URI getURI() {
+ return delegate().getURI();
+ }
+
+ @Nonnull
+ @Override
+ public SizedInputStream getInputStream() throws IOException, IllegalStateException {
+ return delegate().getInputStream();
+ }
+
+ @Nonnull
+ @Override
+ public String getFilename() {
+ return delegate().getFilename();
+ }
+
+ @Override
+ public String getDescription() {
+ return delegate().getDescription();
+ }
+
+ @Override
+ public String getContentType() {
+ return delegate().getContentType();
+ }
+
+ @Override
+ public String getContentId() {
+ return delegate().getContentId();
+ }
+
+ @Override
+ public long getByteSize() {
+ return delegate().getByteSize();
+ }
+
+ @Nonnull
+ @Override
+ public String getFormattedSize() {
+ return delegate().getFormattedSize();
+ }
+
+ @Nonnull
+ @Override
+ public Date getLastModified() {
+ return delegate().getLastModified();
+ }
+
+ @Nonnull
+ @Override
+ public FileMetadata getMetadata() {
+ return delegate().getMetadata();
+ }
+
+ @Override
+ public boolean isPersistent() {
+ return delegate().isPersistent();
+ }
+
+ @Nullable
+ @Override
+ public Icon getImage(Dimensions maxDimensions) {
+ return delegate().getImage(maxDimensions);
+ }
+
+ @Nonnull
+ @Override
+ public FileResourceType getType() {
+ return delegate().getType();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/ForwardingTempFileResource.java b/src/main/java/com/tractionsoftware/commons/io/ForwardingTempFileResource.java
new file mode 100644
index 0000000..060ffe9
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/ForwardingTempFileResource.java
@@ -0,0 +1,81 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import jakarta.annotation.Nonnull;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+
+public abstract class ForwardingTempFileResource extends ForwardingFileResource implements TempFileResource {
+
+ @Nonnull
+ @Override
+ protected abstract TempFileResource delegate();
+
+ @Override
+ public boolean delete() {
+ return delegate().delete();
+ }
+
+ @Override
+ public void save() throws IOException {
+ delegate().save();
+ }
+
+ @Override
+ public boolean exists() {
+ return delegate().exists();
+ }
+
+ @Override
+ public String getErrorMessage() {
+ return delegate().getErrorMessage();
+ }
+
+ @Override
+ public OutputStream getOutputStream() throws IOException {
+ return delegate().getOutputStream();
+ }
+
+ @Override
+ public PrintWriter getUtf8PrintWriter() throws IOException {
+ return delegate().getUtf8PrintWriter();
+ }
+
+ @Override
+ public void flush() throws IOException {
+ delegate().flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ delegate().close();
+ }
+
+ @Nonnull
+ @Override
+ public String getPath() {
+ return delegate().getPath();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/IOResourceTracker.java b/src/main/java/com/tractionsoftware/commons/io/IOResourceTracker.java
new file mode 100644
index 0000000..9123b43
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/IOResourceTracker.java
@@ -0,0 +1,166 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.lang.JavaUtil;
+import com.tractionsoftware.commons.lang.Resource;
+import org.apache.commons.io.function.IORunnable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+public abstract class IOResourceTracker implements Resource {
+
+ static final Logger LOGGER = LoggerFactory.getLogger(IOResourceTracker.class);
+
+ @FunctionalInterface
+ public static interface Creator {
+
+ public C create() throws IOException;
+
+ }
+
+ private static final class Streams implements Resource {
+
+ private final List list = new LinkedList<>();
+
+ private boolean closed;
+
+ private final String sourceIdentifier;
+
+ public Streams(String sourceIdentifier) {
+ this.sourceIdentifier = sourceIdentifier;
+ }
+
+ @Override
+ public synchronized final void close() {
+ if (!setClosed() || list.isEmpty()) {
+ return;
+ }
+ for (C resource : list) {
+ IOUtil.close(resource);
+ }
+ list.clear();
+ }
+
+ @Override
+ public synchronized final boolean isOpen() {
+ return !closed;
+ }
+
+ public synchronized final int size() {
+ return list.size();
+ }
+
+ public synchronized final boolean isEmpty() {
+ return list.isEmpty();
+ }
+
+ public synchronized final C createAndTrack(Creator creator, IOResourceTracker tracker)
+ throws IOException {
+
+ if (isClosed()) {
+ throw new IllegalStateException("closed");
+ }
+
+ C rawResource = creator.create();
+
+ try {
+ LOGGER.debug(getClass().getName(), " created a new stream for ", sourceIdentifier, ".");
+ C trackedResource = tracker.getTrackedStream(rawResource, sourceIdentifier);
+ list.add(trackedResource);
+ return tracker.getCloseNotifyingStream(trackedResource, () -> this.remove(trackedResource));
+ }
+ catch (RuntimeException e) {
+ LOGGER.warn("Failed to create wrapped stream for {}", sourceIdentifier, e);
+ IOUtil.close(rawResource);
+ throw e;
+ }
+
+ }
+
+ private synchronized final void remove(C trackedResource) {
+ list.remove(trackedResource);
+ }
+
+ private final boolean setClosed() {
+ if (closed) {
+ return false;
+ }
+ closed = true;
+ return true;
+ }
+
+ }
+
+ private final Supplier extends Resource> trackerCreator;
+
+ private final JavaUtil.CleanupTargetWrapper> streams;
+
+ public IOResourceTracker(String sourceIdentifier, Supplier extends Resource> trackerCreator) {
+ Objects.requireNonNull(trackerCreator, "tracker creator");
+ this.trackerCreator = trackerCreator;
+ this.streams = JavaUtil.CleanupTargetWrapper.create(this, new Streams<>(sourceIdentifier));
+ }
+
+ @Override
+ public final String toString() {
+ return getClass().getName() + " (" + streams.get().size() + ")";
+ }
+
+ @Override
+ public final boolean isOpen() {
+ return streams.isOpen();
+ }
+
+ @Override
+ public final void close() {
+ streams.close();
+ }
+
+ public final C createTrackedStream(Creator creator) throws IOException {
+ return streams.get().createAndTrack(creator, this);
+ }
+
+ public final boolean hasAnyStreams() {
+ if (streams.get().isEmpty()) {
+ return false;
+ }
+ return true;
+ }
+
+ protected abstract String getStreamTypeName();
+
+ protected abstract C getTrackedStream(C stream, String sourceIdentifier);
+
+ protected abstract C getCloseNotifyingStream(C trackedStream, IORunnable onAfterClose);
+
+ protected final Resource createTracker() {
+ return trackerCreator.get();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/IOUtil.java b/src/main/java/com/tractionsoftware/commons/io/IOUtil.java
new file mode 100644
index 0000000..fb12e86
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/IOUtil.java
@@ -0,0 +1,1479 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.collect.Iterators;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.tractionsoftware.commons.lang.Resource;
+import com.tractionsoftware.commons.lang.ObjectUtil;
+import com.tractionsoftware.commons.text.NumberFormats;
+import com.tractionsoftware.commons.util.AccumulatesCount;
+import com.tractionsoftware.commons.util.MayHaveKnownSize;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.function.IORunnable;
+import org.apache.commons.io.function.IOSupplier;
+import org.apache.commons.io.input.CloseShieldInputStream;
+import org.apache.commons.io.input.CloseShieldReader;
+import org.apache.commons.io.input.ReaderInputStream;
+import org.apache.commons.io.output.CloseShieldOutputStream;
+import org.apache.commons.io.output.CloseShieldWriter;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.ObjectUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiFunction;
+import java.util.function.Supplier;
+
+/**
+ * General I/O related helpers.
+ *
+ * @author Dave Shepperton, Andy Keller
+ */
+public final class IOUtil {
+
+ /*
+ * Not instantiable.
+ */
+ private IOUtil() {
+ }
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(IOUtil.class.getName());
+
+ public static final int DEFAULT_IO_BUFFER_SIZE = 10240;
+
+ /**
+ * Closes the given {@link AutoCloseable}, gracefully handling null and preventing any Exceptions from propagating.
+ *
+ * @param closeMe
+ * to be closed.
+ */
+ public static final void close(@Nullable AutoCloseable closeMe) {
+ if (closeMe != null) {
+ try {
+ closeMe.close();
+ }
+ catch (Exception e) {
+ LOGGER.warn("Failed to close {} ", ObjectUtil.safeToString(closeMe, "(AutoCloseable?)"), e);
+ }
+ }
+ }
+
+ /**
+ * Flushes the given {@link Flushable}, gracefully handling null and preventing any {@link Exception}s from
+ * propagating.
+ *
+ * @param flushMe
+ * to be flushed.
+ */
+ public static final void flush(Flushable flushMe) {
+ if (flushMe != null) {
+ try {
+ flushMe.flush();
+ }
+ catch (IOException e) {
+ LOGGER.warn("Failed to flush {} ", ObjectUtil.safeToString(flushMe, "(Flushable?)"), e);
+ }
+ }
+ }
+
+ public static final class CopyResult {
+
+ public static final long UNCOPIED_BYTES_WRITTEN = Long.MIN_VALUE;
+
+ public static final CopyResult NOT_COPIED = new CopyResult(0, UNCOPIED_BYTES_WRITTEN, false);
+
+ /**
+ * Returns a {@link CopyResult} representing a "full copy", in which the requested size limit was not exceeded.
+ *
+ * @param bytesCopied
+ * the number of bytes actually copied. This should always be the actual number of bytes read by the
+ * transfer operation.
+ * @param bytesWritten
+ * the number of bytes actually written, based on the best available information. It is possible that the
+ * {@link OutputStream} is discarding some bytes, particularly if it is size limited, so the accuracy of
+ * this value isn't guaranteed.
+ * @return a {@link CopyResult} representing a "full copy", in which the requested size limit was not exceeded.
+ */
+ @Nonnull
+ public static final CopyResult getInstanceForFullCopy(long bytesCopied, long bytesWritten) {
+ return new CopyResult(bytesCopied, bytesWritten, false);
+ }
+
+ /**
+ * Returns a {@link CopyResult} representing a "partial copy", in which the requested size limit would have been
+ * exceeded by copying all bytes.
+ *
+ * @param bytesCopied
+ * the number of bytes actually copied. On a best-efforts basis, this should always be the actual number of
+ * bytes read by the transfer operation.
+ * @param bytesWritten
+ * the number of bytes actually written, based on the best available information. It is possible that the
+ * {@link OutputStream} is discarding some bytes, particularly if it is size limited, so the accuracy of
+ * this value isn't guaranteed.
+ * @return a {@link CopyResult} representing a "full copy", in which the requested size limit was not exceeded.
+ */
+ @Nonnull
+ public static final CopyResult getInstanceForPartialCopy(long bytesCopied, long bytesWritten) {
+ return new CopyResult(bytesCopied, bytesWritten, true);
+ }
+
+ @Nonnull
+ public static final CopyResult getInstance(long sizeLimit, long bytesCopied, long bytesWritten) {
+ if (bytesCopied < sizeLimit) {
+ return getInstanceForFullCopy(bytesCopied, bytesWritten);
+ }
+ return getInstanceForPartialCopy(bytesCopied, bytesWritten);
+ }
+
+ private final long bytesRead;
+
+ private final long bytesWritten;
+
+ private final boolean inputWasTooLarge;
+
+ private CopyResult(long bytesRead, long bytesWritten, boolean inputWasTooLarge) {
+ if (bytesRead != UNCOPIED_BYTES_WRITTEN && bytesRead < 0) {
+ throw new IllegalArgumentException(String.format("bytes read %s < 0", bytesRead));
+ }
+ if (bytesWritten < 0 && bytesWritten != Long.MIN_VALUE) {
+ throw new IllegalArgumentException(String.format("bytes written %s < 0", bytesWritten));
+ }
+ this.bytesRead = bytesRead;
+ this.bytesWritten = bytesWritten;
+ this.inputWasTooLarge = inputWasTooLarge;
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return "[read " +
+ NumberFormats.getFormattedByteSize(bytesRead) +
+ ", wrote " +
+ NumberFormats.getFormattedByteSize(bytesWritten) +
+ "]";
+ }
+
+ public final long getBytesRead() {
+ return bytesRead;
+ }
+
+ public final long getBytesWritten() {
+ return bytesWritten;
+ }
+
+ public final boolean triedToCopy() {
+ if (bytesWritten == UNCOPIED_BYTES_WRITTEN) {
+ return false;
+ }
+ return true;
+ }
+
+ public final boolean inputWasTooLarge() {
+ return inputWasTooLarge;
+ }
+
+ }
+
+ private static abstract class CustomFilterInputStream extends FilterInputStream {
+
+ CustomFilterInputStream(InputStream input) {
+ super(input);
+ }
+
+ final boolean isBuffered() {
+ return IOUtil.isBufferedInputStream(in);
+ }
+
+ }
+
+ private static abstract class CustomFilterOutputStream extends FilterOutputStream {
+
+ CustomFilterOutputStream(OutputStream output) {
+ super(output);
+ }
+
+ final boolean isBuffered() {
+ return IOUtil.isBufferedOutputStream(out);
+ }
+
+ }
+
+ private static class CloseNotifyingInputStream extends FilterInputStream {
+
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private final String sourceIdentifier;
+
+ private volatile IORunnable onBeforeClose;
+
+ private volatile IORunnable onAfterClose;
+
+ private CloseNotifyingInputStream(InputStream input, IORunnable onBeforeClose, IORunnable onAfterClose, String sourceIdentifier) {
+ super(input);
+ this.onBeforeClose = onBeforeClose;
+ this.onAfterClose = onAfterClose;
+ this.sourceIdentifier = sourceIdentifier;
+ }
+
+ @Override
+ public final void close() throws IOException {
+ if (closed.compareAndSet(false, true)) {
+ try {
+ runMainIORunnable(onBeforeClose, super::close, onAfterClose);
+ }
+ finally {
+ if (sourceIdentifier != null) {
+ LOGGER.debug("Closing InputStream for {} ", sourceIdentifier);
+ }
+ onBeforeClose = null;
+ onAfterClose = null;
+ }
+ }
+ }
+
+ }
+
+ private static final class CloseNotifyingOutputStream extends CustomFilterOutputStream {
+
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private final String sourceIdentifier;
+
+ private volatile IORunnable onBeforeClose;
+
+ private volatile IORunnable onAfterClose;
+
+ private CloseNotifyingOutputStream(OutputStream output, IORunnable onBeforeClose, IORunnable onAfterClose, String sourceIdentifier) {
+ super(output);
+ this.onBeforeClose = onBeforeClose;
+ this.onAfterClose = onAfterClose;
+ this.sourceIdentifier = sourceIdentifier;
+ }
+
+ @Override
+ public final void close() throws IOException {
+ if (closed.compareAndSet(false, true)) {
+ try {
+ runMainIORunnable(onBeforeClose, super::close, onAfterClose);
+ }
+ finally {
+ if (sourceIdentifier != null) {
+ LOGGER.debug("Closing OutputStream for {}", sourceIdentifier);
+ }
+ onBeforeClose = null;
+ onAfterClose = null;
+ }
+ }
+ }
+
+ }
+
+ private static final class SizeLimiter {
+
+ private static enum Type {
+ INPUT,
+ OUTPUT
+ }
+
+ private final Type type;
+
+ private final long sizeLimit;
+
+ private final boolean errorOnLimitExceeded;
+
+ private long bytesAttempted;
+
+ public SizeLimiter(Type type, long sizeLimit, boolean errorOnLimitExceeded) {
+ this.type = type;
+ this.sizeLimit = sizeLimit;
+ this.errorOnLimitExceeded = errorOnLimitExceeded;
+ this.bytesAttempted = 0;
+ }
+
+ static final SizeLimiter createForInput(long sizeLimit, boolean errorOnLimitExceeded) {
+ return new SizeLimiter(Type.INPUT, sizeLimit, errorOnLimitExceeded);
+ }
+
+ static final SizeLimiter createForOutput(long sizeLimit, boolean errorOnLimitExceeded) {
+ return new SizeLimiter(Type.OUTPUT, sizeLimit, errorOnLimitExceeded);
+ }
+
+ public final boolean attemptOne() throws IOException {
+ if (attemptUpTo(1) == 1) {
+ return true;
+ }
+ return false;
+ }
+
+ public final int attemptUpTo(int requestedSize) throws IOException {
+ int bytesAvailable = (int) (sizeLimit - bytesAttempted);
+ bytesAttempted += requestedSize;
+ if (requestedSize > bytesAvailable) {
+ onOverLimit();
+ return bytesAvailable;
+ }
+ return requestedSize;
+ }
+
+ private final void onOverLimit() throws IOException {
+ if (errorOnLimitExceeded) {
+ throw switch (type) {
+ case INPUT -> StreamSizeLimitExceededException.forRead(bytesAttempted, sizeLimit);
+ case OUTPUT -> StreamSizeLimitExceededException.forWrite(bytesAttempted, sizeLimit);
+ };
+ }
+ }
+
+ }
+
+ private static final class ByteSizeLimitingInputStream extends CustomFilterInputStream {
+
+ private final SizeLimiter limiter;
+
+ private ByteSizeLimitingInputStream(InputStream in, long sizeLimit, boolean errorOnLimitExceeded) {
+ super(in);
+ this.limiter = SizeLimiter.createForInput(sizeLimit, errorOnLimitExceeded);
+ }
+
+ @Override
+ public final int read() throws IOException {
+ if (limiter.attemptOne()) {
+ return in.read();
+ }
+ return -1;
+ }
+
+ @Override
+ public final int read(@Nonnull byte[] b, int off, int len) throws IOException {
+
+ if (len == 0) {
+ return 0;
+ }
+
+ Objects.checkFromIndexSize(off, len, b.length);
+
+ int available = limiter.attemptUpTo(len);
+ if (available > 0) {
+ return in.read(b, off, available);
+ }
+ return -1;
+
+ }
+
+ @Nonnull
+ public final byte[] readNBytes(int len) throws IOException {
+ if (len < 0) {
+ throw new IllegalArgumentException("len < 0");
+ }
+ int max = limiter.attemptUpTo(len);
+ if (max > 0) {
+ return in.readNBytes(max);
+ }
+ return ArrayUtils.EMPTY_BYTE_ARRAY;
+ }
+
+ }
+
+ private static final class ByteSizeLimitingOutputStream extends CustomFilterOutputStream {
+
+ private final SizeLimiter limiter;
+
+ private ByteSizeLimitingOutputStream(OutputStream out, long sizeLimit, boolean errorOnLimitExceeded) {
+ super(out);
+ this.limiter = SizeLimiter.createForOutput(sizeLimit, errorOnLimitExceeded);
+ }
+
+ @Override
+ public final void write(@Nonnull byte[] b, int off, int len) throws IOException {
+ if (len == 0) {
+ return;
+ }
+ int available = limiter.attemptUpTo(len);
+ if (available > 0) {
+ out.write(b, off, available);
+ }
+ }
+
+ @Override
+ public final void write(int b) throws IOException {
+ if (limiter.attemptOne()) {
+ out.write(b);
+ }
+ }
+
+ @Override
+ public final void close() throws IOException {
+
+ RuntimeException suppressed = null;
+
+ try {
+ flush();
+ }
+ catch (IOException e) {
+ IOUtil.close(out);
+ throw e;
+ }
+ catch (RuntimeException e) {
+ suppressed = e;
+ }
+
+ try {
+ super.close();
+ }
+ catch (IOException e) {
+ if (suppressed != null) {
+ e.addSuppressed(suppressed);
+ }
+ throw e;
+ }
+
+ if (suppressed != null) {
+ throw new RuntimeException(suppressed);
+ }
+
+ }
+
+ }
+
+ private static final class CompoundAutoCloseable implements AutoCloseable {
+
+ private final Iterable extends AutoCloseable> resources;
+
+ private CompoundAutoCloseable(Iterable extends AutoCloseable> resources) {
+ this.resources = resources;
+ }
+
+ @Override
+ public final void close() {
+ for (AutoCloseable one : resources) {
+ IOUtil.close(one);
+ }
+ }
+
+ }
+
+ private static final class FlushInsteadOfClosePrintWriter extends PrintWriter {
+
+ private FlushInsteadOfClosePrintWriter(OutputStream out, boolean autoFlush) {
+ super(out, autoFlush);
+ }
+
+ private FlushInsteadOfClosePrintWriter(Writer out, boolean autoFlush) {
+ super(out, autoFlush);
+ }
+
+ @Override
+ public final void close() {
+ this.flush();
+ }
+
+ }
+
+ private static final class PrintWriterOutputStream extends OutputStream {
+
+ private final PrintWriter out;
+
+ private final CharsetDecoder decoder;
+
+ private final byte[] oneByte = new byte[] { 0 };
+
+ private PrintWriterOutputStream(PrintWriter out) {
+ this.out = out;
+ this.decoder = StandardCharsets.UTF_8.newDecoder();
+ }
+
+ @Override
+ public final void write(int b) throws IOException {
+ oneByte[0] = (byte) b;
+ out.print(decoder.decode(ByteBuffer.wrap(oneByte)));
+ }
+
+ @Override
+ public final void write(byte[] b, int off, int len) throws IOException {
+ Objects.checkFromIndexSize(off, len, b.length);
+ out.print(decoder.decode(ByteBuffer.wrap(b, off, len)));
+ }
+
+ @Override
+ public final void flush() {
+ out.flush();
+ }
+
+ @Override
+ public final void close() {
+ out.close();
+ }
+
+ }
+
+ @Nonnull
+ public static final byte[] readContentBytes(@Nullable InputStream in) throws IOException {
+ if (in == null) {
+ return ArrayUtils.EMPTY_BYTE_ARRAY;
+ }
+ return in.readAllBytes();
+ }
+
+ /**
+ * Returns the content read from the given stream in the form of a String created using the Charset with the given
+ * name.
+ *
+ *
+ * WARNING: This operation reads all data from the given InputStream into a StringBuilder, and then creates a
+ * String. It is not advisable to use it for very large inputs.
+ *
+ * @param in
+ * the InputStream whose content should be read and converted to a String.
+ * @param charsetName
+ * the name of the Charset to use to create the String from the InputStream content. UTF-8 will be used if the
+ * argument for this parameter is null or whitespace.
+ * @return the content read as constructed using the Charset with the given name.
+ * @throws IOException
+ * if there is a problem reading the given InputStream, or if the given Charset name is not recognized.
+ */
+ @Nonnull
+ public static final String readContent(@Nullable InputStream in, @Nullable String charsetName) throws IOException {
+ Charset charset;
+ if (StringUtils.isBlank(charsetName)) {
+ charset = StandardCharsets.UTF_8;
+ }
+ else {
+ charset = Charset.forName(charsetName);
+ }
+ return readContent(in, charset);
+ }
+
+ /**
+ * Returns the content read from the given stream in the form of a String created using the given Charset.
+ *
+ *
+ * WARNING: This operation reads all data from the given InputStream into a String. It is not advisable to use it
+ * for very large inputs.
+ *
+ * @param in
+ * the InputStream whose content should be read and converted to a String.
+ * @param charset
+ * the Charset to use to convert the content of the InputStream to a String. If this value is null, UTF-8 will
+ * be used.
+ * @return the content read as constructed using the given Charset.
+ * @throws IOException
+ * if there is a problem reading from the given InputStream.
+ */
+ @Nonnull
+ public static final String readContent(@Nullable InputStream in, @Nullable Charset charset) throws IOException {
+ if (in == null) {
+ return "";
+ }
+ return readContent(new InputStreamReader(in, ObjectUtils.getIfNull(charset, StandardCharsets.UTF_8)));
+ }
+
+ /**
+ * Returns the content read from the given {@link Reader} in the form of a String.
+ *
+ *
+ * WARNING: This operation reads all data from the given Reader into a StringBuilder, and then creates a String. It
+ * is not advisable to use it for very large inputs.
+ *
+ * @param reader
+ * the {@link Reader} whose content should be read out as a String.
+ * @return the content read from the given {@link Reader}.
+ * @throws IOException
+ * if there is a problem reading from the given Reader.
+ */
+ @Nonnull
+ public static final String readContent(@Nullable Reader reader) throws IOException {
+ if (reader == null) {
+ return "";
+ }
+ StringBuilder buff = new StringBuilder();
+ IOUtils.copy(reader, buff);
+ return buff.toString();
+ }
+
+ public static final void copyText(@Nullable InputStream in, @Nullable Charset charset, @Nullable Writer writer)
+ throws IOException {
+ if (in != null && writer != null) {
+ IOUtils.copy(in, writer, Objects.requireNonNullElse(charset, StandardCharsets.UTF_8));
+ }
+ }
+
+ @CanIgnoreReturnValue
+ public static final long copyText(Reader reader, Writer writer) throws IOException {
+ if (reader == null || writer == null) {
+ return 0;
+ }
+ return IOUtils.copy(reader, writer);
+ }
+
+ /**
+ * Attempts to copy the given input to the given output, returning a {@link CopyResult} representing the result. If
+ * the argument for either the {@link InputStream} or {@link OutputStream} is null, this method does nothing and
+ * returns {@link CopyResult#NOT_COPIED}. Otherwise, this method will try to take into account how many bytes are
+ * actually read and written when creating the CopyResult on a best-efforts basis. Ideally, this would mean an
+ * OutputStream that implements {@link AccumulatesCount}. Clients that can't benefit from any of this special
+ * handling or the additional information or don't need null-safe conditional copying should probably simply use
+ * {@link InputStream#transferTo(OutputStream)}.
+ *
+ * @param input
+ * the {@link InputStream} to copy from.
+ * @param output
+ * the {@link OutputStream} to copy to.
+ * @return a {@link CopyResult} representing the result.
+ * @throws IOException
+ * if one is raised during the copy operation.
+ */
+ @Nonnull
+ public static final CopyResult copy(@Nullable InputStream input, @Nullable OutputStream output) throws IOException {
+ if (input == null || output == null) {
+ return CopyResult.NOT_COPIED;
+ }
+ return copyFull(input, output);
+ }
+
+ /**
+ * Attempts to copy the given input to the given output, returning a {@link CopyResult} representing the result. If
+ * the argument for either the {@link InputStream} or {@link OutputStream} is null, this method does nothing and
+ * returns {@link CopyResult#NOT_COPIED}. Otherwise, this method will try to take into account how many bytes are
+ * actually read and written when creating the CopyResult. It also will optimize handling of the size limit if
+ * possible, so for best results, the InputStream should be an instance of {@link MayHaveKnownSize} (e.g.,
+ * {@link ByteSizeLimitingInputStream}) and an OutputStream that implements {@link AccumulatesCount}.
+ *
+ * @param input
+ * the {@link InputStream} to copy from.
+ * @param output
+ * the {@link OutputStream} to copy to.
+ * @param sizeLimit
+ * the upper limit on the number of bytes that should be allowed to be copied. If this value is negative or
+ * {@link Long#MAX_VALUE}, or if the input is a {@link MayHaveKnownSize} and
+ * {@link MayHaveKnownSize#size() reports a size} within this limit, no limit will be applied.
+ * @return a {@link CopyResult} representing the result.
+ * @throws IOException
+ * if one is raised during the copy operation.
+ */
+ @CanIgnoreReturnValue
+ @Nonnull
+ public static final CopyResult copy(@Nullable InputStream input, @Nullable OutputStream output, long sizeLimit)
+ throws IOException {
+
+ if (input == null || output == null) {
+ return CopyResult.NOT_COPIED;
+ }
+
+ if (sizeLimit < 0 || sizeLimit == Long.MAX_VALUE) {
+ return copyFull(input, output);
+ }
+
+ if (input instanceof MayHaveKnownSize sized) {
+ if (sized.hasSizeAtMost(sizeLimit)) {
+ return copyFull(input, output);
+ }
+ return copyPartial(input, output, sizeLimit);
+ }
+
+ return copyLimited(input, output, sizeLimit);
+
+ }
+
+ /**
+ * Provides a callback mechanism for {@link IORunnable}s to be run before and after an {@link InputStream} is
+ * closed. The callbacks will be invoked exactly once, and will run regardless of whether any checked or unchecked
+ * Exceptions are raised during any other part of the procedure, including the {@link InputStream#close()} method
+ * and either one of the operations themselves.
+ *
+ *
+ * If both the before-close and after-close operations are null, the InputStream will be returned as-is.
+ *
+ * @param input
+ * the {@link InputStream} for which the given callbacks are to be invoked before and after closing.
+ * @param onBeforeClose
+ * the callback to be invoked before closing the given {@link InputStream}, if any.
+ * @param onAfterClose
+ * the callback to be invoked after closing the given {@link InputStream}, if any.
+ * @param sourceIdentifier
+ * a simple identifier for the stream source to appear in diagnostic logging as necessary.
+ * @return an {@link InputStream} that is identical to the given InputStream, but which will invoke the given
+ * callbacks before and after its {@link InputStream#close()} method.
+ * @throws NullPointerException
+ * if the argument for the {@link InputStream} is null.
+ */
+ @Nonnull
+ public static final InputStream getCloseNotifyingInputStream(@Nonnull InputStream input, @Nullable IORunnable onBeforeClose, @Nullable IORunnable onAfterClose, @Nullable String sourceIdentifier) {
+ return new CloseNotifyingInputStream(input, onBeforeClose, onAfterClose, sourceIdentifier);
+ }
+
+ /**
+ * Returns an {@link InputStream} wrapping the given stream which will only permit the given maximum number of bytes
+ * to be read from the stream. Exceeding the limit can optionally cause an IOException to be thrown.
+ *
+ *
+ * This method has intelligent special casing for {@link SizedInputStream}, in that if the given InputStream is a
+ * SizedInputStream, and {@link SizedInputStream#size() its size} is under the requested limit, the InputStream is
+ * returned as-is.
+ *
+ * @param input
+ * the {@link InputStream} to be wrapped.
+ * @param sizeLimit
+ * the upper limit on the number of bytes that should be allowed to be read from the stream. If this value is
+ * negative or {@link Long#MAX_VALUE}, no limit will be applied.
+ * @param errorOnLimitExceeded
+ * indicates whether exceeding the limit should cause an {@link IOException} to be thrown.
+ * @return an {@link InputStream} which will only permit the given maximum number of bytes to be read from the given
+ * stream.
+ * @throws NullPointerException
+ * if the given {@link InputStream} is null.
+ */
+ @Nonnull
+ public static final InputStream getSizeLimitingInputStream(@Nonnull InputStream input, long sizeLimit, boolean errorOnLimitExceeded) {
+ Objects.requireNonNull(input, "input");
+ if (sizeLimit < 0 || sizeLimit == Long.MAX_VALUE) {
+ return input;
+ }
+ if (!errorOnLimitExceeded &&
+ input instanceof MayHaveKnownSize sized &&
+ sized.hasSizeBetween(1, sizeLimit)) {
+ return input;
+ }
+ return new ByteSizeLimitingInputStream(input, sizeLimit, errorOnLimitExceeded);
+ }
+
+ /**
+ * Returns an {@link OutputStream} wrapping the given stream which will only permit the given maximum number of
+ * bytes to be written to the stream. Exceeding the limit can optionally cause an {@link IOException} to be thrown.
+ *
+ * @param output
+ * the {@link OutputStream} to be wrapped.
+ * @param sizeLimit
+ * the upper limit on the number of bytes that should be allowed to be written to the stream. If this value is
+ * negative or {@link Integer#MAX_VALUE}, no limit will be applied.
+ * @param errorOnLimitExceeded
+ * indicates whether exceeding the limit should cause an {@link IOException} to be thrown.
+ * @return an {@link OutputStream} which will only permit the given maximum number of bytes to be written to the
+ * stream.
+ * @throws NullPointerException
+ * if the given {@link OutputStream} is null.
+ */
+ @Nonnull
+ public static final OutputStream getSizeLimitingOutputStream(@Nonnull OutputStream output, long sizeLimit, boolean errorOnLimitExceeded) {
+ Objects.requireNonNull(output, "output");
+ if (sizeLimit < 0 || sizeLimit == Long.MAX_VALUE) {
+ return output;
+ }
+ return new ByteSizeLimitingOutputStream(output, sizeLimit, errorOnLimitExceeded);
+ }
+
+ /**
+ * Returns a version of the {@link InputStream} that is known to be buffered. This is intended to be used when
+ * performance requires buffering, and therefore is not necessarily the same as creating a
+ * {@link BufferedInputStream} . If the given InputStream is already a BufferedInputStream, or a
+ * {@link ByteArrayInputStream}, or some other type of InputStream from this library or elsewhere that is known to
+ * already be buffered, then the InputStream will be returned as-is. Otherwise, it will return a new
+ * BufferedInputStream wrapping the given InputStream.
+ *
+ * @param input
+ * the InputStream to buffer.
+ * @return a version of the {@link InputStream} that is known to be buffered
+ * @throws NullPointerException
+ * if the argument for the {@link InputStream} is null.
+ */
+ @Nonnull
+ public static final InputStream getBufferedInputStream(@Nonnull InputStream input) {
+ if (isBufferedInputStream(input)) {
+ return input;
+ }
+ return new BufferedInputStream(input);
+ }
+
+ /**
+ * Returns a new {@link InputStream} wrapping the given InputStream, which will retrieve a {@link Resource} from the
+ * given {@link Supplier} when the new stream is created, which the new stream will be responsible for closing when
+ * its {@link InputStream#close()} method is invoked. This is suitable for starting and stopping a resource tracker,
+ * a timer, or handling some other associated resource which must be managed in conjunction with the same
+ * InputStream.
+ *
+ * @param input
+ * the {@link InputStream} to be tracked.
+ * @param getTracker
+ * supplies a {@link Resource} which should be closed when the returned {@link InputStream} is closed.
+ * @return a wrapped version of the given {@link InputStream}.
+ * @throws NullPointerException
+ * if either of the arguments is null.
+ */
+ public static final InputStream getTrackedInputStream(@Nonnull InputStream input, @Nonnull Supplier extends Resource> getTracker) {
+ return getTrackedInputStream(input, getTracker, null);
+ }
+
+ /**
+ * Returns a new {@link InputStream} wrapping the given InputStream, which will retrieve a {@link Resource} from the
+ * given {@link Supplier} when the new stream is created, which the new stream will be responsible for closing when
+ * its {@link InputStream#close()} method is invoked. This is suitable for starting and stopping a resource tracker,
+ * a timer, or handling some other associated resource which must be managed in conjunction with the same
+ * InputStream.
+ *
+ * @param input
+ * the {@link InputStream} to be tracked.
+ * @param getTracker
+ * supplies a {@link Resource} which should be closed when the returned {@link InputStream} is closed.
+ * @param sourceIdentifier
+ * a simple identifier for the stream source to appear in diagnostic logging as necessary.
+ * @return a wrapped version of the given {@link InputStream}.
+ * @throws NullPointerException
+ * if the argument for the {@link InputStream} or {@link Resource} {@link Supplier} is null.
+ * @see #getCloseNotifyingInputStream(InputStream, IORunnable, IORunnable)
+ */
+ public static final InputStream getTrackedInputStream(@Nonnull InputStream input, @Nonnull Supplier extends Resource> getTracker, @Nullable String sourceIdentifier) {
+ Objects.requireNonNull(input, "input");
+ Objects.requireNonNull(getTracker, "tracker provider");
+ Resource tracker = getTracker.get();
+ try {
+ return getCloseNotifyingInputStream(input, null, tracker::close, sourceIdentifier);
+ }
+ catch (RuntimeException | Error e) {
+ tracker.close();
+ throw e;
+ }
+ }
+
+ /**
+ * Returns a wrapped version of the given {@link File}'s {@link FileInputStream} which incorporates buffering and
+ * tracking.
+ *
+ * @param file
+ * the {@link File} to be read.
+ * @return a wrapped version of the given {@link File}'s {@link FileInputStream} which incorporates buffering and
+ * tracking.
+ * @throws IOException
+ * if one is raised while attempting to create a {@link FileInputStream}.
+ * @throws NullPointerException
+ * if either of the arguments is null.
+ * @see FileUtil#getBufferedInputStream(File)
+ * @see IOUtil#getTrackedInputStream(InputStream, Supplier, String)
+ */
+ public static final InputStream getBufferedTrackedInputStream(@Nonnull File file, @Nonnull Supplier extends Resource> getTracker)
+ throws IOException {
+ return getTrackedInputStream(FileUtil.getBufferedInputStream(file), getTracker, file.toString());
+ }
+
+ /**
+ * Provides a callback mechanism for {@link IORunnable}s to be run before and after an {@link InputStream} is
+ * closed. The callbacks will be invoked exactly once, and will run regardless of whether any checked or unchecked
+ * Exceptions are raised during any other part of the procedure, including the {@link InputStream#close()} method
+ * and either one of the operations themselves.
+ *
+ *
+ * If both the before-close and after-close operations are null, the InputStream will be returned as-is.
+ *
+ * @param input
+ * the {@link InputStream} for which the given callbacks are to be invoked before and after closing.
+ * @param onBeforeClose
+ * the callback to be invoked before closing the given {@link InputStream}, if any.
+ * @param onAfterClose
+ * the callback to be invoked after closing the given {@link InputStream}, if any.
+ * @return an {@link InputStream} that is identical to the given InputStream, but which will invoke the given
+ * callbacks before and after its {@link InputStream#close()} method.
+ * @throws NullPointerException
+ * if the argument for the {@link InputStream} is null.
+ */
+ public static final InputStream getCloseNotifyingInputStream(@Nonnull InputStream input, @Nullable IORunnable onBeforeClose, @Nullable IORunnable onAfterClose) {
+ return getCloseNotifyingInputStream(input, onBeforeClose, onAfterClose, null);
+ }
+
+ /**
+ *
+ * @param output
+ * the {@link OutputStream} to be tracked.
+ * @return a wrapped version of the given {@link OutputStream}.
+ * @throws NullPointerException
+ * if either of the arguments is null.
+ */
+ public static final OutputStream getTrackedOutputStream(@Nonnull OutputStream output, @Nonnull Supplier extends Resource> getTracker) {
+ return getTrackedOutputStream(output, getTracker, null);
+ }
+
+ /**
+ *
+ * @param output
+ * the {@link OutputStream} to be tracked.
+ * @param sourceIdentifier
+ * a simple identifier for the stream source to appear in diagnostic logging as necessary.
+ * @return a wrapped version of the given {@link OutputStream}.
+ * @throws NullPointerException
+ * if the argument for the {@link OutputStream} or {@link Resource} {@link Supplier} is null.
+ */
+ public static final OutputStream getTrackedOutputStream(@Nonnull OutputStream output, @Nonnull Supplier extends Resource> getTracker, @Nullable String sourceIdentifier) {
+ Objects.requireNonNull(getTracker, "resource tracker provider");
+ Resource tracker = getTracker.get();
+ try {
+ return getCloseNotifyingOutputStream(output, null, tracker::close, sourceIdentifier);
+ }
+ catch (RuntimeException | Error e) {
+ tracker.close();
+ throw e;
+ }
+ }
+
+ /**
+ * Provides a callback mechanism for {@link IORunnable}s to be run before and after an {@link OutputStream} is
+ * closed. The callbacks will be invoked exactly once, and will run regardless of whether any checked or unchecked
+ * Exceptions are raised during any other part of the procedure, including the {@link OutputStream#close()} method
+ * and either one of the operations themselves.
+ *
+ *
+ * If both the before-close and after-close operations are null, the OutputStream will be returned as-is.
+ *
+ * @param output
+ * the {@link OutputStream} for which the given callbacks are to be invoked before and after closing.
+ * @param onBeforeClose
+ * the callback to be invoked before closing the given {@link OutputStream}, if any.
+ * @param onAfterClose
+ * the callback to be invoked after closing the given {@link OutputStream}, if any.
+ * @return an {@link OutputStream} that is identical to the given OutputStream, but which will invoke the given
+ * callbacks before and after its {@link OutputStream#close()} method.
+ */
+ public static final OutputStream getCloseNotifyingOutputStream(OutputStream output, IORunnable onBeforeClose, IORunnable onAfterClose) {
+ return getCloseNotifyingOutputStream(output, onBeforeClose, onAfterClose, null);
+ }
+
+ /**
+ * Provides a callback mechanism for {@link IORunnable}s to be run before and after an {@link OutputStream} is
+ * closed. The callbacks will be invoked exactly once, and will run regardless of whether any checked or unchecked
+ * Exceptions are raised during any other part of the procedure, including the {@link OutputStream#close()} method
+ * and either one of the operations themselves.
+ *
+ *
+ * If both the before-close and after-close operations are null, the OutputStream will be returned as-is.
+ *
+ * @param output
+ * the {@link OutputStream} for which the given callbacks are to be invoked before and after closing.
+ * @param onBeforeClose
+ * the callback to be invoked before closing the given {@link OutputStream}, if any.
+ * @param onAfterClose
+ * the callback to be invoked after closing the given {@link OutputStream}, if any.
+ * @param sourceIdentifier
+ * a simple identifier for the stream source to appear in diagnostic logging as necessary.
+ * @return an {@link OutputStream} that is identical to the given OutputStream, but which will invoke the given
+ * callbacks before and after its {@link OutputStream#close()} method.
+ */
+ public static final OutputStream getCloseNotifyingOutputStream(OutputStream output, IORunnable onBeforeClose, IORunnable onAfterClose, String sourceIdentifier) {
+ if (onBeforeClose == null && onAfterClose == null) {
+ return output;
+ }
+ return new CloseNotifyingOutputStream(output, onBeforeClose, onAfterClose, sourceIdentifier);
+ }
+
+ /**
+ * Helper method to produce a SequenceInputStream from varargs.
+ *
+ * @param streams
+ * the streams to be included in the SequenceInputStream.
+ * @return a SequenceInputStream from the given InputStreams.
+ */
+ public static final InputStream getSequenceInputStream(final InputStream... streams) {
+ return new SequenceInputStream(Iterators.asEnumeration(Arrays.asList(streams).iterator()));
+ }
+
+ /**
+ * Runs an {@link IORunnable}, wrapping any {@link IOException} raised in an {@link UncheckedIOException}.
+ *
+ * @param operation
+ * the {@link IORunnable} to run.
+ * @throws UncheckedIOException
+ * if {@link IORunnable#run() running the given operation} raises an IOException.
+ */
+ public static void runIOOperation(IORunnable operation) {
+ try {
+ operation.run();
+ }
+ catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Runs an {@link IORunnable} and prevents an {@link IOException} from propagating, instead wrapping it in an
+ * {@link UncheckedIOException}.
+ *
+ * @param operation
+ * the {@link IORunnable} to run.
+ */
+ public static void runIOOperationSafe(IORunnable operation) {
+ try {
+ operation.run();
+ }
+ catch (IOException e) {
+ LOGGER.warn("{} failed", ObjectUtil.safeToStringObject(operation), e);
+ }
+ }
+
+ /**
+ * Runs an {@link IOSupplier}, wrapping any {@link IOException} raised in an {@link UncheckedIOException}.
+ *
+ * @param supplier
+ * the {@link IOSupplier} to run.
+ * @throws UncheckedIOException
+ * if {@link IOSupplier#get() running the given operation} raises an IOException.
+ */
+ public static final T runIOSupplier(IOSupplier supplier) {
+ try {
+ return supplier.get();
+ }
+ catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Runs an {@link IOSupplier} and prevents an {@link IOException} from propagating, instead wrapping it in an
+ * {@link UncheckedIOException}.
+ *
+ * @param supplier
+ * the {@link IOSupplier} to run.
+ * @throws NullPointerException
+ * if either argument is null.
+ */
+ public static final T runIOSupplierSafe(@Nonnull IOSupplier supplier, @Nonnull Supplier extends T> defaultValue) {
+ try {
+ return supplier.get();
+ }
+ catch (IOException e) {
+ LOGGER.warn("{} failed", ObjectUtil.safeToStringObject(supplier), e);
+ }
+ return defaultValue.get();
+ }
+
+ /**
+ * Returns a {@link Supplier} that will return a {@link ByteArrayInputStream} for the given data.
+ *
+ * @param data
+ * the data.
+ * @return a {@link Supplier} that will return a {@link ByteArrayInputStream} for the given data.
+ * @throws NullPointerException
+ * if data argument is null.
+ */
+ public static final Supplier byteArrayInputStreamSupplier(byte[] data) {
+ Objects.requireNonNull(data, "bytes");
+ return () -> new ByteArrayInputStream(data);
+ }
+
+ public static final OutputStream getPrintWriterOutputStream(PrintWriter out, boolean allowClose) {
+ OutputStream stream = new PrintWriterOutputStream(out);
+ if (allowClose) {
+ return stream;
+ }
+ return getNoCloseOutputStream(stream);
+ }
+
+ private static void runMainIORunnable(IORunnable before, IORunnable main, IORunnable after)
+ throws IOException {
+
+ IOException mainIOE = null;
+ RuntimeException mainRE = null;
+ List suppressed = new CopyOnWriteArrayList<>();
+
+ if (before != null) {
+ try {
+ before.run();
+ }
+ catch (Exception e) {
+ suppressed.add(e);
+ }
+ }
+
+ try {
+ main.run();
+ }
+ catch (IOException e) {
+ mainIOE = e;
+ }
+ catch (RuntimeException e) {
+ if (e.getCause() instanceof IOException ioe) {
+ mainIOE = ioe;
+ }
+ else {
+ mainRE = e;
+ }
+ }
+
+ if (after != null) {
+ try {
+ after.run();
+ }
+ catch (Exception e) {
+ suppressed.add(e);
+ }
+ }
+
+ if (mainIOE != null) {
+ for (Exception e : suppressed) {
+ mainIOE.addSuppressed(e);
+ }
+ throw mainIOE;
+ }
+
+ if (mainRE != null) {
+ for (Exception e : suppressed) {
+ mainRE.addSuppressed(e);
+ }
+ throw mainRE;
+ }
+
+ }
+
+ public static final AutoCloseable createCompoundCloseable(Iterable extends AutoCloseable> resources) {
+ Objects.requireNonNull(resources, "resources");
+ return new CompoundAutoCloseable(resources);
+ }
+
+ public static final BufferedReader getBufferedUtf8Reader(InputStream input) {
+ return getBufferedReader(input, null);
+ }
+
+ public static final BufferedReader getBufferedReader(InputStream input, Charset charset) {
+ Objects.requireNonNull(input, "InputStream");
+ return new BufferedReader(
+ new InputStreamReader(input, charset == null ? StandardCharsets.UTF_8 : charset)
+ );
+ }
+
+ public static final BufferedReader getBufferedReader(Reader reader) {
+ if (reader instanceof BufferedReader alreadyBuffered) {
+ return alreadyBuffered;
+ }
+ return new BufferedReader(reader);
+ }
+
+ public static final InputStream getStringAsUtf8InputStream(String str) {
+ return getStringAsInputStream(str, StandardCharsets.UTF_8);
+ }
+
+ public static final InputStream getStringAsInputStream(String str, Charset charset) {
+ Objects.requireNonNull(str, "string");
+ Objects.requireNonNull(charset, "charset");
+ try {
+ return ReaderInputStream.builder().setReader(new StringReader(str)).setCharset(charset).get();
+ }
+ catch (IOException e) {
+ throw new IllegalStateException("This IOException should not be able to happen.", e);
+ }
+ }
+
+ public static final OutputStream getBufferedOutputStream(OutputStream output) {
+ if (isBufferedOutputStream(output)) {
+ return output;
+ }
+ return new BufferedOutputStream(output);
+ }
+
+ /**
+ * Returns a {@link BufferedWriter} that will write UTF-8 character data to the given {@link OutputStream}.
+ *
+ * @param out
+ * the {@link OutputStream}
+ * @return a {@link BufferedWriter} that will write UTF-8 character data to the given {@link OutputStream}.
+ * @throws NullPointerException
+ * if the given {@link OutputStream} is null.
+ */
+ public static final BufferedWriter getBufferedUtf8Writer(OutputStream out) {
+ Objects.requireNonNull(out, "OutputStream");
+ return new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Returns a new {@link PrintWriter} wrapping the given {@link OutputStream}, and whose {@link PrintWriter#close()}
+ * method will not close it or the OutputStream, but will instead be equivalent to invoking
+ * {@link PrintWriter#flush()}. This can be useful when a PrintWriter is needed as a temporary thin wrapper for an
+ * OutputStream.
+ *
+ * @param out
+ * the {@link OutputStream} to wrap.
+ * @return a new {@link PrintWriter} wrapping the given {@link OutputStream}, and whose {@link PrintWriter#close()}
+ * method will not close it or the OutputStream, but will instead be equivalent to invoking
+ * {@link PrintWriter#flush()}.
+ * @throws NullPointerException
+ * if the given {@link OutputStream} is null.
+ */
+ public static final PrintWriter getFlushInsteadOfClosePrintWriter(OutputStream out) {
+ return getFlushInsteadOfClosePrintWriter(out, false);
+ }
+
+ /**
+ * Returns a new {@link PrintWriter} wrapping the given {@link OutputStream}, and whose {@link PrintWriter#close()}
+ * method will not close it or the OutputStream, but will instead be equivalent to invoking
+ * {@link PrintWriter#flush()}. This can be useful when a PrintWriter is needed as a temporary thin wrapper for an
+ * OutputStream.
+ *
+ * @param out
+ * the {@link OutputStream} to wrap.
+ * @param autoFlush
+ * whether the returned {@link PrintWriter}'s {@code #println}, {@code printf}, and {@code format} methods will
+ * flush the output buffer.
+ * @return a new {@link PrintWriter} wrapping the given {@link OutputStream}, and whose {@link PrintWriter#close()}
+ * method will not close it or the OutputStream, but will instead be equivalent to invoking
+ * {@link PrintWriter#flush()}.
+ * @throws NullPointerException
+ * if the given {@link OutputStream} is null.
+ */
+ public static final PrintWriter getFlushInsteadOfClosePrintWriter(OutputStream out, boolean autoFlush) {
+ Objects.requireNonNull(out, "OutputStream");
+ return new FlushInsteadOfClosePrintWriter(out, autoFlush);
+ }
+
+ /**
+ * Returns a new {@link PrintWriter} wrapping the given {@link Writer}, and whose {@link PrintWriter#close()} method
+ * will not close it or the Writer, but will instead be equivalent to invoking {@link PrintWriter#flush()}. This can
+ * be useful when a PrintWriter is needed as a temporary thin wrapper for another Writer.
+ *
+ * @param out
+ * the {@link Writer} to wrap.
+ * @return a new {@link PrintWriter} wrapping the given {@link OutputStream}, and whose {@link PrintWriter#close()}
+ * method will not close it or the Writer, but will instead be equivalent to invoking
+ * {@link PrintWriter#flush()}.
+ * @throws NullPointerException
+ * if the given {@link Writer} is null.
+ */
+ public static final PrintWriter getFlushInsteadOfClosePrintWriter(Writer out) {
+ return getFlushInsteadOfClosePrintWriter(out, false);
+ }
+
+ /**
+ * Returns a new {@link PrintWriter} wrapping the given {@link Writer}, and whose {@link PrintWriter#close()} method
+ * will not close it or the Writer, but will instead be equivalent to invoking {@link PrintWriter#flush()}. This can
+ * be useful when a PrintWriter is needed as a temporary thin wrapper for another Writer.
+ *
+ * @param out
+ * the {@link Writer} to wrap.
+ * @param autoFlush
+ * whether the returned {@link PrintWriter}'s {@code #println}, {@code printf}, and {@code format} methods will
+ * flush the output buffer.
+ * @return a new {@link PrintWriter} wrapping the given {@link OutputStream}, and whose {@link PrintWriter#close()}
+ * method will not close it or the Writer, but will instead be equivalent to invoking
+ * {@link PrintWriter#flush()}.
+ * @throws NullPointerException
+ * if the given {@link Writer} is null.
+ */
+ public static final PrintWriter getFlushInsteadOfClosePrintWriter(Writer out, boolean autoFlush) {
+ Objects.requireNonNull(out, "Writer");
+ return new FlushInsteadOfClosePrintWriter(out, autoFlush);
+ }
+
+ public static final InputStream getNoCloseInputStream(InputStream in) {
+ Objects.requireNonNull(in, "InputStream");
+ return CloseShieldInputStream.wrap(in);
+ }
+
+ public static final OutputStream getNoCloseOutputStream(OutputStream out) {
+ Objects.requireNonNull(out, "OutputStream");
+ return CloseShieldOutputStream.wrap(out);
+ }
+
+ public static final Reader getNoCloseReader(Reader reader) {
+ Objects.requireNonNull(reader, "Reader");
+ return CloseShieldReader.wrap(reader);
+ }
+
+ public static final Writer getNoCloseWriter(Writer writer) {
+ Objects.requireNonNull(writer, "Writer");
+ return CloseShieldWriter.wrap(writer);
+ }
+
+ /**
+ * Exhausts (reads all data) and closes the given object if possible.
+ *
+ * @param object
+ * an object to try to exhaust and close. If the object is a {@link InputStream} or {@link Writer} object, it
+ * will be handled. Otherwise, this method will do nothing.
+ * @throws IOException
+ * if one is raised attempting to exhaust the object.
+ */
+ public static void exhaustAndClose(Object object) throws IOException {
+ if (object instanceof InputStream input) {
+ exhaustAndClose(input);
+ }
+ else if (object instanceof Reader reader) {
+ exhaustAndClose(reader);
+ }
+ }
+
+ /**
+ * Exhausts (reads all data) and closes the given {@link InputStream}.
+ *
+ * @param input
+ * an {@link InputStream}.
+ * @throws IOException
+ * if one is raised attempting to exhaust the {@link InputStream}.
+ */
+ public static void exhaustAndClose(InputStream input) throws IOException {
+ if (input != null) {
+ try {
+ input.transferTo(OutputStream.nullOutputStream());
+ }
+ finally {
+ IOUtils.closeQuietly(input);
+ }
+ }
+ }
+
+ /**
+ * Exhausts (reads all data) and closes the given {@link Reader}.
+ *
+ * @param reader
+ * an {@link Reader}.
+ * @throws IOException
+ * if one is raised attempting to exhaust the {@link Reader}.
+ */
+ public static void exhaustAndClose(Reader reader) throws IOException {
+ if (reader != null) {
+ try {
+ reader.transferTo(Writer.nullWriter());
+ }
+ finally {
+ IOUtils.closeQuietly(reader);
+ }
+ }
+ }
+
+ private static final boolean isBufferedInputStream(InputStream input) {
+ if (input instanceof BufferedInputStream || input instanceof ByteArrayInputStream) {
+ return true;
+ }
+ if (input instanceof CustomFilterInputStream custom) {
+ return custom.isBuffered();
+ }
+ return false;
+ }
+
+ private static final boolean isBufferedOutputStream(OutputStream output) {
+ if (output instanceof BufferedOutputStream || output instanceof ByteArrayOutputStream) {
+ return true;
+ }
+ if (output instanceof CustomFilterOutputStream custom) {
+ return custom.isBuffered();
+ }
+ return false;
+ }
+
+ /**
+ * Copy implementation to use when all bytes can be copied.
+ *
+ * @param input
+ * the source {@link InputStream}.
+ * @param output
+ * the destination {@link OutputStream}.
+ * @return a {@link CopyResult} representing the result.
+ * @throws IOException
+ * if one is raised during the copy operation.
+ */
+ private static final CopyResult copyFull(@Nonnull InputStream input, @Nonnull OutputStream output)
+ throws IOException {
+ return copyImpl(input, output, CopyResult::getInstanceForFullCopy);
+ }
+
+ /**
+ * Copy implementation to use when it is known that not all bytes will be copied, but the input does not need to be
+ * limited.
+ *
+ * @param input
+ * the source {@link InputStream}.
+ * @param output
+ * the destination {@link OutputStream}.
+ * @return a {@link CopyResult} representing the result.
+ * @throws IOException
+ * if one is raised during the copy operation.
+ */
+ private static final CopyResult copyPartial(@Nonnull InputStream input, @Nonnull OutputStream output, long sizeLimit)
+ throws IOException {
+// return copyImpl(
+// new ByteSizeLimitingInputStream(input, sizeLimit, false),
+// output,
+// (bytesCopied, bytesWritten) -> CopyResult.getInstance(sizeLimit, bytesCopied, bytesWritten)
+// );
+ return copyImpl(
+ new ByteSizeLimitingInputStream(input, sizeLimit, false), output, CopyResult::getInstanceForPartialCopy
+ );
+ }
+
+ /**
+ * Copy implementation to use when it is not known whether all bytes can be copied and still stay within the
+ * requested size limit.
+ *
+ * @param input
+ * the source {@link InputStream}.
+ * @param output
+ * the destination {@link OutputStream}.
+ * @return a {@link CopyResult} representing the result.
+ * @throws IOException
+ * if one is raised during the copy operation.
+ */
+ private static final CopyResult copyLimited(@Nonnull InputStream input, @Nonnull OutputStream output, long sizeLimit)
+ throws IOException {
+ return copyImpl(
+ new ByteSizeLimitingInputStream(input, sizeLimit, false),
+ output,
+ (bytesCopied, bytesWritten) -> CopyResult.getInstance(sizeLimit, bytesCopied, bytesWritten)
+ );
+ }
+
+ /**
+ * Shared copy implementation.
+ *
+ * @param input
+ * the source {@link InputStream}.
+ * @param output
+ * the destination {@link OutputStream}.
+ * @param resultCreator
+ * to be invoked to create a {@link CopyResult}.
+ * @return a {@link CopyResult} representing the result.
+ * @throws IOException
+ * if one is raised during the copy operation.
+ */
+ private static final CopyResult copyImpl(@Nonnull InputStream input, @Nonnull OutputStream output, @Nonnull BiFunction resultCreator)
+ throws IOException {
+
+ AccumulatesCount bytesWritten;
+ if (output instanceof AccumulatesCount alreadyCounting) {
+ bytesWritten = alreadyCounting.startingFromCurrentCount();
+ }
+ else {
+ bytesWritten = null;
+ }
+
+ long copiedBytes = input.transferTo(output);
+ if (bytesWritten == null) {
+ return resultCreator.apply(copiedBytes, copiedBytes);
+ }
+ return resultCreator.apply(copiedBytes, bytesWritten.getCount());
+
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/InputStreamTracker.java b/src/main/java/com/tractionsoftware/commons/io/InputStreamTracker.java
new file mode 100644
index 0000000..a0a2824
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/InputStreamTracker.java
@@ -0,0 +1,50 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.lang.Resource;
+import org.apache.commons.io.function.IORunnable;
+
+import java.io.InputStream;
+import java.util.function.Supplier;
+
+public final class InputStreamTracker extends IOResourceTracker {
+
+ public InputStreamTracker(String sourceIdentifier, Supplier extends Resource> trackerCreator) {
+ super(sourceIdentifier, trackerCreator);
+ }
+
+ @Override
+ protected final String getStreamTypeName() {
+ return "InputStream";
+ }
+
+ @Override
+ protected final InputStream getTrackedStream(InputStream stream, String sourceIdentifier) {
+ return IOUtil.getTrackedInputStream(stream, this::createTracker, sourceIdentifier);
+ }
+
+ @Override
+ protected final InputStream getCloseNotifyingStream(InputStream trackedStream, IORunnable onAfterClose) {
+ return IOUtil.getCloseNotifyingInputStream(trackedStream, null, onAfterClose);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/LocalFileResource.java b/src/main/java/com/tractionsoftware/commons/io/LocalFileResource.java
new file mode 100644
index 0000000..cbacc51
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/LocalFileResource.java
@@ -0,0 +1,104 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import jakarta.annotation.Nonnull;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Date;
+import java.util.Objects;
+
+/**
+ * A {@link FileResource} corresponding to a local file on disk. It extends {@link FileMetadataBasedFileResource} so
+ * that things like {@link #getURI() its published URI} and {@link #getFilename() file name} do not need to correspond
+ * to the physical file's location.
+ *
+ * @author Dave Shepperton
+ */
+public final class LocalFileResource extends FileMetadataBasedFileResource implements FileResource {
+
+ public final static LocalFileResource createInstance(File file) {
+ Objects.requireNonNull(file, "File");
+ SimpleMutableFileMetadata metadata = SimpleMutableFileMetadata.createFromFileName(file.getName());
+ metadata.setURI(file.toURI());
+ metadata.ensureGoodFilename();
+ metadata.setResourceType(CommonFileResourceType.OTHER);
+ return new LocalFileResource(file, metadata);
+ }
+
+ public final static LocalFileResource createInstance(File file, FileMetadata metadata) {
+ Objects.requireNonNull(file, "File");
+ Objects.requireNonNull(metadata, "FileMetadata");
+ return new LocalFileResource(file, metadata);
+ }
+
+ private final File file;
+
+ private LocalFileResource(File file, FileMetadata metadata) {
+ super(metadata);
+ this.file = file;
+ }
+
+ @Override
+ public final boolean equals(Object other) {
+ if (!(other instanceof LocalFileResource otherFile)) {
+ return false;
+ }
+ if (file.equals(otherFile.file) && metadata.equals(otherFile.metadata)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public final int hashCode() {
+ return Objects.hash(file, metadata);
+ }
+
+ @Override
+ public final boolean isValid() {
+ return file.exists() && file.canRead();
+ }
+
+ @Override
+ public final boolean isDirectory() {
+ return file.isDirectory();
+ }
+
+ @Nonnull
+ @Override
+ public final SizedInputStream getInputStream() throws IOException {
+ return SizedInputStream.forInputStream(FileUtil.getBufferedInputStream(file), file.length());
+ }
+
+ @Override
+ public final long getByteSize() {
+ return file.length();
+ }
+
+ @Nonnull
+ @Override
+ public final Date getLastModified() {
+ return new Date(file.lastModified());
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/LocalTempFileResource.java b/src/main/java/com/tractionsoftware/commons/io/LocalTempFileResource.java
new file mode 100644
index 0000000..c441b77
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/LocalTempFileResource.java
@@ -0,0 +1,103 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import jakarta.annotation.Nonnull;
+
+import java.io.*;
+import java.util.Date;
+import java.util.Objects;
+
+final class LocalTempFileResource extends AbstractTempFileResource implements TempFileResource {
+
+ private final File file;
+
+ LocalTempFileResource(File file, MutableFileMetadata metadata, long sizeLimit) {
+ super(metadata, sizeLimit);
+ this.file = file;
+ }
+
+ @Override
+ public final boolean equals(Object other) {
+ if (other instanceof LocalTempFileResource otherFile) {
+ return file.equals(otherFile.file);
+ }
+ return false;
+ }
+
+ @Override
+ public final int hashCode() {
+ return Objects.hash(file);
+ }
+
+ @Override
+ public final boolean isValid() {
+ if (FileUtil.fileIsOrWouldBeReadable(file)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected final void doDelete() throws IOException {
+ FileUtil.DeleteRequestStatus result = FileUtil.deleteOrDeleteOnExit(file);
+ if (result.failed()) {
+ throw new IOException("The file " + file + " could not be deleted (" + result + ")");
+ }
+ }
+
+ @Override
+ protected final void doSave() {
+ // Nothing to do here.
+ }
+
+ @Override
+ public final boolean exists() {
+ return file.exists();
+ }
+
+ @Nonnull
+ @Override
+ public final Date getLastModified() {
+ return new Date(file.lastModified());
+ }
+
+ @Override
+ public final long getByteSize() {
+ return file.length();
+ }
+
+ @Override
+ protected final InputStream createRawInputStream() throws IOException {
+ return FileUtil.getBufferedInputStream(file);
+ }
+
+ @Override
+ protected final OutputStream createRawOutputStream() throws IOException {
+ return FileUtil.getBufferedOutputStream(file);
+ }
+
+ @Override
+ protected final void onRealOutputStreamClosed() {
+ // Nothing to do here.
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/LocalTempFileService.java b/src/main/java/com/tractionsoftware/commons/io/LocalTempFileService.java
new file mode 100644
index 0000000..53e8a24
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/LocalTempFileService.java
@@ -0,0 +1,192 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.lang.JavaUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.io.function.IOSupplier;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+public abstract class LocalTempFileService {
+
+ public static final TempFileResource.Factory DEFAULT_FACTORY = new TempFileResource.AbstractFactory() {
+
+ @Nonnull
+ @Override
+ protected final TempFileResource createImpl(@Nonnull MutableFileMetadata metadata, @Nullable IOSupplier extends InputStream> content, @Nonnull Logger logger)
+ throws IOException {
+
+ TempFileResource temp = LocalTempFileService.get().createNew(metadata);
+ if (content == null) {
+ return temp;
+ }
+
+ try (InputStream source = content.get()) {
+ temp.setContent(source);
+ }
+ catch (IOException | RuntimeException e) {
+ temp.delete();
+ throw e;
+ }
+ return temp;
+
+ }
+
+ @Nonnull
+ @Override
+ protected final TempFileResource loadExistingImpl(@Nonnull MutableFileMetadata metadata, @Nonnull Logger logger)
+ throws IOException {
+ File file = LocalTempFileService.get().getFile(metadata.getURI());
+ MutableFileMetadata updatedMetadata = metadata.mutableCopy();
+ return new LocalTempFileResource(file, updatedMetadata, -1);
+ }
+
+ };
+
+ public static final LocalTempFileService DEFAULT = new LocalTempFileService() {
+
+ @Nonnull
+ @Override
+ protected final LocalTempFileResource createNewImpl(@Nonnull FileMetadata metadata) throws IOException {
+ MutableFileMetadata updatedMetadata = metadata.mutableCopy();
+ File temp = FileUtil.createSystemTempFile("temp-", metadata.getExtension());
+ try {
+ updatedMetadata.setURI(getURIImpl(temp));
+ updatedMetadata.ensureGoodFilename();
+ }
+ catch (RuntimeException e) {
+ FileUtil.deleteOrDeleteOnExit(temp);
+ throw e;
+ }
+ return new LocalTempFileResource(temp, updatedMetadata, -1);
+ }
+
+ @Nonnull
+ @Override
+ protected final URI getURIImpl(@Nonnull File file) {
+ return URI.create(TempFileResource.URI_SCHEME_PREFIX + file.getName());
+ }
+
+ @Nonnull
+ @Override
+ protected final File getFileImpl(@Nonnull URI uri) throws FileNotFoundException {
+ String part = uri.getSchemeSpecificPart();
+ String name = FileNameUtil.stripPath(part);
+ if (StringUtils.isBlank(name)) {
+ throw new FileNotFoundException();
+ }
+ File file = new File(FileUtil.getTempDirectory(), name);
+ if (!file.exists() || !file.canRead()) {
+ throw new FileNotFoundException(part);
+ }
+ return file;
+ }
+
+ };
+
+ private static final Supplier extends LocalTempFileService> instance = JavaUtil.lazyServiceLoader(
+ LocalTempFileService.class, DEFAULT, TempFileResource.LOGGER
+ );
+
+ @Nonnull
+ public static final LocalTempFileService get() {
+ return instance.get();
+ }
+
+ /**
+ * Returns a {@link LocalTempFileResource} wrapping a newly created local temp file.
+ *
+ * @param metadata
+ * containing the metadata for the temporary file.
+ * @return a {@link LocalTempFileResource} wrapping a newly created local temp file
+ * @throws IOException
+ * if the temp file can't be created.
+ */
+ @Nonnull
+ public final TempFileResource createNew(@Nonnull FileMetadata metadata) throws IOException {
+ Objects.requireNonNull(metadata, "metadata");
+ return createNewImpl(metadata);
+ }
+
+ /**
+ * Returns a {@link TempFileResource#URI_SCHEME_PREFIX "temp:"} {@link URI} referring to the given temporary file.
+ *
+ * @param file
+ * the {@link File} representing the temp file on disk, generally created via
+ * {@link File#createTempFile(String, String)}.
+ * @return a {@link TempFileResource#URI_SCHEME_PREFIX "temp:"} {@link URI} referring to the given temporary file.
+ * @throws NullPointerException
+ * if the {@link File} is null.
+ * @throws IllegalArgumentException
+ * if the {@link File} is considered invalid for a temp file.
+ */
+ @Nonnull
+ public final URI getURI(@Nonnull File file) {
+ Objects.requireNonNull(file, "file");
+ if (!file.exists() || !file.canRead()) {
+ throw new IllegalArgumentException("Invalid file.");
+ }
+ return getURIImpl(file);
+ }
+
+ /**
+ * Returns the {@link File} corresponding to the given published temp file {@link URI}, if one exists.
+ *
+ * @param uri
+ * the logical "published" URI for the temp file.
+ * @return the {@link File} corresponding to the given temp file {@link URI}, if one exists.
+ * @throws NullPointerException
+ * if the {@link URI} is null.
+ * @throws IllegalArgumentException
+ * if the {@link URI} is invalid, including if it does not use the
+ * {@link TempFileResource#URI_SCHEME_PREFIX "temp:" URI scheme prefix}.
+ * @throws FileNotFoundException
+ * if the requested {@link URI} does not correspond to a known temp file.
+ */
+ @Nonnull
+ public final File getFile(@Nonnull URI uri) throws FileNotFoundException {
+ Objects.requireNonNull(uri, "URI");
+ if (!TempFileResource.URI_SCHEME_NAME.equals(uri.getScheme())) {
+ throw new IllegalArgumentException("Illegal temp file URI.");
+ }
+ return getFileImpl(uri);
+ }
+
+ @Nonnull
+ protected abstract TempFileResource createNewImpl(@Nonnull FileMetadata metadata) throws IOException;
+
+ @Nonnull
+ protected abstract URI getURIImpl(@Nonnull File file);
+
+ @Nonnull
+ protected abstract File getFileImpl(@Nonnull URI uri) throws FileNotFoundException;
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/MutableFileMetadata.java b/src/main/java/com/tractionsoftware/commons/io/MutableFileMetadata.java
new file mode 100644
index 0000000..ab039bb
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/MutableFileMetadata.java
@@ -0,0 +1,254 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.net.MediaType;
+import com.tractionsoftware.commons.net.URLUtil;
+import jakarta.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+
+import java.net.URI;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+public interface MutableFileMetadata extends FileMetadata {
+
+ /**
+ * Sets the logical public name of the file. This represents the name of the file so far as a user is concerned. It
+ * is almost certain to differ from the URL, URI, file path, or other identifier that represents the underlying
+ * resource.
+ *
+ * @param fileName
+ * the new logical name of the file.
+ * @throws UnsupportedOperationException
+ * if the file name is considered a read-only property for this FileMetadata.
+ */
+ public void setFilename(@Nullable String fileName);
+
+ /**
+ * Sets the {@link URI} that defines the location of the file in a store of some sort. This may be a file: URI or
+ * something else.
+ *
+ * @param uri
+ * a {@link URI} that defines the location of the file in a store of some sort. This may be a file: URI or
+ * something else.
+ * @throws UnsupportedOperationException
+ * if the {@link URI} is a read-only property.
+ */
+ public void setURI(@Nullable URI uri);
+
+ /**
+ * Sets the {@link URI} that defines the location of the file in a store of some sort. This may be a file: URI or
+ * something else.
+ *
+ * @param uriSpec
+ * the specification for {@link URI} that defines the location of the file in a store of some sort. This may be
+ * a file: URI or something else.
+ * @throws UnsupportedOperationException
+ * if the {@link URI} is a read-only property.
+ */
+ public default void setURISpec(@Nullable String uriSpec) {
+ if (uriSpec == null) {
+ setURI(null);
+ }
+ else {
+ setURI(URLUtil.tryToCreateUri(uriSpec));
+ }
+ }
+
+ /**
+ * Sets a text description of the file.
+ *
+ * @param description
+ * the new text description to use for this file.
+ * @throws UnsupportedOperationException
+ * if the description is considered a read-only property for this FileMetadata.
+ */
+ public void setDescription(@Nullable String description);
+
+ /**
+ * Sets the media type designation -- "Content-Type" or "mime type" -- that was associated with this file as it was
+ * originally created. This may come from the "Content-Type" header in a MIME message part (i.e., an email
+ * attachment), the "Content-Type" HTTP request header, or some other source. See:
+ *
+ *
+ *
+ * @param contentType
+ * the {@link MediaType} representing the "Content-Type" to be used for the file if one is known or can be
+ * determined for this FileMetadata; null otherwise.
+ * @throws UnsupportedOperationException
+ * if the "Content-Type" is considered a read-only property for this FileMetadata.
+ * @throws IllegalArgumentException
+ * if the given value is considered an invalid "Content-Type" for this FileMetadata.
+ */
+ public default void setContentType(MediaType contentType) {
+ setContentType(Objects.toString(contentType, null));
+ }
+
+ /**
+ * Sets the media type designation -- "Content-Type" or "mime type" -- that was associated with this file as it was
+ * originally created. This may come from the "Content-Type" header in a MIME message part (i.e., an email
+ * attachment), the "Content-Type" HTTP request header, or some other source. See:
+ *
+ *
+ *
+ * @param contentType
+ * the "Content-Type" to be used for the file if one is known or can be determined for this FileMetadata; null
+ * otherwise.
+ * @throws UnsupportedOperationException
+ * if the "Content-Type" is considered a read-only property for this FileMetadata.
+ * @throws IllegalArgumentException
+ * if the given value is considered an invalid "Content-Type" for this FileMetadata.
+ */
+ public void setContentType(@Nullable String contentType);
+
+ /**
+ * Sets the serial number for the file in the context of a list of files. This is generally used for an assigned and
+ * immutable numeric identifier for a file; more specifically, it usually corresponds to an attachment's ID.
+ *
+ * @param number
+ * the serial number to use for the file in the context of a list of files, or -1 to indicate that it has no
+ * such number.
+ * @throws UnsupportedOperationException
+ * if the file's number is considered a read-only property for this FileMetadata.
+ * @throws IllegalArgumentException
+ * if the given number is not a valid value for this FileMetadata.
+ */
+ public void setNumber(int number);
+
+ /**
+ * Sets the property whether this FileMetadata represents a reference to a "persisted" file, such as an attachment
+ * or shared file that has been stored in the appropriate repository, as opposed to a temporary file.
+ *
+ *
+ * For some FileMetadata implementations, this is a read-only property so far as public SDK clients are concerned,
+ * provided chiefly to support serialization and deserialization of lists of files via
+ * {@link SimpleMutableFileMetadata} objects.
+ *
+ * @param isReference
+ * indicating whether this FileMetadata represents a reference to a "persisted" file, such as an attachment or
+ * shared file that has been stored in the appropriate repository, as opposed to a temporary file.
+ * @throws UnsupportedOperationException
+ * if this is a read-only property for this FileMetadata.
+ */
+ public void setReferenceToPersistedFile(boolean isReference);
+
+ /**
+ * Sets the "Content-ID" header that was associated with this file as it was originally created from a MIME message
+ * part (i.e., an email attachment). See RFC 2392 .
+ *
+ * @param contentId
+ * the value of the "Content-ID" header that should be associated with this file.
+ * @throws UnsupportedOperationException
+ * if the "Content-ID" is considered a read-only property for this FileMetadata.
+ * @throws IllegalArgumentException
+ * if the given value is not considered valid for a "Content-ID" MIME header.
+ */
+ public void setContentId(@Nullable String contentId);
+
+ /**
+ * Sets the "Content-Location" header that was associated with this file as it was originally created from a MIME
+ * message part (i.e., an email attachment). See RFC 2557 .
+ *
+ * @param contentLocation
+ * the value of the "Content-Location" header that should be associated with this file.
+ * @throws UnsupportedOperationException
+ * if the "Content-Location" is considered a read-only property for this FileMetadata.
+ * @throws IllegalArgumentException
+ * if the given value is not considered valid for a "Content-Location" MIME header.
+ */
+ public void setContentLocation(@Nullable String contentLocation);
+
+ /**
+ * Sets the "Content-Base" header that was associated with this file as it was originally created from a MIME
+ * message part (i.e., an email attachment). The "Content-Base" header is meant to serve as a base URL so that
+ * relative URLs in the "Content-Location" header can be fully qualified. See RFC 2110 .
+ *
+ * @param contentBase
+ * the value of the "Content-Base" header that should be associated with this file.
+ * @throws UnsupportedOperationException
+ * if the "Content-Base" is considered a read-only property for this FileMetadata.
+ * @throws IllegalArgumentException
+ * if the given value is not considered valid for a "Content-Base" MIME header.
+ */
+ public void setContentBase(@Nullable String contentBase);
+
+ public default void ensureGoodFilename() {
+ ensureGoodFilename(null);
+ }
+
+ public void setResourceType(@Nullable FileResourceType resourceType);
+
+ /**
+ * Modifies the file name as necessary to make it "valid" and minimally normalized. This default implementation
+ * delegates to {@link FileNameUtil#getGoodFileName(String, java.util.function.Supplier, String)} to
+ * transform the {@link #getFilename() current file name}, passing the
+ * {@link #getContentType() currently set Content-Type} and not requesting that a file extension be added to an
+ * otherwise valid file name, and using {@link #setFilename(String)} to apply the result. It should be adequate for
+ * all implementations.
+ *
+ * @param getDefaultBaseName
+ * an optional provider for the base of the default file name (without the extension).
+ */
+ public default void ensureGoodFilename(@Nullable Supplier getDefaultBaseName) {
+ setFilename(FileNameUtil.getGoodFileName(getFilename(), getDefaultBaseName, getContentType()));
+ }
+
+ /**
+ * Sets the file name extension portion of the file name.
+ *
+ *
+ * This default implementation determines the new file name that incorporates the new file name extension, using
+ * {@link FileNameUtil#getGoodFileName(String, Supplier, String)} to ensure the result will be valid, and
+ * using {@link #setFilename(String)} to update the file name accordingly. It should be suitable for all
+ * implementations.
+ *
+ * @param ext
+ * the new extension, or a blank or null String to remove the extension.
+ */
+ public default void setExtension(String ext) {
+ String namePart = FileNameUtil.stripExtension(getFilename());
+ if (StringUtils.isBlank(ext)) {
+ // Remove the extension.
+ setFilename(namePart);
+ }
+ else {
+ // Ensure the file name is "minimally valid" even with the given extension.
+ setFilename(
+ FileNameUtil.getGoodFileName(
+ namePart + FileNameUtil.EXTENSION_SEPARATOR_CHAR + ext, null, getContentType()
+ )
+ );
+ }
+ }
+
+ public void setMissingMutableMetadata(FileMetadata metadata);
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/OutputStreamTracker.java b/src/main/java/com/tractionsoftware/commons/io/OutputStreamTracker.java
new file mode 100644
index 0000000..97c3e91
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/OutputStreamTracker.java
@@ -0,0 +1,50 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.lang.Resource;
+import org.apache.commons.io.function.IORunnable;
+
+import java.io.OutputStream;
+import java.util.function.Supplier;
+
+public final class OutputStreamTracker extends IOResourceTracker {
+
+ public OutputStreamTracker(String sourceIdentifier, Supplier extends Resource> trackerCreator) {
+ super(sourceIdentifier, trackerCreator);
+ }
+
+ @Override
+ protected final String getStreamTypeName() {
+ return "OutputStream";
+ }
+
+ @Override
+ protected final OutputStream getTrackedStream(OutputStream stream, String sourceIdentifier) {
+ return IOUtil.getTrackedOutputStream(stream, this::createTracker, sourceIdentifier);
+ }
+
+ @Override
+ protected final OutputStream getCloseNotifyingStream(OutputStream trackedStream, IORunnable onAfterClose) {
+ return IOUtil.getCloseNotifyingOutputStream(trackedStream, null, onAfterClose);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/SimpleMutableFileMetadata.java b/src/main/java/com/tractionsoftware/commons/io/SimpleMutableFileMetadata.java
new file mode 100644
index 0000000..3ea341b
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/SimpleMutableFileMetadata.java
@@ -0,0 +1,1114 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.net.MediaType;
+import com.tractionsoftware.commons.image.Icon;
+import com.tractionsoftware.commons.image.IconFileResource;
+import com.tractionsoftware.commons.image.ImageUtil;
+import com.tractionsoftware.commons.lang.NativeTypeConversion;
+import com.tractionsoftware.commons.lang.StringUtil;
+import com.tractionsoftware.commons.net.MediaTypeUtil;
+import com.tractionsoftware.commons.properties.*;
+import com.tractionsoftware.commons.text.NumberFormats;
+import com.tractionsoftware.commons.util.Dimensions;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URL;
+import java.util.*;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * A generic and mutable implementation of {@link FileMetadata}. Because it is a {@link ComplexProperty}, it is a
+ * convenient class to use when serialization and deserialization is needed.
+ *
+ * @author Andy Keller, Dave Shepperton
+ */
+public class SimpleMutableFileMetadata implements MutableFileMetadata, ComplexProperty {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(SimpleMutableFileMetadata.class);
+
+ /**
+ * Used as the name of a property for the file name, in the context of {@link #saveInstance(GetPutProperty)},
+ * {@link #LOADER the Loader}, and the {@link GetPutProperty} returned by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_FILE_NAME = "fname";
+
+ /**
+ * Used as the name of a property for the file name extension, in the context of the {@link GetPutProperty} returned
+ * by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_EXTENSION = "fextension";
+
+ /**
+ * Used as the name of a property for the description, in the context of {@link #saveInstance(GetPutProperty)},
+ * {@link #LOADER the Loader}, and the {@link GetPutProperty} returned by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_DESCRIPTION = "desc";
+
+ /**
+ * Used as the name of a property for the content type, in the context of {@link #saveInstance(GetPutProperty)},
+ * {@link #LOADER the Loader}, and the {@link GetPutProperty} returned by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_MIMETYPE = "mimetype";
+
+ /**
+ * Used as the name of a property for the file resource path, in the context of
+ * {@link #saveInstance(GetPutProperty)}, {@link #LOADER the Loader}, and the {@link GetPutProperty} returned by
+ * {@link #asGetPutProperty(FileResource)}. It has this name for historical reasons.
+ *
+ *
+ * Notice that this property does not necessarily represent a real local path in the host file system. See
+ * {@link #getURI()} and {@link #setURI(URI)}.
+ */
+ public static final String PROP_NAME_URI = "localfname";
+
+ /**
+ * Used as the name of a property for the file's serial number, in the context of
+ * {@link #saveInstance(GetPutProperty)}, {@link #LOADER the Loader}, and the {@link GetPutProperty} returned by
+ * {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_NUMBER = "number";
+
+ /**
+ * Used as the name of a property to indicate whether a FileMetadata is a reference to a persisted file, in the
+ * context of {@link #saveInstance(GetPutProperty)}, {@link #LOADER the Loader}, and the {@link GetPutProperty}
+ * returned by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_REFERENCE_TO_PERSISTED_FILE = "isref";
+
+ /**
+ * Used as the name of a property for the Content-ID, in the context of {@link #saveInstance(GetPutProperty)},
+ * {@link #LOADER the Loader}, and the {@link GetPutProperty} returned by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_CID = "cid";
+
+ /**
+ * Used as the name of a property for the Content-Location, in the context of {@link #saveInstance(GetPutProperty)},
+ * {@link #LOADER the Loader}, and the {@link GetPutProperty} returned by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_CONTENT_LOCATION = "cloc";
+
+ /**
+ * Used as the name of a property for the Content-Base, in the context of {@link #saveInstance(GetPutProperty)},
+ * {@link #LOADER the Loader}, and the {@link GetPutProperty} returned by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_CONTENT_BASE = "cbase";
+
+ /**
+ * Used as the name a read-only property referring to a formatted representation of the file's size, in the context
+ * of the {@link GetPutProperty} returned by {@link #asGetPutProperty(FileResource)}.
+ */
+ public static final String PROP_NAME_FORMATTED_SIZE = "size";
+
+ public static final String PROP_NAME_BYTESIZE = "bytesize";
+
+ public static final String PROP_NAME_ERROR = "error";
+
+ public static final String PROP_NAME_IMAGE = "image";
+
+ public static final String PROP_NAME_IMAGE_WIDTH = "imagewidth";
+
+ public static final String PROP_NAME_IMAGE_HEIGHT = "imageheight";
+
+ public static final String PROP_NAME_ICON_URL = "iconurl";
+
+ public static final String PROP_NAME_ICON_WIDTH = "iconwidth";
+
+ public static final String PROP_NAME_ICON_HEIGHT = "iconheight";
+
+ public static final String PROP_NAME_DISPLAY_NAME = "displayname";
+
+ public static final ComplexProperty.Loader LOADER = (GetProperty namespace) -> {
+ if (!namespace.hasProperty(PROP_NAME_FILE_NAME)) {
+ // The file name is the only required property.
+ return null;
+ }
+ SimpleMutableFileMetadata loaded = new SimpleMutableFileMetadata();
+ loaded.getLoadSaveProperties().putAllProperties(namespace);
+ return loaded;
+ };
+
+ private static final Set LOAD_SAVE_BASE_PROPERTY_NAMES = ImmutableSet.of(
+ PROP_NAME_FILE_NAME,
+ PROP_NAME_DESCRIPTION, PROP_NAME_MIMETYPE, PROP_NAME_URI,
+ PROP_NAME_REFERENCE_TO_PERSISTED_FILE, PROP_NAME_NUMBER,
+ PROP_NAME_ERROR, PROP_NAME_DISPLAY_NAME, PROP_NAME_CONTENT_LOCATION
+ );
+
+ /**
+ * Creates a new FileData by copying properties of the given {@link FileResource}. If it is a {@link FileMetadata},
+ * this method defers to {@link FileMetadata#mutableCopy()}. Otherwise, it copies properties in a straightforward
+ * way.
+ *
+ * @param fileResource
+ * the {@link FileResource} whose properties should be used to create a {@link SimpleMutableFileMetadata}.
+ * @return a new FileData representing the properties of the given {@link FileResource}.
+ */
+ @Nonnull
+ public static final SimpleMutableFileMetadata createCopyFromFileInfo(@Nonnull FileResource fileResource) {
+ Objects.requireNonNull(fileResource, "file resource");
+ SimpleMutableFileMetadata metadata = new SimpleMutableFileMetadata();
+ fileResource.getMetadata().copyTo(metadata);
+ return metadata;
+ }
+
+ @Nonnull
+ public static final SimpleMutableFileMetadata createForIconFileInfo(@Nonnull IconFileResource iconFile) {
+ Objects.requireNonNull(iconFile, "icon file");
+ SimpleMutableFileMetadata ret = createCopyFromFileInfo(iconFile);
+ ret.setDisplayName(iconFile.getDisplayName());
+ GetPutProperty props = ret.asGetPutProperty(iconFile);
+ props.getProperty(PROP_NAME_FORMATTED_SIZE);
+ props.getProperty(PROP_NAME_BYTESIZE);
+ props.getProperty(PROP_NAME_IMAGE_WIDTH);
+ props.getProperty(PROP_NAME_IMAGE_HEIGHT);
+ return ret;
+ }
+
+ @Nonnull
+ public static final SimpleMutableFileMetadata createCopy(@Nonnull FileMetadata source) {
+ Objects.requireNonNull(source, "source");
+ SimpleMutableFileMetadata copy = new SimpleMutableFileMetadata();
+ source.copyTo(copy);
+ return copy;
+ }
+
+ @Nonnull
+ public static final SimpleMutableFileMetadata createFromFileNameAndContentType(@Nullable String fileName, @Nullable String contentType) {
+ SimpleMutableFileMetadata ret = new SimpleMutableFileMetadata();
+ ret.setFilename(fileName);
+ ret.setContentType(contentType);
+ return ret;
+ }
+
+ @Nonnull
+ public static final SimpleMutableFileMetadata createFromFileName(@Nullable String fileName) {
+ SimpleMutableFileMetadata ret = new SimpleMutableFileMetadata();
+ ret.setFilename(fileName);
+ String ext = FileNameUtil.getExtension(fileName, null);
+ if (StringUtils.isNotBlank(ext)) {
+ ret.setContentType(MediaTypeUtil.getContentTypeFromExtension(ext));
+ }
+ return ret;
+ }
+
+ /**
+ * Attempts to use the supplied information, and the optional content of the file resource, to construct a file name
+ * and/or make a guess at the appropriate content-type, if one is not supplied, and creates a
+ * {@link SimpleMutableFileMetadata} carrying that file name and content-type.
+ *
+ * @param suggestedFilename
+ * the suggested file name, without an extension.
+ * @param contentType
+ * the content-type, if that's already known.
+ * @param inputSupplier
+ * an optional {@link Supplier} for an {@link InputStream} for retrieving the file resource's content. The
+ * argument for this parameter can be null, but if it is not null, unlike the general case of a Supplier, each
+ * invocation of {@link Supplier#get()} should really return a new InputStream instance in case multiple
+ * attempts are required to read the contents from the beginning to guess at a content-type.
+ * @return a {@link SimpleMutableFileMetadata} carrying the file name and content-type based upon the given
+ * suggested name, content type and content.
+ */
+ @Nonnull
+ public static final SimpleMutableFileMetadata createInstanceForUnnamedResource(@Nullable String suggestedFilename, @Nullable String contentType, @Nullable Supplier extends InputStream> inputSupplier) {
+
+ String fileExtension = null;
+ if (StringUtils.isNotBlank(contentType)) {
+ fileExtension = MediaTypeUtil.getExtensionFromContentType(contentType);
+ }
+
+ if (StringUtils.isBlank(fileExtension)) {
+ fileExtension = guessFileExtensionFromContents(inputSupplier);
+ if (fileExtension == null) {
+ contentType = MediaType.OCTET_STREAM.toString();
+ fileExtension = "";
+ }
+ else {
+ contentType = Objects.toString(
+ MediaTypeUtil.getContentTypeFromExtension(fileExtension), null
+ );
+ }
+ }
+
+ return createFromFileNameAndContentType(getDefaultFileName(suggestedFilename, fileExtension), contentType);
+
+ }
+
+ @Nonnull
+ public static final String getDefaultFileName(@Nullable String suggestedFileName, @Nullable String fileExtension) {
+ StringBuilder ret = new StringBuilder();
+ if (StringUtils.isBlank(suggestedFileName)) {
+ ret.append("file");
+ }
+ else {
+ ret.append(suggestedFileName);
+ }
+ if (StringUtils.isNotBlank(fileExtension)) {
+ ret.append(".");
+ ret.append(fileExtension);
+ }
+ return ret.toString();
+ }
+
+ @Nullable
+ private static final String guessFileExtensionFromContents(@Nullable Supplier extends InputStream> inputSupplier) {
+
+ if (inputSupplier == null) {
+ return null;
+ }
+
+ // Guess image.
+ try (InputStream input = inputSupplier.get()) {
+ return ImageUtil.getImageFileExtensionFromContents(input);
+ }
+ catch (Exception e) {
+ LOGGER.debug("Unexpected problem determining whether this file is an image", e);
+ }
+
+ // No other guesses right now.
+ return null;
+
+ }
+
+ private final class ReadOnlyView implements FileMetadata {
+
+ @Override
+ public final boolean equals(Object other) {
+ if (other instanceof ReadOnlyView otherReadOnly &&
+ SimpleMutableFileMetadata.this == otherReadOnly.container()) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public final int hashCode() {
+ return Objects.hash(getURI());
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return "Read-only:{" + SimpleMutableFileMetadata.this + "}";
+ }
+
+ @Nullable
+ @Override
+ public final String getFilename() {
+ return SimpleMutableFileMetadata.this.getFilename();
+ }
+
+ @Nullable
+ @Override
+ public final URI getURI() {
+ return SimpleMutableFileMetadata.this.getURI();
+ }
+
+ @Nullable
+ @Override
+ public final String getDescription() {
+ return SimpleMutableFileMetadata.this.getDescription();
+ }
+
+ @Nullable
+ @Override
+ public final String getContentType() {
+ return SimpleMutableFileMetadata.this.getContentType();
+ }
+
+ @Override
+ public final int getNumber() {
+ return SimpleMutableFileMetadata.this.getNumber();
+ }
+
+ @Override
+ public final boolean isReferenceToPersistedFile() {
+ return SimpleMutableFileMetadata.this.isReferenceToPersistedFile();
+ }
+
+ @Nullable
+ @Override
+ public final String getContentId() {
+ return SimpleMutableFileMetadata.this.getContentId();
+ }
+
+ @Nullable
+ @Override
+ public final String getContentLocation() {
+ return SimpleMutableFileMetadata.this.getContentLocation();
+ }
+
+ @Nullable
+ @Override
+ public final String getContentBase() {
+ return SimpleMutableFileMetadata.this.getContentBase();
+ }
+
+ @Nullable
+ @Override
+ public final FileResourceType getResourceType() {
+ return SimpleMutableFileMetadata.this.getResourceType();
+ }
+
+ @Nonnull
+ @Override
+ public final FileMetadata toReadOnly() {
+ return this;
+ }
+
+ private final SimpleMutableFileMetadata container() {
+ return SimpleMutableFileMetadata.this;
+ }
+
+ }
+
+ private final class LoadSaveProperties implements GetPutProperty {
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return "LoadSaveProperties for FileData:{" + SimpleMutableFileMetadata.this + "}";
+ }
+
+ @Override
+ public final String getProperty(String name) {
+
+ return switch (name) {
+ case PROP_NAME_FILE_NAME -> SimpleMutableFileMetadata.this.fileName;
+ case PROP_NAME_DESCRIPTION -> SimpleMutableFileMetadata.this.description;
+ case PROP_NAME_MIMETYPE -> SimpleMutableFileMetadata.this.contentType;
+ case PROP_NAME_URI -> Objects.toString(SimpleMutableFileMetadata.this.uri, null);
+ case PROP_NAME_REFERENCE_TO_PERSISTED_FILE -> NativeTypeConversion.booleanToString(
+ SimpleMutableFileMetadata.this.isReferenceToPersistedFile
+ );
+ case PROP_NAME_NUMBER -> Integer.toString(SimpleMutableFileMetadata.this.number);
+ case PROP_NAME_CID -> SimpleMutableFileMetadata.this.contentId;
+ case PROP_NAME_CONTENT_LOCATION -> SimpleMutableFileMetadata.this.contentLocation;
+ case PROP_NAME_CONTENT_BASE -> SimpleMutableFileMetadata.this.contentBase;
+ case PROP_NAME_ERROR -> SimpleMutableFileMetadata.this.errorMessage;
+ case PROP_NAME_DISPLAY_NAME -> SimpleMutableFileMetadata.this.displayName;
+ case null -> null;
+ default -> (SimpleMutableFileMetadata.this.extendedProperties == null) ?
+ null : SimpleMutableFileMetadata.this.extendedProperties.get(name);
+ };
+
+ }
+
+ @Override
+ public final Set getPropertyNames() {
+ Set base = SimpleMutableFileMetadata.LOAD_SAVE_BASE_PROPERTY_NAMES;
+ if (SimpleMutableFileMetadata.this.extendedProperties == null) {
+ return base;
+ }
+ return Sets.union(
+ base, Collections.unmodifiableSet(SimpleMutableFileMetadata.this.extendedProperties.keySet())
+ );
+ }
+
+ @Override
+ public final void putProperty(String name, String value) {
+
+ switch (name) {
+
+ case PROP_NAME_FILE_NAME:
+ SimpleMutableFileMetadata.this.fileName = value;
+ break;
+
+ case PROP_NAME_DESCRIPTION:
+ SimpleMutableFileMetadata.this.description = value;
+ break;
+
+ case PROP_NAME_MIMETYPE:
+ SimpleMutableFileMetadata.this.contentType = value;
+ break;
+
+ case PROP_NAME_URI:
+ SimpleMutableFileMetadata.this.setURISpec(value);
+ break;
+
+ case PROP_NAME_REFERENCE_TO_PERSISTED_FILE:
+ SimpleMutableFileMetadata.this.isReferenceToPersistedFile =
+ NativeTypeConversion.stringToBoolean(value, false);
+ break;
+
+ case PROP_NAME_NUMBER:
+ SimpleMutableFileMetadata.this.number = NativeTypeConversion.stringToInt(value, -1);
+ break;
+
+ case PROP_NAME_CID:
+ SimpleMutableFileMetadata.this.contentId = value;
+ break;
+
+ case PROP_NAME_CONTENT_LOCATION:
+ SimpleMutableFileMetadata.this.contentLocation = value;
+ break;
+
+ case PROP_NAME_CONTENT_BASE:
+ SimpleMutableFileMetadata.this.contentBase = value;
+ break;
+
+ case PROP_NAME_ERROR:
+ SimpleMutableFileMetadata.this.errorMessage = value;
+ break;
+
+ case PROP_NAME_DISPLAY_NAME:
+ SimpleMutableFileMetadata.this.displayName = value;
+ break;
+
+ case PROP_NAME_EXTENSION:
+ case null:
+ // ignore
+ break;
+
+ default:
+ SimpleMutableFileMetadata.this.getExtendedProperties().put(name, value);
+ break;
+
+ }
+
+ }
+
+ }
+
+ private final class BaseGetPutProperty implements GetPutProperty {
+
+ private BaseGetPutProperty() {
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return "BaseGetPutProperty for FileData:{" + SimpleMutableFileMetadata.this + "}";
+ }
+
+ @Override
+ public final String getProperty(String name) {
+ return switch (name) {
+ case PROP_NAME_DISPLAY_NAME -> SimpleMutableFileMetadata.this.getDisplayName();
+ case PROP_NAME_FILE_NAME -> SimpleMutableFileMetadata.this.getFilename();
+ case PROP_NAME_DESCRIPTION -> SimpleMutableFileMetadata.this.getDescription();
+ case PROP_NAME_MIMETYPE -> SimpleMutableFileMetadata.this.getContentType();
+ case PROP_NAME_URI -> SimpleMutableFileMetadata.this.getURISpec();
+ case PROP_NAME_NUMBER -> Integer.toString(SimpleMutableFileMetadata.this.getNumber());
+ case PROP_NAME_REFERENCE_TO_PERSISTED_FILE ->
+ NativeTypeConversion.booleanToString(SimpleMutableFileMetadata.this.isReferenceToPersistedFile());
+ case PROP_NAME_CID -> SimpleMutableFileMetadata.this.getContentId();
+ case PROP_NAME_CONTENT_LOCATION -> SimpleMutableFileMetadata.this.getContentLocation();
+ case PROP_NAME_CONTENT_BASE -> SimpleMutableFileMetadata.this.getContentBase();
+ case PROP_NAME_ERROR -> SimpleMutableFileMetadata.this.getErrorMessage();
+ case PROP_NAME_EXTENSION -> SimpleMutableFileMetadata.this.getExtension();
+ case null, default -> null;
+ };
+ }
+
+ @Override
+ public final Set getPropertyNames() {
+ return ImmutableSet.of(
+ PROP_NAME_FILE_NAME, PROP_NAME_DESCRIPTION,
+ PROP_NAME_MIMETYPE, PROP_NAME_URI,
+ PROP_NAME_REFERENCE_TO_PERSISTED_FILE, PROP_NAME_NUMBER,
+ PROP_NAME_ERROR, PROP_NAME_DISPLAY_NAME, PROP_NAME_EXTENSION
+ );
+ }
+
+ @Override
+ public final void putProperty(String name, String value) {
+ switch (name) {
+ case PROP_NAME_DISPLAY_NAME -> SimpleMutableFileMetadata.this.setDisplayName(value);
+ case PROP_NAME_FILE_NAME -> SimpleMutableFileMetadata.this.setFilename(value);
+ case PROP_NAME_DESCRIPTION -> SimpleMutableFileMetadata.this.setDescription(value);
+ case PROP_NAME_MIMETYPE -> SimpleMutableFileMetadata.this.setContentType(value);
+ case PROP_NAME_URI -> SimpleMutableFileMetadata.this.setURISpec(value);
+ case PROP_NAME_NUMBER -> SimpleMutableFileMetadata.this.setNumber(NativeTypeConversion.stringToInt(
+ value,
+ SimpleMutableFileMetadata.this.number
+ ));
+ case PROP_NAME_REFERENCE_TO_PERSISTED_FILE ->
+ SimpleMutableFileMetadata.this.setReferenceToPersistedFile(NativeTypeConversion.stringToBoolean(
+ value,
+ SimpleMutableFileMetadata.this.isReferenceToPersistedFile
+ ));
+ case PROP_NAME_CID -> SimpleMutableFileMetadata.this.setContentId(value);
+ case PROP_NAME_CONTENT_LOCATION -> SimpleMutableFileMetadata.this.setContentLocation(value);
+ case PROP_NAME_CONTENT_BASE -> SimpleMutableFileMetadata.this.setContentBase(value);
+ case PROP_NAME_ERROR -> SimpleMutableFileMetadata.this.errorMessage = value;
+ case PROP_NAME_EXTENSION -> SimpleMutableFileMetadata.this.setExtension(value);
+ case null, default -> {
+ }
+ }
+
+ }
+
+ }
+
+ /**
+ * A {@link GetPutProperty} implementation backed by a combination of the enclosing FileData instance and a
+ * {@link FileResource} supplied to allow extended properties to be filled in and retrieved on-demand for read-only
+ * access.
+ */
+ private final class ExtendedGetPutProperty implements GetPutProperty {
+
+ private final Supplier extends FileResource> getFileInfo;
+
+ private final Supplier icon = Suppliers.memoize(this::loadIcon);
+
+ private final Supplier iconUrl = Suppliers.memoize(this::loadIconUrl);
+
+ private Dimensions imageDimensions = null;
+
+ private long byteSize = Long.MIN_VALUE;
+
+ private ExtendedGetPutProperty(Supplier extends FileResource> getFileInfo) {
+ this.getFileInfo = getFileInfo;
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return "ExtendedGetPutProperty for FileData:{" + SimpleMutableFileMetadata.this + "}";
+ }
+
+ @Override
+ public final String getProperty(String name) {
+ return switch (name) {
+ case PROP_NAME_DISPLAY_NAME, PROP_NAME_FILE_NAME, PROP_NAME_DESCRIPTION,
+ PROP_NAME_MIMETYPE, PROP_NAME_URI, PROP_NAME_NUMBER, PROP_NAME_REFERENCE_TO_PERSISTED_FILE,
+ PROP_NAME_CID, PROP_NAME_CONTENT_LOCATION, PROP_NAME_CONTENT_BASE,
+ PROP_NAME_ERROR, PROP_NAME_EXTENSION -> null;
+ case PROP_NAME_IMAGE ->
+ NativeTypeConversion.booleanToString(SimpleMutableFileMetadata.this.appearsToBeImage());
+ case PROP_NAME_FORMATTED_SIZE, PROP_NAME_BYTESIZE, PROP_NAME_ICON_URL, PROP_NAME_ICON_WIDTH,
+ PROP_NAME_ICON_HEIGHT,
+ PROP_NAME_IMAGE_WIDTH,
+ PROP_NAME_IMAGE_HEIGHT -> SimpleMutableFileMetadata.this.getExtendedProperties()
+ .computeIfAbsent(name, this::computeExtendedProperty);
+ case null -> null;
+ default -> getOtherExtendedProperty(name);
+ };
+ }
+
+ @Override
+ public final void putProperty(String name, String value) {
+ switch (name) {
+ case PROP_NAME_DISPLAY_NAME, PROP_NAME_FILE_NAME, PROP_NAME_DESCRIPTION,
+ PROP_NAME_MIMETYPE, PROP_NAME_URI, PROP_NAME_NUMBER, PROP_NAME_REFERENCE_TO_PERSISTED_FILE,
+ PROP_NAME_CID, PROP_NAME_CONTENT_LOCATION, PROP_NAME_CONTENT_BASE,
+ PROP_NAME_ERROR, PROP_NAME_EXTENSION,
+ PROP_NAME_FORMATTED_SIZE, PROP_NAME_BYTESIZE, PROP_NAME_ICON_URL, PROP_NAME_ICON_WIDTH,
+ PROP_NAME_ICON_HEIGHT, PROP_NAME_IMAGE,
+ PROP_NAME_IMAGE_WIDTH, PROP_NAME_IMAGE_HEIGHT ->
+ // not supported
+ LOGGER.warn(
+ "SimpleMutableFileMetadata::putProperty does not support setting the {} property.",
+ name,
+ new UnsupportedOperationException()
+ );
+ case null -> {
+ }
+ default -> SimpleMutableFileMetadata.this.getExtendedProperties().put(name, value);
+ }
+ }
+
+ @Override
+ public final Set getPropertyNames() {
+ Set base = ImmutableSet.of(
+ PROP_NAME_FORMATTED_SIZE, PROP_NAME_IMAGE, PROP_NAME_IMAGE_WIDTH, PROP_NAME_IMAGE_HEIGHT,
+ PROP_NAME_ICON_URL, PROP_NAME_ICON_WIDTH,
+ PROP_NAME_ICON_HEIGHT
+ );
+ if (SimpleMutableFileMetadata.this.extendedProperties == null) {
+ return base;
+ }
+ return Sets.union(
+ base,
+ Collections.unmodifiableSet(SimpleMutableFileMetadata.this.extendedProperties.keySet())
+ );
+ }
+
+ private final String getOtherExtendedProperty(String name) {
+ if (SimpleMutableFileMetadata.this.extendedProperties == null) {
+ return null;
+ }
+ return SimpleMutableFileMetadata.this.extendedProperties.get(name);
+ }
+
+ private final String computeExtendedProperty(String name) {
+ return switch (name) {
+ case PROP_NAME_FORMATTED_SIZE -> NumberFormats.getFormattedByteSize(byteSize());
+ case PROP_NAME_BYTESIZE -> Long.toString(byteSize());
+ case PROP_NAME_ICON_URL -> iconUrl.get();
+ case PROP_NAME_ICON_WIDTH -> Integer.toString(icon.get().getWidth());
+ case PROP_NAME_ICON_HEIGHT -> Integer.toString(icon.get().getHeight());
+ case PROP_NAME_IMAGE_WIDTH -> imageDimensions().getWidth().toString();
+ case PROP_NAME_IMAGE_HEIGHT -> imageDimensions().getHeight().toString();
+ default -> "";
+ };
+ }
+
+ private final Icon loadIcon() {
+ return FileIconService.get().getIcon(getFileInfo.get());
+ }
+
+ private final String loadIconUrl() {
+ URL urlObj = FileIconService.get().getURL(icon.get());
+ if (urlObj == null) {
+ return null;
+ }
+ return urlObj.toString();
+ }
+
+ /**
+ * Retrieves the {@link Dimensions} of the underlying image file, if the file appears to be an image and its
+ * dimensions can be retrieved.
+ *
+ * @return the {@link Dimensions} of the underlying image file, if the file appears to be an image and its
+ * dimensions can be retrieved;
+ * {@link Dimensions#getInvalidInstanceInPixels() an invalid Dimensions instance} otherwise.
+ */
+ private final Dimensions imageDimensions() {
+ if (imageDimensions == null) {
+ if (SimpleMutableFileMetadata.this.appearsToBeImage()) {
+ this.imageDimensions = Objects.requireNonNullElseGet(
+ readImageDimensions(), Dimensions::getInvalidInstanceInPixels
+ );
+ }
+ else {
+ this.imageDimensions = Dimensions.getInvalidInstanceInPixels();
+ }
+ }
+ return imageDimensions;
+ }
+
+ @Nullable
+ private final Dimensions readImageDimensions() {
+ FileResource fileResource = getFileInfo.get();
+ if (fileResource == null) {
+ return null;
+ }
+ if (fileResource instanceof IconFileResource iconFile) {
+ return iconFile.getOriginalDimensions();
+ }
+ Icon image = fileResource.getImage();
+ if (image == null) {
+ return null;
+ }
+ return image.getDimensions();
+ }
+
+ private final long byteSize() {
+ if (byteSize == Long.MIN_VALUE) {
+ FileResource fileResource = getFileInfo.get();
+ if (fileResource == null) {
+ byteSize = 0;
+ }
+ else {
+ byteSize = fileResource.getByteSize();
+ }
+ }
+ return byteSize;
+ }
+
+ }
+
+ private static final Function toStringProperty(GetProperty baseProperties) {
+ return (String propName) -> {
+ String propValue = baseProperties.getProperty(propName);
+ if (StringUtils.isBlank(propValue)) {
+ return null;
+ }
+ return propName + "=" + StringUtil.truncate(propValue, 100);
+ };
+ }
+
+ /**
+ * See {@link #getFilename()} and {@link #setFilename(String)}.
+ */
+ private String fileName = null;
+
+ /**
+ * See {@link #getURI()} and {@link #setURI(URI)}.
+ */
+ private URI uri = null;
+
+ /**
+ * See {@link #getDescription()} and {@link #setDescription(String)}.
+ */
+ private String description = "";
+
+ /**
+ * See {@link #getContentType()} and {@link #setContentType(String)}.
+ */
+ private String contentType = null;
+
+ /**
+ * See {@link #getNumber()} and {@link #setNumber(int)}.
+ */
+ private int number = 0;
+
+ /**
+ * See {@link #isReferenceToPersistedFile()} and {@link #setReferenceToPersistedFile(boolean)}.
+ */
+ private boolean isReferenceToPersistedFile = false;
+
+ /**
+ * See {@link #getContentId()} and {@link #setContentId(String)}.
+ */
+ private String contentId = null;
+
+ /**
+ * See {@link #getContentLocation()} and {@link #setContentLocation(String)}.
+ */
+ private String contentLocation = null;
+
+ /**
+ * See {@link #getContentBase()} and {@link #setContentBase(String)}.
+ */
+ private String contentBase = null;
+
+ private FileResourceType resourceType = null;
+
+ /**
+ * Tracks any error message associated with an attempt to find and reserve a temporary location for the file when it
+ * was initially received or created. See {@link #hasError()} and {@link #getErrorMessage()}.
+ */
+ private String errorMessage = null;
+
+ private String displayName = null;
+
+ /**
+ * Used to store other arbitrary properties. See {@link #asGetPutProperty(FileResource)}.
+ */
+ private HashMap extendedProperties = null;
+
+ /**
+ * Constructs a new FileData.
+ */
+ public SimpleMutableFileMetadata() {
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return StringUtil.join(getToStringProperties(), ", ");
+ }
+
+ @Override
+ public final boolean equals(Object other) {
+ if (!(other instanceof SimpleMutableFileMetadata)) {
+ return false;
+ }
+ if (Objects.equals(getURI(), ((SimpleMutableFileMetadata) other).getURI())) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public final int hashCode() {
+ return Objects.hash(getURI());
+ }
+
+ public final boolean isSameFileData(SimpleMutableFileMetadata metadata) {
+ if (metadata == null) {
+ return false;
+ }
+ if (Objects.equals(fileName, metadata.fileName) &&
+ Objects.equals(uri, metadata.uri) &&
+ Objects.equals(description, metadata.description) &&
+ Objects.equals(this.contentType, metadata.contentType) &&
+ (number == metadata.number) &&
+ (isReferenceToPersistedFile == metadata.isReferenceToPersistedFile) &&
+ Objects.equals(contentId, metadata.contentId) &&
+ Objects.equals(contentLocation, metadata.contentLocation) &&
+ Objects.equals(contentBase, metadata.contentBase) &&
+ Objects.equals(errorMessage, metadata.errorMessage) &&
+ Objects.equals(extendedProperties, metadata.extendedProperties)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Nullable
+ public final String getDisplayName() {
+ return Objects.toString(displayName, getFilename());
+ }
+
+ public final void setDisplayName(@Nullable String displayName) {
+ this.displayName = displayName;
+ }
+
+ @Nullable
+ @Override
+ public final String getFilename() {
+ return fileName;
+ }
+
+ @Override
+ public final void setFilename(@Nullable String fileName) {
+ this.fileName = fileName;
+ }
+
+ @Nullable
+ @Override
+ public final URI getURI() {
+ return uri;
+ }
+
+ @Override
+ public final void setURI(@Nullable URI uri) {
+ this.uri = uri;
+ }
+
+ @Nullable
+ @Override
+ public final String getDescription() {
+ return description;
+ }
+
+ @Override
+ public final void setDescription(@Nullable String description) {
+ this.description = description;
+ }
+
+ @Nullable
+ @Override
+ public final String getContentType() {
+ return contentType;
+ }
+
+ @Override
+ public final void setContentType(@Nullable String contentType) {
+ this.contentType = contentType;
+ }
+
+ @Override
+ public final int getNumber() {
+ return number;
+ }
+
+ /**
+ * Sets the sequence number for this FileData, if it is possible to do so. The number can be set as long as this
+ * FileData is not {@link #isReferenceToPersistedFile() a reference to a persisted file} which already has a
+ * non-zero number. (This prevents accidentally re-numbering a file that has a persistent numeric identifier, such
+ * as an existing attachment to an existing entry.)
+ */
+ @Override
+ public final void setNumber(int number) {
+ if (checkCanSetNumber(number)) {
+ this.number = number;
+ }
+ }
+
+ /**
+ * Returns true if this FileData represents metadata for a file that is has been persisted, as opposed to a
+ * temporary file resource.
+ */
+ @Override
+ public final boolean isReferenceToPersistedFile() {
+ return isReferenceToPersistedFile;
+ }
+
+ @Override
+ public final void setReferenceToPersistedFile(boolean isReferenceToPersistedFile) {
+ this.isReferenceToPersistedFile = isReferenceToPersistedFile;
+ }
+
+ @Override
+ public final String getContentId() {
+ return contentId;
+ }
+
+ @Override
+ public final void setContentId(@Nullable String contentId) {
+ this.contentId = contentId;
+ }
+
+ @Override
+ public final String getContentLocation() {
+ return contentLocation;
+ }
+
+ @Override
+ public final void setContentLocation(@Nullable String contentLocation) {
+ this.contentLocation = contentLocation;
+ }
+
+ @Override
+ public final void setContentBase(@Nullable String contentBase) {
+ this.contentBase = contentBase;
+ }
+
+ @Override
+ public final void setResourceType(@Nullable FileResourceType resourceType) {
+ this.resourceType = resourceType;
+ }
+
+ @Override
+ public final String getContentBase() {
+ return contentBase;
+ }
+
+ @Override
+ public FileResourceType getResourceType() {
+ return resourceType;
+ }
+
+ @Nonnull
+ @Override
+ public FileMetadata toReadOnly() {
+ return new ReadOnlyView();
+ }
+
+ @Override
+ public final void saveInstance(GetPutProperty namespace) {
+ namespace.putAllProperties(getLoadSaveProperties());
+ }
+
+ @Nonnull
+ @Override
+ public final SimpleMutableFileMetadata mutableCopy() {
+ SimpleMutableFileMetadata copy = createCopy(this);
+ if (extendedProperties != null) {
+ copy.extendedProperties = new HashMap<>(extendedProperties);
+ }
+ return copy;
+ }
+
+ private final GetPutProperty getBaseProperties() {
+ return new BaseGetPutProperty();
+ }
+
+ public final GetPutProperty asGetPutProperty(FileResource fileResource) {
+ return getExtendedProperties(fileResource).withDefaults(getBaseProperties());
+ }
+
+ private final GetPutProperty getExtendedProperties(FileResource fileResource) {
+ return new ExtendedGetPutProperty(Suppliers.ofInstance(fileResource));
+ }
+
+ /**
+ * Returns true if this FileData represents a place-holder for a file that could not be received or stored (e.g.,
+ * when a temporary file would have been created for an upload, but the upload failed). This should generally
+ * correspond to {@link TempFileResource#hadError()}.
+ *
+ * @return true if this FileData represents a place-holder for a file that could not be received; false otherwise.
+ */
+ public final boolean hasError() {
+ if (errorMessage == null) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Returns an error message if this FileData represents a place-holder for a file that could not be received (e.g.,
+ * when a temporary file would have been created for an upload, but the upload failed). This should generally
+ * correspond to {@link TempFileResource#getErrorMessage()}.
+ *
+ * @return an error message if this FileData represents a place-holder for a file that could not be received (e.g.,
+ * could not be stored as a temporary file).
+ */
+ public final String getErrorMessage() {
+ return errorMessage;
+ }
+
+ public final void setErrorMessage(String error) {
+ this.errorMessage = error;
+ }
+
+ private final HashMap getExtendedProperties() {
+ if (extendedProperties == null) {
+ extendedProperties = new HashMap<>();
+ }
+ return extendedProperties;
+ }
+
+ @Override
+ public final void setMissingMutableMetadata(FileMetadata metadata) {
+ if (StringUtils.isBlank(getFilename())) {
+ setFilename(metadata.getFilename());
+ }
+ if (StringUtils.isBlank(getDescription())) {
+ setDescription(metadata.getDescription());
+ }
+ if (StringUtils.isBlank(getContentType())) {
+ setContentType(metadata.getContentType());
+ }
+ if (getNumber() == 0) {
+ setNumber(metadata.getNumber());
+ }
+ if (StringUtils.isBlank(getContentId())) {
+ setContentId(metadata.getContentId());
+ }
+ if (StringUtils.isBlank(getContentLocation())) {
+ setContentLocation(metadata.getContentLocation());
+ }
+ if (StringUtils.isBlank(getContentBase())) {
+ setContentBase(metadata.getContentBase());
+ }
+ }
+
+ private final boolean checkCanSetNumber(int number) {
+ if (isReferenceToPersistedFile && number != this.number && this.number != 0) {
+ LOGGER.warn(
+ "Attempted to change the number of an existing persisted and numbered file",
+ new IllegalArgumentException()
+ );
+ return false;
+ }
+ return true;
+ }
+
+ private final Iterator getToStringProperties() {
+ return getToStringPropertyNames().stream()
+ .map(toStringProperty())
+ .iterator();
+ }
+
+ private final Function toStringProperty() {
+ return toStringProperty(getBaseProperties().toReadOnly());
+ }
+
+ private final List getToStringPropertyNames() {
+ return ImmutableList.of(
+ PROP_NAME_FILE_NAME, PROP_NAME_MIMETYPE, PROP_NAME_URI, PROP_NAME_NUMBER,
+ PROP_NAME_REFERENCE_TO_PERSISTED_FILE, PROP_NAME_ERROR, PROP_NAME_CID, PROP_NAME_CONTENT_LOCATION,
+ PROP_NAME_CONTENT_BASE
+ );
+ }
+
+ private final GetPutProperty getLoadSaveProperties() {
+ return new LoadSaveProperties();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/SingleThreadPrintWriter.java b/src/main/java/com/tractionsoftware/commons/io/SingleThreadPrintWriter.java
new file mode 100644
index 0000000..d8719b7
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/SingleThreadPrintWriter.java
@@ -0,0 +1,271 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.annotations.Beta;
+import jakarta.annotation.Nonnull;
+import org.apache.commons.lang3.ObjectUtils;
+
+import java.io.*;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * A {@link PrintWriter} that dispenses with the thread-safe mechanisms used by the default PrintWriter implementation
+ * methods. This can be useful in cases in which thread-safety is not required, either because a {@link Writer} or
+ * {@link OutputStream} being wrapped already implements it, or all I/O operations for the PrintWriter are definitely
+ * going to be confined to a single execution thread.
+ *
+ * @author Dave Shepperton
+ */
+@Beta
+public final class SingleThreadPrintWriter extends AbstractCustomPrintWriter {
+
+ public static SingleThreadPrintWriter createInstance(Writer out) {
+ return createInstance(out, false);
+ }
+
+ public static SingleThreadPrintWriter createInstance(Writer out, boolean autoFlush) {
+ Objects.requireNonNull(out, "Writer");
+ if (out instanceof PrintWriter) {
+ throw new IllegalArgumentException("Wrapping an existing PrintWriter.");
+ }
+ return new SingleThreadPrintWriter(out, autoFlush);
+ }
+
+ public static SingleThreadPrintWriter createUtf8Instance(OutputStream out) {
+ return createInstance(out, StandardCharsets.UTF_8);
+ }
+
+ public static SingleThreadPrintWriter createInstance(OutputStream out, Charset charset) {
+ return createInstance(out, charset, false);
+ }
+
+ public static SingleThreadPrintWriter createInstance(OutputStream out, Charset charset, boolean autoFlush) {
+ Objects.requireNonNull(out, "OutputStream");
+ Objects.requireNonNull(charset, "Charset");
+ return createInstance(
+ new OutputStreamWriter(IOUtil.getBufferedOutputStream(out), charset), autoFlush
+ );
+ }
+
+ public static SingleThreadPrintWriter createUtf8Instance(File file) throws IOException {
+ return createInstance(file, null);
+ }
+
+ public static SingleThreadPrintWriter createInstance(File file, Charset charset) throws IOException {
+ return createInstance(file, charset, false);
+ }
+
+ public static SingleThreadPrintWriter createInstance(File file, Charset charset, boolean autoFlush)
+ throws IOException {
+ Objects.requireNonNull(file, "File");
+ return createInstance(
+ Files.newBufferedWriter(file.toPath(), ObjectUtils.getIfNull(charset, StandardCharsets.UTF_8)),
+ autoFlush
+ );
+ }
+
+ private SingleThreadPrintWriter(Writer out, boolean autoFlush) {
+ super(out, autoFlush);
+ }
+
+ @Override
+ public void write(int c) {
+ try {
+ checkOpenX();
+ out.write(c);
+ }
+ catch (InterruptedIOException e) {
+ Thread.currentThread().interrupt();
+ }
+ catch (IOException e) {
+ setError();
+ }
+ }
+
+ @Override
+ public void write(@Nonnull char[] buf, int off, int len) {
+ try {
+ checkOpenX();
+ out.write(buf, off, len);
+ }
+ catch (InterruptedIOException e) {
+ Thread.currentThread().interrupt();
+ }
+ catch (IOException e) {
+ setError();
+ }
+ }
+
+ @Override
+ public void write(@Nonnull String s, int off, int len) {
+ try {
+ checkOpenX();
+ out.write(s, off, len);
+ }
+ catch (InterruptedIOException e) {
+ Thread.currentThread().interrupt();
+ }
+ catch (IOException e) {
+ setError();
+ }
+ }
+
+ @Override
+ public void println() {
+ try {
+ checkOpenX();
+ printlnImpl();
+ }
+ catch (InterruptedIOException e) {
+ Thread.currentThread().interrupt();
+ }
+ catch (IOException e) {
+ setError();
+ }
+ }
+
+ @Override
+ public void println(boolean x) {
+ printlnOther(String.valueOf(x));
+ }
+
+ @Override
+ public void println(char x) {
+ printlnOther(String.valueOf(x));
+ }
+
+ @Override
+ public void println(int x) {
+ printlnOther(String.valueOf(x));
+ }
+
+ @Override
+ public void println(long x) {
+ printlnOther(String.valueOf(x));
+ }
+
+ @Override
+ public void println(float x) {
+ printlnOther(String.valueOf(x));
+ }
+
+ @Override
+ public void println(double x) {
+ printlnOther(String.valueOf(x));
+ }
+
+ @Override
+ public void println(@Nonnull char[] x) {
+ print(x);
+ println();
+ }
+
+ @Override
+ public void println(String x) {
+ print(x);
+ println();
+ }
+
+ @Override
+ public void println(Object x) {
+ String s = String.valueOf(x);
+ print(s);
+ println();
+ }
+
+ @Override
+ public SingleThreadPrintWriter printf(@Nonnull String format, Object... args) {
+ return format(format, args);
+ }
+
+ @Override
+ public SingleThreadPrintWriter printf(Locale l, @Nonnull String format, Object... args) {
+ return format(l, format, args);
+ }
+
+ @Override
+ public SingleThreadPrintWriter format(@Nonnull String format, Object... args) {
+ try {
+ checkOpenX();
+ getDefaultFormatter().format(Locale.getDefault(), format, args);
+ autoFlush();
+ }
+ catch (InterruptedIOException e) {
+ Thread.currentThread().interrupt();
+ }
+ catch (IOException e) {
+ setError();
+ }
+ return this;
+ }
+
+ @Override
+ public SingleThreadPrintWriter format(Locale l, @Nonnull String format, Object... args) {
+ try {
+ checkOpenX();
+ getFormatter(l).format(l, format, args);
+ autoFlush();
+ }
+ catch (InterruptedIOException e) {
+ Thread.currentThread().interrupt();
+ }
+ catch (IOException e) {
+ setError();
+ }
+ return this;
+ }
+
+ @Override
+ public SingleThreadPrintWriter append(CharSequence csq) {
+ write(String.valueOf(csq));
+ return this;
+ }
+
+ @Override
+ public SingleThreadPrintWriter append(CharSequence csq, int start, int end) {
+ if (csq == null) {
+ csq = "null";
+ }
+ return append(csq.subSequence(start, end));
+ }
+
+ @Override
+ public SingleThreadPrintWriter append(char c) {
+ write(c);
+ return this;
+ }
+
+ private void printlnImpl() throws IOException {
+ write(System.lineSeparator());
+ autoFlush();
+ }
+
+ private void printlnOther(String s) {
+ print(s + System.lineSeparator());
+ autoFlush();
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/SizedInputStream.java b/src/main/java/com/tractionsoftware/commons/io/SizedInputStream.java
new file mode 100644
index 0000000..2fb1cf9
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/SizedInputStream.java
@@ -0,0 +1,62 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.util.MayHaveKnownSize;
+import jakarta.annotation.Nonnull;
+
+import java.io.FilterInputStream;
+import java.io.InputStream;
+import java.util.Objects;
+
+public abstract class SizedInputStream extends FilterInputStream implements MayHaveKnownSize {
+
+ @Nonnull
+ public static final SizedInputStream forInputStream(@Nonnull InputStream input, long byteSize) {
+
+ Objects.requireNonNull(input, "InputStream");
+
+ return new SizedInputStream(input) {
+
+ @Override
+ public final long size() {
+ return byteSize;
+ }
+
+ };
+
+ }
+
+ public SizedInputStream(InputStream input) {
+ super(input);
+ }
+
+ /**
+ * Returns the size of this {@link InputStream}, if the size is known.
+ *
+ * @return the size of this {@link InputStream}, if the size is known; {@link Long#MIN_VALUE} otherwise.
+ */
+ @Override
+ public long size() {
+ return Long.MIN_VALUE;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/StreamSizeLimitExceededException.java b/src/main/java/com/tractionsoftware/commons/io/StreamSizeLimitExceededException.java
new file mode 100644
index 0000000..0ae9333
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/StreamSizeLimitExceededException.java
@@ -0,0 +1,53 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import java.io.IOException;
+import java.io.Serial;
+
+/**
+ * An {@link IOException} that is raised when a stream size limit has been exceeded -- either reading or writing too
+ * many bytes.
+ *
+ * @author Dave Shepperton
+ */
+public final class StreamSizeLimitExceededException extends IOException {
+
+ @Serial
+ private static final long serialVersionUID = -4169472154436382298L;
+
+ public static final StreamSizeLimitExceededException forWrite(long maximumBytes, long bytesAttempted) {
+ return new StreamSizeLimitExceededException(
+ "Attempted to write " + bytesAttempted + "B > " + maximumBytes + "B."
+ );
+ }
+
+ public static final StreamSizeLimitExceededException forRead(long maximumBytes, long bytesAttempted) {
+ return new StreamSizeLimitExceededException(
+ "Attempted to read " + bytesAttempted + "B > " + maximumBytes + "B."
+ );
+ }
+
+ private StreamSizeLimitExceededException(String message) {
+ super(message);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/StringWriteUtil.java b/src/main/java/com/tractionsoftware/commons/io/StringWriteUtil.java
new file mode 100644
index 0000000..0ba620c
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/StringWriteUtil.java
@@ -0,0 +1,296 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.tractionsoftware.commons.lang.EnhancedCharSequence;
+import com.tractionsoftware.commons.lang.ObjectUtil;
+import com.tractionsoftware.commons.lang.StringUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.io.output.TeeWriter;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+public final class StringWriteUtil {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(StringWriteUtil.class.getName());
+
+ private StringWriteUtil() {
+ }
+
+ private static final class CodePointChars {
+
+ private final char[] chars;
+
+ private final int end;
+
+ private CodePointChars(char[] chars, int end) {
+ this.chars = chars;
+ this.end = end;
+ }
+
+ public static final CodePointChars createInstance(@Nonnull int[] codePoints, int start, int end) {
+ char[] chars = new char[2 * (end - start)];
+ int nextIndex = 0;
+ for (int i = start; i < end; i++) {
+ char[] toChars = Character.toChars(codePoints[i]);
+ int size = toChars.length;
+ System.arraycopy(toChars, 0, chars, nextIndex, size);
+ nextIndex += size;
+ }
+ return new CodePointChars(chars, nextIndex);
+ }
+
+ public final void append(@Nonnull Appendable out) throws IOException {
+ out.append(EnhancedCharSequence.getInstance(chars, 0, end));
+ }
+
+ public final void write(@Nonnull PrintWriter out) {
+ out.write(chars, 0, end);
+ }
+
+ }
+
+ public static final void safeAppend(StringBuilder buffer, Object o) {
+ if (buffer == null || o == null) {
+ return;
+ }
+ buffer.append(o);
+ }
+
+ public static final void safeAppend(@Nullable Appendable out, @Nullable CharSequence str) {
+ if (out == null || StringUtils.isEmpty(str)) {
+ return;
+ }
+ try {
+ out.append(str);
+ }
+ catch (IOException e) {
+ LOGGER.warn("append failed", e);
+ }
+ }
+
+ public static final void safeAppend(@Nullable Appendable out, @Nullable CharSequence str, int start, int end) {
+ if (out == null || StringUtils.isEmpty(str)) {
+ return;
+ }
+ try {
+ out.append(str, start, end);
+ }
+ catch (IOException e) {
+ LOGGER.warn("append failed", e);
+ }
+ }
+
+ public static final void safeAppend(@Nullable Appendable buffer, char c) {
+ if (buffer == null) {
+ return;
+ }
+ try {
+ buffer.append(c);
+ }
+ catch (IOException e) {
+ LOGGER.warn("append failed", e);
+ }
+ }
+
+ /**
+ * This utility method implements the commonly required retrieval of a String representing the output written to a
+ * PrintWriter.
+ *
+ * @param callback
+ * allows the client to supply the {@link PrintWriter} to whatever code will be using it to write output.
+ * @return the String representing the output written to the {@link PrintWriter} that is provided to the callback's
+ * {@link Consumer#accept(Object)} method.
+ */
+ public static final String getPrintedString(@Nonnull Consumer callback) {
+ Objects.requireNonNull(callback, "callback");
+ StringWriter sw = new StringWriter();
+ PrintWriter out = SingleThreadPrintWriter.createInstance(sw);
+ callback.accept(out);
+ out.flush();
+ return sw.toString();
+ }
+
+ public static final String getString(Consumer callback) {
+ StringBuilder buff = new StringBuilder();
+ callback.accept(buff);
+ return buff.toString();
+ }
+
+ /**
+ * This version does the same thing as {@link StringWriteUtil#getPrintedString(Consumer)}, but throws an Exception
+ * of the given type if any RuntimeException caught as a result of invoking {@link Consumer#accept(Object)} on the
+ * given {@link Consumer} is found to wrap an Exception of that type.
+ *
+ * @param callback
+ * allows the client to supply the {@link PrintWriter} to whatever code will be using it to write output.
+ * @param exceptionType
+ * the type of Exception that will be thrown if {@link Consumer#accept(Object)} throws a RuntimeException that
+ * wraps an assignment-compatible type.
+ * @return the String representing the output written to the {@link PrintWriter} that is provided to the callback's
+ * {@link Consumer#accept(Object)} method.
+ * @throws X
+ * if one is wrapped by a RuntimeException thrown by {@link Consumer#accept(Object)}.
+ */
+ public static final String getPrintedString(Consumer callback, Class exceptionType)
+ throws X {
+ try {
+ return getPrintedString(callback);
+ }
+ catch (RuntimeException e) {
+ X eligible = ObjectUtil.castIfAssignmentCompatible(e.getCause(), exceptionType);
+ if (eligible != null) {
+ throw eligible;
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * This version does the same thing as {@link StringWriteUtil#getPrintedString(Consumer)}, but never allows
+ * Exceptions to propagate, and returns either the successfully generated String, or as much as was generated before
+ * any Exception prevented the operation from completing.
+ *
+ * @param callback
+ * allows the client to supply the {@link PrintWriter} to whatever code will be using it to write output.
+ * @return the String representing the output written to the {@link PrintWriter} that is provided to the callback's
+ * {@link Consumer#accept(Object)} method.
+ */
+ public static final String getFullOrPartialPrintedString(Consumer callback) {
+ StringWriter sw = new StringWriter();
+ PrintWriter out = SingleThreadPrintWriter.createInstance(sw);
+ try {
+ callback.accept(out);
+ }
+ catch (RuntimeException e) {
+ LOGGER.warn("Error caught while generating printed string via {}", callback, e);
+ }
+ out.flush();
+ return sw.toString();
+ }
+
+ public static final String getPrintedResultAndTee(Consumer callback, Writer... also) {
+ StringWriter sw = new StringWriter();
+ ImmutableList.Builder writers = ImmutableList.builder();
+ writers.add(sw);
+ writers.add(also);
+ PrintWriter out = SingleThreadPrintWriter.createInstance(new TeeWriter(writers.build()));
+ callback.accept(out);
+ out.flush();
+ return sw.toString();
+ }
+
+ public static final void appendCodePoints(@Nonnull StringBuilder buff, @Nonnull int[] codePoints, int start, int end) {
+ Objects.requireNonNull(buff, "buffer");
+ Objects.requireNonNull(codePoints, "code points");
+ for (int i = start; i < end; i++) {
+ buff.appendCodePoint(codePoints[i]);
+ }
+ }
+
+ public static final void appendCodePoints(PrintWriter out, int[] codePoints, int start, int end) {
+ CodePointChars.createInstance(codePoints, start, end).write(out);
+ }
+
+ public static final void appendCodePoints(Appendable out, int[] codePoints, int start, int end) throws IOException {
+ if (out instanceof StringBuilder buff) {
+ appendCodePoints(buff, codePoints, start, end);
+ return;
+ }
+ if (out instanceof PrintWriter pw) {
+ appendCodePoints(pw, codePoints, start, end);
+ return;
+ }
+ CodePointChars.createInstance(codePoints, start, end).append(out);
+ }
+
+ public static final void appendTrimmed(@Nonnull StringBuilder buff, CharSequence str) {
+ appendMatching(buff, str, CharMatcher.whitespace().negate());
+ }
+
+ public static final void printTrimmed(@Nullable PrintWriter out, CharSequence str) {
+ printMatching(out, str, CharMatcher.whitespace().negate());
+ }
+
+ public static final void appendTrimmed(@Nullable Appendable out, CharSequence str) throws IOException {
+ appendMatching(out, str, CharMatcher.whitespace().negate());
+ }
+
+ public static final void appendMatching(@Nonnull StringBuilder buff, CharSequence str, CharMatcher matcher) {
+ StringUtil.getMatchingRange(str, matcher).append(buff, str);
+ }
+
+ public static final void printMatching(@Nullable PrintWriter out, CharSequence str, CharMatcher matcher) {
+ StringUtil.getMatchingRange(str, matcher).print(out, str);
+ }
+
+ public static final void appendMatching(@Nullable Appendable out, CharSequence str, CharMatcher matcher) throws IOException {
+ StringUtil.getMatchingRange(str, matcher).append(out, str);
+ }
+
+ public static final boolean appendTo(@Nullable Object appendTo, String appendValue, Consumer onUpdate) throws IOException {
+ if (appendTo instanceof Appendable out) {
+ out.append(appendValue);
+ return true;
+ }
+ if (appendTo instanceof CharSequence sequence) {
+ onUpdate.accept(sequence + appendValue);
+ return true;
+ }
+ return false;
+ }
+
+ public static final boolean appendToSafe(Object appendTo, String appendValue, Consumer onUpdate) {
+ try {
+ return appendTo(appendTo, appendValue, onUpdate);
+ }
+ catch (IOException | RuntimeException e) {
+ LOGGER.warn(
+ "Failed to append {} to {}",
+ StringUtil.truncatedToStringForLog(appendValue),
+ ObjectUtil.safeToStringObject(appendTo),
+ e
+ );
+ }
+ return false;
+ }
+
+ public static final void appendUtf8Bytes(Appendable out, byte[] b, int offset, int length) {
+ safeAppend(out, new String(b, offset, length, StandardCharsets.UTF_8));
+ }
+
+ public static final void appendUtf16Bytes(Appendable out, byte[] b) {
+ safeAppend(out, new String(b, StandardCharsets.UTF_16));
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/io/TempFileResource.java b/src/main/java/com/tractionsoftware/commons/io/TempFileResource.java
new file mode 100644
index 0000000..5ae880d
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/io/TempFileResource.java
@@ -0,0 +1,299 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.io;
+
+import com.tractionsoftware.commons.net.URLUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.apache.commons.io.function.IOSupplier;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+
+import java.io.*;
+import java.net.URI;
+import java.util.Objects;
+
+/**
+ * A simple interface representing a temporary file, with support for reading, writing, moving, deleting, etc.
+ *
+ *
+ * TempFiles do not guarantee support for concurrent access to both an {@link InputStream} and an {@link OutputStream}
+ * for the same instance; nor to concurrent access to two different objects that both provide read or write access to
+ * the temp file's contents.
+ *
+ *
+ * TempFiles, like other {@link FileResource}s, should be assumed to created only on demand contingent upon various
+ * factors, and should never be shared across threads.
+ */
+public interface TempFileResource extends FileResource, Flushable, Closeable {
+
+ public static final String URI_SCHEME_NAME = "temp";
+
+ public static final String URI_SCHEME_PREFIX = URI_SCHEME_NAME + URLUtil.SCHEME_QUALIFIER_CHAR;
+
+ public static final String URI_ID_ERROR = "error";
+
+ public static final URI URI_ERROR = URI.create(URI_SCHEME_PREFIX + URI_ID_ERROR);
+
+ public static interface Factory {
+
+ /**
+ * Returns a {@link TempFileResource} for a newly created temporary file resource. Its metadata will be based on
+ * the given metadata, and its contents will be populated with the given {@link InputStream} supplier, if one is
+ * provided. Clients should use {@link TempFileResource#hadError()} to see whether the temporary file resource
+ * was successfully created.
+ *
+ * @param metadata
+ * the base metadata for the temp file. A modified version of the metadata may be
+ * {@link TempFileResource#getMetadata() carried by the returned TempFileResource}.
+ * @param content
+ * an optional supplier for the content of the temporary file.
+ * @param logger
+ * an optional {@link Logger} to use for logging.
+ * @return a {@link TempFileResource} for a newly created temporary file resource.
+ * @throws NullPointerException
+ * if the {@link FileMetadata} or {@link FileMetadata#getFilename() its file name} is null.
+ */
+ @Nonnull
+ public TempFileResource create(@Nonnull FileMetadata metadata, @Nullable IOSupplier extends InputStream> content, @Nullable Logger logger);
+
+ /**
+ * Returns a {@link TempFileResource} for an existing temporary file resource identified by
+ * {@link FileMetadata#getURI() the given metadata's file resource path}.
+ *
+ * @param metadata
+ * the base metadata for the temp file whose {@link FileMetadata#getURI() file resource path} refers to the
+ * requested file. A modified version of the metadata may be
+ * {@link TempFileResource#getMetadata() carried by the returned TempFileResource}.
+ * @param logger
+ * an optional {@link Logger} to use for logging.
+ * @return a {@link TempFileResource} for an existing temporary file resource identified by
+ * {@link FileMetadata#getURI() the given metadata's file resource path}.
+ * @throws NullPointerException
+ * if the {@link FileMetadata} or {@link FileMetadata#getURI() its file resource path} is null.
+ */
+ @Nonnull
+ public TempFileResource loadExisting(@Nonnull FileMetadata metadata, @Nullable Logger logger);
+
+ }
+
+ public static abstract class AbstractFactory implements Factory {
+
+ @Nonnull
+ @Override
+ public final TempFileResource create(@Nonnull FileMetadata metadata, @Nullable IOSupplier extends InputStream> content, @Nullable Logger logger) {
+
+ Objects.requireNonNull(metadata, "metadata");
+ Objects.requireNonNull(metadata.getFilename(), "file name from metadata");
+
+ SimpleMutableFileMetadata updatedMetadata = SimpleMutableFileMetadata.createCopy(metadata);
+
+ updatedMetadata.setReferenceToPersistedFile(false);
+
+ Logger useLogger = Objects.requireNonNullElse(logger, LOGGER);
+
+ try {
+ return createImpl(updatedMetadata, content, useLogger);
+ }
+ catch (IOException | RuntimeException e) {
+ useLogger.error("Failed to create temp file for {}", metadata.getFilename(), e);
+ return ErrorTempFileResource.createInstance(updatedMetadata, e);
+ }
+
+ }
+
+ @Nonnull
+ public final TempFileResource loadExisting(@Nonnull FileMetadata metadata, @Nullable Logger logger) {
+
+ Objects.requireNonNull(metadata, "metadata");
+ Objects.requireNonNull(metadata.getURI(), "file path from metadata");
+
+ SimpleMutableFileMetadata updatedMetadata = SimpleMutableFileMetadata.createCopy(metadata);
+ updatedMetadata.setReferenceToPersistedFile(false);
+
+ Logger useLogger = Objects.requireNonNullElse(logger, LOGGER);
+
+ try {
+ return loadExistingImpl(updatedMetadata, useLogger);
+ }
+ catch (IOException | RuntimeException e) {
+ useLogger.error("Failed to load existing temp file for {}", metadata.getFilename(), e);
+ return ErrorTempFileResource.createInstance(updatedMetadata, e);
+ }
+
+ }
+
+ @Nonnull
+ protected abstract TempFileResource createImpl(@Nonnull MutableFileMetadata metadata, @Nullable IOSupplier extends InputStream> content, @Nonnull Logger logger)
+ throws IOException;
+
+ @Nonnull
+ protected abstract TempFileResource loadExistingImpl(@Nonnull MutableFileMetadata metadata, @Nonnull Logger logger)
+ throws IOException;
+
+ }
+
+ /**
+ * {@link TempFileResource}s are the canonical example of a non-persistent file resource, so this implementation
+ * returns false.
+ */
+ @Override
+ public default boolean isPersistent() {
+ return false;
+ }
+
+ /**
+ * Returns a path that can be used to uniquely refer to this temp file resource. It will almost certainly not
+ * reflect the details of the underlying storage location, such as a path to an actual file.
+ *
+ *
+ * This default implementation returns the result of {@code getURI().getSchemeSpecificPart()}, because the default
+ * temp file resource URI is of the form "temp:[file-or-path-identifier]". Subclasses may override this if a
+ * different URI scheme or structure is required.
+ *
+ * @return a path that can be used to uniquely refer to this temp file resource.
+ */
+ @Nonnull
+ public default String getPath() {
+ return getURI().getSchemeSpecificPart();
+ }
+
+ /**
+ * Deletes the underlying file associated with this TempFile, if it exists, and releases any resources (e.g.,
+ * memory, disk space, or a row in a database) that it had been occupying. This method will first close any open
+ * resources ({@link InputStream}s, {@link OutputStream}s, etc.) before attempting to delete the file, as though the
+ * {@link #close()} method had been invoked.
+ *
+ *
+ * This method is idempotent, and will not return false or raise any unexpected exceptions if it is invoked multiple
+ * times for the same TempFile instance, or the same underlying resource.
+ *
+ * @return false if there was an error that prevented the deletion from being completed; true otherwise, including
+ * if the deletion was a no-op because the underlying resource has already been deleted.
+ */
+ public boolean delete();
+
+ /**
+ * Saves any changes that have been made to this temporary file's properties using the set* methods, or writing to
+ * its {@link OutputStream}. Clients that are modifying the TempFile's content or metadata should expect that it may
+ * be necessary to invoke this method before the result of {@link #getMetadata()} and certain other methods will
+ * reflect all the changes. Clients should also expect that invoking this method will have as a side effect the same
+ * result as invoking {@link #close()}.
+ *
+ * @throws IOException
+ * if there is a problem committing any changes.
+ */
+ public abstract void save() throws IOException;
+
+ /**
+ * Returns true if the temp file actually exists.
+ *
+ * @return true if the temp file actually exists.
+ */
+ public abstract boolean exists();
+
+ /**
+ * Returns the error message, if any, associated with the attempt to create this temporary file, or to automatically
+ * populate it (e.g., with the contents of an upload or received email attachment).
+ *
+ * @return the error message, if any, associated with the attempt to create this temporary file, or to automatically
+ * populate it (e.g., with the contents of an upload or received email attachment).
+ */
+ public abstract String getErrorMessage();
+
+ /**
+ * Returns true if the attempt to create this TempFile failed.
+ *
+ * @return true if the attempt to create this TempFile failed.
+ */
+ public default boolean hadError() {
+ if (getErrorMessage() == null) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Returns an {@link OutputStream} that allows the caller to write into the TempFile.
+ *
+ *
+ * Multiple invocations of this method will always return the same OutputStream until that stream is closed. This is
+ * for the sake of convenience: if multiple methods need to write data to the OutputStream for a single TempFile,
+ * the TempFile can just be passed around. Contrast this with {@link #getInputStream()}, which must produce a new
+ * {@link InputStream} on each invocation.
+ *
+ * @return an {@link OutputStream} that allows the caller to write into the TempFile.
+ * @throws IOException
+ * if one is raised while attempting to create the necessary {@link OutputStream}.
+ * @throws IllegalStateException
+ * if another method has already been invoked that provides write access to the file (e.g., another call to
+ * {@code getOutputStream()}) and the resulting object has not been closed.
+ */
+ public abstract OutputStream getOutputStream() throws IOException;
+
+ /**
+ * Returns a {@link PrintWriter} that can be used to write UTF-8 text to this TempFile.
+ *
+ *
+ * Multiple invocations of this method will always return the same PrintWriter until that PrintWriter is closed.
+ * This is for the sake of convenience, in case multiple methods need to write to the PrintWriter for a single
+ * TempFile.
+ *
+ * @return a {@link PrintWriter} that can be used to write UTF-8 text to this TempFile.
+ * @throws IOException
+ * if one is raised while attempting to create the necessary {@link OutputStream} for the {@link PrintWriter}.
+ * @throws IllegalStateException
+ * if another method has already been invoked that provides write access to the file (e.g., another call to
+ * {@code getUtf8PrintWriter()}) and the resulting object has not been closed.
+ */
+ public abstract PrintWriter getUtf8PrintWriter() throws IOException;
+
+ /**
+ * This method must flush the {@link OutputStream}s, if any, associated with this TempFile.
+ */
+ @Override
+ public abstract void flush() throws IOException;
+
+ /**
+ * This method must close any open {@link InputStream} and {@link OutputStream}s associated with this TempFile, but
+ * must NOT prevent additional read and write access to this TempFile via methods such as {@link #getInputStream()}
+ * or {@link #getOutputStream()}.
+ */
+ @Override
+ public abstract void close() throws IOException;
+
+ public default void setContent(@Nonnull InputStream input) throws IOException {
+ Objects.requireNonNull(input, "input");
+ try (OutputStream destination = getOutputStream()) {
+ input.transferTo(destination);
+ }
+ save();
+ }
+
+ /**
+ * Returns false because all TempFile instances must represent files, not directories.
+ */
+ @Override
+ public default boolean isDirectory() {
+ return false;
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/lang/EnhancedCharSequence.java b/src/main/java/com/tractionsoftware/commons/lang/EnhancedCharSequence.java
new file mode 100644
index 0000000..6574fd7
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/lang/EnhancedCharSequence.java
@@ -0,0 +1,1584 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.lang;
+
+import com.google.common.annotations.Beta;
+import com.google.common.collect.ImmutableList;
+import com.tractionsoftware.commons.util.CollectionUtil;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+
+import jakarta.annotation.Nonnull;
+import org.apache.commons.lang3.Strings;
+
+import java.util.*;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+/**
+ * A special kind of {@link CharSequence} which is immutable and offers an augmented API. The objects returned strive to
+ * be as "lazy" as possible, attempting to use available information for efficiency and to avoid instantiating new and
+ * possibly expensive objects or performing unnecessary copy operations.
+ *
+ * @author Dave Shepperton
+ */
+@Beta
+public abstract class EnhancedCharSequence implements CharSequence {
+
+ private static final boolean isBlankImpl(CharSequence sequence) {
+ if (sequence instanceof String str) {
+ return str.isBlank();
+ }
+ if (sequence instanceof EnhancedCharSequence enhanced) {
+ return enhanced.isBlank();
+ }
+ return isBlankImpl(sequence, 0, sequence.length());
+ }
+
+ private static final boolean isBlankImpl(CharSequence sequence, int start, int stop) {
+ for (int i = start; i < stop; i++) {
+ if (!Character.isWhitespace(sequence.charAt(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static final boolean isBlankImpl(char[] data, int start, int stop) {
+ for (int i = start; i < stop; i++) {
+ if (!Character.isWhitespace(data[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static final int firstNonWhitespaceIndex(CharSequence sequence, int start, int stop) {
+ for (int i = start; i < stop; i++) {
+ if (!Character.isWhitespace(sequence.charAt(i))) {
+ return i;
+ }
+ }
+ return stop;
+ }
+
+ private static final int lastNonWhitespaceIndex(CharSequence sequence, int start, int stop) {
+ for (int i = stop - 1; i >= start; i--) {
+ if (!Character.isWhitespace(sequence.charAt(i))) {
+ return i;
+ }
+ }
+ return start - 1;
+ }
+
+ private static final int firstNonWhitespaceIndex(char[] data, int start, int stop) {
+ for (int i = start; i < stop; i++) {
+ if (!Character.isWhitespace(data[i])) {
+ return i;
+ }
+ }
+ return stop;
+ }
+
+ private static final int lastNonWhitespaceIndex(char[] data, int start, int stop) {
+ for (int i = stop - 1; i >= start; i--) {
+ if (!Character.isWhitespace(data[i])) {
+ return i;
+ }
+ }
+ return start - 1;
+ }
+
+ private static final class WrappedSequence extends EnhancedCharSequence {
+
+ static final WrappedSequence EMPTY = new WrappedSequence(StringUtils.EMPTY);
+
+ private final CharSequence wrapped;
+
+ private WrappedSequence(CharSequence wrapped) {
+ this.wrapped = wrapped;
+ }
+
+ @Override
+ public final int length() {
+ return wrapped.length();
+ }
+
+ @Override
+ public final char charAt(int index) {
+ return wrapped.charAt(index);
+ }
+
+ @Override
+ public final boolean isEmpty() {
+ return wrapped.isEmpty();
+ }
+
+ @Nonnull
+ @Override
+ public final CharSequence subSequence(int start, int end) {
+ if (start == 0 && end == length()) {
+ return this;
+ }
+ return wrapped.subSequence(start, end);
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return wrapped.toString();
+ }
+
+ @Override
+ public final EnhancedCharSequence trim() {
+ String trimmed = wrapped.toString().trim();
+ if (wrapped.equals(trimmed)) {
+ return this;
+ }
+ return EnhancedCharSequence.enhance(trimmed);
+ }
+
+ @Nonnull
+ @Override
+ public final IntStream chars() {
+ return wrapped.chars();
+ }
+
+ @Nonnull
+ @Override
+ public final IntStream codePoints() {
+ return wrapped.codePoints();
+ }
+
+ @Override
+ protected final boolean matchesImpl(CharSequence otherSameLength) {
+ if (otherSameLength instanceof WrappedSequence otherWrapped &&
+ wrapped instanceof String s1 &&
+ otherWrapped.wrapped instanceof String s2) {
+ return s1.equals(s2);
+ }
+ return super.matchesImpl(otherSameLength);
+ }
+
+ }
+
+ private static abstract class LazyEnhancedCharSequence extends EnhancedCharSequence {
+
+ protected final int length;
+
+ LazyEnhancedCharSequence(int length) {
+ this.length = length;
+ }
+
+ @Override
+ public final int length() {
+ return length;
+ }
+
+ @Override
+ public final char charAt(int index) {
+ if (index < 0) {
+ throw new IndexOutOfBoundsException(0);
+ }
+ if (index > length) {
+ throw new IndexOutOfBoundsException(index + " > " + length);
+ }
+ return charAtImpl(index);
+ }
+
+ @Override
+ public final boolean isEmpty() {
+ if (length == 0) {
+ return true;
+ }
+ return false;
+ }
+
+ @Nonnull
+ @Override
+ public final CharSequence subSequence(int start, int end) {
+ if (start < 0) {
+ throw new IndexOutOfBoundsException(start + " < 0");
+ }
+ if (start > end) {
+ throw new IndexOutOfBoundsException(start + " > " + end);
+ }
+ if (end > length) {
+ throw new IndexOutOfBoundsException(end + " > " + length);
+ }
+ if (start == end) {
+ return WrappedSequence.EMPTY;
+ }
+ if (start == 0 && end == length) {
+ return this;
+ }
+ return differentNonEmptySubSequence(start, end);
+ }
+
+ /**
+ * Implements {@link #charAt(int)} only after is it known that the requested index is in bounds.
+ *
+ * @param index
+ * the index of the {@code char} value to be returned.
+ * @return the specified {@code char} value.
+ */
+ protected abstract char charAtImpl(int index);
+
+ /**
+ * Implements {@link #subSequence(int, int)} only after it is known that the requested start and end indexes are
+ * in bounds, the interval is not empty (start != end), and the interval is not the same as this instance's
+ * interval already.
+ *
+ * @param start
+ * the start index, inclusive.
+ * @param end
+ * the end index, exclusive.
+ * @return a different {@code CharSequence} from this instance which is a subsequence of this sequence starting
+ * and ending at the requested indices.
+ */
+ protected abstract CharSequence differentNonEmptySubSequence(int start, int end);
+
+ }
+
+ private static final class CharArrayRangeSequence extends LazyEnhancedCharSequence {
+
+ private final char[] data;
+
+ private final int offset;
+
+ private CharArrayRangeSequence(char[] data) {
+ this(data, 0, data.length);
+ }
+
+ private CharArrayRangeSequence(char[] data, int offset, int length) {
+ super(length);
+ this.data = data;
+ this.offset = offset;
+ }
+
+ @Override
+ public final boolean isBlank() {
+ return isBlankImpl(data, offset, stop());
+ }
+
+ @Override
+ public final EnhancedCharSequence trim() {
+
+ int originalEndExclusive = stop();
+ int startInclusive = EnhancedCharSequence.firstNonWhitespaceIndex(data, offset, originalEndExclusive);
+
+ if (startInclusive == originalEndExclusive) {
+ return WrappedSequence.EMPTY;
+ }
+
+ int endInclusive = EnhancedCharSequence.lastNonWhitespaceIndex(
+ data, startInclusive + 1, originalEndExclusive
+ );
+ int endExclusive = endInclusive + 1;
+ if (startInclusive == offset && endExclusive == originalEndExclusive) {
+ return this;
+ }
+
+ return new CharArrayRangeSequence(data, startInclusive, endExclusive - startInclusive);
+
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return String.valueOf(data, offset, length);
+ }
+
+ @Override
+ protected final boolean matchesImpl(CharSequence otherSameLength) {
+ if (otherSameLength instanceof CharArrayRangeSequence otherRange) {
+ return Arrays.equals(
+ data, offset, stop(),
+ otherRange.data, otherRange.offset, otherRange.stop()
+ );
+ }
+ return super.matchesImpl(otherSameLength);
+ }
+
+ @Override
+ protected final char charAtImpl(int index) {
+ return data[index + offset];
+ }
+
+ @Override
+ protected final CharSequence differentNonEmptySubSequence(int start, int end) {
+ int newLength = end - start;
+ if (newLength == 1) {
+ return new SingleBmpCharSequence(data[start + offset]);
+ }
+ return new CharArrayRangeSequence(data, start + offset, newLength);
+ }
+
+ private final int stop() {
+ return offset + length;
+ }
+
+ }
+
+ private static abstract class LazySingleBmpCharSequence extends LazyEnhancedCharSequence {
+
+ protected final char c;
+
+ LazySingleBmpCharSequence(char c, int length) {
+ super(length);
+ this.c = c;
+ }
+
+ @Override
+ public final boolean isBlank() {
+ if (Character.isWhitespace(c)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected final char charAtImpl(int index) {
+ return c;
+ }
+
+ @Override
+ protected EnhancedCharSequence prependToImpl(CharSequence other) {
+ if (other instanceof LazySingleBmpCharSequence otherSingle) {
+ if (c == otherSingle.c) {
+ return new RepeatingBmpCharSequence(c, length + otherSingle.length);
+ }
+ return LazyConcatenatedCharSequence.getInstance(other, this);
+ }
+ return super.prependToImpl(other);
+ }
+
+ @Override
+ protected EnhancedCharSequence appendToImpl(CharSequence other) {
+ if (other instanceof LazySingleBmpCharSequence otherSingle) {
+ if (c == otherSingle.c) {
+ return new RepeatingBmpCharSequence(c, length + otherSingle.length);
+ }
+ return LazyConcatenatedCharSequence.getInstance(other, this);
+ }
+ return super.appendToImpl(other);
+ }
+
+ @Override
+ public final EnhancedCharSequence trim() {
+ if (Character.isWhitespace(c)) {
+ return WrappedSequence.EMPTY;
+ }
+ return this;
+ }
+
+ }
+
+ private static final class SingleBmpCharSequence extends LazySingleBmpCharSequence {
+
+ private SingleBmpCharSequence(char c) {
+ super(c, 1);
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ return String.valueOf(c);
+ }
+
+ @Override
+ protected final boolean matchesImpl(CharSequence otherSameLength) {
+ if (otherSameLength.charAt(0) == c) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected final EnhancedCharSequence differentNonEmptySubSequence(int start, int end) {
+ throw new IllegalStateException();
+ }
+
+ @Override
+ protected final EnhancedCharSequence prependToImpl(CharSequence other) {
+ if (other instanceof SingleBmpCharSequence otherSingle && c != otherSingle.c) {
+ return new CharArrayRangeSequence(new char[] { otherSingle.c, c });
+ }
+ return super.prependToImpl(other);
+ }
+
+ @Override
+ protected final EnhancedCharSequence appendToImpl(CharSequence other) {
+ if (other instanceof SingleBmpCharSequence otherSingle && c != otherSingle.c) {
+ return new CharArrayRangeSequence(new char[] { c, otherSingle.c });
+ }
+ return super.appendToImpl(other);
+ }
+
+ @Override
+ protected final EnhancedCharSequence repeatedImpl(int repetitions) {
+ return new RepeatingBmpCharSequence(c, repetitions);
+ }
+
+ }
+
+ private static final class RepeatingBmpCharSequence extends LazySingleBmpCharSequence {
+
+ private RepeatingBmpCharSequence(char c, int repetitions) {
+ super(c, repetitions);
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ StringBuilder buff = new StringBuilder(length);
+ buff.repeat(c, length);
+ return buff.toString();
+ }
+
+ @Override
+ protected final boolean matchesImpl(CharSequence otherSameLength) {
+ if (otherSameLength instanceof RepeatingBmpCharSequence otherSingletonRepeating) {
+ if (c == otherSingletonRepeating.c) {
+ return true;
+ }
+ return false;
+ }
+ if (otherSameLength.chars().allMatch(codePoint -> codePoint == c)) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected final EnhancedCharSequence repeatedImpl(int repetitions) {
+ return new RepeatingBmpCharSequence(c, this.length * repetitions);
+ }
+
+ @Override
+ protected final CharSequence differentNonEmptySubSequence(int start, int end) {
+ int newLength = end - start;
+ if (newLength == 1) {
+ return new SingleBmpCharSequence(c);
+ }
+ return new RepeatingBmpCharSequence(c, newLength);
+ }
+
+ }
+
+ private static final class RepeatingCharSequence extends LazyEnhancedCharSequence {
+
+ static final RepeatingCharSequence getInstance(CharSequence baseSequence, int repetitions) {
+ if (repetitions < 2) {
+ throw new IllegalArgumentException(repetitions + " < 2");
+ }
+ int sequenceLength = baseSequence.length();
+ return new RepeatingCharSequence(baseSequence, sequenceLength, sequenceLength * repetitions, repetitions);
+ }
+
+ private final CharSequence baseSequence;
+
+ private final int baseLength;
+
+ private final int repetitions;
+
+ private RepeatingCharSequence(CharSequence baseSequence, int baseLength, int fullLength, int repetitions) {
+ super(fullLength);
+ this.baseSequence = baseSequence;
+ this.baseLength = baseLength;
+ this.repetitions = repetitions;
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ StringBuilder buff = new StringBuilder(length);
+ buff.repeat(baseSequence, repetitions);
+ return buff.toString();
+ }
+
+ @Override
+ public final boolean isBlank() {
+ return isBlankImpl(baseSequence);
+ }
+
+ @Override
+ public final EnhancedCharSequence trim() {
+
+ int startInclusive = EnhancedCharSequence.firstNonWhitespaceIndex(baseSequence, 0, baseLength);
+ if (startInclusive == baseLength) {
+ return WrappedSequence.EMPTY;
+ }
+
+ int endInclusive = EnhancedCharSequence.lastNonWhitespaceIndex(
+ baseSequence, startInclusive + 1, baseLength
+ );
+
+ int endExclusive = endInclusive + 1;
+
+ if (startInclusive == 0) {
+ if (endExclusive == baseLength) {
+ return this;
+ }
+ CharSequence first;
+ if (repetitions == 2) {
+ first = baseSequence;
+ }
+ else {
+ first = new RepeatingCharSequence(baseSequence, baseLength, length, repetitions - 1);
+ }
+ CharSequence last = baseSequence.subSequence(0, endInclusive);
+ return LazyConcatenatedCharSequence.getInstance(first, last);
+ }
+
+ if (endExclusive == length) {
+ CharSequence first = baseSequence.subSequence(startInclusive, baseLength);
+ CharSequence last;
+ if (repetitions == 2) {
+ last = baseSequence;
+ }
+ else {
+ last = new RepeatingCharSequence(baseSequence, baseLength, length, repetitions - 1);
+ }
+ return LazyConcatenatedCharSequence.getInstance(first, last);
+ }
+
+ CharSequence first = baseSequence.subSequence(startInclusive, baseLength);
+ CharSequence last = baseSequence.subSequence(0, endExclusive);
+ if (repetitions == 2) {
+ return LazyConcatenatedCharSequence.getInstance(first, last);
+ }
+ int newRepetitions = repetitions - 2;
+ CharSequence middle;
+ if (newRepetitions == 1) {
+ middle = baseSequence;
+ }
+ else {
+ middle = new RepeatingCharSequence(
+ baseSequence, baseLength, newRepetitions * baseLength, repetitions - 2
+ );
+ }
+ return LazyConcatenatedCharSequence.getInstance(first, middle, last);
+
+ }
+
+ @Override
+ protected final boolean matchesImpl(CharSequence otherSameLength) {
+ if (otherSameLength instanceof RepeatingCharSequence otherRepeating &&
+ baseSequence instanceof EnhancedCharSequence b1) {
+ return b1.matches(otherRepeating);
+ }
+ return super.matchesImpl(otherSameLength);
+ }
+
+ @Override
+ protected final EnhancedCharSequence getLeftRotatedImpl(int offset) {
+ int baseOffset = offset % baseLength;
+ if (baseOffset == 0) {
+ return this;
+ }
+ return EnhancedCharSequence.enhance(getRepeatingRotated(baseOffset, length));
+ }
+
+ @Override
+ protected final EnhancedCharSequence repeatedImpl(int repetitions) {
+ int newRepetitions = this.repetitions * repetitions;
+ return new RepeatingCharSequence(baseSequence, baseLength, newRepetitions * baseLength, newRepetitions);
+ }
+
+ @Override
+ protected final char charAtImpl(int index) {
+ return baseSequence.charAt(index % baseLength);
+ }
+
+ @Override
+ protected final CharSequence differentNonEmptySubSequence(int start, int end) {
+
+ // The sequence will be [head] + [middle] + [tail].
+ // The head and tail are the sub-sequences of the base sequence.
+ // The middle part contains a repeating set of the base sequence.
+ // Any of these may be empty.
+
+ int newLength = end - start;
+
+ // This is where in the base sequence the head starts.
+ int headStart = start % baseLength;
+ // This is where in the base sequence the tail ends.
+ int tailEnd = end % baseLength;
+
+ int availableHeadLength = baseLength - headStart;
+ if (newLength <= availableHeadLength) {
+ // The sequence is so short that the head is the whole new sequence.
+ return baseSequence.subSequence(headStart, tailEnd == 0 ? baseLength : tailEnd);
+ }
+
+ if (newLength % baseLength == 0) {
+ // The new sequence can be represented as just a new set of repeating versions of the base sequence,
+ // rotated left enough that the head start index becomes the first index (0).
+ return getRepeatingRotated(headStart, newLength);
+ }
+
+ CharSequence head = getSubSequenceFrom(headStart);
+ CharSequence tail = getSubSequenceTo(tailEnd);
+
+ int remainingNewLength = newLength - head.length() - tail.length();
+
+ if (remainingNewLength == 0) {
+ // No middle section needed.
+ return EnhancedCharSequence.getConcatenatedSequences(head, tail);
+ }
+
+ CharSequence middle = getRepeating(remainingNewLength / baseLength);
+ remainingNewLength -= middle.length();
+ if (remainingNewLength != 0) {
+ throw new IllegalStateException();
+ }
+
+ return EnhancedCharSequence.getConcatenatedSequences(head, middle, tail);
+
+ }
+
+ private final CharSequence getRepeatingRotated(int offset, int newLength) {
+ CharSequence newBaseSequence = EnhancedCharSequence.getLeftRotatedSequence(baseSequence, offset);
+ int newRepetitions = newLength / baseLength;
+ if (newRepetitions == 1) {
+ // Only one repetition.
+ return newBaseSequence;
+ }
+ return new RepeatingCharSequence(newBaseSequence, baseLength, newLength, newRepetitions);
+ }
+
+ private final CharSequence getSubSequenceFrom(int start) {
+ if (start == 0) {
+ return WrappedSequence.EMPTY;
+ }
+ return baseSequence.subSequence(start, baseLength);
+ }
+
+ private final CharSequence getSubSequenceTo(int end) {
+ if (end == 0) {
+ return WrappedSequence.EMPTY;
+ }
+ return baseSequence.subSequence(0, end);
+ }
+
+ private final CharSequence getRepeating(int newRepetitions) {
+ return switch (newRepetitions) {
+ case 0 -> WrappedSequence.EMPTY;
+ case 1 -> baseSequence;
+ default -> new RepeatingCharSequence(
+ baseSequence,
+ baseLength,
+ baseLength * newRepetitions,
+ newRepetitions
+ );
+ };
+ }
+
+ }
+
+ private static final class RotatedCharSequence extends LazyEnhancedCharSequence {
+
+ private final CharSequence sequence;
+
+ private final int leftRotateOffset;
+
+ private RotatedCharSequence(CharSequence sequence, int sequenceLength, int leftRotateOffset) {
+ super(sequenceLength);
+ this.sequence = sequence;
+ this.leftRotateOffset = leftRotateOffset;
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ StringBuilder buff = new StringBuilder(length);
+ buff.append(sequence, leftRotateOffset, length);
+ buff.append(sequence, 0, leftRotateOffset);
+ return buff.toString();
+ }
+
+ @Override
+ public final boolean isBlank() {
+ return isBlankImpl(sequence);
+ }
+
+ @Override
+ public final EnhancedCharSequence trim() {
+
+ int startInclusive = EnhancedCharSequence.firstNonWhitespaceIndex(sequence, leftRotateOffset, length);
+ int endInclusive = EnhancedCharSequence.lastNonWhitespaceIndex(sequence, 0, leftRotateOffset);
+
+ CharSequence head, tail;
+
+ if (startInclusive == length) {
+ // Head blank.
+ head = null;
+ }
+ else {
+ head = sequence.subSequence(startInclusive, length);
+ }
+
+ if (endInclusive == leftRotateOffset) {
+ // Tail blank.
+ tail = null;
+ }
+ else {
+ tail = sequence.subSequence(0, endInclusive + 1);
+ }
+
+ if (head == null) {
+ if (tail == null) {
+ return WrappedSequence.EMPTY;
+ }
+ return EnhancedCharSequence.enhance(tail);
+ }
+ if (tail == null) {
+ return EnhancedCharSequence.enhance(head);
+ }
+ return LazyConcatenatedCharSequence.getInstance(head, tail);
+
+ }
+
+ @Override
+ protected final boolean matchesImpl(CharSequence otherSameLength) {
+ if (otherSameLength instanceof RotatedCharSequence otherRotated &&
+ leftRotateOffset == otherRotated.leftRotateOffset &&
+ sequence instanceof EnhancedCharSequence s1 &&
+ otherRotated.sequence instanceof EnhancedCharSequence s2 &&
+ s1.matches(s2)) {
+ return true;
+ }
+ return super.matchesImpl(otherSameLength);
+ }
+
+ @Override
+ protected final char charAtImpl(int index) {
+ int realIndex = index + leftRotateOffset;
+ if (realIndex >= length) {
+ realIndex -= length;
+ }
+ return sequence.charAt(realIndex);
+ }
+
+ @Override
+ protected final CharSequence differentNonEmptySubSequence(int start, int end) {
+ int newLength = end - start;
+ int headStart = leftRotateOffset + start;
+ int availableHeadLength = length - headStart;
+ if (newLength <= availableHeadLength) {
+ return sequence.subSequence(headStart, headStart + newLength);
+ }
+ return getConcatenatedSequences(
+ sequence.subSequence(headStart, length),
+ sequence.subSequence(0, (leftRotateOffset + end) % length)
+ );
+ }
+
+ @Override
+ protected final EnhancedCharSequence getLeftRotatedImpl(int newOffset) {
+ int effectiveOffset = (leftRotateOffset + newOffset) % length;
+ if (effectiveOffset == 0) {
+ // Undo the exact rotation applied by this object by returning the wrapped sequence as-is.
+ return EnhancedCharSequence.enhance(sequence);
+ }
+ if (sequence instanceof EnhancedCharSequence enhanced) {
+ // Delegate to the wrapped sequence's implementation.
+ return enhanced.getLeftRotatedImpl(effectiveOffset);
+ }
+ return new RotatedCharSequence(sequence, length, effectiveOffset);
+ }
+
+ }
+
+ private static final class LazyConcatenatedCharSequence extends LazyEnhancedCharSequence {
+
+ private static final class Coordinate {
+
+ private final int sequenceIndex;
+
+ private final int charIndex;
+
+ private Coordinate(int sequenceIndex, int charIndex) {
+ this.sequenceIndex = sequenceIndex;
+ this.charIndex = charIndex;
+ }
+
+ @Override
+ public final String toString() {
+ return sequenceIndex + ":" + charIndex;
+ }
+
+ }
+
+ static final LazyConcatenatedCharSequence getInstance(CharSequence first, CharSequence last) {
+ return getInstance(ImmutableList.of(first, last));
+ }
+
+ static final LazyConcatenatedCharSequence getInstance(CharSequence first, CharSequence middle, CharSequence last) {
+ return getInstance(ImmutableList.of(first, middle, last));
+ }
+
+ static final LazyConcatenatedCharSequence getInstance(List sequences) {
+ int totalLength = 0;
+ for (CharSequence sequence : sequences) {
+ totalLength += sequence.length();
+ }
+ return new LazyConcatenatedCharSequence(sequences, totalLength);
+ }
+
+ private final List sequences;
+
+ private LazyConcatenatedCharSequence(List sequences, int totalLength) {
+ super(totalLength);
+ this.sequences = sequences;
+ }
+
+ @Nonnull
+ @Override
+ public final String toString() {
+ StringBuilder buff = new StringBuilder(length);
+ for (CharSequence sequence : sequences) {
+ buff.append(sequence);
+ }
+ return buff.toString();
+ }
+
+ @Override
+ public final boolean isBlank() {
+ for (CharSequence sequence : sequences) {
+ if (sequence instanceof EnhancedCharSequence enhanced) {
+ if (!enhanced.isBlank()) {
+ return false;
+ }
+ }
+ else {
+ if (!StringUtils.isBlank(sequence)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public final EnhancedCharSequence trim() {
+
+ Coordinate start = getTrimStart();
+ if (start == null) {
+ return WrappedSequence.EMPTY;
+ }
+ Coordinate end = getTrimEnd();
+
+ CharSequence first = sequences.get(start.sequenceIndex);
+
+ int newSequenceCount = end.sequenceIndex - start.sequenceIndex + 1;
+
+ if (newSequenceCount == 1) {
+ return EnhancedCharSequence.enhance(
+ first.subSequence(start.charIndex, end.charIndex)
+ );
+ }
+
+ first = first.subSequence(start.charIndex, first.length());
+ CharSequence last = sequences.get(end.sequenceIndex).subSequence(0, end.charIndex);
+ if (newSequenceCount == 2) {
+ return getInstance(first, last);
+ }
+
+ if (newSequenceCount == 3) {
+ return getInstance(first, sequences.get(start.sequenceIndex), last);
+ }
+
+ List newList = new ArrayList<>(newSequenceCount);
+ newList.add(first);
+ newList.addAll(sequences.subList(start.sequenceIndex + 1, end.sequenceIndex));
+ newList.add(last);
+ return getInstance(newList);
+
+ }
+
+ @Override
+ protected final char charAtImpl(int index) {
+ for (CharSequence sequence : sequences) {
+ int length = sequence.length();
+ if (index < length) {
+ return sequence.charAt(index);
+ }
+ index -= length;
+ }
+ throw new IllegalStateException();
+ }
+
+ @Override
+ protected final CharSequence differentNonEmptySubSequence(int start, int end) {
+
+ Iterator iter = sequences.iterator();
+
+ CharSequence first = getSequenceStartingAt(start, iter);
+
+ int newLength = end - start;
+
+ int startWithLength = first.length();
+ if (startWithLength >= newLength) {
+ return first.subSequence(0, newLength);
+ }
+
+ ImmutableList.Builder newSequences = ImmutableList.builder();
+ newSequences.add(first);
+ addNewSequencesToLength(newLength, iter, newSequences);
+
+ return new LazyConcatenatedCharSequence(newSequences.build(), newLength);
+
+ }
+
+ /**
+ * Creates a new LazyConcatenatedCharSequence that contains all this instance's sequences followed by the other
+ * sequence. If the other sequence is also a LazyConcatenatedCharSequence, its members will be included
+ * directly.
+ */
+ @Override
+ protected final EnhancedCharSequence prependToImpl(CharSequence other) {
+ List newSequences;
+ if (other instanceof LazyConcatenatedCharSequence concat) {
+ newSequences = new ArrayList<>(sequences.size() + concat.sequences.size());
+ newSequences.addAll(sequences);
+ newSequences.addAll(concat.sequences);
+ }
+ else {
+ newSequences = new ArrayList<>(sequences.size() + 1);
+ newSequences.addAll(sequences);
+ newSequences.add(other);
+ }
+ return getInstance(newSequences);
+ }
+
+ /**
+ * Creates a new LazyConcatenatedCharSequence that contains the other sequence followed by all this instance's
+ * sequences. If the other sequence is also a LazyConcatenatedCharSequence, its members will be included
+ * directly.
+ */
+ @Override
+ protected final EnhancedCharSequence appendToImpl(CharSequence other) {
+ List newSequences;
+ if (other instanceof LazyConcatenatedCharSequence concat) {
+ newSequences = new ArrayList<>(sequences.size() + concat.sequences.size());
+ newSequences.addAll(concat.sequences);
+ newSequences.addAll(sequences);
+ }
+ else {
+ newSequences = new ArrayList<>(sequences.size() + 1);
+ newSequences.add(other);
+ newSequences.addAll(sequences);
+ }
+ return getInstance(newSequences);
+ }
+
+ protected final EnhancedCharSequence getLeftRotatedImpl(int offset) {
+ Coordinate first = getLeftRotatedStart(offset);
+ if (first.charIndex == 0) {
+ int sequenceCount = sequences.size();
+ List newSequences = new ArrayList<>(sequenceCount);
+ newSequences.addAll(sequences.subList(first.sequenceIndex, sequenceCount));
+ newSequences.addAll(sequences.subList(0, first.sequenceIndex));
+ return getInstance(newSequences);
+ }
+ return super.getLeftRotatedImpl(offset);
+ }
+
+ private static final CharSequence getSequenceStartingAt(int start, Iterator iter) {
+ int remaining = start;
+ while (iter.hasNext()) {
+ CharSequence candidate = iter.next();
+ int candidateLen = candidate.length();
+ if (candidateLen > remaining) {
+ return candidate.subSequence(remaining, candidateLen);
+ }
+ remaining -= candidateLen;
+ }
+ throw new IllegalStateException();
+ }
+
+ private static void addNewSequencesToLength(int newLength, Iterator iter, ImmutableList.Builder newSequences) {
+ int remaining = newLength;
+ while (iter.hasNext()) {
+ CharSequence next = iter.next();
+ int nextLen = next.length();
+ if (nextLen > remaining) {
+ newSequences.add(next.subSequence(0, remaining));
+ remaining = 0;
+ break;
+ }
+ newSequences.add(next);
+ remaining -= nextLen;
+ }
+
+ if (remaining > 0) {
+ throw new IllegalStateException();
+ }
+ }
+
+ private final Coordinate getTrimStart() {
+ int sequenceIndex = 0;
+ for (CharSequence sequence : sequences) {
+ int len = sequence.length();
+ int firstStartInclude = EnhancedCharSequence.firstNonWhitespaceIndex(sequence, 0, len);
+ if (firstStartInclude == len) {
+ sequenceIndex++;
+ continue;
+ }
+ return new Coordinate(sequenceIndex, firstStartInclude);
+ }
+ return null;
+ }
+
+ private final Coordinate getTrimEnd() {
+ int sequenceIndex = sequences.size() - 1;
+ for (CharSequence sequence : sequences.reversed()) {
+ int len = sequence.length();
+ int lastEndInclude = EnhancedCharSequence.lastNonWhitespaceIndex(sequence, 0, len);
+ if (lastEndInclude == -1) {
+ sequenceIndex--;
+ continue;
+ }
+ return new Coordinate(sequenceIndex, lastEndInclude + 1);
+ }
+ throw new IllegalStateException();
+ }
+
+ private final Coordinate getLeftRotatedStart(int offset) {
+ int remainingOffset = offset;
+ int sequenceIndex = 0;
+ for (CharSequence sequence : sequences) {
+ int len = sequence.length();
+ if (remainingOffset >= len) {
+ sequenceIndex++;
+ remainingOffset -= len;
+ continue;
+ }
+ return new Coordinate(sequenceIndex, remainingOffset);
+ }
+ throw new IllegalStateException();
+ }
+
+ }
+
+ /**
+ * Returns an EnhancedCharSequence equivalent to the given sequence.
+ *
+ * @param sequence
+ * the sequence to be enhanced.
+ * @return an EnhancedCharSequence wrapping the given sequence if it is not already an EnhancedCharSequence; or the
+ * already enhanced instance.
+ */
+ public static final EnhancedCharSequence enhance(CharSequence sequence) {
+ if (sequence instanceof EnhancedCharSequence enhanced) {
+ return enhanced;
+ }
+ return switch (StringUtils.length(sequence)) {
+ case 0 -> WrappedSequence.EMPTY;
+ case 1 -> getSingletonSequence(sequence.charAt(0));
+ default -> new WrappedSequence(sequence);
+ };
+ }
+
+ /**
+ * Returns an EnhancedCharSequence containing the given characters.
+ *
+ * @param chars
+ * the characters.
+ * @return an EnhancedCharSequence containing the given characters; or an empty sequence if the characters are empty
+ * or null.
+ */
+ public static final EnhancedCharSequence getInstance(char[] chars) {
+ int length = ArrayUtils.getLength(chars);
+ if (length == 0) {
+ return WrappedSequence.EMPTY;
+ }
+ return new CharArrayRangeSequence(chars, 0, length);
+ }
+
+ /**
+ * Returns an EnhancedCharSequence containing the range of the given characters starting at the given offset and
+ * extending for the given length.
+ *
+ * @param chars
+ * the characters.
+ * @param offset
+ * the starting offset for the region.
+ * @param length
+ * the length of the region.
+ * @return an EnhancedCharSequence containing the range of the given characters starting at the given offset and
+ * extending for the given length; or an empty sequence if the requested region is empty.
+ * @throws IndexOutOfBoundsException
+ * if the offset is negative; if either the offset or the effective final index of the region is beyond the end
+ * of the array.
+ */
+ public static final EnhancedCharSequence getInstance(char[] chars, int offset, int length) {
+ if (offset < 0) {
+ throw new IndexOutOfBoundsException(offset + " < 0");
+ }
+ int charsLength = ArrayUtils.getLength(chars);
+ if (offset > charsLength) {
+ throw new IndexOutOfBoundsException(offset + " > " + charsLength);
+ }
+ int endIndexExclusive = offset + length;
+ if (endIndexExclusive > charsLength) {
+ throw new IndexOutOfBoundsException(offset + " + " + length + " >= " + charsLength);
+ }
+ if (length == 0) {
+ return WrappedSequence.EMPTY;
+ }
+ return new CharArrayRangeSequence(chars, offset, length);
+ }
+
+ public static final EnhancedCharSequence empty() {
+ return WrappedSequence.EMPTY;
+ }
+
+ public static final EnhancedCharSequence getSingletonSequence(char c) {
+ return new SingleBmpCharSequence(c);
+ }
+
+ public static final EnhancedCharSequence getSingletonSequence(int codePoint) {
+ char[] chars = Character.toChars(codePoint);
+ if (chars.length == 1) {
+ return new SingleBmpCharSequence(chars[0]);
+ }
+ return new CharArrayRangeSequence(chars);
+ }
+
+ public static final EnhancedCharSequence getRepeatingSequence(int codePoint, int repetitions) {
+ if (repetitions < 0) {
+ throw new IllegalArgumentException(repetitions + " < 0");
+ }
+ if (repetitions == 0) {
+ return WrappedSequence.EMPTY;
+ }
+ if (repetitions == 1) {
+ return getSingletonSequence(codePoint);
+ }
+ char[] chars = Character.toChars(codePoint);
+ if (chars.length == 1) {
+ return new RepeatingBmpCharSequence(chars[0], repetitions);
+ }
+ return RepeatingCharSequence.getInstance(new CharArrayRangeSequence(chars), repetitions);
+ }
+
+ public static final EnhancedCharSequence getRepeatingSequence(char c, int repetitions) {
+ if (repetitions < 0) {
+ throw new IllegalArgumentException(repetitions + " < 0");
+ }
+ if (repetitions == 0) {
+ return WrappedSequence.EMPTY;
+ }
+ if (repetitions == 1) {
+ return getSingletonSequence(c);
+ }
+ return new RepeatingBmpCharSequence(c, repetitions);
+ }
+
+ /**
+ * Returns a CharSequence representing the requested number of repetitions of the given sequence.
+ *
+ * @param sequence
+ * the sequence to be repeated.
+ * @param repetitions
+ * the number of times to repeat the sequence.
+ * @return an EnhancedCharSequence representing the requested number of repetitions of the given sequence; or an
+ * empty sequence if the given sequence is empty or 0 repetitions were requested.
+ * @throws IllegalArgumentException
+ * if the requested number of repetitions is negative.
+ */
+ public static final EnhancedCharSequence getRepeatingSequence(CharSequence sequence, int repetitions) {
+ if (sequence instanceof EnhancedCharSequence enhanced) {
+ return enhanced.repeated(repetitions);
+ }
+ if (repetitions < 0) {
+ throw new IllegalArgumentException(repetitions + " < 0");
+ }
+ if (repetitions == 0) {
+ return WrappedSequence.EMPTY;
+ }
+ return RepeatingCharSequence.getInstance(sequence, repetitions);
+ }
+
+ /**
+ * Returns a CharSequence representing the concatenation of the given collection of sequences.
+ *
+ * @param sequences
+ * the sequences to be concatenated.
+ * @return a CharSequence representing the concatenation of the given collection of sequences; or an empty sequence
+ * if a null or empty collection is supplied.
+ */
+ public static final CharSequence getConcatenatedSequences(Collection extends CharSequence> sequences) {
+ if (CollectionUtil.isEmpty(sequences)) {
+ return WrappedSequence.EMPTY;
+ }
+ return concatSequencesImpl(sequences.stream());
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing the concatenation of the given collection of sequences.
+ *
+ * @param sequences
+ * the sequences to be concatenated.
+ * @return an EnhancedCharSequence representing the concatenation of the given collection of sequences; or an empty
+ * sequence if no sequences were supplied.
+ */
+ public static final CharSequence getConcatenatedSequences(CharSequence... sequences) {
+ if (sequences == null) {
+ return WrappedSequence.EMPTY;
+ }
+ return concatSequencesImpl(Arrays.stream(sequences));
+ }
+
+ /**
+ * Returns a sequence representing the left rotation of the given sequence by the requested offset.
+ *
+ * @param sequence
+ * the sequence to be left-rotated.
+ * @param offset
+ * the offset, representing how much left rotation is requested.
+ * @return a sequence representing the left rotation of the given sequence by the requested offset.
+ */
+ public static final CharSequence getLeftRotatedSequence(CharSequence sequence, int offset) {
+ if (sequence == null) {
+ return WrappedSequence.EMPTY;
+ }
+ int len = sequence.length();
+ if (len <= 1 || offset == 0) {
+ return sequence;
+ }
+ offset %= len;
+ if (offset == 0) {
+ return sequence;
+ }
+ if (offset < 0) {
+ offset += len;
+ }
+ if (sequence instanceof EnhancedCharSequence enhanced) {
+ return enhanced.getLeftRotatedImpl(offset);
+ }
+ return new RotatedCharSequence(sequence, sequence.length(), offset);
+ }
+
+ /**
+ * Returns a sequence representing the right rotation of the given sequence by the requested offset.
+ *
+ * @param sequence
+ * the sequence to be right-rotated.
+ * @param offset
+ * the offset, representing how much left rotation is requested.
+ * @return a sequence representing the left rotation of the given sequence by the requested offset.
+ */
+ public static final CharSequence getRightRotatedSequence(CharSequence sequence, int offset) {
+ if (sequence == null) {
+ return WrappedSequence.EMPTY;
+ }
+ return getLeftRotatedSequence(sequence, sequence.length() - offset);
+ }
+
+ private static final CharSequence concatSequencesImpl(Stream extends CharSequence> sequences) {
+ ImmutableList nonEmptySequences = ImmutableList.copyOf(
+ sequences.filter(Objects::nonNull).filter(seq -> !seq.isEmpty()).iterator()
+ );
+ return switch (nonEmptySequences.size()) {
+ case 0 -> WrappedSequence.EMPTY;
+ case 1 -> nonEmptySequences.getFirst();
+ default -> LazyConcatenatedCharSequence.getInstance(nonEmptySequences);
+ };
+ }
+
+ private EnhancedCharSequence() {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ @Nonnull
+ @Override
+ public abstract String toString();
+
+ /**
+ * Tests whether this sequence and the other given sequence effectively match. "Match" here means that they contain
+ * the same sequence of characters. A null sequence is treated the same as an empty sequence. This is meant to be as
+ * close as possible to applying {@link String#equals(Object)} to the result of invoking
+ * {@link CharSequence#toString()} on both this and the other sequence, but without having to do so.
+ *
+ * @param other
+ * the other sequence to test.
+ * @return if this sequence and the other match, or if {@link #isEmpty() this sequence is empty} and the other
+ * sequence is null; false otherwise.
+ */
+ public final boolean matches(CharSequence other) {
+ if (other == null) {
+ return isEmpty();
+ }
+ if (length() != other.length()) {
+ return false;
+ }
+ return matchesImpl(other);
+ }
+
+ /**
+ * Tests whether this sequence is blank, i.e., containing only whitespace characters, just like
+ * {@link String#isBlank()} or {@link StringUtils#isBlank(CharSequence)}.
+ *
+ * @return true if this sequence is blank; false otherwise.
+ */
+ public boolean isBlank() {
+ return StringUtils.isBlank(this);
+ }
+
+ /**
+ * Tests whether this sequence contains the other given sequence, just like {@link String#contains(CharSequence)}.
+ *
+ * @param other
+ * the sequence to search for in this sequence.
+ * @return true if the given {@link CharSequence} is not null and appears within this sequence.
+ */
+ public final boolean contains(CharSequence other) {
+ if (other == null) {
+ return false;
+ }
+ if (other.isEmpty()) {
+ return true;
+ }
+ return containsImpl(other);
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing a trimmed version of this sequence, with leading and trailing
+ * whitespace removed, just like {@link String#trim()}.
+ *
+ * @return an EnhancedCharSequence representing a trimmed version of this sequence.
+ */
+ public abstract EnhancedCharSequence trim();
+
+ /**
+ * Returns an EnhancedCharSequence representing the result of prepending this sequence to the other sequence.
+ *
+ * @param other
+ * the other sequence.
+ * @return an EnhancedCharSequence representing the result of prepending this sequence to the other sequence.
+ */
+ public final EnhancedCharSequence prependTo(CharSequence other) {
+ if (StringUtils.isEmpty(other)) {
+ return this;
+ }
+ return prependToImpl(other);
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing the result of appending this sequence to the other sequence.
+ *
+ * @param other
+ * the other sequence.
+ * @return an EnhancedCharSequence representing the result of appending this sequence to the other sequence.
+ */
+ public final EnhancedCharSequence appendTo(CharSequence other) {
+ if (StringUtils.isEmpty(other)) {
+ return this;
+ }
+ return appendToImpl(other);
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing the result of prepending the other sequence to this one.
+ *
+ * @param other
+ * the other sequence.
+ * @return an EnhancedCharSequence representing the result of prepending the other sequence to this one.
+ */
+ public final EnhancedCharSequence withPrepended(CharSequence other) {
+ return appendTo(other);
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing the result of appending the other sequence to this one.
+ *
+ * @param other
+ * the other sequence.
+ * @return an EnhancedCharSequence representing the result of appending the other sequence to this one.
+ */
+ public final EnhancedCharSequence withAppended(CharSequence other) {
+ return prependTo(other);
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing the result of prepending a repeated version of the other sequence to
+ * this one. It is identical to invoking {@link #appendTo(CharSequence)} after creating a repeating sequence via
+ * {@link #getRepeatingSequence(CharSequence, int)}.
+ *
+ * @param other
+ * the other sequence.
+ * @return an EnhancedCharSequence representing the result of prepending the other sequence to this one.
+ */
+ public final EnhancedCharSequence withRepeatingPrepended(CharSequence other, int repetitions) {
+ return appendTo(getRepeatingSequence(other, repetitions));
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing the result of appending a repeated version of the other sequence to
+ * this one. It is identical to invoking {@link #appendTo(CharSequence)} after creating a repeating sequence via
+ * {@link #getRepeatingSequence(CharSequence, int)}.
+ *
+ * @param other
+ * the other sequence.
+ * @return an EnhancedCharSequence representing the result of appending a repeated version of the other sequence to
+ * this one.
+ */
+ public final EnhancedCharSequence withRepeatingAppended(CharSequence other, int repetitions) {
+ return prependTo(getRepeatingSequence(other, repetitions));
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing a repetition of this sequence.
+ *
+ * @param repetitions
+ * the requested number of repetitions.
+ * @return an EnhancedCharSequence representing a repetition of this sequence; or an empty sequence if the given
+ * sequence is empty or 0 repetitions were requested.
+ * @throws IllegalArgumentException
+ * if the requested number of repetitions is negative.
+ */
+ public final EnhancedCharSequence repeated(int repetitions) {
+ if (repetitions < 0) {
+ throw new IllegalArgumentException(repetitions + " < 0");
+ }
+ if (repetitions == 0) {
+ return WrappedSequence.EMPTY;
+ }
+ if (repetitions == 1) {
+ return this;
+ }
+ return repeatedImpl(repetitions);
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing the left rotation of this sequence by the requested offset.
+ *
+ * @param offset
+ * the offset, representing how much left rotation is requested.
+ * @return an EnhancedCharSequence representing the left rotation of the given sequence by the requested offset.
+ */
+ public final EnhancedCharSequence getLeftRotated(int offset) {
+ int len = length();
+ offset %= len;
+ if (offset == 0) {
+ return this;
+ }
+ if (offset < 0) {
+ offset += len;
+ }
+ return getLeftRotatedImpl(offset);
+ }
+
+ /**
+ * Returns an EnhancedCharSequence representing the right rotation of the given sequence by the requested offset.
+ *
+ * @param offset
+ * the offset, representing how much right rotation is requested.
+ * @return a sequence representing the right rotation of the given sequence by the requested offset.
+ */
+ public final EnhancedCharSequence getRightRotated(int offset) {
+ return getLeftRotated(length() - offset);
+ }
+
+ /**
+ * Implements {@link #matches(CharSequence)}, after it has been established that the other sequence is the same
+ * length as this one.
+ *
+ * @param otherSameLength
+ * the other sequence to test, which is the same length as this sequence.
+ * @return if this sequence and the other match; false otherwise.
+ */
+ protected boolean matchesImpl(CharSequence otherSameLength) {
+ if (Arrays.equals(chars().toArray(), otherSameLength.chars().toArray())) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Implements {@link #contains(CharSequence)}, after it has been established that the given sequence is not null or
+ * empty. This default implementation uses {@code Strings.CS#contains(this, other)}.
+ *
+ * @param other
+ * the sequence to search for in this sequence.
+ * @return true if the given {@link CharSequence} is appears within this sequence.
+ */
+ protected boolean containsImpl(CharSequence other) {
+ return Strings.CS.contains(this, other);
+ }
+
+ /**
+ * Implements {@link #prependTo(CharSequence)}, creating a new sequence with this sequence prepended to the other,
+ * after it has been established that the other sequence is not null or empty.
+ *
+ * @param other
+ * the other non-null non-empty sequence.
+ * @return a new {@link EnhancedCharSequence} representing the result of prepending this sequence to the other
+ * sequence.
+ */
+ protected EnhancedCharSequence prependToImpl(CharSequence other) {
+ return LazyConcatenatedCharSequence.getInstance(this, other);
+ }
+
+ /**
+ * Implements {@link #appendTo(CharSequence)}, creating a new sequence with this sequence appended to the other,
+ * after it has been established that the other sequence is not null or empty.
+ *
+ * @param other
+ * the other non-null non-empty sequence.
+ * @return a new {@link EnhancedCharSequence} representing the result of appending this sequence to the other
+ * sequence.
+ */
+ protected EnhancedCharSequence appendToImpl(CharSequence other) {
+ return LazyConcatenatedCharSequence.getInstance(other, this);
+ }
+
+ /**
+ * Implements {@link #getLeftRotated(int)} after the offset has been adjusted (modulo the length of this sequence)
+ * and was not 0.
+ *
+ * @param offset
+ * a non-zero offset less than the length of this sequence.
+ * @return a new EnhancedCharSequence representing the left rotation of the given sequence by the requested offset.
+ */
+ protected EnhancedCharSequence getLeftRotatedImpl(int offset) {
+ return new RotatedCharSequence(this, length(), offset);
+ }
+
+ /**
+ * Implements {@link #repeated(int)} after it has been established that the requested number of repetitions is at
+ * least 2.
+ *
+ * @param repetitions
+ * the requested number of repetitions of this sequence, no less than 2.
+ * @return a new EnhancedCharSequence representing the requested number of repetitions of this sequence.
+ */
+ protected EnhancedCharSequence repeatedImpl(int repetitions) {
+ return RepeatingCharSequence.getInstance(this, repetitions);
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/lang/EnumUtil.java b/src/main/java/com/tractionsoftware/commons/lang/EnumUtil.java
new file mode 100644
index 0000000..b6a8b3f
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/lang/EnumUtil.java
@@ -0,0 +1,59 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.lang;
+
+import org.apache.commons.lang3.StringUtils;
+
+public final class EnumUtil {
+
+ private EnumUtil() {
+ }
+
+ /**
+ * This method is similar to {@link Enum#valueOf(Class, String)}, but performs case-insensitive comparison on the
+ * input string, handles null, and will return a default value if no match is found.
+ *
+ * @param enumType
+ * the {@link Class} representing the desired type of {@link Enum}'s.
+ * @param str
+ * the {@link String} to be converted.
+ * @param defaultValue
+ * the default value to return if no matching enum constant is found for the given {@link Enum} type.
+ * @return the matching value from the given Enum values, as matched by case-insensitive lexical comparison on the
+ * name of each value, if one can be so identified; the given default value otherwise.
+ */
+ public static > E enumFromString(Class enumType, String str, E defaultValue) {
+
+ if (enumType == null || StringUtils.isBlank(str)) {
+ return defaultValue;
+ }
+
+ for (E value : enumType.getEnumConstants()) {
+ if (value.name().equalsIgnoreCase(str)) {
+ return value;
+ }
+ }
+
+ return defaultValue;
+
+ }
+
+}
diff --git a/src/main/java/com/tractionsoftware/commons/lang/JavaUtil.java b/src/main/java/com/tractionsoftware/commons/lang/JavaUtil.java
new file mode 100644
index 0000000..110f7ea
--- /dev/null
+++ b/src/main/java/com/tractionsoftware/commons/lang/JavaUtil.java
@@ -0,0 +1,287 @@
+/*
+ *
+ * Copyright 1996-2026 Traction Software, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it.
+
+package com.tractionsoftware.commons.lang;
+
+import com.google.common.annotations.Beta;
+import com.google.common.base.Suppliers;
+import com.tractionsoftware.commons.io.IOUtil;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.slf4j.Logger;
+
+import java.lang.ref.Cleaner;
+import java.util.*;
+import java.util.function.Supplier;
+
+/**
+ * Helper methods pertaining to basic Java capabilities.
+ *
+ * @author Dave Shepperton
+ */
+@Beta
+public final class JavaUtil {
+
+ private JavaUtil() {
+ }
+
+ /**
+ * A {@link Cleaner} that can be used application-wide. This hard reference ensures it will never become only
+ * phantom-reachable, and will therefore never terminate.
+ */
+ public static final Cleaner RESOURCE_CLEANER = Cleaner.create();
+
+ public static final char QUALIFIER_CHAR = '.';
+
+ /**
+ * A {@link Supplier} that implements {@link AutoCloseable}, which is
+ * {@link JavaUtil#registerCloseCleanerAction(Object, AutoCloseable) registered to be closed as a cleanup action}
+ * for a given reference object.
+ *
+ * @param
+ * the type of object that is being supplied.
+ */
+ public static final class CleanupTargetWrapper implements Supplier, Resource {
+
+ /**
+ * Returns a new CleanupTargetWrapper that provides access to the given instance, and which is
+ * {@link JavaUtil#registerCloseCleanerAction(Object, AutoCloseable) registered to be closed as a cleanup
+ * action} for the given reference object. Specifically, the returned instance's {@link #close()} method will be
+ * invoked when the given reference object becomes "phantom reachable." The client code may also invoke the
+ * close method, which will prevent the cleanup action from having to do so.
+ *
+ * @param object
+ * the reference object, to be monitored.
+ * @param instance
+ * the object to be supplied.
+ * @param
+ * the type of object that is being supplied.
+ * @return a new CleanupTargetWrapper that provides access to the given instance, and which is
+ * {@link JavaUtil#registerCloseCleanerAction(Object, AutoCloseable) registered to be closed as a cleanup
+ * action} for the given reference object.
+ */
+ @Nonnull
+ public static final CleanupTargetWrapper create(@Nonnull Object object, @Nonnull T instance) {
+ Objects.requireNonNull(object, "object");
+ Objects.requireNonNull(instance, "instance");
+ return new CleanupTargetWrapper<>(instance, registerCloseCleanerAction(object, instance));
+ }
+
+ private T instance;
+
+ private final Cleaner.Cleanable closer;
+
+ private CleanupTargetWrapper(T instance, Cleaner.Cleanable closer) {
+ this.instance = instance;
+ this.closer = closer;
+ }
+
+ @Nonnull
+ @Override
+ public final T get() {
+ return instance;
+ }
+
+ /**
+ * Runs the cleanup closer action.
+ */
+ @Override
+ public final void close() {
+ try {
+ closer.clean();
+ }
+ finally {
+ instance = null;
+ }
+ }
+
+ @Override
+ public final boolean isOpen() {
+ if (instance == null) {
+ return false;
+ }
+ return true;
+ }
+
+ }
+
+ /**
+ * Returns a {@link Cleaner.Cleanable} that will {@link AutoCloseable#close() close the given resource} when the
+ * given object becomes "phantom reachable."
+ *
+ * @param object
+ * the object to monitor.
+ * @param resource
+ * the resource to be {@link AutoCloseable#close() closed} as the cleanup action for the given object.
+ * @return a {@link Cleaner.Cleanable} that will {@link AutoCloseable#close() close the given resource} when the
+ * given object becomes "phantom reachable."
+ */
+ public static final Cleaner.Cleanable registerCloseCleanerAction(Object object, AutoCloseable resource) {
+ return JavaUtil.RESOURCE_CLEANER.register(object, () -> IOUtil.close(resource));
+ }
+
+ /**
+ * Returns the approximate internal byte size of the given {@link String}.
+ *
+ * @param s
+ * the {@link String} to examine.
+ * @return the approximate internal byte size of the given {@link String}; or 0 if the given String is null.
+ */
+ public static final int getApproximateInternalByteSize(@Nullable String s) {
+ if (s == null) {
+ return 0;
+ }
+ // We estimate 8B of "static" overhead for the String (4B for
+ // the char[], 4B the cached hash int); and n chars * 2B each.
+ return 8 + (s.length() * 2);
+ }
+
+ /**
+ * Returns the approximate internal byte size of the given {@link Collection}.
+ *
+ * @param list
+ * the {@link Collection} to examine.
+ * @return the approximate internal byte size of the given {@link Collection}; or 0 if the given Collection is null.
+ */
+ public static final int getApproximateInternalByteSize(@Nullable Collection list) {
+ if (list == null) {
+ return 0;
+ }
+ // We estimate 16B of "static" overhead of the collection's fields.
+ // This can vary widely by implementation, so it is a rough
+ // estimate at best.
+ int ret = 16;
+ for (String s : list) {
+ ret += getApproximateInternalByteSize(s);
+ }
+ return ret;
+ }
+
+ /**
+ * Returns the approximate internal byte size of the given {@link Map}.
+ *
+ * @param map
+ * the {@link Map} to examine.
+ * @return the approximate internal byte size of the given {@link Map}; or 0 if the given Map is null.
+ */
+ public static final int getApproximateInternalByteSize(@Nullable Map map) {
+ if (map == null) {
+ return 0;
+ }
+ // We estimate 16B of "static" overhead of the map's fields.
+ // This can vary widely by implementation, so it is a rough
+ // estimate at best.
+ int ret = 16;
+ for (Map.Entry e : map.entrySet()) {
+ // We estimate 16B of "static" overhead for each
+ // Map.Entry. This can also vary widely with
+ // implementation, so is also a rough estimate at best.
+ ret += 16;
+ ret += getApproximateInternalByteSize(e.getKey());
+ ret += getApproximateInternalByteSize(e.getValue());
+ }
+ return ret;
+ }
+
+ /**
+ * Returns a "lazy service loader," which will simply be a {@link Supplier} of the requested type of object loaded
+ * on demand via {@code ServiceLoader.load(type).findFirst()}.
+ *
+ * @param type
+ * the requested type of service object to load.
+ * @param defaultService
+ * an optional default service to be used if no service can be found, or if there is a problem encountered while
+ * loading it.
+ * @param logger
+ * an optional {@link Logger} for logging errors or other diagnostics.
+ * @param
+ * the type of service object to be supplied.
+ * @return a "lazy service loader," which will simply be a {@link Supplier} of the requested type of object loaded
+ * on demand.
+ * @throws NullPointerException
+ * if the given service type is null
+ */
+ public static final Supplier extends T> lazyServiceLoader(@Nonnull Class type, @Nullable T defaultService, @Nullable Logger logger) {
+ return lazyServiceLoader(type, Suppliers.ofInstance(defaultService), logger);
+ }
+
+ /**
+ * Returns a "lazy service loader," which will simply be a {@link Supplier} of the requested type of object loaded
+ * on demand via {@code ServiceLoader.load(type).findFirst()}.
+ *
+ * @param type
+ * the requested type of service object to load.
+ * @param defaultService
+ * an optional supplier for a default service to be used if no service can be found, or if there is a problem
+ * encountered while loading it.
+ * @param logger
+ * an optional {@link Logger} for logging errors or other diagnostics.
+ * @param
+ * the type of service object to be supplied.
+ * @return a "lazy service loader," which will simply be a {@link Supplier} of the requested type of object loaded
+ * on demand.
+ * @throws NullPointerException
+ * if the given service type is null
+ */
+ public static final Supplier extends T> lazyServiceLoader(@Nonnull Class type, @Nullable Supplier extends T> defaultService, @Nullable Logger logger) {
+ Objects.requireNonNull(type, "service type");
+ return Suppliers.memoize(() -> loadService(type, defaultService, logger));
+ }
+
+ /**
+ * Loads a service of the given type without allowing any exceptions to propagate.
+ *
+ * @param type
+ * the requested type of service object to load.
+ * @param defaultService
+ * an optional default service to be used if no service can be found, or if there is a problem encountered while
+ * loading it.
+ * @param logger
+ * an optional {@link Logger} for logging errors or other diagnostics.
+ * @param
+ * the type of service object to be supplied.
+ * @return a service of the given type if one can be loaded via {@code ServiceLoader.load(type).findFirst()}; else
+ * the given default.
+ * @throws NullPointerException
+ * if the given service type is null
+ */
+ public static final