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 b34f0f4a9e Issue #3219 : Add Binary File Output transform (#7707)
b34f0f4a9e is described below

commit b34f0f4a9ee104231c13866bdabf7e31bd015d57
Author: Matt Casters <[email protected]>
AuthorDate: Fri Jul 31 09:05:22 2026 +0200

    Issue #3219 : Add Binary File Output transform (#7707)
    
    * Issue #3219 : Add Binary File Output transform
    
    Write raw binary field content to a file with the filename taken from
    an input field so each row can target a different file. This provides a
    proper alternative to Text File Output workarounds for BLOB/binary
    export. Includes GUI, unit tests, integration tests, and documentation.
    
    * issue #3219 spotless
---
 assemblies/plugins/pom.xml                         |   6 +
 .../images/transforms/icons/binaryfileoutput.svg   |  15 +
 docs/hop-user-manual/modules/ROOT/nav.adoc         |   1 +
 .../modules/ROOT/pages/pipeline/transforms.adoc    |   1 +
 .../pipeline/transforms/binaryfileoutput.adoc      |  71 +++++
 .../transforms/0107-binary-file-output-verify.hpl  | 253 +++++++++++++++++
 .../transforms/0107-binary-file-output.hpl         | 156 +++++++++++
 .../transforms/files/random-data.binary            | Bin 0 -> 1024 bytes
 .../transforms/main-0107-binary-file-output.hwf    | 138 ++++++++++
 plugins/transforms/binaryfileoutput/pom.xml        |  32 +++
 .../binaryfileoutput/src/assembly/assembly.xml     |  46 ++++
 .../binaryfileoutput/BinaryFileOutput.java         | 203 ++++++++++++++
 .../binaryfileoutput/BinaryFileOutputData.java     |  35 +++
 .../binaryfileoutput/BinaryFileOutputDialog.java   | 281 +++++++++++++++++++
 .../binaryfileoutput/BinaryFileOutputMeta.java     | 170 ++++++++++++
 .../src/main/resources/binaryfileoutput.svg        |  15 +
 .../messages/messages_en_US.properties             |  63 +++++
 .../src/main/resources/version.xml                 |  20 ++
 .../binaryfileoutput/BinaryFileOutputMetaTest.java |  62 +++++
 .../binaryfileoutput/BinaryFileOutputTest.java     | 304 +++++++++++++++++++++
 plugins/transforms/pom.xml                         |   1 +
 21 files changed, 1873 insertions(+)

diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index a30482a610..af5781ff32 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -740,6 +740,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-transform-binaryfileoutput</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-blockingtransform</artifactId>
diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/binaryfileoutput.svg
 
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/binaryfileoutput.svg
new file mode 100644
index 0000000000..15bfc66077
--- /dev/null
+++ 
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/binaryfileoutput.svg
@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg"; width="42px" height="42px" viewBox="0 
0 42 42">
+    <!-- Binary File Output: document with binary-style dashes and export 
arrow -->
+    <polygon fill="#0E3A5A" points="9.148,5.794 9.148,8.877 3.923,3.652 
2.579,4.996 7.805,10.221 4.659,10.221 4.659,12.122
+       11.049,12.121 11.049,5.794 "/>
+    <path fill="#C9E8FB" 
d="M11.468,33.541h17.694l-0.058-20.152h-4.257V9.131h-6.251l-7.128,7.128V33.541z"/>
+    <polygon fill="#0E3A5A" points="30.867,35.24 30.805,13.398 29.095,11.689 
26.547,11.689 26.547,9.141 24.837,7.431 20.295,7.431
+               18.596,9.131 24.847,9.131 24.847,13.388 29.104,13.388 
29.162,33.541 11.468,33.541 11.468,16.258 9.768,17.959 9.768,35.24        "/>
+    <!-- binary-looking content bars -->
+    <rect x="14.365" y="17.552" fill="#0E3A5A" width="11.902" height="0.801"/>
+    <rect x="14.365" y="20.935" fill="#0E3A5A" width="7.5" height="0.801"/>
+    <rect x="14.365" y="24.318" fill="#0E3A5A" width="11.902" height="0.801"/>
+    <rect x="14.365" y="27.701" fill="#0E3A5A" width="5.2" height="0.801"/>
+    <!-- small binary indicator (1/0 style) -->
+    <circle fill="#5CC0C4" cx="27.2" cy="28.5" r="1.2"/>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc 
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index ba8f2e6e25..4c459403d3 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -95,6 +95,7 @@ under the License.
 *** xref:pipeline/transforms/beamgcpsubscriber.adoc[Beam GCP Pub/Sub: 
subscribe]
 *** xref:pipeline/transforms/beamkafkaconsume.adoc[Beam Kafka consume]
 *** xref:pipeline/transforms/beamkafkaproduce.adoc[Beam Kafka produce]
+*** xref:pipeline/transforms/binaryfileoutput.adoc[Binary File Output]
 *** xref:pipeline/transforms/beamkinesisconsume.adoc[Beam Kinesis consume]
 *** xref:pipeline/transforms/beamkinesisproduce.adoc[Beam Kinesis produce]
 *** xref:pipeline/transforms/beamtimestamp.adoc[Beam timestamp]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
index ea18f1b1fe..c4f117eec5 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
@@ -49,6 +49,7 @@ The pages nested under this topic contain information on how 
to use the transfor
 * xref:pipeline/transforms/beamfileoutput.adoc[Beam file output]
 * xref:pipeline/transforms/beamkafkaconsume.adoc[Beam Kafka consume]
 * xref:pipeline/transforms/beamkafkaproduce.adoc[Beam Kafka produce]
