This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 06dca45c54 Fix Text File Output writing a byte order mark on every
UTF-16 fragment (#9054)
06dca45c54 is described below
commit 06dca45c5491e065f0ca95402e8e665569dce5f9
Author: Lance <[email protected]>
AuthorDate: Wed Oct 21 19:02:21 2026 +0800
Fix Text File Output writing a byte order mark on every UTF-16 fragment
(#9054)
Signed-off-by: leehaut <[email protected]>
---
.../transforms/textfileoutput/TextFileCharset.java | 146 ++++++++
.../transforms/textfileoutput/TextFileOutput.java | 48 ++-
.../textfileoutput/TextFileOutputEncodingTest.java | 378 +++++++++++++++++++++
3 files changed, 555 insertions(+), 17 deletions(-)
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileCharset.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileCharset.java
new file mode 100644
index 0000000000..bbb431eb4b
--- /dev/null
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileCharset.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+package org.apache.hop.pipeline.transforms.textfileoutput;
+
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.IllegalCharsetNameException;
+import java.nio.charset.UnsupportedCharsetException;
+import java.util.Arrays;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * Encodes text-file fragments without repeating a byte order mark.
+ *
+ * <p>{@code UTF-16}, {@code x-UTF-16LE-BOM}, {@code X-UTF-32BE-BOM} and
{@code X-UTF-32LE-BOM}
+ * prefix every {@code String.getBytes} result. Fragments are encoded with the
matching charset that
+ * does not write a mark. {@link TextFileOutput} writes that mark once, at the
start of a new file.
+ *
+ * <p>{@code ISO-2022-KR}, {@code x-ISO-2022-CN-CNS} and {@code
x-ISO-2022-CN-GB} can leave a
+ * fragment in shift-out. A following separator encoded on its own is then
read as a double-byte
+ * character, so a fragment that ends shifted out is closed with shift-in.
+ */
+final class TextFileCharset {
+
+ private static final byte SHIFT_OUT = 0x0E;
+ private static final byte SHIFT_IN = 0x0F;
+
+ private TextFileCharset() {}
+
+ static byte[] byteOrderMark(String encoding) {
+ String canonical = canonical(encoding);
+ if (canonical == null) {
+ return new byte[0];
+ }
+ return switch (canonical) {
+ case "UTF-16" -> new byte[] {(byte) 0xFE, (byte) 0xFF};
+ case "x-UTF-16LE-BOM" -> new byte[] {(byte) 0xFF, (byte) 0xFE};
+ case "X-UTF-32BE-BOM" -> new byte[] {0x00, 0x00, (byte) 0xFE, (byte)
0xFF};
+ case "X-UTF-32LE-BOM" -> new byte[] {(byte) 0xFF, (byte) 0xFE, 0x00,
0x00};
+ default -> new byte[0];
+ };
+ }
+
+ static byte[] encode(String text, String encoding) throws
UnsupportedEncodingException {
+ if (text == null) {
+ return new byte[0];
+ }
+ byte[] encoded = text.getBytes(payloadCharset(encoding));
+ if (endsShiftedOut(canonical(encoding), encoded)) {
+ encoded = Arrays.copyOf(encoded, encoded.length + 1);
+ encoded[encoded.length - 1] = SHIFT_IN;
+ }
+ return encoded;
+ }
+
+ /**
+ * Drops one leading mark from a value that was encoded with {@code
encoding} itself, which is
+ * what {@code ValueMetaBase} does for non-string fields.
+ */
+ static byte[] stripLeadingByteOrderMark(byte[] encoded, String encoding) {
+ if (encoded == null) {
+ return null;
+ }
+ byte[] mark = byteOrderMark(encoding);
+ if (mark.length == 0 || encoded.length < mark.length) {
+ return encoded;
+ }
+ for (int i = 0; i < mark.length; i++) {
+ if (encoded[i] != mark[i]) {
+ return encoded;
+ }
+ }
+ return Arrays.copyOfRange(encoded, mark.length, encoded.length);
+ }
+
+ private static Charset payloadCharset(String encoding) throws
UnsupportedEncodingException {
+ String canonical = canonical(encoding);
+ if (canonical == null) {
+ throw unsupported(encoding, null);
+ }
+ String payload =
+ switch (canonical) {
+ case "UTF-16" -> "UTF-16BE";
+ case "x-UTF-16LE-BOM" -> "UTF-16LE";
+ case "X-UTF-32BE-BOM" -> "UTF-32BE";
+ case "X-UTF-32LE-BOM" -> "UTF-32LE";
+ default -> canonical;
+ };
+ try {
+ return Charset.forName(payload);
+ } catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
+ throw unsupported(encoding, e);
+ }
+ }
+
+ private static boolean endsShiftedOut(String canonical, byte[] encoded) {
+ if (!("ISO-2022-KR".equals(canonical)
+ || "x-ISO-2022-CN-CNS".equals(canonical)
+ || "x-ISO-2022-CN-GB".equals(canonical))) {
+ return false;
+ }
+ boolean shiftedOut = false;
+ for (byte unit : encoded) {
+ if (unit == SHIFT_OUT) {
+ shiftedOut = true;
+ } else if (unit == SHIFT_IN) {
+ shiftedOut = false;
+ }
+ }
+ return shiftedOut;
+ }
+
+ private static String canonical(String encoding) {
+ if (Utils.isEmpty(encoding)) {
+ return null;
+ }
+ try {
+ return Charset.forName(encoding).name();
+ } catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
+ return null;
+ }
+ }
+
+ private static UnsupportedEncodingException unsupported(String encoding,
Exception cause) {
+ UnsupportedEncodingException error = new
UnsupportedEncodingException(encoding);
+ if (cause != null) {
+ error.initCause(cause);
+ }
+ return error;
+ }
+}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileOutput.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileOutput.java
index f9e616ecaf..7f594700dd 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileOutput.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileOutput.java
@@ -126,6 +126,7 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
assignWriter(null, null);
try {
TextFileOutputData.FileStream fileStreams = null;
+ boolean writeByteOrderMark = false;
try {
if (data.splitEvery > 0) {
@@ -137,8 +138,19 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
}
boolean writingToFileForFirstTime = fileStreams == null;
+ // Opening file for first time
+ if (writingToFileForFirstTime) {
+ boolean appendToExistingFile =
meta.getFileSettings().isFileAppended();
+ ICompressionProvider compressionProvider = getCompressionProvider();
+ boolean isZipFile = compressionProvider instanceof
ZipCompressionProvider;
+ if (appendToExistingFile && isZipFile && isFileExists(filename)) {
+ throw new HopException("Can not append to an existing zip file : "
+ filename);
+ }
+ // A BOM charset adds its mark on every getBytes call. Write it
once, and skip it when
+ // appending to a file that already has content.
+ writeByteOrderMark =
+ !appendToExistingFile || !isFileExists(filename) ||
isFileEmpty(filename);
- if (writingToFileForFirstTime) { // Opening file for first time
if (meta.getFileSettings().isAddToResultFiles()) {
// Add this to the result file names...
ResultFile resultFile =
@@ -151,14 +163,7 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
addResultFile(resultFile);
}
- ICompressionProvider compressionProvider = getCompressionProvider();
- boolean isZipFile = compressionProvider instanceof
ZipCompressionProvider;
boolean createParentDirIfNotExists = meta.isCreateParentFolder();
- boolean appendToExistingFile =
meta.getFileSettings().isFileAppended();
-
- if (appendToExistingFile && isZipFile && isFileExists(filename)) {
- throw new HopException("Can not append to an existing zip file : "
+ filename);
- }
int maxOpenFiles = getMaxOpenFiles();
if ((maxOpenFiles > 0)
@@ -248,6 +253,12 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
data.fos = fileStreams.getFileOutputStream();
data.out = fileStreams.getCompressedOutputStream();
assignWriter(fileStreams.getBufferedOutputStream(), fileStreams);
+ if (writeByteOrderMark) {
+ byte[] byteOrderMark =
TextFileCharset.byteOrderMark(meta.getEncoding());
+ if (byteOrderMark.length > 0) {
+ data.writer.write(byteOrderMark);
+ }
+ }
} catch (HopException ke) {
throw ke;
} catch (Exception e) {
@@ -556,7 +567,8 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
return convertStringToBinaryString(v, Const.trimToType(svalue,
v.getTrimType()));
}
} else {
- return v.getBinaryString(valueData);
+ return TextFileCharset.stripLeadingByteOrderMark(
+ v.getBinaryString(valueData), meta.getEncoding());
}
}
@@ -574,7 +586,7 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
return tmp.getBytes();
} else {
try {
- return tmp.getBytes(v.getStringEncoding());
+ return TextFileCharset.encode(tmp, v.getStringEncoding());
} catch (UnsupportedEncodingException e) {
throw new HopValueException(
"Unable to convert String to Binary with specified string
encoding ["
@@ -589,7 +601,7 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
text = string.getBytes();
} else {
try {
- text = string.getBytes(meta.getEncoding());
+ text = TextFileCharset.encode(string, meta.getEncoding());
} catch (UnsupportedEncodingException e) {
throw new HopValueException(
"Unable to convert String to Binary with specified string
encoding ["
@@ -609,7 +621,7 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
byte[] filler = null;
try {
if (!Utils.isEmpty(meta.getEncoding())) {
- filler = " ".getBytes(meta.getEncoding());
+ filler = TextFileCharset.encode(" ", meta.getEncoding());
} else {
filler = " ".getBytes();
}
@@ -640,7 +652,7 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
private byte[] getBinaryString(String string) throws HopTransformException {
try {
if (data.hasEncoding) {
- return string.getBytes(meta.getEncoding());
+ return TextFileCharset.encode(string, meta.getEncoding());
} else {
return string.getBytes();
}
@@ -1097,13 +1109,15 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
if (data.hasEncoding) {
if (!Utils.isEmpty(meta.getSeparator())) {
- data.binarySeparator =
resolve(meta.getSeparator()).getBytes(meta.getEncoding());
+ data.binarySeparator =
+ TextFileCharset.encode(resolve(meta.getSeparator()),
meta.getEncoding());
}
if (!Utils.isEmpty(meta.getEnclosure())) {
- data.binaryEnclosure =
resolve(meta.getEnclosure()).getBytes(meta.getEncoding());
+ data.binaryEnclosure =
+ TextFileCharset.encode(resolve(meta.getEnclosure()),
meta.getEncoding());
}
if (!Utils.isEmpty(meta.getNewLine())) {
- data.binaryNewline = meta.getNewLine().getBytes(meta.getEncoding());
+ data.binaryNewline = TextFileCharset.encode(meta.getNewLine(),
meta.getEncoding());
}
} else {
if (!Utils.isEmpty(meta.getSeparator())) {
@@ -1123,7 +1137,7 @@ public class TextFileOutput extends
BaseTransform<TextFileOutputMeta, TextFileOu
String nullString = meta.getOutputFields().get(i).getNullString();
if (!Utils.isEmpty(nullString)) {
if (data.hasEncoding) {
- data.binaryNullValue[i] = nullString.getBytes(meta.getEncoding());
+ data.binaryNullValue[i] = TextFileCharset.encode(nullString,
meta.getEncoding());
} else {
data.binaryNullValue[i] = nullString.getBytes();
}
diff --git
a/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileOutputEncodingTest.java
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileOutputEncodingTest.java
new file mode 100644
index 0000000000..3d807cb419
--- /dev/null
+++
b/plugins/transforms/textfile/src/test/java/org/apache/hop/pipeline/transforms/textfileoutput/TextFileOutputEncodingTest.java
@@ -0,0 +1,378 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+package org.apache.hop.pipeline.transforms.textfileoutput;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import org.apache.hop.core.compress.CompressionPluginType;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mockito;
+
+/** Unit test for {@link TextFileOutput} encoding */
+class TextFileOutputEncodingTest {
+ @RegisterExtension
+ static RestoreHopEngineEnvironmentExtension env = new
RestoreHopEngineEnvironmentExtension();
+
+ private static final String TEXT = "V001;Maria\nV002;Jose\nV003;Ana\n";
+ private static final String CJK = "V001;日本\n";
+
+ private TransformMockHelper<TextFileOutputMeta, TextFileOutputData> helper;
+
+ @BeforeAll
+ static void setUpBeforeClass() throws Exception {
+ PluginRegistry.addPluginType(CompressionPluginType.getInstance());
+ PluginRegistry.init();
+ }
+
+ @BeforeEach
+ void setUp() {
+ helper =
+ new TransformMockHelper<>(
+ "Text file output encoding", TextFileOutputMeta.class,
TextFileOutputData.class);
+ Mockito.when(helper.logChannelFactory.create(Mockito.any(), Mockito.any()))
+ .thenReturn(helper.iLogChannel);
+ Mockito.when(helper.pipeline.isRunning()).thenReturn(true);
+ }
+
+ @AfterEach
+ void tearDown() {
+ helper.cleanUp();
+ }
+
+ static Stream<String> encodings() {
+ Stream<String> available =
+ Charset.availableCharsets().values().stream()
+ .map(Charset::name)
+ .filter(name -> canEncode(name, TEXT));
+ Stream<String> aliases =
+ Stream.of("UnicodeBig", "unicode", "UnicodeLittle", "UTF-32BE-BOM",
"UTF-32LE-BOM");
+ return Stream.concat(available, aliases).distinct();
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("encodings")
+ void charsetRoundTripsWithoutRepeatingByteOrderMark(String encoding) throws
Exception {
+ byte[] bytes =
+ write(
+ encoding,
+ stringRowMeta(),
+ List.of(
+ new Object[] {"V001", "Maria"},
+ new Object[] {"V002", "Jose"},
+ new Object[] {"V003", "Ana"}),
+ options -> {});
+ assertRoundTrip(encoding, bytes, TEXT);
+ }
+
+ @Test
+ void cjkFieldsRoundTripForEncodingsThatCanRepresentThem() throws Exception {
+ List<String> encodings = new ArrayList<>();
+ for (Charset charset : Charset.availableCharsets().values()) {
+ if (canEncode(charset.name(), CJK)) {
+ encodings.add(charset.name());
+ }
+ }
+ assertTrue(encodings.contains("UTF-8"));
+ assertTrue(encodings.contains("UTF-16"));
+ assertTrue(encodings.contains("ISO-2022-JP"));
+ assertTrue(encodings.contains("ISO-2022-KR"));
+ assertTrue(encodings.contains("x-ISO-2022-CN-GB"));
+ assertTrue(encodings.contains("x-ISO-2022-CN-CNS"));
+ List<Object[]> rows = List.<Object[]>of(new Object[] {"V001", "日本"});
+ for (String encoding : encodings) {
+ byte[] bytes = write(encoding, stringRowMeta(), rows, options -> {});
+ assertRoundTrip(encoding, bytes, CJK);
+ }
+ }
+
+ @Test
+ void appendDoesNotWriteASecondByteOrderMark() throws Exception {
+ ByteArrayOutputStream storage = new ByteArrayOutputStream();
+ List<Object[]> firstRows =
+ List.<Object[]>of(new Object[] {"V001", "Maria"}, new Object[]
{"V002", "Jose"});
+ write("UTF-16", stringRowMeta(), firstRows, options -> {}, storage, false);
+ write(
+ "UTF-16",
+ stringRowMeta(),
+ List.<Object[]>of(new Object[] {"V003", "Ana"}),
+ options -> options.getFileSettings().setFileAppended(true),
+ storage,
+ true);
+ assertRoundTrip("UTF-16", storage.toByteArray(), TEXT);
+ }
+
+ @Test
+ void headerIsEncodedAfterTheSingleByteOrderMark() throws Exception {
+ byte[] bytes =
+ write(
+ "x-UTF-16LE-BOM",
+ stringRowMeta(),
+ List.<Object[]>of(new Object[] {"V001", "Maria"}),
+ options -> options.setHeaderEnabled(true));
+ assertRoundTrip("x-UTF-16LE-BOM", bytes, "id;name\nV001;Maria\n");
+ }
+
+ @Test
+ void dosLineEndingIsEncodedOnce() throws Exception {
+ byte[] bytes =
+ write(
+ "UTF-16",
+ stringRowMeta(),
+ List.<Object[]>of(new Object[] {"V001", "Maria"}),
+ options -> options.setFileFormat("DOS"));
+ assertRoundTrip("UTF-16", bytes, "V001;Maria\r\n");
+ }
+
+ @Test
+ void integerFieldDoesNotRepeatByteOrderMark() throws Exception {
+ RowMeta rowMeta = new RowMeta();
+ rowMeta.addValueMeta(new ValueMetaInteger("id"));
+ rowMeta.addValueMeta(new ValueMetaString("name"));
+ byte[] bytes =
+ write(
+ "X-UTF-32LE-BOM",
+ rowMeta,
+ List.<Object[]>of(new Object[] {1L, "Maria"}, new Object[] {2L,
"Jose"}),
+ options -> {});
+ assertRoundTrip("X-UTF-32LE-BOM", bytes, "1;Maria\n2;Jose\n");
+ }
+
+ @Test
+ void characterUfeffIsDataAndNotASecondByteOrderMark() throws Exception {
+ byte[] bytes =
+ write(
+ "UTF-16",
+ stringRowMeta(),
+ List.<Object[]>of(new Object[] {"\uFEFFV001", "Maria"}),
+ options -> {});
+ assertEquals("\uFEFFV001;Maria\n", new String(bytes,
StandardCharsets.UTF_16));
+ assertEquals(2, count(bytes, new byte[] {(byte) 0xFE, (byte) 0xFF}));
+ }
+
+ @Test
+ void rightPadDoesNotRepeatByteOrderMarkInTheFiller() throws Exception {
+ RowMeta rowMeta = new RowMeta();
+ rowMeta.addValueMeta(new ValueMetaString("id"));
+ byte[] bytes =
+ write(
+ "UTF-16",
+ rowMeta,
+ List.<Object[]>of(new Object[] {"A"}),
+ options -> {
+ options.setSeparator("");
+ options.getOutputFields().getFirst().setLength(4);
+ });
+ assertRoundTrip("UTF-16", bytes, "A \n");
+ }
+
+ private byte[] write(
+ String encoding,
+ IRowMeta rowMeta,
+ List<Object[]> rows,
+ Consumer<TextFileOutputMeta> customize)
+ throws Exception {
+ ByteArrayOutputStream storage = new ByteArrayOutputStream();
+ return write(encoding, rowMeta, rows, customize, storage, false);
+ }
+
+ private byte[] write(
+ String encoding,
+ IRowMeta rowMeta,
+ List<Object[]> rows,
+ Consumer<TextFileOutputMeta> customize,
+ ByteArrayOutputStream storage,
+ boolean append)
+ throws Exception {
+ TextFileOutputMeta meta = new TextFileOutputMeta();
+ meta.setDefault();
+ meta.setFileCompression("None");
+ meta.setHeaderEnabled(false);
+ meta.setFooterEnabled(false);
+ meta.setSeparator(";");
+ meta.setEnclosure("");
+ meta.setFileFormat("UNIX");
+ meta.setEncoding(encoding);
+ meta.setCreateParentFolder(false);
+ meta.setEndedLine(null);
+ meta.getFileSettings().setFileName("out.txt");
+ meta.getFileSettings().setExtension("");
+ meta.getFileSettings().setDoNotOpenNewFileInit(false);
+ meta.getFileSettings().setAddToResultFiles(false);
+ meta.getFileSettings().setFastDump(false);
+ meta.getFileSettings().setFileAppended(append);
+ meta.getOutputFields().clear();
+ for (int i = 0; i < rowMeta.size(); i++) {
+ IValueMeta valueMeta = rowMeta.getValueMeta(i);
+ TextFileField field = new TextFileField();
+ field.setName(valueMeta.getName());
+ field.setType(valueMeta.getType());
+ field.setLength(-1);
+ field.setPrecision(-1);
+ meta.getOutputFields().add(field);
+ }
+ customize.accept(meta);
+
+ TextFileOutputData data = new TextFileOutputData();
+ // The bytes are captured in memory. Skip the VFS lineage lookup that runs
when a file closes.
+ data.setBeamContext(true);
+ EncodingProbe probe =
+ new EncodingProbe(
+ helper.transformMeta, meta, data, helper.pipelineMeta,
helper.pipeline, storage);
+ probe.setInputRowMeta(rowMeta);
+ assertTrue(probe.init(), encoding);
+ for (Object[] row : rows) {
+ probe.row = row;
+ assertTrue(probe.processRow(), encoding);
+ }
+ probe.row = null;
+ assertFalse(probe.processRow(), encoding);
+ assertEquals(0, probe.getErrors(), encoding);
+ probe.dispose();
+ return storage.toByteArray();
+ }
+
+ private static void assertRoundTrip(String encoding, byte[] bytes, String
expected) {
+ byte[] mark = TextFileCharset.byteOrderMark(encoding);
+ if (mark.length > 0 && !expected.contains("\uFEFF")) {
+ assertTrue(bytes.length >= mark.length, encoding);
+ assertArrayEquals(mark, Arrays.copyOf(bytes, mark.length), encoding);
+ assertEquals(1, count(bytes, mark), encoding);
+ }
+ String decoded = new String(bytes, Charset.forName(encoding));
+ if (mark.length > 0 && decoded.startsWith("\uFEFF")) {
+ decoded = decoded.substring(1);
+ }
+ assertEquals(expected, decoded, encoding);
+ }
+
+ private static boolean canEncode(String encoding, String text) {
+ try {
+ return Charset.forName(encoding).newEncoder().canEncode(text);
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ private static int count(byte[] data, byte[] pattern) {
+ int found = 0;
+ for (int i = 0; i + pattern.length <= data.length; i++) {
+ boolean match = true;
+ for (int j = 0; j < pattern.length; j++) {
+ if (data[i + j] != pattern[j]) {
+ match = false;
+ break;
+ }
+ }
+ if (match) {
+ found++;
+ i += pattern.length - 1;
+ }
+ }
+ return found;
+ }
+
+ private static RowMeta stringRowMeta() {
+ RowMeta rowMeta = new RowMeta();
+ rowMeta.addValueMeta(new ValueMetaString("id"));
+ rowMeta.addValueMeta(new ValueMetaString("name"));
+ return rowMeta;
+ }
+
+ private static final class EncodingProbe extends TextFileOutput {
+ private Object[] row;
+ private final ByteArrayOutputStream written;
+
+ private EncodingProbe(
+ org.apache.hop.pipeline.transform.TransformMeta transformMeta,
+ TextFileOutputMeta meta,
+ TextFileOutputData data,
+ org.apache.hop.pipeline.PipelineMeta pipelineMeta,
+ org.apache.hop.pipeline.Pipeline pipeline,
+ ByteArrayOutputStream written) {
+ super(transformMeta, meta, data, 0, pipelineMeta, pipeline);
+ this.written = written;
+ }
+
+ @Override
+ public Object[] getRow() {
+ return row;
+ }
+
+ @Override
+ public void putRow(IRowMeta rowMeta, Object[] row) {
+ // Rows are not chained to a downstream transform.
+ }
+
+ @Override
+ public boolean isFileExists(String filename) {
+ return written.size() > 0;
+ }
+
+ @Override
+ public boolean isFileEmpty(String filename) {
+ return written.size() == 0;
+ }
+
+ @Override
+ protected OutputStream getOutputStream(
+ String vfsFilename, IVariables variables, boolean append) {
+ if (!append) {
+ written.reset();
+ }
+ return new OutputStream() {
+ @Override
+ public void write(int b) {
+ written.write(b);
+ }
+
+ @Override
+ public void write(byte @NotNull [] b, int off, int len) {
+ written.write(b, off, len);
+ }
+ };
+ }
+ }
+}