+* xref:pipeline/transforms/binaryfileoutput.adoc[Binary File Output]
 * xref:pipeline/transforms/beamkinesisconsume.adoc[Beam Kinesis consume]
 * xref:pipeline/transforms/beamkinesisproduce.adoc[Beam Kinesis produce]
 * xref:pipeline/transforms/beamtimestamp.adoc[Beam timestamp]
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/binaryfileoutput.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/binaryfileoutput.adoc
new file mode 100644
index 0000000000..1a7a752bb3
--- /dev/null
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/binaryfileoutput.adoc
@@ -0,0 +1,71 @@
+////
+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.
+////
+:documentationPath: /pipeline/transforms/
+:language: en_US
+:description: The Binary File Output transform writes a binary field to a 
file. The filename is taken from an input field so each row can write a 
different file.
+
+= image:transforms/icons/binaryfileoutput.svg[Binary File Output transform 
Icon, role="image-doc-icon"] Binary File Output
+
+[%noheader,cols="3a,1a", role="table-no-borders" ]
+|===
+|
+== Description
+
+The Binary File Output transform writes raw binary content from a stream field 
to a file.
+
+Use this transform when you need a byte-for-byte write of BLOB/binary data, 
for example:
+
+* Exporting a database BLOB column to individual files
+* Saving content loaded with xref:pipeline/transforms/loadfileinput.adoc[Load 
file content in memory]
+* Writing binary payloads received from HTTP/REST transforms
+
+The target filename is read from an input field, so every row can be written 
to a different file.
+
+TIP: Prefer this transform over 
xref:pipeline/transforms/textfileoutput.adoc[Text File Output] for binary data. 
Text File Output applies text encoding and formatting and can corrupt raw bytes.
+
+|
+== Supported Engines
+[%noheader,cols="2,1a",frame=none, role="table-supported-engines"]
+!===
+!Hop Engine! image:check_mark.svg[Supported, 24]
+!Single Threaded! image:check_mark.svg[Supported, 24]
+!Native Spark! image:question_mark.svg[Maybe Supported, 24]
+!Beam Spark! image:question_mark.svg[Maybe Supported, 24]
+!Beam Flink! image:question_mark.svg[Maybe Supported, 24]
+!Beam Dataflow! image:question_mark.svg[Maybe Supported, 24]
+!===
+|===
+
+== Options
+
+[options="header"]
+|===
+|Option|Description
+|Transform name|Name of the transform. This name has to be unique in a single 
pipeline.
+|Binary field|The input field that contains the binary content to write. A 
Binary data type is preferred.
+|Filename field|The input field that contains the target filename (local path 
or VFS URI) for the current row.
+|Create parent folder|Create the parent folder of the target file when it does 
not exist.
+|Overwrite existing file|Overwrite the target file when it already exists. 
When disabled, an existing file causes an error for that row.
+|Add filename to result|Add written filenames to the list of result filenames 
that can be used by the next workflow action.
+|===
+
+== Notes
+
+* Null binary content fails the row (or is sent to the error hop when error 
handling is configured).
+* An empty binary value (`0` bytes) writes an empty file.
+* No character encoding is applied: bytes are written exactly as stored in the 
field.
+* Supports transform error handling so failed rows can be routed without 
stopping the pipeline.
diff --git a/integration-tests/transforms/0107-binary-file-output-verify.hpl 
b/integration-tests/transforms/0107-binary-file-output-verify.hpl
new file mode 100644
index 0000000000..8c2ca3369d
--- /dev/null
+++ b/integration-tests/transforms/0107-binary-file-output-verify.hpl
@@ -0,0 +1,253 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+  <info>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <pipeline_type>Normal</pipeline_type>
+    <pipeline_status>-1</pipeline_status>
+    <parameters/>
+    <name>New pipeline</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <created_user>-</created_user>
+    <modified_user>-</modified_user>
+    <created_date>2026/07/30 22:17:24.432</created_date>
+    <modified_date>2026/07/30 22:17:24.432</modified_date>
+  </info>
+  <transform>
+    <type>LoadFileInput</type>
+    <name>random-data.binary</name>
+    <include>N</include>
+    <include_field/>
+    <rownum>Y</rownum>
+    <rownum_field>nr</rownum_field>
+    <limit>0</limit>
+    <encoding/>
+    <DynamicFilenameField/>
+    <IsInFields>N</IsInFields>
+    <addresultfile>Y</addresultfile>
+    <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+    <IsIgnoreMissingPath>N</IsIgnoreMissingPath>
+    <shortFileFieldName/>
+    <extensionFieldName/>
+    <pathFieldName/>
+    <hiddenFieldName/>
+    <lastModificationTimeFieldName/>
+    <uriNameFieldName/>
+    <rootUriNameFieldName/>
+    <files>
+      <file>
+        <name>${PROJECT_HOME}/files/random-data.binary</name>
+        <filemask/>
+        <exclude_filemask/>
+        <file_required>N</file_required>
+        <include_subfolders>N</include_subfolders>
+      </file>
+    </files>
+    <fields>
+      <field>
+        <name>binary</name>
+        <type>Binary</type>
+        <length>-1</length>
+        <format/>
+        <trim_type>none</trim_type>
+        <element_type>content</element_type>
+        <precision>-1</precision>
+        <currency/>
+        <decimal/>
+        <group/>
+        <repeat>N</repeat>
+      </field>
+    </fields>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>224</xloc>
+      <yloc>128</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>LoadFileInput</type>
+    <name>random-copy.binary</name>
+    <include>N</include>
+    <include_field/>
+    <rownum>Y</rownum>
+    <rownum_field>nr</rownum_field>
+    <limit>0</limit>
+    <encoding/>
+    <DynamicFilenameField/>
+    <IsInFields>N</IsInFields>
+    <addresultfile>Y</addresultfile>
+    <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+    <IsIgnoreMissingPath>N</IsIgnoreMissingPath>
+    <shortFileFieldName/>
+    <extensionFieldName/>
+    <pathFieldName/>
+    <hiddenFieldName/>
+    <lastModificationTimeFieldName/>
+    <uriNameFieldName/>
+    <rootUriNameFieldName/>
+    <files>
+      <file>
+        <name>${PROJECT_HOME}/files/random-copy.binary</name>
+        <filemask/>
+        <exclude_filemask/>
+        <file_required>N</file_required>
+        <include_subfolders>N</include_subfolders>
+      </file>
+    </files>
+    <fields>
+      <field>
+        <name>binary</name>
+        <type>Binary</type>
+        <length>-1</length>
+        <format/>
+        <trim_type>none</trim_type>
+        <element_type>content</element_type>
+        <precision>-1</precision>
+        <currency/>
+        <decimal/>
+        <group/>
+        <repeat>N</repeat>
+      </field>
+    </fields>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>224</xloc>
+      <yloc>240</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>MergeRows</type>
+    <name>Merge rows (diff)</name>
+    <flag_field>flagfield</flag_field>
+    <keys>
+      <key>nr</key>
+    </keys>
+    <values>
+      <value>binary</value>
+    </values>
+    <reference>random-data.binary</reference>
+    <compare>random-copy.binary</compare>
+    <diff-field/>
+    <align_input_layouts>Y</align_input_layouts>
+    <passthrough-fields/>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>384</xloc>
+      <yloc>192</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>FilterRows</type>
+    <name>not identical?</name>
+    <compare>
+      <condition>
+        <negated>N</negated>
+        <operator>-</operator>
+        <leftvalue>flagfield</leftvalue>
+        <function>&lt;></function>
+        <value>
+          <name>constant</name>
+          <type>String</type>
+          <text>identical</text>
+          <length>-1</length>
+          <precision>-1</precision>
+          <isnull>N</isnull>
+          <mask/>
+        </value>
+        <conditions/>
+      </condition>
+    </compare>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>528</xloc>
+      <yloc>192</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>Abort</type>
+    <name>Error: not identical</name>
+    <row_threshold>0</row_threshold>
+    <message>The binary file output didn't do its job! The files are not 
identical!</message>
+    <always_log_rows>Y</always_log_rows>
+    <abort_option>ABORT_WITH_ERROR</abort_option>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>656</xloc>
+      <yloc>192</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <order>
+    <hop>
+      <from>random-data.binary</from>
+      <to>Merge rows (diff)</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>random-copy.binary</from>
+      <to>Merge rows (diff)</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>Merge rows (diff)</from>
+      <to>not identical?</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>not identical?</from>
+      <to>Error: not identical</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <notepads/>
+  <attributes/>
+  <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/transforms/0107-binary-file-output.hpl 
b/integration-tests/transforms/0107-binary-file-output.hpl
new file mode 100644
index 0000000000..57de3fd5e7
--- /dev/null
+++ b/integration-tests/transforms/0107-binary-file-output.hpl
@@ -0,0 +1,156 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+  <info>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <pipeline_type>Normal</pipeline_type>
+    <pipeline_status>-1</pipeline_status>
+    <parameters/>
+    <name>New pipeline</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <created_user>-</created_user>
+    <modified_user>-</modified_user>
+    <created_date>2026/07/30 22:12:59.055</created_date>
+    <modified_date>2026/07/30 22:12:59.055</modified_date>
+  </info>
+  <transform>
+    <type>LoadFileInput</type>
+    <name>Load file content in memory</name>
+    <include>Y</include>
+    <include_field>filename</include_field>
+    <rownum>N</rownum>
+    <rownum_field/>
+    <limit>0</limit>
+    <encoding/>
+    <DynamicFilenameField/>
+    <IsInFields>N</IsInFields>
+    <addresultfile>Y</addresultfile>
+    <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+    <IsIgnoreMissingPath>N</IsIgnoreMissingPath>
+    <shortFileFieldName/>
+    <extensionFieldName/>
+    <pathFieldName/>
+    <hiddenFieldName/>
+    <lastModificationTimeFieldName/>
+    <uriNameFieldName/>
+    <rootUriNameFieldName/>
+    <files>
+      <file>
+        <name>${PROJECT_HOME}/files/random-data.binary</name>
+        <filemask/>
+        <exclude_filemask/>
+        <file_required>N</file_required>
+        <include_subfolders>N</include_subfolders>
+      </file>
+    </files>
+    <fields>
+      <field>
+        <name>random-data</name>
+        <type>Binary</type>
+        <length>-1</length>
+        <format/>
+        <trim_type>none</trim_type>
+        <element_type>content</element_type>
+        <precision>-1</precision>
+        <currency/>
+        <decimal/>
+        <group/>
+        <repeat>N</repeat>
+      </field>
+    </fields>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>208</xloc>
+      <yloc>112</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>BinaryFileOutput</type>
+    <name>Binary File Output</name>
+    <binaryfield>random-data</binaryfield>
+    <filenamefield>filename</filenamefield>
+    <createparentfolder>Y</createparentfolder>
+    <overwritefile>Y</overwritefile>
+    <addresultfilenames>N</addresultfilenames>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>544</xloc>
+      <yloc>112</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>ReplaceString</type>
+    <name>random-copy.binary</name>
+    <fields>
+      <field>
+        <in_stream_name>filename</in_stream_name>
+        <out_stream_name/>
+        <use_regex>N</use_regex>
+        <replace_string>random-data</replace_string>
+        <replace_by_string>random-copy</replace_by_string>
+        <set_empty_string>N</set_empty_string>
+        <replace_field_by_string/>
+        <whole_word>N</whole_word>
+        <case_sensitive>N</case_sensitive>
+        <is_unicode>N</is_unicode>
+      </field>
+    </fields>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>384</xloc>
+      <yloc>112</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <order>
+    <hop>
+      <from>Load file content in memory</from>
+      <to>random-copy.binary</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>random-copy.binary</from>
+      <to>Binary File Output</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <notepads/>
+  <attributes/>
+  <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/transforms/files/random-data.binary 
b/integration-tests/transforms/files/random-data.binary
new file mode 100644
index 0000000000..956b4c67e1
Binary files /dev/null and 
b/integration-tests/transforms/files/random-data.binary differ
diff --git a/integration-tests/transforms/main-0107-binary-file-output.hwf 
b/integration-tests/transforms/main-0107-binary-file-output.hwf
new file mode 100644
index 0000000000..5960f52640
--- /dev/null
+++ b/integration-tests/transforms/main-0107-binary-file-output.hwf
@@ -0,0 +1,138 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+  <name>New workflow</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <created_user>-</created_user>
+  <modified_user>-</modified_user>
+  <created_date>2026/07/30 22:12:21.141</created_date>
+  <modified_date>2026/07/30 22:12:21.141</modified_date>
+  <parameters/>
+  <actions>
+    <action>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <intervalSeconds>0</intervalSeconds>
+      <intervalMinutes>60</intervalMinutes>
+      <DayOfMonth>1</DayOfMonth>
+      <weekDay>1</weekDay>
+      <minutes>0</minutes>
+      <hour>12</hour>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <xloc>112</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <filename>${PROJECT_HOME}/0107-binary-file-output.hpl</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <clear_rows>N</clear_rows>
+      <clear_files>N</clear_files>
+      <create_parent_folder>N</create_parent_folder>
+      <set_logfile>N</set_logfile>
+      <set_append_logfile>N</set_append_logfile>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <wait_until_finished>Y</wait_until_finished>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <run_configuration>local</run_configuration>
+      <name>0107-binary-file-output</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <xloc>288</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <filename>${PROJECT_HOME}/0107-binary-file-output-verify.hpl</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <clear_rows>N</clear_rows>
+      <clear_files>N</clear_files>
+      <create_parent_folder>N</create_parent_folder>
+      <set_logfile>N</set_logfile>
+      <set_append_logfile>N</set_append_logfile>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <wait_until_finished>Y</wait_until_finished>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <run_configuration>local</run_configuration>
+      <name>0107-binary-file-output-verify</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <xloc>512</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+    <action>
+      <filename>${PROJECT_HOME}/files/random-copy.binary</filename>
+      <fail_if_file_not_exists>Y</fail_if_file_not_exists>
+      <name>Delete files/random-copy.binary</name>
+      <description/>
+      <type>DELETE_FILE</type>
+      <attributes/>
+      <xloc>736</xloc>
+      <yloc>80</yloc>
+      <parallel>N</parallel>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>0107-binary-file-output</to>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>0107-binary-file-output</from>
+      <to>0107-binary-file-output-verify</to>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>0107-binary-file-output-verify</from>
+      <to>Delete files/random-copy.binary</to>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+      <enabled>Y</enabled>
+    </hop>
+  </hops>
+  <notepads/>
+  <attributes/>
+</workflow>
diff --git a/plugins/transforms/binaryfileoutput/pom.xml 
b/plugins/transforms/binaryfileoutput/pom.xml
new file mode 100644
index 0000000000..aa355f79b9
--- /dev/null
+++ b/plugins/transforms/binaryfileoutput/pom.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  ~
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.hop</groupId>
+        <artifactId>hop-plugins-transforms</artifactId>
+        <version>2.19.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>hop-transform-binaryfileoutput</artifactId>
+    <packaging>jar</packaging>
+    <name>Hop Plugins Transforms Binary File Output</name>
+
+</project>
diff --git a/plugins/transforms/binaryfileoutput/src/assembly/assembly.xml 
b/plugins/transforms/binaryfileoutput/src/assembly/assembly.xml
new file mode 100644
index 0000000000..a76174873e
--- /dev/null
+++ b/plugins/transforms/binaryfileoutput/src/assembly/assembly.xml
@@ -0,0 +1,46 @@
+<!--
+  ~ 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.
+  ~
+  -->
+
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 
http://maven.apache.org/xsd/assembly-2.2.0.xsd";>
+    <id>hop-transform-binaryfileoutput</id>
+    <formats>
+        <format>zip</format>
+    </formats>
+    <baseDirectory>.</baseDirectory>
+    <files>
+        <file>
+            <source>${project.basedir}/src/main/resources/version.xml</source>
+            <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+            <filtered>true</filtered>
+        </file>
+    </files>
+
+    <fileSets>
+        <fileSet>
+            <directory>${project.basedir}/src/main/samples</directory>
+            <outputDirectory>config/projects/samples/</outputDirectory>
+        </fileSet>
+    </fileSets>
+
+
+    <componentDescriptors>
+        
<componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+    </componentDescriptors>
+</assembly>
diff --git 
a/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutput.java
 
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutput.java
new file mode 100644
index 0000000000..c42552521d
--- /dev/null
+++ 
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutput.java
@@ -0,0 +1,203 @@
+/*
+ * 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.binaryfileoutput;
+
+import java.io.OutputStream;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.ResultFile;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.lineage.LineageFileIoEmitter;
+import org.apache.hop.lineage.model.FileIoOperation;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+/** Write raw binary field content to a file. Filename is taken from an input 
field per row. */
+public class BinaryFileOutput extends BaseTransform<BinaryFileOutputMeta, 
BinaryFileOutputData> {
+  private static final Class<?> PKG = BinaryFileOutputMeta.class;
+
+  public BinaryFileOutput(
+      TransformMeta transformMeta,
+      BinaryFileOutputMeta meta,
+      BinaryFileOutputData data,
+      int copyNr,
+      PipelineMeta pipelineMeta,
+      Pipeline pipeline) {
+    super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+  }
+
+  @Override
+  public boolean processRow() throws HopException {
+    Object[] r = getRow();
+    if (r == null) {
+      setOutputDone();
+      return false;
+    }
+
+    if (first) {
+      first = false;
+      processFirst();
+    }
+
+    try {
+      writeBinaryFile(r);
+      putRow(data.outputRowMeta, r);
+      incrementLinesOutput();
+
+      if (checkFeedback(getLinesRead()) && isBasic()) {
+        logBasic(
+            BaseMessages.getString(
+                PKG, "BinaryFileOutput.LineNumber", 
Long.toString(getLinesRead())));
+      }
+    } catch (Exception e) {
+      if (getTransformMeta().isDoingErrorHandling()) {
+        putError(data.outputRowMeta, r, 1L, e.toString(), null, 
"BINARYFILEOUTPUT001");
+      } else {
+        logError(
+            BaseMessages.getString(PKG, 
"BinaryFileOutput.ErrorInTransformRunning", e.getMessage()),
+            e);
+        setErrors(1);
+        stopAll();
+        setOutputDone();
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  private void processFirst() throws HopException {
+    data.outputRowMeta = getInputRowMeta().clone();
+
+    if (Utils.isEmpty(meta.getBinaryField())) {
+      throw new HopException(
+          BaseMessages.getString(PKG, 
"BinaryFileOutput.Error.BinaryFieldMissing"));
+    }
+    if (Utils.isEmpty(meta.getFilenameField())) {
+      throw new HopException(
+          BaseMessages.getString(PKG, 
"BinaryFileOutput.Error.FilenameFieldMissing"));
+    }
+
+    data.indexOfBinaryField = 
getInputRowMeta().indexOfValue(meta.getBinaryField());
+    if (data.indexOfBinaryField < 0) {
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "BinaryFileOutput.Exception.CouldnotFindField", 
meta.getBinaryField()));
+    }
+
+    data.indexOfFilenameField = 
getInputRowMeta().indexOfValue(meta.getFilenameField());
+    if (data.indexOfFilenameField < 0) {
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "BinaryFileOutput.Exception.CouldnotFindField", 
meta.getFilenameField()));
+    }
+  }
+
+  private void writeBinaryFile(Object[] r) throws HopException {
+    String filename = getInputRowMeta().getString(r, 
data.indexOfFilenameField);
+    if (Utils.isEmpty(filename)) {
+      throw new HopException(BaseMessages.getString(PKG, 
"BinaryFileOutput.Error.FilenameEmpty"));
+    }
+
+    byte[] content = getInputRowMeta().getBinary(r, data.indexOfBinaryField);
+    if (content == null) {
+      throw new HopException(BaseMessages.getString(PKG, 
"BinaryFileOutput.Error.BinaryNull"));
+    }
+
+    try (FileObject file = HopVfs.getFileObject(filename, variables)) {
+      ensureParentFolder(file);
+      if (file.exists() && !meta.isOverwriteFile()) {
+        throw new HopException(
+            BaseMessages.getString(PKG, "BinaryFileOutput.Error.FileExists", 
filename));
+      }
+
+      try (OutputStream out = HopVfs.getOutputStream(file, false)) {
+        out.write(content);
+      }
+
+      long written = content.length;
+      dataVolumeOut = (dataVolumeOut != null ? dataVolumeOut : 0L) + written;
+
+      if (!data.isBeamContext() && written > 0) {
+        try {
+          LineageFileIoEmitter.emitTransformFileIo(
+              this, FileIoOperation.WRITE, null, file, written, true, null);
+        } catch (Exception ignored) {
+          // optional lineage
+        }
+      }
+
+      if (meta.isAddResultFilenames()) {
+        ResultFile resultFile =
+            new ResultFile(
+                ResultFile.FILE_TYPE_GENERAL,
+                file,
+                getPipelineMeta().getName(),
+                getTransformName());
+        resultFile.setComment(BaseMessages.getString(PKG, 
"BinaryFileOutput.Log.FileAddedResult"));
+        addResultFile(resultFile);
+        if (isDetailed()) {
+          logDetailed(
+              BaseMessages.getString(
+                  PKG, "BinaryFileOutput.Log.FilenameAddResult", 
file.toString()));
+        }
+      }
+
+      if (isDetailed()) {
+        logDetailed(
+            BaseMessages.getString(
+                PKG, "BinaryFileOutput.Log.FileWritten", 
Long.toString(written), filename));
+      }
+    } catch (HopException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "BinaryFileOutput.Error.WriteFailed", filename, 
e.getMessage()),
+          e);
+    }
+  }
+
+  private void ensureParentFolder(FileObject file) throws Exception {
+    FileObject parent = file.getParent();
+    if (parent == null) {
+      return;
+    }
+    try {
+      if (!parent.exists()) {
+        if (!meta.isCreateParentFolder()) {
+          throw new HopException(
+              BaseMessages.getString(
+                  PKG, "BinaryFileOutput.Error.ParentFolderNotExists", 
parent.toString()));
+        }
+        parent.createFolder();
+        if (isDetailed()) {
+          logDetailed(
+              BaseMessages.getString(
+                  PKG, "BinaryFileOutput.Log.ParentFolderCreated", 
parent.toString()));
+        }
+      }
+    } finally {
+      parent.close();
+    }
+  }
+}
diff --git 
a/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputData.java
 
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputData.java
new file mode 100644
index 0000000000..afcd84df9c
--- /dev/null
+++ 
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputData.java
@@ -0,0 +1,35 @@
+/*
+ * 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.binaryfileoutput;
+
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.pipeline.transform.BaseTransformData;
+import org.apache.hop.pipeline.transform.ITransformData;
+
+@SuppressWarnings("java:S1104")
+public class BinaryFileOutputData extends BaseTransformData implements 
ITransformData {
+  public int indexOfBinaryField;
+  public int indexOfFilenameField;
+  public IRowMeta outputRowMeta;
+
+  public BinaryFileOutputData() {
+    super();
+    indexOfBinaryField = -1;
+    indexOfFilenameField = -1;
+  }
+}
diff --git 
a/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputDialog.java
 
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputDialog.java
new file mode 100644
index 0000000000..6c01ac30c5
--- /dev/null
+++ 
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputDialog.java
@@ -0,0 +1,281 @@
+/*
+ * 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.binaryfileoutput;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CCombo;
+import org.eclipse.swt.events.FocusEvent;
+import org.eclipse.swt.events.FocusListener;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.graphics.Cursor;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+
+public class BinaryFileOutputDialog extends BaseTransformDialog {
+  private static final Class<?> PKG = BinaryFileOutputMeta.class;
+
+  private CCombo wBinaryField;
+  private CCombo wFilenameField;
+  private Button wCreateParentFolder;
+  private Button wOverwriteFile;
+  private Button wAddResult;
+
+  private final BinaryFileOutputMeta input;
+  private boolean gotPreviousFields;
+
+  public BinaryFileOutputDialog(
+      Shell parent,
+      IVariables variables,
+      BinaryFileOutputMeta transformMeta,
+      PipelineMeta pipelineMeta) {
+    super(parent, variables, transformMeta, pipelineMeta);
+    input = transformMeta;
+  }
+
+  @Override
+  public String open() {
+    createShell(BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.Shell.Title"));
+
+    buildButtonBar().ok(e -> ok()).cancel(e -> cancel()).build();
+
+    ModifyListener lsMod = e -> input.setChanged();
+    SelectionAdapter lsButtonChanged =
+        new SelectionAdapter() {
+          @Override
+          public void widgetSelected(SelectionEvent e) {
+            input.setChanged();
+          }
+        };
+    changed = input.hasChanged();
+
+    Control lastControl = wSpacer;
+
+    // Binary field
+    Label wlBinaryField = new Label(shell, SWT.RIGHT);
+    wlBinaryField.setText(BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.BinaryField.Label"));
+    PropsUi.setLook(wlBinaryField);
+    FormData fdlBinaryField = new FormData();
+    fdlBinaryField.left = new FormAttachment(0, 0);
+    fdlBinaryField.right = new FormAttachment(middle, -margin);
+    fdlBinaryField.top = new FormAttachment(lastControl, margin);
+    wlBinaryField.setLayoutData(fdlBinaryField);
+
+    wBinaryField = new CCombo(shell, SWT.BORDER | SWT.READ_ONLY);
+    PropsUi.setLook(wBinaryField);
+    wBinaryField.setToolTipText(
+        BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.BinaryField.Tooltip"));
+    wBinaryField.addModifyListener(lsMod);
+    FormData fdBinaryField = new FormData();
+    fdBinaryField.left = new FormAttachment(middle, 0);
+    fdBinaryField.top = new FormAttachment(lastControl, margin);
+    fdBinaryField.right = new FormAttachment(100, 0);
+    wBinaryField.setLayoutData(fdBinaryField);
+    wBinaryField.addFocusListener(fieldFocusListener());
+    lastControl = wBinaryField;
+
+    // Filename field
+    Label wlFilenameField = new Label(shell, SWT.RIGHT);
+    wlFilenameField.setText(
+        BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.FilenameField.Label"));
+    PropsUi.setLook(wlFilenameField);
+    FormData fdlFilenameField = new FormData();
+    fdlFilenameField.left = new FormAttachment(0, 0);
+    fdlFilenameField.right = new FormAttachment(middle, -margin);
+    fdlFilenameField.top = new FormAttachment(lastControl, margin);
+    wlFilenameField.setLayoutData(fdlFilenameField);
+
+    wFilenameField = new CCombo(shell, SWT.BORDER | SWT.READ_ONLY);
+    PropsUi.setLook(wFilenameField);
+    wFilenameField.setToolTipText(
+        BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.FilenameField.Tooltip"));
+    wFilenameField.addModifyListener(lsMod);
+    FormData fdFilenameField = new FormData();
+    fdFilenameField.left = new FormAttachment(middle, 0);
+    fdFilenameField.top = new FormAttachment(lastControl, margin);
+    fdFilenameField.right = new FormAttachment(100, 0);
+    wFilenameField.setLayoutData(fdFilenameField);
+    wFilenameField.addFocusListener(fieldFocusListener());
+    lastControl = wFilenameField;
+
+    // Create parent folder
+    Label wlCreateParentFolder = new Label(shell, SWT.RIGHT);
+    wlCreateParentFolder.setText(
+        BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.CreateParentFolder.Label"));
+    PropsUi.setLook(wlCreateParentFolder);
+    FormData fdlCreateParentFolder = new FormData();
+    fdlCreateParentFolder.left = new FormAttachment(0, 0);
+    fdlCreateParentFolder.right = new FormAttachment(middle, -margin);
+    fdlCreateParentFolder.top = new FormAttachment(lastControl, margin);
+    wlCreateParentFolder.setLayoutData(fdlCreateParentFolder);
+
+    wCreateParentFolder = new Button(shell, SWT.CHECK);
+    PropsUi.setLook(wCreateParentFolder);
+    wCreateParentFolder.setToolTipText(
+        BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.CreateParentFolder.Tooltip"));
+    wCreateParentFolder.addSelectionListener(lsButtonChanged);
+    FormData fdCreateParentFolder = new FormData();
+    fdCreateParentFolder.left = new FormAttachment(middle, 0);
+    fdCreateParentFolder.top = new FormAttachment(wlCreateParentFolder, 0, 
SWT.CENTER);
+    wCreateParentFolder.setLayoutData(fdCreateParentFolder);
+    lastControl = wlCreateParentFolder;
+
+    // Overwrite existing file
+    Label wlOverwriteFile = new Label(shell, SWT.RIGHT);
+    wlOverwriteFile.setText(BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.Overwrite.Label"));
+    PropsUi.setLook(wlOverwriteFile);
+    FormData fdlOverwriteFile = new FormData();
+    fdlOverwriteFile.left = new FormAttachment(0, 0);
+    fdlOverwriteFile.right = new FormAttachment(middle, -margin);
+    fdlOverwriteFile.top = new FormAttachment(lastControl, margin);
+    wlOverwriteFile.setLayoutData(fdlOverwriteFile);
+
+    wOverwriteFile = new Button(shell, SWT.CHECK);
+    PropsUi.setLook(wOverwriteFile);
+    wOverwriteFile.setToolTipText(
+        BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.Overwrite.Tooltip"));
+    wOverwriteFile.addSelectionListener(lsButtonChanged);
+    FormData fdOverwriteFile = new FormData();
+    fdOverwriteFile.left = new FormAttachment(middle, 0);
+    fdOverwriteFile.top = new FormAttachment(wlOverwriteFile, 0, SWT.CENTER);
+    wOverwriteFile.setLayoutData(fdOverwriteFile);
+    lastControl = wlOverwriteFile;
+
+    // Add filename to result
+    Label wlAddResult = new Label(shell, SWT.RIGHT);
+    wlAddResult.setText(BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.AddResult.Label"));
+    PropsUi.setLook(wlAddResult);
+    FormData fdlAddResult = new FormData();
+    fdlAddResult.left = new FormAttachment(0, 0);
+    fdlAddResult.right = new FormAttachment(middle, -margin);
+    fdlAddResult.top = new FormAttachment(lastControl, margin);
+    wlAddResult.setLayoutData(fdlAddResult);
+
+    wAddResult = new Button(shell, SWT.CHECK);
+    PropsUi.setLook(wAddResult);
+    wAddResult.setToolTipText(
+        BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.AddResult.Tooltip"));
+    wAddResult.addSelectionListener(lsButtonChanged);
+    FormData fdAddResult = new FormData();
+    fdAddResult.left = new FormAttachment(middle, 0);
+    fdAddResult.top = new FormAttachment(wlAddResult, 0, SWT.CENTER);
+    wAddResult.setLayoutData(fdAddResult);
+
+    getData();
+    input.setChanged(changed);
+    focusTransformName();
+    BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+
+    return transformName;
+  }
+
+  private FocusListener fieldFocusListener() {
+    return new FocusListener() {
+      @Override
+      public void focusLost(FocusEvent e) {
+        // Do nothing
+      }
+
+      @Override
+      public void focusGained(FocusEvent e) {
+        Cursor busy = new Cursor(shell.getDisplay(), SWT.CURSOR_WAIT);
+        shell.setCursor(busy);
+        getFields();
+        shell.setCursor(null);
+        busy.dispose();
+      }
+    };
+  }
+
+  public void getData() {
+    if (input.getBinaryField() != null) {
+      wBinaryField.setText(input.getBinaryField());
+    }
+    if (input.getFilenameField() != null) {
+      wFilenameField.setText(input.getFilenameField());
+    }
+    wCreateParentFolder.setSelection(input.isCreateParentFolder());
+    wOverwriteFile.setSelection(input.isOverwriteFile());
+    wAddResult.setSelection(input.isAddResultFilenames());
+  }
+
+  private void cancel() {
+    transformName = null;
+    input.setChanged(changed);
+    dispose();
+  }
+
+  private void ok() {
+    if (Utils.isEmpty(wTransformName.getText())) {
+      return;
+    }
+    input.setBinaryField(wBinaryField.getText());
+    input.setFilenameField(wFilenameField.getText());
+    input.setCreateParentFolder(wCreateParentFolder.getSelection());
+    input.setOverwriteFile(wOverwriteFile.getSelection());
+    input.setAddResultFilenames(wAddResult.getSelection());
+    transformName = wTransformName.getText();
+    dispose();
+  }
+
+  private void getFields() {
+    if (gotPreviousFields) {
+      return;
+    }
+    try {
+      String binaryValue = wBinaryField.getText();
+      String filenameValue = wFilenameField.getText();
+      wBinaryField.removeAll();
+      wFilenameField.removeAll();
+      IRowMeta r = pipelineMeta.getPrevTransformFields(variables, 
transformName);
+      if (r != null) {
+        String[] fields = r.getFieldNames();
+        wBinaryField.setItems(fields);
+        wFilenameField.setItems(fields);
+      }
+      if (binaryValue != null) {
+        wBinaryField.setText(binaryValue);
+      }
+      if (filenameValue != null) {
+        wFilenameField.setText(filenameValue);
+      }
+      gotPreviousFields = true;
+    } catch (HopException ke) {
+      new ErrorDialog(
+          shell,
+          BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.FailedToGetFields.DialogTitle"),
+          BaseMessages.getString(PKG, 
"BinaryFileOutputDialog.FailedToGetFields.DialogMessage"),
+          ke);
+    }
+  }
+}
diff --git 
a/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMeta.java
 
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMeta.java
new file mode 100644
index 0000000000..b38697d6cf
--- /dev/null
+++ 
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMeta.java
@@ -0,0 +1,170 @@
+/*
+ * 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.binaryfileoutput;
+
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.CheckResult;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+@Transform(
+    id = "BinaryFileOutput",
+    image = "binaryfileoutput.svg",
+    name = "i18n::BinaryFileOutput.Name",
+    description = "i18n::BinaryFileOutput.Description",
+    categoryDescription = 
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Output",
+    keywords = "i18n::BinaryFileOutputMeta.keyword",
+    documentationUrl = "/pipeline/transforms/binaryfileoutput.html")
+@Getter
+@Setter
+public class BinaryFileOutputMeta
+    extends BaseTransformMeta<BinaryFileOutput, BinaryFileOutputData> {
+  private static final Class<?> PKG = BinaryFileOutputMeta.class;
+
+  /** Field containing the binary content to write */
+  @HopMetadataProperty(
+      key = "binaryfield",
+      injectionKey = "BINARY_FIELD",
+      injectionKeyDescription = "BinaryFileOutput.Injection.BinaryField")
+  private String binaryField;
+
+  /** Field containing the target filename */
+  @HopMetadataProperty(
+      key = "filenamefield",
+      injectionKey = "FILENAME_FIELD",
+      injectionKeyDescription = "BinaryFileOutput.Injection.FilenameField")
+  private String filenameField;
+
+  /** Create parent folder if it does not exist */
+  @HopMetadataProperty(
+      key = "createparentfolder",
+      injectionKey = "CREATE_PARENT_FOLDER",
+      injectionKeyDescription = 
"BinaryFileOutput.Injection.CreateParentFolder")
+  private boolean createParentFolder;
+
+  /** Overwrite the target file when it already exists */
+  @HopMetadataProperty(
+      key = "overwritefile",
+      injectionKey = "OVERWRITE_FILE",
+      injectionKeyDescription = "BinaryFileOutput.Injection.OverwriteFile")
+  private boolean overwriteFile;
+
+  /** Add written filenames to the result filenames */
+  @HopMetadataProperty(
+      key = "addresultfilenames",
+      injectionKey = "ADD_RESULT_FILENAMES",
+      injectionKeyDescription = 
"BinaryFileOutput.Injection.AddResultFilenames")
+  private boolean addResultFilenames;
+
+  public BinaryFileOutputMeta() {
+    super();
+  }
+
+  @Override
+  public Object clone() {
+    return super.clone();
+  }
+
+  @Override
+  public void setDefault() {
+    binaryField = null;
+    filenameField = null;
+    createParentFolder = true;
+    overwriteFile = true;
+    addResultFilenames = false;
+  }
+
+  @Override
+  public void check(
+      List<ICheckResult> remarks,
+      PipelineMeta pipelineMeta,
+      TransformMeta transformMeta,
+      IRowMeta prev,
+      String[] input,
+      String[] output,
+      IRowMeta info,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider) {
+    CheckResult cr;
+
+    if (Utils.isEmpty(binaryField)) {
+      cr =
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"BinaryFileOutputMeta.CheckResult.BinaryFieldMissing"),
+              transformMeta);
+      remarks.add(cr);
+    } else {
+      cr =
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_OK,
+              BaseMessages.getString(PKG, 
"BinaryFileOutputMeta.CheckResult.BinaryFieldOK"),
+              transformMeta);
+      remarks.add(cr);
+    }
+
+    if (Utils.isEmpty(filenameField)) {
+      cr =
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"BinaryFileOutputMeta.CheckResult.FilenameFieldMissing"),
+              transformMeta);
+      remarks.add(cr);
+    } else {
+      cr =
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_OK,
+              BaseMessages.getString(PKG, 
"BinaryFileOutputMeta.CheckResult.FilenameFieldOK"),
+              transformMeta);
+      remarks.add(cr);
+    }
+
+    if (input.length > 0) {
+      cr =
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_OK,
+              BaseMessages.getString(
+                  PKG, 
"BinaryFileOutputMeta.CheckResult.ReceivingInfoFromOtherTransforms"),
+              transformMeta);
+      remarks.add(cr);
+    } else {
+      cr =
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"BinaryFileOutputMeta.CheckResult.NoInputReceived"),
+              transformMeta);
+      remarks.add(cr);
+    }
+  }
+
+  @Override
+  public boolean supportsErrorHandling() {
+    return true;
+  }
+}
diff --git 
a/plugins/transforms/binaryfileoutput/src/main/resources/binaryfileoutput.svg 
b/plugins/transforms/binaryfileoutput/src/main/resources/binaryfileoutput.svg
new file mode 100644
index 0000000000..15bfc66077
--- /dev/null
+++ 
b/plugins/transforms/binaryfileoutput/src/main/resources/binaryfileoutput.svg
@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg"; width="42px" height="42px" viewBox="0 
0 42 42">
+    <!-- Binary File Output: document with binary-style dashes and export 
arrow -->
+    <polygon fill="#0E3A5A" points="9.148,5.794 9.148,8.877 3.923,3.652 
2.579,4.996 7.805,10.221 4.659,10.221 4.659,12.122
+       11.049,12.121 11.049,5.794 "/>
+    <path fill="#C9E8FB" 
d="M11.468,33.541h17.694l-0.058-20.152h-4.257V9.131h-6.251l-7.128,7.128V33.541z"/>
+    <polygon fill="#0E3A5A" points="30.867,35.24 30.805,13.398 29.095,11.689 
26.547,11.689 26.547,9.141 24.837,7.431 20.295,7.431
+               18.596,9.131 24.847,9.131 24.847,13.388 29.104,13.388 
29.162,33.541 11.468,33.541 11.468,16.258 9.768,17.959 9.768,35.24        "/>
+    <!-- binary-looking content bars -->
+    <rect x="14.365" y="17.552" fill="#0E3A5A" width="11.902" height="0.801"/>
+    <rect x="14.365" y="20.935" fill="#0E3A5A" width="7.5" height="0.801"/>
+    <rect x="14.365" y="24.318" fill="#0E3A5A" width="11.902" height="0.801"/>
+    <rect x="14.365" y="27.701" fill="#0E3A5A" width="5.2" height="0.801"/>
+    <!-- small binary indicator (1/0 style) -->
+    <circle fill="#5CC0C4" cx="27.2" cy="28.5" r="1.2"/>
+</svg>
diff --git 
a/plugins/transforms/binaryfileoutput/src/main/resources/org/apache/hop/pipeline/transforms/binaryfileoutput/messages/messages_en_US.properties
 
b/plugins/transforms/binaryfileoutput/src/main/resources/org/apache/hop/pipeline/transforms/binaryfileoutput/messages/messages_en_US.properties
new file mode 100644
index 0000000000..89a505e058
--- /dev/null
+++ 
b/plugins/transforms/binaryfileoutput/src/main/resources/org/apache/hop/pipeline/transforms/binaryfileoutput/messages/messages_en_US.properties
@@ -0,0 +1,63 @@
+#
+# 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.
+#
+
+BinaryFileOutput.Name=Binary File Output
+BinaryFileOutput.Description=Write a binary field to a file. The filename is 
taken from an input field so each row can write a different file.
+BinaryFileOutputMeta.keyword=binary,file,output,blob,bytes,write
+
+BinaryFileOutput.Error.BinaryFieldMissing=Binary field is missing\!
+BinaryFileOutput.Error.FilenameFieldMissing=Filename field is missing\!
+BinaryFileOutput.Error.FilenameEmpty=Filename is empty for this row\!
+BinaryFileOutput.Error.BinaryNull=Binary content is null for this row\!
+BinaryFileOutput.Error.FileExists=File already exists and overwrite is 
disabled\: {0}
+BinaryFileOutput.Error.ParentFolderNotExists=Parent folder does not exist and 
create parent folder is disabled\: {0}
+BinaryFileOutput.Error.WriteFailed=Failed to write binary content to file 
[{0}]\: {1}
+BinaryFileOutput.Exception.CouldnotFindField=Couldn''t find field ''{0}'' in 
row\!
+BinaryFileOutput.ErrorInTransformRunning=Because of an error, this transform 
can''t continue\: {0}
+BinaryFileOutput.LineNumber=linenr {0}
+BinaryFileOutput.Log.FileWritten=Wrote {0} bytes to [{1}]
+BinaryFileOutput.Log.FileAddedResult=File was written by a Binary File Output 
transform
+BinaryFileOutput.Log.FilenameAddResult=Filename [{0}] was added to result 
filenames.
+BinaryFileOutput.Log.ParentFolderCreated=Parent folder created\: {0}
+
+BinaryFileOutputDialog.Shell.Title=Binary File Output
+BinaryFileOutputDialog.TransformName.Label=Transform name
+BinaryFileOutputDialog.BinaryField.Label=Binary field
+BinaryFileOutputDialog.BinaryField.Tooltip=Field containing the binary content 
to write to the file
+BinaryFileOutputDialog.FilenameField.Label=Filename field
+BinaryFileOutputDialog.FilenameField.Tooltip=Field containing the target 
filename (local path or VFS URI)
+BinaryFileOutputDialog.CreateParentFolder.Label=Create parent folder
+BinaryFileOutputDialog.CreateParentFolder.Tooltip=Create the parent folder of 
the target file if it does not exist
+BinaryFileOutputDialog.Overwrite.Label=Overwrite existing file
+BinaryFileOutputDialog.Overwrite.Tooltip=Overwrite the target file when it 
already exists
+BinaryFileOutputDialog.AddResult.Label=Add filename to result
+BinaryFileOutputDialog.AddResult.Tooltip=Add written filenames to the result 
filenames
+BinaryFileOutputDialog.FailedToGetFields.DialogTitle=Get fields failed
+BinaryFileOutputDialog.FailedToGetFields.DialogMessage=Unable to get fields 
from previous transforms because of an error
+
+BinaryFileOutputMeta.CheckResult.BinaryFieldMissing=Binary field name is 
missing\!
+BinaryFileOutputMeta.CheckResult.BinaryFieldOK=Binary field name was specified
+BinaryFileOutputMeta.CheckResult.FilenameFieldMissing=Filename field name is 
missing\!
+BinaryFileOutputMeta.CheckResult.FilenameFieldOK=Filename field name was 
specified
+BinaryFileOutputMeta.CheckResult.NoInputReceived=No input received from other 
transforms\!
+BinaryFileOutputMeta.CheckResult.ReceivingInfoFromOtherTransforms=Transform is 
receiving info from other transforms.
+
+BinaryFileOutput.Injection.BinaryField=Binary field name
+BinaryFileOutput.Injection.FilenameField=Filename field name
+BinaryFileOutput.Injection.CreateParentFolder=Create parent folder (Y/N)
+BinaryFileOutput.Injection.OverwriteFile=Overwrite existing file (Y/N)
+BinaryFileOutput.Injection.AddResultFilenames=Add filenames to result (Y/N)
diff --git a/plugins/transforms/binaryfileoutput/src/main/resources/version.xml 
b/plugins/transforms/binaryfileoutput/src/main/resources/version.xml
new file mode 100644
index 0000000000..36ab20e22e
--- /dev/null
+++ b/plugins/transforms/binaryfileoutput/src/main/resources/version.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  ~
+  -->
+
+<version>${project.version}</version>
diff --git 
a/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMetaTest.java
 
b/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMetaTest.java
new file mode 100644
index 0000000000..d29f5f3d97
--- /dev/null
+++ 
b/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMetaTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.binaryfileoutput;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.transforms.loadsave.LoadSaveTester;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class BinaryFileOutputMetaTest {
+  @RegisterExtension
+  static RestoreHopEngineEnvironmentExtension env = new 
RestoreHopEngineEnvironmentExtension();
+
+  @Test
+  void testTransformMeta() throws HopException {
+    List<String> attributes =
+        Arrays.asList(
+            "binaryField",
+            "filenameField",
+            "createParentFolder",
+            "overwriteFile",
+            "addResultFilenames");
+
+    Map<String, String> getterMap = new HashMap<>();
+    getterMap.put("binaryField", "getBinaryField");
+    getterMap.put("filenameField", "getFilenameField");
+    getterMap.put("createParentFolder", "isCreateParentFolder");
+    getterMap.put("overwriteFile", "isOverwriteFile");
+    getterMap.put("addResultFilenames", "isAddResultFilenames");
+
+    Map<String, String> setterMap = new HashMap<>();
+    setterMap.put("binaryField", "setBinaryField");
+    setterMap.put("filenameField", "setFilenameField");
+    setterMap.put("createParentFolder", "setCreateParentFolder");
+    setterMap.put("overwriteFile", "setOverwriteFile");
+    setterMap.put("addResultFilenames", "setAddResultFilenames");
+
+    LoadSaveTester loadSaveTester =
+        new LoadSaveTester(BinaryFileOutputMeta.class, attributes, getterMap, 
setterMap);
+    loadSaveTester.testSerialization();
+  }
+}
diff --git 
a/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputTest.java
 
b/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputTest.java
new file mode 100644
index 0000000000..74afad6f22
--- /dev/null
+++ 
b/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputTest.java
@@ -0,0 +1,304 @@
+/*
+ * 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.binaryfileoutput;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Constructor;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.apache.hop.core.BlockingRowSet;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaBinary;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.PipelineTestingUtil;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+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.api.io.TempDir;
+
+class BinaryFileOutputTest {
+
+  private static final String BINARY_FIELD = "content";
+  private static final String FILENAME_FIELD = "filename";
+
+  @RegisterExtension
+  static RestoreHopEngineEnvironmentExtension env = new 
RestoreHopEngineEnvironmentExtension();
+
+  private TransformMockHelper<BinaryFileOutputMeta, BinaryFileOutputData> 
helper;
+
+  @BeforeAll
+  static void initHop() throws Exception {
+    HopEnvironment.init();
+  }
+
+  @BeforeEach
+  void setUp() {
+    helper =
+        new TransformMockHelper<>(
+            "BinaryFileOutputTest", BinaryFileOutputMeta.class, 
BinaryFileOutputData.class);
+    when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(helper.iLogChannel);
+    
when(helper.logChannelFactory.create(any())).thenReturn(helper.iLogChannel);
+    when(helper.pipeline.isRunning()).thenReturn(true);
+  }
+
+  @AfterEach
+  void tearDown() {
+    helper.cleanUp();
+  }
+
+  private BinaryFileOutput createTransform(BinaryFileOutputMeta meta, 
BinaryFileOutputData data)
+      throws Exception {
+    Constructor<BinaryFileOutput> kons =
+        BinaryFileOutput.class.getConstructor(
+            TransformMeta.class,
+            BinaryFileOutputMeta.class,
+            BinaryFileOutputData.class,
+            int.class,
+            PipelineMeta.class,
+            Pipeline.class);
+    return kons.newInstance(
+        helper.transformMeta, meta, data, 0, helper.pipelineMeta, 
helper.pipeline);
+  }
+
+  private RowMeta rowMeta() {
+    RowMeta rm = new RowMeta();
+    rm.addValueMeta(new ValueMetaBinary(BINARY_FIELD));
+    rm.addValueMeta(new ValueMetaString(FILENAME_FIELD));
+    return rm;
+  }
+
+  private static String vfsPath(Path path) {
+    return path.toUri().toString();
+  }
+
+  private void wireInput(BinaryFileOutput transform, RowMeta rm, Object[]... 
rows) {
+    BlockingRowSet input = new BlockingRowSet(Math.max(16, rows.length + 1));
+    for (Object[] row : rows) {
+      input.putRow(rm, row);
+    }
+    input.setDone();
+    transform.setInputRowSets(new 
ArrayList<>(Collections.singletonList(input)));
+  }
+
+  private BinaryFileOutputMeta baseMeta() {
+    BinaryFileOutputMeta meta = new BinaryFileOutputMeta();
+    meta.setDefault();
+    meta.setBinaryField(BINARY_FIELD);
+    meta.setFilenameField(FILENAME_FIELD);
+    meta.setCreateParentFolder(false);
+    meta.setOverwriteFile(true);
+    meta.setAddResultFilenames(false);
+    return meta;
+  }
+
+  @Test
+  void writesRawBytesUnchanged(@TempDir Path tempDir) throws Exception {
+    // Include non-UTF8 / control bytes that text writers often corrupt
+    byte[] payload = new byte[] {0x00, 0x01, (byte) 0x98, (byte) 0xFF, 0x0A, 
0x0D, 0x1A};
+    Path target = tempDir.resolve("out.bin");
+
+    BinaryFileOutputMeta meta = baseMeta();
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+    List<Object[]> out = PipelineTestingUtil.execute(transform, 1, false);
+    assertArrayEquals(payload, (byte[]) out.getFirst()[0]);
+    assertEquals(vfsPath(target), out.getFirst()[1]);
+
+    assertTrue(Files.isRegularFile(target));
+    assertArrayEquals(payload, Files.readAllBytes(target));
+  }
+
+  @Test
+  void writesEmptyBinaryFile(@TempDir Path tempDir) throws Exception {
+    byte[] payload = new byte[0];
+    Path target = tempDir.resolve("empty.bin");
+
+    BinaryFileOutputMeta meta = baseMeta();
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+    PipelineTestingUtil.execute(transform, 1, false);
+
+    assertTrue(Files.isRegularFile(target));
+    assertEquals(0, Files.size(target));
+  }
+
+  @Test
+  void createsParentFolderWhenConfigured(@TempDir Path tempDir) throws 
Exception {
+    byte[] payload = new byte[] {0x01, 0x02, 0x03};
+    Path target = tempDir.resolve("nested/dir/out.bin");
+
+    BinaryFileOutputMeta meta = baseMeta();
+    meta.setCreateParentFolder(true);
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+    PipelineTestingUtil.execute(transform, 1, false);
+
+    assertTrue(Files.isRegularFile(target));
+    assertArrayEquals(payload, Files.readAllBytes(target));
+  }
+
+  @Test
+  void failsWhenParentMissingAndCreateDisabled(@TempDir Path tempDir) throws 
Exception {
+    byte[] payload = new byte[] {0x01};
+    Path target = tempDir.resolve("missing/parent/out.bin");
+
+    BinaryFileOutputMeta meta = baseMeta();
+    meta.setCreateParentFolder(false);
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+    assertFalse(transform.processRow());
+    assertTrue(transform.getErrors() > 0);
+    assertFalse(Files.exists(target));
+  }
+
+  @Test
+  void overwritesWhenEnabled(@TempDir Path tempDir) throws Exception {
+    Path target = tempDir.resolve("out.bin");
+    Files.write(target, new byte[] {0x11, 0x22});
+    byte[] payload = new byte[] {(byte) 0xAA, (byte) 0xBB, (byte) 0xCC};
+
+    BinaryFileOutputMeta meta = baseMeta();
+    meta.setOverwriteFile(true);
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+    PipelineTestingUtil.execute(transform, 1, false);
+
+    assertArrayEquals(payload, Files.readAllBytes(target));
+  }
+
+  @Test
+  void failsWhenExistsAndOverwriteDisabled(@TempDir Path tempDir) throws 
Exception {
+    Path target = tempDir.resolve("out.bin");
+    byte[] original = new byte[] {0x11, 0x22};
+    Files.write(target, original);
+    byte[] payload = new byte[] {(byte) 0xAA};
+
+    BinaryFileOutputMeta meta = baseMeta();
+    meta.setOverwriteFile(false);
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+    assertFalse(transform.processRow());
+    assertTrue(transform.getErrors() > 0);
+    assertArrayEquals(original, Files.readAllBytes(target));
+  }
+
+  @Test
+  void failsOnNullBinary(@TempDir Path tempDir) throws Exception {
+    Path target = tempDir.resolve("out.bin");
+
+    BinaryFileOutputMeta meta = baseMeta();
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {null, vfsPath(target)});
+
+    assertFalse(transform.processRow());
+    assertTrue(transform.getErrors() > 0);
+    assertFalse(Files.exists(target));
+  }
+
+  @Test
+  void failsOnEmptyFilename(@TempDir Path tempDir) throws Exception {
+    BinaryFileOutputMeta meta = baseMeta();
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {new byte[] {0x01}, ""});
+
+    assertFalse(transform.processRow());
+    assertTrue(transform.getErrors() > 0);
+  }
+
+  @Test
+  void throwsWhenBinaryFieldNotConfigured(@TempDir Path tempDir) throws 
Exception {
+    Path target = tempDir.resolve("out.bin");
+    BinaryFileOutputMeta meta = baseMeta();
+    meta.setBinaryField(null);
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {new byte[] {0x01}, 
vfsPath(target)});
+
+    assertThrows(HopException.class, () -> 
PipelineTestingUtil.execute(transform, 1, false));
+  }
+
+  @Test
+  void writesEachRowToItsOwnFile(@TempDir Path tempDir) throws Exception {
+    Path t1 = tempDir.resolve("a.bin");
+    Path t2 = tempDir.resolve("b.bin");
+    byte[] p1 = new byte[] {0x01, 0x02};
+    byte[] p2 = new byte[] {(byte) 0xFE, (byte) 0xFD, (byte) 0xFC};
+
+    BinaryFileOutputMeta meta = baseMeta();
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {p1, vfsPath(t1)}, new Object[] {p2, 
vfsPath(t2)});
+
+    List<Object[]> out = PipelineTestingUtil.execute(transform, 2, false);
+    assertEquals(2, out.size());
+    assertArrayEquals(p1, Files.readAllBytes(t1));
+    assertArrayEquals(p2, Files.readAllBytes(t2));
+  }
+
+  @Test
+  void addResultFilenames_registersTarget(@TempDir Path tempDir) throws 
Exception {
+    Path target = tempDir.resolve("result.bin");
+    byte[] payload = new byte[] {0x0A};
+
+    BinaryFileOutputMeta meta = baseMeta();
+    meta.setAddResultFilenames(true);
+    BinaryFileOutput transform = createTransform(meta, new 
BinaryFileOutputData());
+    RowMeta rm = rowMeta();
+    wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+    PipelineTestingUtil.execute(transform, 1, false);
+
+    assertEquals(1, transform.getResultFiles().size());
+    
assertTrue(transform.getResultFiles().containsKey(target.toUri().toString()));
+  }
+}
diff --git a/plugins/transforms/pom.xml b/plugins/transforms/pom.xml
index ec796c1588..531d2fde67 100644
--- a/plugins/transforms/pom.xml
+++ b/plugins/transforms/pom.xml
@@ -33,6 +33,7 @@
         <module>addsequence</module>
         <module>analyticquery</module>
         <module>append</module>
+        <module>binaryfileoutput</module>
         <module>blockingtransform</module>
         <module>blockuntiltransformsfinish</module>
         <module>calculator</module>

Reply via email to