From 40e8c5662cd9589f9f27f1b9e9811acce6685a52 Mon Sep 17 00:00:00 2001 From: Bart Maertens Date: Sat, 30 May 2026 13:53:52 +0200 Subject: [PATCH 1/2] ODS support in Excel Writer transform. fixes #7191 --- .../pipeline/transforms/excelwriter.adoc | 69 +- .../transforms/0101-check-ods-file-exists.hpl | 175 +++++ .../transforms/0101-create-ods-file.hpl | 153 +++++ .../main-0101-excel-writer-output-ods.hwf | 187 ++++++ .../excelwriter/ExcelWriterOutputFormat.java | 39 ++ .../excelwriter/ExcelWriterTransform.java | 40 +- .../ExcelWriterTransformDialog.java | 29 +- .../ExcelWriterWorkbookDefinition.java | 45 ++ .../excelwriter/ods/OdsExcelWriter.java | 628 ++++++++++++++++++ .../excelwriter/ods/OdsFormatConverter.java | 39 ++ .../excelwriter/ods/OdsFormulaConverter.java | 96 +++ .../excelwriter/ods/OdsFormulaHelper.java | 75 +++ .../excelwriter/ods/OdsStyleHelper.java | 155 +++++ .../excelwriter/ods/OdsTableHelper.java | 179 +++++ .../excelwriter/ods/OdsWorkbookHandle.java | 45 ++ .../messages/messages_de_DE.properties | 4 +- .../messages/messages_en_US.properties | 15 +- .../messages/messages_it_IT.properties | 4 +- .../messages/messages_pt_BR.properties | 4 +- .../messages/messages_zh_CN.properties | 4 +- .../ods/OdsExcelWriterIntegrationTest.java | 217 ++++++ .../excelwriter/ods/OdsExcelWriterTest.java | 480 +++++++++++++ .../ods/OdsFormatConverterTest.java | 30 + .../ods/OdsFormulaConverterTest.java | 45 ++ 24 files changed, 2713 insertions(+), 44 deletions(-) create mode 100644 integration-tests/transforms/0101-check-ods-file-exists.hpl create mode 100644 integration-tests/transforms/0101-create-ods-file.hpl create mode 100644 integration-tests/transforms/main-0101-excel-writer-output-ods.hwf create mode 100644 plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterOutputFormat.java create mode 100644 plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriter.java create mode 100644 plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormatConverter.java create mode 100644 plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaConverter.java create mode 100644 plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaHelper.java create mode 100644 plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsStyleHelper.java create mode 100644 plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsTableHelper.java create mode 100644 plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsWorkbookHandle.java create mode 100644 plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriterIntegrationTest.java create mode 100644 plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriterTest.java create mode 100644 plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormatConverterTest.java create mode 100644 plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaConverterTest.java diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/excelwriter.adoc b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/excelwriter.adoc index b1ee906e60e..dc8d709a3ee 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/excelwriter.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/excelwriter.adoc @@ -16,7 +16,7 @@ under the License. //// :documentationPath: /pipeline/transforms/ :language: en_US -:description: The Microsoft Excel Writer transform writes incoming rows from Hop out to an MS Excel file. It supports both the .xls and .xlsx file formats. +:description: The Excel Writer transform writes incoming rows to Microsoft Excel (.xls, .xlsx) or OpenDocument Spreadsheet (.ods) files. = image:transforms/icons/excelwriter.svg[Excel writer transform Icon, role="image-doc-icon"] Excel writer @@ -25,9 +25,17 @@ under the License. | == Description -The Microsoft Excel Writer transform writes incoming rows from Hop out to an MS Excel file. It supports both the .xls and .xlsx file formats. +The Excel Writer transform writes incoming rows from Hop to spreadsheet files. +It supports three output formats: -The .xls files use a binary format which is better suited for simple content, while the .xlsx files use the Open XML format which works well with templates since it can better preserve charts and miscellaneous objects. +* `.xls` — legacy Excel binary format (Apache POI) +* `.xlsx` — modern Excel Open XML format (Apache POI) +* `.ods` — OpenDocument Spreadsheet format (LibreOffice Calc, Apache OpenOffice; written via ODFDOM) + +The `.xls` and `.xlsx` backends share the same POI code path. +The `.ods` backend is a separate implementation that mirrors the same transform options where the ODF format allows it. + +The `.xls` files use a binary format which is better suited for simple content, while the `.xlsx` files use the Open XML format which works well with templates since it can better preserve charts and miscellaneous objects. | == Supported Engines @@ -40,6 +48,16 @@ The .xls files use a binary format which is better suited for simple content, wh !=== |=== +== Output formats + +[options="header"] +|=== +|Format|Extension|Backend|Notes +|Excel 97–2003|`.xls`|POI|Sheet password protection supported +|Excel 2007+|`.xlsx`|POI|Streaming mode for large files; no sheet password protection +|OpenDocument Spreadsheet|`.ods`|ODFDOM|LibreOffice Calc compatible; see <> below +|=== + == Options === File & sheet tab @@ -49,11 +67,11 @@ The .xls files use a binary format which is better suited for simple content, wh [options="header"] |=== |Option|Description -|Stream XLSX data|Check this option when writing large XLSX files. +|Extension|Choose `xls`, `xlsx`, or `ods`. This determines the output file format. +|Stream XLSX data|Check this option when writing large XLSX files (not available for `.xls` or `.ods`). It uses internally a streaming API and is able to write large files without any memory restrictions (of course not exceeding Excel's limit of 1,048,575 rows and 16,384 columns). |Create parent folder|Enable to create the parent folder -|If output file exists|Check this option when writing large XLSX files. -It uses internally a streaming API and is able to write large files without any memory restrictions (of course not exceeding Excel's limit of 1,048,575 rows and 16,384 columns). +|If output file exists|Choose to reuse an existing file or create a new one. |Add filename(s) to result|Check to have the filename added to the result filenames |Wait for first row before creating file|Checking this option makes the transform create the file only after it has seen a row. If this is disabled the output file is always created, regardless of whether rows are actually written to the file. @@ -65,17 +83,15 @@ If this is disabled the output file is always created, regardless of whether row |=== |Option|Description |Sheet Name|The sheet name the transform will write rows to. -|Make this the active sheet|If checked the Excel file will by default open on the above sheet when opened in MS Excel. +|Make this the active sheet|If checked the spreadsheet file will open on this sheet by default (in Excel, LibreOffice Calc, etc.). |If sheet exists in output file|The output file already has this sheet (for example when using a template, or writing to existing files), you can choose to write to the existing sheet, or replace it. -|Protect Sheet|The XLS file format allows to protect an entire sheet from changes. -If checked you need to provide a password. -Excel will indicate that the sheet was protected by the user you provide here. +|Protect Sheet|Lock the sheet with an optional password. Supported for `.xls` and `.ods` output. The *protected by user* field applies to `.xls` only. Not supported for `.xlsx`. |=== *Template section* When creating new files (when existing files are replaced, or completely fresh files are created) you may choose to create a copy of an existing template file instead. -Please make sure that the template file is of the same type as the output file (both must be xls or xlsx respectively). +The template and output file must use the same extension (`.xls`, `.xlsx`, or `.ods`). When creating new sheets, the transform may copy a sheet from the current document (the template or an otherwise existing file the transform is writing to). A new sheet is created if the target sheet is not present, or the existing one shall be replaced as per configuration above. @@ -92,7 +108,7 @@ A new sheet is created if the target sheet is not present, or the existing one s |Write Header|If checked the first line written will contain the field names |Write Footer|If checked the last line written will contains the field names |Auto Size Columns|If checked the transform tries to automatically size the columns to fit their content. -Since this is not a feature the xls(x) file formats support directly, results may vary. +For `.xls`/`.xlsx` this is approximated by POI; for `.ods` the OpenDocument *optimal column width* flag is set. |Force formula recalculation a|If checked, the transform tries to make sure all formula fields in the output file are updated. * The xls file format supports a "dirty" flag that the transform sets. @@ -100,6 +116,7 @@ The formulas are recalculated as soon as the file is opened in MS Excel. * For the xlsx file format, the transform must try to recalculate the formula fields itself. Since the underlying POI library does not support the full set of Excel formulas yet, this may give errors. The transform will throw errors if it cannot recalculate the formulas. +* For `.ods`, formula results are cleared before save so Calc recalculates on open. Hop does not evaluate ODF formulas at write time. |Leave styles of existing cells unchanged|If checked, the transform will not try to set the style of existing cells it is writing to. This is useful when writing to pre-styled template sheets. |=== @@ -112,9 +129,8 @@ This is useful when writing to pre-styled template sheets. |Start writing at end of sheet|The transform will try to find the last line of the sheet, and start writing from there. |Offset by ... rows|Any non-0 number will cause the transform to move this amount of rows down (positive numbers) or up (negative numbers) before writing rows. Negative numbers may be useful if you need to append to a sheet, but still preserve a pre-styled footer. -|Begin by writing ... empty lines|The transform will try to find the last line of the sheet, and start writing from there. -|Omit Header|Any non-0 number will cause the transform to move this amount of rows down (positive numbers) or up (negative numbers) before writing rows. -Negative numbers may be useful if you need to append to a sheet, but still preserve a pre-styled footer. +|Begin by writing ... empty lines|When *shift existing cells down* is selected, empty rows are inserted at the write position instead of simply skipping ahead. +|Omit Header|Skip the header row when appending to an existing sheet. |=== *Fields section* @@ -135,12 +151,29 @@ The `ignore manual fields` ignores any fields manually defined in the transform' |Format|The Excel format to use in the sheet. Please consult the Excel manual for valid formats. There are some online references as well. +For `.ods`, common Excel format tokens are converted to OpenDocument equivalents where possible. |Style from cell|A cell (i.e. A1, B3 etc.) to copy the styling from for this column (usually some pre-styled cell in a template) |Field Title|If set, this is used for the Header/Footer instead of the Hop field name |Header/Footer style from cell|A cell to copy the styling from for headers/footers (usually some pre-styled cell in a template) -|Field Contains Formula|Set to Yes, if the field contains an Excel formula (no leading '=') +|Field Contains Formula|Set to Yes, if the field contains an Excel formula (no leading '='). +For `.ods`, Excel-style formulas are converted to OpenFormula syntax on a best-effort basis. |Hyperlink|A field, that contains the target to link to. The supported targets are Link to other cells, http, ftp, email, and local documents -|Cell Comment / Cell Author|The xlsx format allows to put comments on cells. -If you'd like to generate comments, you may specify fields holding the comment and author for a given column. +|Cell Comment / Cell Author|Comments are written for `.xlsx` and `.ods` (OpenDocument annotations). +Excel may not display ODS annotations; LibreOffice Calc does. |=== + +[[ods-limitations]] +== ODS output notes and limitations + +The `.ods` backend supports the same transform dialog options as `.xls`/`.xlsx` wherever the OpenDocument format allows. +Known differences: + +* *Formulas* — Excel syntax is converted to OpenFormula (`of:=...`). Complex Excel-only functions may not translate. Formula results are not calculated by Hop; LibreOffice Calc recalculates when the file is opened. +* *Comments* — Stored as ODF annotations. Visible in LibreOffice Calc; Microsoft Excel may ignore them in `.ods` files. +* *Hyperlinks* — Stored as ODF `text:a` elements. +* *Format masks* — Excel format strings are mapped to ODF number formats on a best-effort basis. +* *Style copy* — Copies the referenced cell's style name, not a full POI cell style object. +* *Sheet protection* — Uses ODF `table:protected` with SHA-1 password hash (LibreOffice Calc compatible). The *protected by user* field is not used for `.ods`. +* *Streaming* — The *Stream XLSX data* option applies to `.xlsx` only. +* *Sheet names* — The 31-character Excel sheet name limit is not enforced for `.ods`. diff --git a/integration-tests/transforms/0101-check-ods-file-exists.hpl b/integration-tests/transforms/0101-check-ods-file-exists.hpl new file mode 100644 index 00000000000..93c7a8e0d85 --- /dev/null +++ b/integration-tests/transforms/0101-check-ods-file-exists.hpl @@ -0,0 +1,175 @@ + + + + + 0101-check-ods-file-exists + Y + + + + Normal + + + N + 1000 + 100 + - + 2026/05/29 12:00:00.000 + - + 2026/05/29 12:00:00.000 + + + + + + File exists! + Cleanup temporary file + Y + + + Look for test ODS file + Detect empty stream + Y + + + Detect empty stream + Abort because expected file not found! + Y + + + Look for test ODS file + File exists! + Y + + + + Abort because expected file not found! + Abort + + Y + + 1 + + none + + + ABORT_WITH_ERROR + Y + Expected ODS file not found! + 0 + + + 336 + 368 + + + + Cleanup temporary file + ProcessFiles + + Y + + 1 + + none + + + N + N + delete + N + N + filename + + + 720 + 144 + + + + Detect empty stream + DetectEmptyStream + + Y + + 1 + + none + + + + + 336 + 256 + + + + File exists! + Dummy + + Y + + 1 + + none + + + + + 560 + 144 + + + + Look for test ODS file + GetFileNames + + N + + 1 + + none + + + N + N + + N + N + ${PROJECT_HOME}/files/excel/temp-ods-output.ods + + N + + all_files + + Y + 0 + N + N + + + 336 + 144 + + + + + + diff --git a/integration-tests/transforms/0101-create-ods-file.hpl b/integration-tests/transforms/0101-create-ods-file.hpl new file mode 100644 index 00000000000..55ec0ba6037 --- /dev/null +++ b/integration-tests/transforms/0101-create-ods-file.hpl @@ -0,0 +1,153 @@ + + + + + 0101-create-ods-file + Y + + + + Normal + + + N + 1000 + 100 + - + 2026/05/29 12:00:00.000 + - + 2026/05/29 12:00:00.000 + + + + + + Data grid + Write test ODS file + Y + + + + Data grid + DataGrid + + Y + + 1 + + none + + + + + A + + + + + -1 + -1 + N + f1 + String + + + + + 256 + 192 + + + + Write test ODS file + TypeExitExcelWriterTransform + + Y + + 1 + + none + + + Y + 0 + N + 0 + N + + + + + + N + + f1 + + f1 + + String + + + + N + N + N + N + Y + + Y + ods + + N + new + new + ${PROJECT_HOME}/files/excel/temp-ods-output + + N + + Sheet1 + N + 0 + N + +
N
+ N +
Y
+ N + Y + overwrite + A1 + + + + 448 + 192 + +
+ + + +
diff --git a/integration-tests/transforms/main-0101-excel-writer-output-ods.hwf b/integration-tests/transforms/main-0101-excel-writer-output-ods.hwf new file mode 100644 index 00000000000..8d91574fd45 --- /dev/null +++ b/integration-tests/transforms/main-0101-excel-writer-output-ods.hwf @@ -0,0 +1,187 @@ + + + + main-0101-excel-writer-output-ods + Y + Verify Excel Writer transform can write a basic ODS file (OpenDocument Spreadsheet). + + + - + 2026/05/29 12:00:00.000 + - + 2026/05/29 12:00:00.000 + + + + + Start + + SPECIAL + + 1 + 12 + 60 + 0 + 0 + N + 0 + 1 + N + 208 + 96 + + + + 0101-create-ods-file.hpl + + PIPELINE + + ${PROJECT_HOME}/0101-create-ods-file.hpl + N + N + N + N + N + + + N + N + Basic + N + Y + N + local + + Y + + N + 368 + 96 + + + + 0101-check-ods-file-exists.hpl + + PIPELINE + + ${PROJECT_HOME}/0101-check-ods-file-exists.hpl + N + N + N + N + N + + + N + N + Basic + N + Y + N + local + + Y + + N + 592 + 96 + + + + Dummy + + DUMMY + + N + 464 + 208 + + + + Success + + SUCCESS + + N + 752 + 96 + + + + Error + + ABORT + + N + Something wrong happened! + N + 464 + 320 + + + + + + Start + 0101-create-ods-file.hpl + Y + Y + Y + + + 0101-create-ods-file.hpl + 0101-check-ods-file-exists.hpl + Y + Y + N + + + 0101-create-ods-file.hpl + Dummy + Y + N + N + + + 0101-check-ods-file-exists.hpl + Dummy + Y + N + N + + + Dummy + Error + Y + Y + Y + + + 0101-check-ods-file-exists.hpl + Success + Y + Y + N + + + + + + diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterOutputFormat.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterOutputFormat.java new file mode 100644 index 00000000000..469a4719318 --- /dev/null +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterOutputFormat.java @@ -0,0 +1,39 @@ +/* + * 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.excelwriter; + +public final class ExcelWriterOutputFormat { + + public static final String EXT_XLS = "xls"; + public static final String EXT_XLSX = "xlsx"; + public static final String EXT_ODS = "ods"; + + private ExcelWriterOutputFormat() {} + + public static boolean isOds(String extension) { + return EXT_ODS.equalsIgnoreCase(extension); + } + + public static boolean isXlsx(String extension) { + return EXT_XLSX.equalsIgnoreCase(extension); + } + + public static boolean isXls(String extension) { + return EXT_XLS.equalsIgnoreCase(extension); + } +} diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterTransform.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterTransform.java index 86451308595..5d2f9b4660e 100644 --- a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterTransform.java +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterTransform.java @@ -43,6 +43,7 @@ import org.apache.hop.pipeline.PipelineMeta; import org.apache.hop.pipeline.transform.BaseTransform; import org.apache.hop.pipeline.transform.TransformMeta; +import org.apache.hop.pipeline.transforms.excelwriter.ods.OdsExcelWriter; import org.apache.hop.staticschema.metadata.SchemaDefinition; import org.apache.hop.staticschema.metadata.SchemaFieldDefinition; import org.apache.hop.staticschema.util.SchemaDefinitionUtil; @@ -84,6 +85,8 @@ public class ExcelWriterTransform public static final String CONST_COULDN_T_BE_FOUND_IN_THE_INPUT_STREAM = "] couldn't be found in the input stream!"; + private OdsExcelWriter odsExcelWriter; + public ExcelWriterTransform( TransformMeta transformMeta, ExcelWriterTransformMeta meta, @@ -282,7 +285,7 @@ public void closeFiles() throws HopException { data.usedFiles.clear(); } - private void createParentFolder(FileObject filename) throws Exception { + public void createParentFolder(FileObject filename) throws Exception { // Check for parent folder FileObject parentfolder = null; try { @@ -332,6 +335,10 @@ private void createParentFolder(FileObject filename) throws Exception { } private void closeOutputFile(ExcelWriterWorkbookDefinition file) throws HopException { + if (file.isOds()) { + getOdsExcelWriter().closeOutputFile(file); + return; + } OutputStream out = null; CountingOutputStream countingOut = null; try { @@ -436,6 +443,10 @@ void recalculateAllWorkbookFormulas(ExcelWriterWorkbookDefinition workbookDefini public void writeNextLine(ExcelWriterWorkbookDefinition workbookDefinition, Object[] r) throws HopException { + if (workbookDefinition.isOds()) { + getOdsExcelWriter().writeNextLine(workbookDefinition, r); + return; + } try { openLine(workbookDefinition.getSheet(), workbookDefinition.getPosY()); Row xlsRow = workbookDefinition.getSheet().getRow(workbookDefinition.getPosY()); @@ -795,10 +806,14 @@ public static void copyFile(FileObject in, FileObject out) throws HopException { } public void prepareNextOutputFile(Object[] row) throws HopException { + if (ExcelWriterOutputFormat.isOds(meta.getFile().getExtension())) { + getOdsExcelWriter().prepareNextOutputFile(row); + return; + } try { // Validation // - // sheet name shouldn't exceed 31 character + // sheet name shouldn't exceed 31 character (Excel limit) if (data.realSheetname != null && data.realSheetname.length() > 31) { throw new HopException( BaseMessages.getString( @@ -1217,7 +1232,7 @@ private FileObject getFileLocation(Object[] row) throws HopFileException { * @param fileName * @return */ - private int getNextSplitNr(String fileName) { + public int getNextSplitNr(String fileName) { int splitNr = 0; boolean fileFound = false; // Check if file exists and fetch max splitNr @@ -1236,4 +1251,23 @@ private int getNextSplitNr(String fileName) { } return splitNr; } + + OdsExcelWriter getOdsExcelWriter() { + if (odsExcelWriter == null) { + odsExcelWriter = new OdsExcelWriter(this); + } + return odsExcelWriter; + } + + public void recordBytesWritten(long written, FileObject file) { + dataVolumeOut = (dataVolumeOut != null ? dataVolumeOut : 0L) + written; + if (!data.isBeamContext() && written > 0 && file != null) { + try { + LineageFileIoEmitter.emitTransformFileIo( + this, FileIoOperation.WRITE, null, file, written, true, null); + } catch (Exception ignored) { + // optional lineage + } + } + } } diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterTransformDialog.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterTransformDialog.java index 1421d492c76..ac57e325a07 100644 --- a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterTransformDialog.java +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterTransformDialog.java @@ -177,6 +177,7 @@ public class ExcelWriterTransformDialog extends BaseTransformDialog { private static final String LABEL_FORMATXLSX = "ExcelWriterDialog.FormatXLSX.Label"; private static final String LABEL_FORMATXLS = "ExcelWriterDialog.FormatXLS.Label"; + private static final String LABEL_FORMATODS = "ExcelWriterDialog.FormatODS.Label"; public ExcelWriterTransformDialog( Shell parent, @@ -273,9 +274,11 @@ public void widgetSelected(SelectionEvent e) { String xlsLabel = BaseMessages.getString(PKG, LABEL_FORMATXLS); String xlsxLabel = BaseMessages.getString(PKG, LABEL_FORMATXLSX); - wExtension.setItems(new String[] {xlsLabel, xlsxLabel}); + String odsLabel = BaseMessages.getString(PKG, LABEL_FORMATODS); + wExtension.setItems(new String[] {xlsLabel, xlsxLabel, odsLabel}); wExtension.setData(xlsLabel, "xls"); wExtension.setData(xlsxLabel, "xlsx"); + wExtension.setData(odsLabel, "ods"); PropsUi.setLook(wExtension); wExtension.addModifyListener(lsMod); @@ -1555,10 +1558,11 @@ public void widgetSelected(SelectionEvent e) { shell, wFilename, variables, - new String[] {"*.xlsx", "*.xls", "*.*"}, + new String[] {"*.xlsx", "*.xls", "*.ods", "*.*"}, new String[] { BaseMessages.getString(PKG, LABEL_FORMATXLSX), BaseMessages.getString(PKG, LABEL_FORMATXLS), + BaseMessages.getString(PKG, LABEL_FORMATODS), BaseMessages.getString(PKG, "System.FileType.AllFiles") }, true)); @@ -1569,10 +1573,11 @@ public void widgetSelected(SelectionEvent e) { shell, wTemplateFilename, variables, - new String[] {"*.xlsx", "*.xls", "*.*"}, + new String[] {"*.xlsx", "*.xls", "*.ods", "*.*"}, new String[] { BaseMessages.getString(PKG, LABEL_FORMATXLSX), BaseMessages.getString(PKG, LABEL_FORMATXLS), + BaseMessages.getString(PKG, LABEL_FORMATODS), BaseMessages.getString(PKG, "System.FileType.AllFiles") }, true)); @@ -1739,9 +1744,10 @@ public void getData() { } wDoNotOpenNewFileInit.setSelection(file.isDoNotOpenNewFileInit()); if (file.getExtension() != null) { - if (file.getExtension().equals("xlsx")) { wExtension.select(1); + } else if (file.getExtension().equals("ods")) { + wExtension.select(2); } else { wExtension.select(0); } @@ -2012,15 +2018,22 @@ private void enableIgnorefiedls() { } private void enableExtension() { - wProtectSheet.setEnabled(wExtension.getSelectionIndex() == 0); - if (wExtension.getSelectionIndex() == 0) { + int extensionIndex = wExtension.getSelectionIndex(); + boolean xlsFormat = extensionIndex == 0; + boolean odsFormat = extensionIndex == 2; + wProtectSheet.setEnabled(xlsFormat || odsFormat); + if (xlsFormat || odsFormat) { wPassword.setEnabled(wProtectSheet.getSelection()); - wProtectedBy.setEnabled(wProtectSheet.getSelection()); + wProtectedBy.setEnabled(wProtectSheet.getSelection() && xlsFormat); wStreamData.setEnabled(false); - } else { + } else if (extensionIndex == 1) { wPassword.setEnabled(false); wProtectedBy.setEnabled(false); wStreamData.setEnabled(true); + } else { + wPassword.setEnabled(false); + wProtectedBy.setEnabled(false); + wStreamData.setEnabled(false); } } diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterWorkbookDefinition.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterWorkbookDefinition.java index 9ec2e117799..ea21c651281 100644 --- a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterWorkbookDefinition.java +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ExcelWriterWorkbookDefinition.java @@ -18,6 +18,7 @@ package org.apache.hop.pipeline.transforms.excelwriter; import org.apache.commons.vfs2.FileObject; +import org.apache.hop.pipeline.transforms.excelwriter.ods.OdsWorkbookHandle; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; @@ -28,12 +29,15 @@ public class ExcelWriterWorkbookDefinition { private String fileName; private Workbook workbook; private Sheet sheet; + private OdsWorkbookHandle odsWorkbookHandle; private int posX; private int posY; private int datalines; private int splitNr; private CellStyle[] cellStyleCache; private CellStyle[] cellLinkStyleCache; + private String[] odsStyleNameCache; + private String[] odsLinkStyleNameCache; public ExcelWriterWorkbookDefinition( String fileName, FileObject file, Workbook workbook, Sheet sheet, int posX, int posY) { @@ -47,6 +51,21 @@ public ExcelWriterWorkbookDefinition( this.splitNr = 0; } + public ExcelWriterWorkbookDefinition( + String fileName, FileObject file, OdsWorkbookHandle odsWorkbookHandle, int posX, int posY) { + this.fileName = fileName; + this.file = file; + this.odsWorkbookHandle = odsWorkbookHandle; + this.posX = posX; + this.posY = posY; + this.datalines = 0; + this.splitNr = 0; + } + + public boolean isOds() { + return odsWorkbookHandle != null; + } + public FileObject getFile() { return file; } @@ -71,6 +90,14 @@ public void setSheet(Sheet sheet) { this.sheet = sheet; } + public OdsWorkbookHandle getOdsWorkbookHandle() { + return odsWorkbookHandle; + } + + public void setOdsWorkbookHandle(OdsWorkbookHandle odsWorkbookHandle) { + this.odsWorkbookHandle = odsWorkbookHandle; + } + public int getPosX() { return posX; } @@ -122,6 +149,24 @@ public void incrementX() { public void clearStyleCache(int nrFields) { cellStyleCache = new CellStyle[nrFields]; cellLinkStyleCache = new CellStyle[nrFields]; + odsStyleNameCache = new String[nrFields]; + odsLinkStyleNameCache = new String[nrFields]; + } + + public void cacheOdsStyle(int fieldNr, String styleName) { + odsStyleNameCache[fieldNr] = styleName; + } + + public void cacheOdsLinkStyle(int fieldNr, String styleName) { + odsLinkStyleNameCache[fieldNr] = styleName; + } + + public String getCachedOdsStyle(int fieldNr) { + return odsStyleNameCache[fieldNr]; + } + + public String getCachedOdsLinkStyle(int fieldNr) { + return odsLinkStyleNameCache[fieldNr]; } public void cacheStyle(int fieldNr, CellStyle style) { diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriter.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriter.java new file mode 100644 index 00000000000..9c406c122cd --- /dev/null +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriter.java @@ -0,0 +1,628 @@ +/* + * 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.excelwriter.ods; + +import java.io.BufferedOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Calendar; +import org.apache.commons.vfs2.FileObject; +import org.apache.hop.core.ResultFile; +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.exception.HopFileException; +import org.apache.hop.core.io.CountingOutputStream; +import org.apache.hop.core.row.IValueMeta; +import org.apache.hop.core.row.value.ValueMetaString; +import org.apache.hop.core.util.Utils; +import org.apache.hop.core.vfs.HopVfs; +import org.apache.hop.i18n.BaseMessages; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterOutputField; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransform; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransformData; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransformMeta; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterWorkbookDefinition; +import org.apache.poi.ss.util.CellReference; +import org.odftoolkit.odfdom.doc.OdfSpreadsheetDocument; +import org.odftoolkit.odfdom.doc.table.OdfTable; +import org.odftoolkit.odfdom.doc.table.OdfTableCell; +import org.odftoolkit.odfdom.dom.element.table.TableTableElement; +import org.w3c.dom.Node; + +/** + * ODS output backend for {@link ExcelWriterTransform}. Supports basic cell values, styling, + * hyperlinks, comments, formulas, headers, footers, append, templates, starting cell, sheet clone, + * row push-down, auto-size columns, and active sheet selection. + */ +public class OdsExcelWriter { + + private static final Class PKG = ExcelWriterTransformMeta.class; + + private final ExcelWriterTransform transform; + private final ExcelWriterTransformMeta meta; + private final ExcelWriterTransformData data; + + public OdsExcelWriter(ExcelWriterTransform transform) { + this.transform = transform; + this.meta = transform.getMeta(); + this.data = transform.getData(); + } + + public void prepareNextOutputFile(Object[] row) throws HopException { + try { + if (data.isBeamContext() && meta.getFile().isFileNameInField()) { + throw new HopException( + BaseMessages.getString( + PKG, "ExcelWriterTransform.Exception.FilenameFromFieldNotSupportedInBeam")); + } + + int numOfFields = !Utils.isEmpty(meta.getOutputFields()) ? meta.getOutputFields().size() : 0; + if (numOfFields == 0) { + numOfFields = data.inputRowMeta != null ? data.inputRowMeta.size() : 0; + } + + int splitNr = 0; + if (!meta.getFile().isFileNameInField()) { + splitNr = transform.getNextSplitNr(meta.getFile().getFileName()); + } + + FileObject file = getFileLocation(row); + + if (!file.getParent().exists() && meta.getFile().isCreateParentFolder()) { + transform.createParentFolder(file); + } + + if (transform.isDebug()) { + transform.logDebug( + BaseMessages.getString( + PKG, "ExcelWriterTransform.Log.OpeningFile", file.getName().toString())); + } + + if (file.exists() && data.createNewFile && !file.delete()) { + if (transform.isBasic()) { + transform.logBasic( + BaseMessages.getString( + PKG, + "ExcelWriterTransform.Log.CouldNotDeleteStaleFile", + file.getName().toString())); + } + transform.setErrors(1); + throw new HopException("Could not delete stale file " + file.getName().toString()); + } + + if (meta.isAddToResultFilenames()) { + ResultFile resultFile = + new ResultFile( + ResultFile.FILE_TYPE_GENERAL, + file, + transform.getPipelineMeta().getName(), + transform.getTransformName()); + resultFile.setComment( + "This file was created with an Excel writer transform by Hop : The Hop Orchestration Platform"); + transform.addResultFile(resultFile); + } + + boolean appendingToSheet = true; + if (!file.exists()) { + if (meta.getTemplate().isTemplateEnabled()) { + ensureTemplateExtensionMatches(); + FileObject templateFile = HopVfs.getFileObject(data.realTemplateFileName, transform); + if (templateFile.exists()) { + ExcelWriterTransform.copyFile(templateFile, file); + } else { + if (transform.isBasic()) { + transform.logBasic( + BaseMessages.getString( + PKG, "ExcelWriterTransform.Log.TemplateMissing", data.realTemplateFileName)); + } + transform.setErrors(1); + throw new HopException("Template file missing: " + data.realTemplateFileName); + } + } else { + createEmptyOdsFile(file); + } + appendingToSheet = false; + } + + OdfSpreadsheetDocument document; + try (InputStream inputStream = HopVfs.getInputStream(HopVfs.getFilename(file), transform)) { + document = OdfSpreadsheetDocument.loadDocument(inputStream); + } + + ResolvedTable resolved = resolveTable(document); + OdfTable table = resolved.table(); + if (resolved.newlyCreated()) { + appendingToSheet = false; + } + + if (!Utils.isEmpty(data.realStartingCell)) { + CellReference cellRef = new CellReference(data.realStartingCell); + data.startingRow = cellRef.getRow(); + data.startingCol = cellRef.getCol(); + } else { + data.startingRow = 0; + data.startingCol = 0; + } + + int posX = data.startingCol; + int posY = data.startingRow; + + if (!data.createNewSheet && meta.isAppendLines() && appendingToSheet) { + posY = findLastUsedRow(table) + 1; + } + + if (!data.createNewSheet && meta.getAppendOffset() != 0 && appendingToSheet) { + posY += meta.getAppendOffset(); + } + + if (!data.createNewSheet && meta.getAppendEmpty() > 0 && appendingToSheet) { + for (int i = 0; i < meta.getAppendEmpty(); i++) { + openLine(table, posY); + if (!data.shiftExistingCells || meta.isAppendLines()) { + posY++; + } + } + } + + if (meta.getFile().isProtectsheet()) { + OdsTableHelper.protectTable(table, data.realPassword); + } + + String baseFileName = + !meta.getFile().isFileNameInField() + ? meta.getFile().getFileName() + : file.getName().toString(); + + int startY = + !Utils.isEmpty(data.realStartingCell) ? posY : Math.max(posY, findLastUsedRow(table)); + + ExcelWriterWorkbookDefinition workbookDefinition = + prepareWorkbookDefinition( + numOfFields, splitNr, file, document, table, posX, baseFileName, startY); + + if (meta.isHeaderEnabled() + && !(!data.createNewSheet && meta.isAppendOmitHeader() && appendingToSheet)) { + writeHeader(workbookDefinition, posX, posY); + } + + if (transform.isDebug()) { + transform.logDebug( + BaseMessages.getString( + PKG, "ExcelWriterTransform.Log.FileOpened", file.getName().toString())); + } + } catch (Exception e) { + transform.logError("Error opening new ODS file", e); + transform.setErrors(1); + throw new HopException("Error opening new ODS file", e); + } + } + + public void writeNextLine(ExcelWriterWorkbookDefinition workbookDefinition, Object[] row) + throws HopException { + try { + OdfTable table = workbookDefinition.getOdsWorkbookHandle().getTable(); + openLine(table, workbookDefinition.getPosY()); + int rowIndex = workbookDefinition.getPosY(); + + if (Utils.isEmpty(meta.getOutputFields())) { + int nr = data.inputRowMeta.size(); + int x = workbookDefinition.getPosX(); + for (int i = 0; i < nr; i++) { + writeField( + workbookDefinition, + table, + rowIndex, + x++, + row[i], + data.inputRowMeta.getValueMeta(i), + null, + row, + i, + false); + } + workbookDefinition.setPosX(data.startingCol); + workbookDefinition.incrementY(); + } else { + int x = workbookDefinition.getPosX(); + for (int i = 0; i < meta.getOutputFields().size(); i++) { + ExcelWriterOutputField field = meta.getOutputFields().get(i); + writeField( + workbookDefinition, + table, + rowIndex, + x++, + row[data.fieldnrs[i]], + data.inputRowMeta.getValueMeta(data.fieldnrs[i]), + field, + row, + i, + false); + } + workbookDefinition.setPosX(data.startingCol); + workbookDefinition.incrementY(); + } + } catch (Exception e) { + transform.logError("Error writing ODS line: " + e); + throw new HopException(e); + } + } + + public void closeOutputFile(ExcelWriterWorkbookDefinition fileDefinition) throws HopException { + OutputStream out = null; + CountingOutputStream countingOut = null; + try { + countingOut = + new CountingOutputStream(HopVfs.getOutputStream(fileDefinition.getFile(), false)); + out = new BufferedOutputStream(countingOut); + + if (meta.isFooterEnabled()) { + writeHeader(fileDefinition, fileDefinition.getPosX(), fileDefinition.getPosY()); + } + + OdfSpreadsheetDocument document = fileDefinition.getOdsWorkbookHandle().getDocument(); + OdfTable table = fileDefinition.getOdsWorkbookHandle().getTable(); + if (meta.getFile().isAutosizecolums()) { + int columnCount = + !Utils.isEmpty(meta.getOutputFields()) + ? meta.getOutputFields().size() + : (data.inputRowMeta != null ? data.inputRowMeta.size() : 0); + OdsTableHelper.autoSizeColumns(table, data.startingCol, columnCount); + } + if (meta.isForceFormulaRecalculation()) { + OdsFormulaHelper.prepareForRecalculation(document); + } + document.save(out); + document.close(); + } catch (Exception e) { + throw new HopException(e); + } finally { + if (out != null) { + try { + out.flush(); + if (countingOut != null) { + long written = countingOut.getCount(); + transform.recordBytesWritten(written, fileDefinition.getFile()); + } + out.close(); + } catch (Exception e) { + throw new HopException("Error closing ODS file " + fileDefinition.getFile(), e); + } + } + } + } + + private void writeHeader(ExcelWriterWorkbookDefinition workbookDefinition, int posX, int posY) + throws HopException { + try { + OdfTable table = workbookDefinition.getOdsWorkbookHandle().getTable(); + openLine(table, posY); + int x = posX; + if (!Utils.isEmpty(meta.getOutputFields())) { + for (int i = 0; i < meta.getOutputFields().size(); i++) { + ExcelWriterOutputField field = meta.getOutputFields().get(i); + String fieldName = !Utils.isEmpty(field.getTitle()) ? field.getTitle() : field.getName(); + writeField( + workbookDefinition, + table, + posY, + x++, + fieldName, + new ValueMetaString(fieldName), + field, + null, + i, + true); + } + } else if (data.inputRowMeta != null) { + for (int i = 0; i < data.inputRowMeta.size(); i++) { + String fieldName = data.inputRowMeta.getFieldNames()[i]; + writeField( + workbookDefinition, + table, + posY, + x++, + fieldName, + new ValueMetaString(fieldName), + null, + null, + -1, + true); + } + } + workbookDefinition.setPosY(posY + 1); + transform.incrementLinesOutput(); + } catch (Exception e) { + throw new HopException(e); + } + } + + private void writeField( + ExcelWriterWorkbookDefinition workbookDefinition, + OdfTable table, + int rowIndex, + int colIndex, + Object value, + IValueMeta vMeta, + ExcelWriterOutputField excelField, + Object[] row, + int fieldNr, + boolean isTitle) + throws Exception { + OdfSpreadsheetDocument document = workbookDefinition.getOdsWorkbookHandle().getDocument(); + OdfTableCell cell = table.getCellByPosition(colIndex, rowIndex); + boolean cellExisted = OdsStyleHelper.cellHadContent(cell); + + if (!(cellExisted && meta.isLeaveExistingStylesUnchanged())) { + if (!isTitle + && fieldNr >= 0 + && !Utils.isEmpty(workbookDefinition.getCachedOdsStyle(fieldNr))) { + applyStyleName(cell, workbookDefinition.getCachedOdsStyle(fieldNr)); + } else { + if (excelField != null) { + String styleRef = null; + if (!isTitle && !Utils.isEmpty(excelField.getStyleCell())) { + styleRef = excelField.getStyleCell(); + } else if (isTitle && !Utils.isEmpty(excelField.getTitleStyleCell())) { + styleRef = excelField.getTitleStyleCell(); + } + if (styleRef != null) { + OdfTableCell styleCell = OdsStyleHelper.getCellFromReference(document, table, styleRef); + if (styleCell != null && styleCell != cell) { + OdsStyleHelper.copyStyle(cell, styleCell); + } + } + } + + if (!isTitle && fieldNr >= 0) { + workbookDefinition.cacheOdsStyle(fieldNr, cell.getStyleName()); + } + } + } + + boolean isFormulaField = !isTitle && excelField != null && excelField.isFormula(); + + if (!isFormulaField) { + setCellValue(cell, value, vMeta); + } + + if (!(cellExisted && meta.isLeaveExistingStylesUnchanged())) { + if (!isTitle + && excelField != null + && !Utils.isEmpty(excelField.getFormat()) + && !excelField.getFormat().startsWith("Image")) { + OdsStyleHelper.applyFormat(cell, excelField.getFormat()); + } else if (!isTitle + && excelField != null + && Utils.isEmpty(excelField.getFormat()) + && (vMeta.getType() == IValueMeta.TYPE_DATE + || vMeta.getType() == IValueMeta.TYPE_TIMESTAMP)) { + String format = vMeta.getFormatMask(); + if (!Utils.isEmpty(format)) { + OdsStyleHelper.applyFormat(cell, format); + } + } + } + + if (!isTitle && excelField != null && fieldNr >= 0 && data.linkfieldnrs[fieldNr] >= 0) { + String link = + data.inputRowMeta + .getValueMeta(data.linkfieldnrs[fieldNr]) + .getString(row[data.linkfieldnrs[fieldNr]]); + if (!Utils.isEmpty(link)) { + String displayText = value != null ? vMeta.getString(value) : ""; + if (!(cellExisted && meta.isLeaveExistingStylesUnchanged())) { + if (!Utils.isEmpty(workbookDefinition.getCachedOdsLinkStyle(fieldNr))) { + applyStyleName(cell, workbookDefinition.getCachedOdsLinkStyle(fieldNr)); + } + } + OdsStyleHelper.applyHyperlink(document, table, cell, link, displayText); + if (!(cellExisted && meta.isLeaveExistingStylesUnchanged()) && fieldNr >= 0) { + workbookDefinition.cacheOdsLinkStyle(fieldNr, cell.getStyleName()); + } + } + } + + if (!isTitle && excelField != null && fieldNr >= 0 && data.commentfieldnrs[fieldNr] >= 0) { + String comment = + data.inputRowMeta + .getValueMeta(data.commentfieldnrs[fieldNr]) + .getString(row[data.commentfieldnrs[fieldNr]]); + if (!Utils.isEmpty(comment)) { + String author = + data.commentauthorfieldnrs[fieldNr] >= 0 + ? data.inputRowMeta + .getValueMeta(data.commentauthorfieldnrs[fieldNr]) + .getString(row[data.commentauthorfieldnrs[fieldNr]]) + : "Apache Hop"; + OdsStyleHelper.applyComment(cell, author, comment); + } + } + + if (isFormulaField && value != null) { + OdsFormulaHelper.applyFormula(cell, vMeta.getString(value)); + } + } + + private void applyStyleName(OdfTableCell cell, String styleName) { + if (cell != null && !Utils.isEmpty(styleName)) { + ((org.odftoolkit.odfdom.dom.element.OdfStylableElement) cell.getOdfElement()) + .setStyleName(styleName); + } + } + + private void setCellValue(OdfTableCell cell, Object value, IValueMeta vMeta) throws Exception { + if (value == null) { + return; + } + switch (vMeta.getType()) { + case IValueMeta.TYPE_DATE, IValueMeta.TYPE_TIMESTAMP -> { + if (vMeta.getDate(value) != null) { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(vMeta.getDate(value)); + cell.setDateValue(calendar); + } + } + case IValueMeta.TYPE_BOOLEAN -> cell.setBooleanValue(vMeta.getBoolean(value)); + case IValueMeta.TYPE_BIGNUMBER, IValueMeta.TYPE_NUMBER, IValueMeta.TYPE_INTEGER -> + cell.setDoubleValue(vMeta.getNumber(value)); + default -> cell.setStringValue(vMeta.getString(value)); + } + } + + private void createEmptyOdsFile(FileObject file) throws Exception { + OdfSpreadsheetDocument document = OdfSpreadsheetDocument.newSpreadsheetDocument(); + try { + OdfTable table = document.getTableByName(data.realSheetname); + if (table == null) { + if (!document.getTableList().isEmpty()) { + table = document.getTableList().get(0); + table.setTableName(data.realSheetname); + } else { + table = OdfTable.newTable(document, 1, 1); + table.setTableName(data.realSheetname); + } + } + try (OutputStream out = HopVfs.getOutputStream(file, false)) { + document.save(out); + } + } finally { + document.close(); + } + } + + private void openLine(OdfTable table, int rowIndex) { + if (data.shiftExistingCells) { + OdsTableHelper.shiftRowsDown(table, rowIndex); + } + } + + private ResolvedTable resolveTable(OdfSpreadsheetDocument document) throws Exception { + String existingActiveTable = OdsTableHelper.getActiveTableName(document); + int replacingTableAt = -1; + boolean newlyCreated = false; + + OdfTable table = document.getTableByName(data.realSheetname); + if (table != null && data.createNewSheet) { + replacingTableAt = OdsTableHelper.getTableIndex(document, data.realSheetname); + removeTable(document, table); + table = null; + } + + if (table == null) { + if (meta.getTemplate().isTemplateSheetEnabled()) { + OdfTable templateTable = document.getTableByName(data.realTemplateSheetName); + if (templateTable == null) { + throw new HopException( + BaseMessages.getString( + PKG, + "ExcelWriterTransform.Exception.TemplateNotFound", + data.realTemplateSheetName)); + } + table = OdsTableHelper.cloneTable(document, templateTable, data.realSheetname); + if (meta.getTemplate().isTemplateSheetHidden()) { + OdsTableHelper.setTableVisible(templateTable, false); + } + } else { + table = OdfTable.newTable(document, 1, 1); + table.setTableName(data.realSheetname); + } + newlyCreated = true; + if (replacingTableAt > -1) { + OdsTableHelper.moveTableToIndex(document, table, replacingTableAt); + } + if (!Utils.isEmpty(existingActiveTable) && !meta.isMakeSheetActive()) { + OdsTableHelper.setActiveTableName(document, existingActiveTable); + } + } + + if (meta.isMakeSheetActive()) { + OdsTableHelper.setActiveTableName(document, data.realSheetname); + } + return new ResolvedTable(table, newlyCreated); + } + + private record ResolvedTable(OdfTable table, boolean newlyCreated) {} + + private void removeTable(OdfSpreadsheetDocument document, OdfTable table) { + TableTableElement tableElement = table.getOdfElement(); + Node parent = tableElement.getParentNode(); + if (parent != null) { + parent.removeChild(tableElement); + } + } + + private int findLastUsedRow(OdfTable table) { + int rowCount = table.getRowCount(); + for (int row = rowCount - 1; row >= 0; row--) { + if (rowHasContent(table, row)) { + return row; + } + } + return -1; + } + + private boolean rowHasContent(OdfTable table, int rowIndex) { + int columnCount = table.getColumnCount(); + for (int col = 0; col < columnCount; col++) { + OdfTableCell cell = table.getCellByPosition(col, rowIndex); + if (cell != null && !Utils.isEmpty(cell.getDisplayText())) { + return true; + } + } + return false; + } + + private void ensureTemplateExtensionMatches() throws HopException { + String templateExt = + HopVfs.getFileObject(data.realTemplateFileName, transform).getName().getExtension(); + if (!meta.getFile().getExtension().equalsIgnoreCase(templateExt)) { + throw new HopException( + "Template Format Mismatch: Template has extension: " + + templateExt + + ", but output file has extension: " + + meta.getFile().getExtension() + + ". Template and output file must share the same format!"); + } + } + + private ExcelWriterWorkbookDefinition prepareWorkbookDefinition( + int numOfFields, + int splitNr, + FileObject file, + OdfSpreadsheetDocument document, + OdfTable table, + int posX, + String baseFileName, + int startY) { + OdsWorkbookHandle handle = new OdsWorkbookHandle(document, table); + ExcelWriterWorkbookDefinition workbookDefinition = + new ExcelWriterWorkbookDefinition(baseFileName, file, handle, posX, startY); + workbookDefinition.setSplitNr(splitNr); + data.usedFiles.add(workbookDefinition); + data.currentWorkbookDefinition = workbookDefinition; + workbookDefinition.clearStyleCache(numOfFields); + return workbookDefinition; + } + + private FileObject getFileLocation(Object[] row) throws HopFileException { + String buildFilename = + (!meta.getFile().isFileNameInField()) + ? transform.buildFilename(transform.getNextSplitNr(meta.getFile().getFileName())) + : transform.buildFilename(data.inputRowMeta, row); + return HopVfs.getFileObject(buildFilename, transform); + } +} diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormatConverter.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormatConverter.java new file mode 100644 index 00000000000..94ca2055cf7 --- /dev/null +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormatConverter.java @@ -0,0 +1,39 @@ +/* + * 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.excelwriter.ods; + +import org.apache.hop.core.util.Utils; + +/** Best-effort conversion from Excel-style format masks to ODF format strings. */ +final class OdsFormatConverter { + + private OdsFormatConverter() {} + + static String toOdfFormat(String excelFormat) { + if (Utils.isEmpty(excelFormat)) { + return excelFormat; + } + return excelFormat + .replace("yyyy", "YYYY") + .replace("yy", "YY") + .replace("dd", "DD") + .replace("hh", "HH") + .replace("mm", "MM") + .replace("ss", "SS"); + } +} diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaConverter.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaConverter.java new file mode 100644 index 00000000000..bc0924067f9 --- /dev/null +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaConverter.java @@ -0,0 +1,96 @@ +/* + * 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.excelwriter.ods; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.hop.core.util.Utils; + +/** Converts Excel-style formula strings to OpenFormula syntax for ODS output. */ +public final class OdsFormulaConverter { + + private static final Pattern SHEET_CELL = + Pattern.compile( + "((?:'[^']+'|[A-Za-z0-9_]+))!(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)", + Pattern.CASE_INSENSITIVE); + + private static final Pattern CELL_RANGE = + Pattern.compile( + "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+):(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)", + Pattern.CASE_INSENSITIVE); + + private static final Pattern CELL_REFERENCE = + Pattern.compile("(? tables = document.getTableList(); + for (int i = 0; i < tables.size(); i++) { + if (tableName.equals(tables.get(i).getTableName())) { + return i; + } + } + return -1; + } + + static void moveTableToIndex(OdfSpreadsheetDocument document, OdfTable table, int targetIndex) { + List tables = document.getTableList(); + if (targetIndex < 0 || targetIndex >= tables.size()) { + return; + } + TableTableElement element = table.getOdfElement(); + Node parent = element.getParentNode(); + Node reference = tables.get(targetIndex).getOdfElement(); + if (reference == element) { + return; + } + parent.removeChild(element); + parent.insertBefore(element, reference); + } + + static String getActiveTableName(OdfSpreadsheetDocument document) throws Exception { + OdfSettingsDom settingsDom = document.getSettingsDom(); + if (settingsDom == null || settingsDom.getRootElement() == null) { + return null; + } + return findConfigItemValue(settingsDom.getRootElement(), ACTIVE_TABLE); + } + + static void setActiveTableName(OdfSpreadsheetDocument document, String tableName) + throws Exception { + if (Utils.isEmpty(tableName)) { + return; + } + OdfSettingsDom settingsDom = document.getSettingsDom(); + if (settingsDom == null || settingsDom.getRootElement() == null) { + return; + } + if (!updateConfigItemValue(settingsDom.getRootElement(), ACTIVE_TABLE, tableName)) { + // Settings from minimal documents may not contain view settings yet; ignore quietly. + } + } + + static void shiftRowsDown(OdfTable table, int rowIndex) { + if (rowIndex < 0) { + return; + } + table.insertRowsBefore(rowIndex, 1); + } + + static void autoSizeColumns(OdfTable table, int startColumn, int columnCount) { + if (columnCount <= 0) { + return; + } + for (int i = 0; i < columnCount; i++) { + int columnIndex = startColumn + i; + if (columnIndex < 0 || columnIndex >= table.getColumnCount()) { + continue; + } + OdfTableColumn column = table.getColumnByIndex(columnIndex); + if (column != null) { + column.setUseOptimalWidth(true); + } + } + } + + static void protectTable(OdfTable table, String password) throws Exception { + TableTableElement element = table.getOdfElement(); + element.setTableProtectedAttribute(true); + if (Utils.isEmpty(password)) { + return; + } + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + digest.update(password.getBytes(StandardCharsets.UTF_8)); + element.setTableProtectionKeyAttribute(Base64.getEncoder().encodeToString(digest.digest())); + element.setTableProtectionKeyDigestAlgorithmAttribute(SHA1_DIGEST_ALGORITHM); + } + + private static String findConfigItemValue(Node node, String itemName) { + if (node instanceof ConfigConfigItemElement configItem + && itemName.equals(configItem.getConfigNameAttribute())) { + return configItem.getTextContent(); + } + NodeList children = node.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + String value = findConfigItemValue(children.item(i), itemName); + if (!Utils.isEmpty(value)) { + return value; + } + } + return null; + } + + private static boolean updateConfigItemValue(Node node, String itemName, String value) { + if (node instanceof ConfigConfigItemElement configItem + && itemName.equals(configItem.getConfigNameAttribute())) { + configItem.setTextContent(value); + return true; + } + NodeList children = node.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + if (updateConfigItemValue(children.item(i), itemName, value)) { + return true; + } + } + return false; + } +} diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsWorkbookHandle.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsWorkbookHandle.java new file mode 100644 index 00000000000..b281c9e5361 --- /dev/null +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsWorkbookHandle.java @@ -0,0 +1,45 @@ +/* + * 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.excelwriter.ods; + +import org.odftoolkit.odfdom.doc.OdfSpreadsheetDocument; +import org.odftoolkit.odfdom.doc.table.OdfTable; + +/** Holds ODF-specific workbook state for the Excel Writer transform. */ +public class OdsWorkbookHandle { + + private final OdfSpreadsheetDocument document; + private OdfTable table; + + public OdsWorkbookHandle(OdfSpreadsheetDocument document, OdfTable table) { + this.document = document; + this.table = table; + } + + public OdfSpreadsheetDocument getDocument() { + return document; + } + + public OdfTable getTable() { + return table; + } + + public void setTable(OdfTable table) { + this.table = table; + } +} diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_de_DE.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_de_DE.properties index e9da6613c3d..a3490c03520 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_de_DE.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_de_DE.properties @@ -136,7 +136,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=\u00DCbergeordneten Ordner an ExcelWriterMeta.Injection.DateInFilename.Field=Datum zu Dateinamen hinzuf\u00FCgen ? ExcelWriterMeta.Injection.DateTimeFormat.Field=Datetime Format Feld ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=Mit Erzeugung der Datei auf erste Zeile warten ? -ExcelWriterMeta.Injection.Extension.Field=Dateierweiterung (xls/xlsx) +ExcelWriterMeta.Injection.Extension.Field=Dateierweiterung (xls/xlsx/ods) ExcelWriterMeta.Injection.Field=Feld ExcelWriterMeta.Injection.Fields=Felder ExcelWriterMeta.Injection.FileName.Field=Dateiname @@ -161,7 +161,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=Kopf-/Fu\u00DFzeilenstil a ExcelWriterMeta.Injection.Output.Type.Field=Typ ExcelWriterMeta.Injection.Password.Field=Passwort ExcelWriterMeta.Injection.ProtectedBy.Field=gesch\u00FCtzt durch Benutzer -ExcelWriterMeta.Injection.ProtectSheet.Field=Blatt sch\u00FCtzen ( nur XLS Format ) ? +ExcelWriterMeta.Injection.ProtectSheet.Field=Blatt sch\u00FCtzen (XLS- und ODS-Format)? ExcelWriterMeta.Injection.RowWritingMethod.Field=Schreibmethode f\u00FCr Zeilen (\u00FCberschreiben/push) ExcelWriterMeta.Injection.SchemaDefinition.Field=Schemadefinition ExcelWriterMeta.Injection.SheetName.Field=Blattname diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_en_US.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_en_US.properties index 54267843864..1122ae2b2c5 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_en_US.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_en_US.properties @@ -58,6 +58,7 @@ ExcelWriterDialog.ForceFormulaRecalculation.Tooltip=Check this if you want Hop t ExcelWriterDialog.FormatColumn.Column=Format ExcelWriterDialog.FormatXLS.Label=xls [Excel 97 and above] ExcelWriterDialog.FormatXLSX.Label=xlsx [Excel 2007 and above] +ExcelWriterDialog.FormatODS.Label=ods [OpenDocument Spreadsheet] ExcelWriterDialog.FormulaField.Column=Field contains formula ExcelWriterDialog.Header.Label=Write Header ExcelWriterDialog.Header.Tooltip=Writes the field names (or field titles if specified) as the first row of the output. @@ -88,8 +89,8 @@ ExcelWriterDialog.Password.Label=Password ExcelWriterDialog.Password.Tooltip=Password to protect the sheet ExcelWriterDialog.ProtectedBy.Label=Protected by user ExcelWriterDialog.ProtectedBy.Tooltip=The name of the user protecting the sheet -ExcelWriterDialog.ProtectSheet.Label=Protect sheet? (XLS format only) -ExcelWriterDialog.ProtectSheet.Tooltip=Lock the sheet for modification by setting a password +ExcelWriterDialog.ProtectSheet.Label=Protect sheet? (XLS and ODS formats) +ExcelWriterDialog.ProtectSheet.Tooltip=Lock the sheet for modification by setting a password. Supported for XLS and ODS output. The protected-by user field applies to XLS only. XLSX sheet protection is not supported. ExcelWriterDialog.RowWritingMethod.Label=When writing rows ExcelWriterDialog.RowWritingMethod.Overwrite.Label=overwrite existing cells ExcelWriterDialog.RowWritingMethod.PushDown.Label=shift existing cells down @@ -136,7 +137,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=Create parent folder ExcelWriterMeta.Injection.DateInFilename.Field=Include date in filename? ExcelWriterMeta.Injection.DateTimeFormat.Field=Date time format field ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=Wait for first row before creating new file? -ExcelWriterMeta.Injection.Extension.Field=Extension (xls/xlsx) +ExcelWriterMeta.Injection.Extension.Field=Extension (xls/xlsx/ods) ExcelWriterMeta.Injection.Field=Field ExcelWriterMeta.Injection.Fields=Fields ExcelWriterMeta.Injection.FileName.Field=Filename @@ -149,8 +150,8 @@ ExcelWriterMeta.Injection.IfFileExists.Field=If output file exists (reuse/new)? ExcelWriterMeta.Injection.IfSheetExists.Field=If sheet exists in output file (reuse/new)? ExcelWriterMeta.Injection.LeaveExistingStylesUnchanged.Field=Leave styles of existing cells unchanged? ExcelWriterMeta.Injection.MakeSheetActive.Field=Make this the active sheet? -ExcelWriterMeta.Injection.Output.Comment.Field=Cell comment (XLSX) -ExcelWriterMeta.Injection.Output.CommentAuthor.Field=Cell comment author (XLSX) +ExcelWriterMeta.Injection.Output.Comment.Field=Cell comment (XLSX/ODS) +ExcelWriterMeta.Injection.Output.CommentAuthor.Field=Cell comment author (XLSX/ODS) ExcelWriterMeta.Injection.Output.FieldContainFormula.Field=Field contains formula? ExcelWriterMeta.Injection.Output.FieldName.Field=Field name ExcelWriterMeta.Injection.Output.Format.Field=Format @@ -161,7 +162,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=Header/footer style from c ExcelWriterMeta.Injection.Output.Type.Field=Type ExcelWriterMeta.Injection.Password.Field=Password ExcelWriterMeta.Injection.ProtectedBy.Field=Protected by user -ExcelWriterMeta.Injection.ProtectSheet.Field=Protect sheet (XLS format only)? +ExcelWriterMeta.Injection.ProtectSheet.Field=Protect sheet (XLS and ODS formats)? ExcelWriterMeta.Injection.RowWritingMethod.Field=Row writing method (overwrite/pus) ExcelWriterMeta.Injection.SchemaDefinition.Field=Schema definition ExcelWriterMeta.Injection.SheetName.Field=Sheet name @@ -194,6 +195,6 @@ ExcelWriterTransformMeta.CheckResult.FieldsReceived=Transform is connected to pr ExcelWriterTransformMeta.CheckResult.FilenameFieldNotFound=Filename field ''{0}'' not found in input stream. ExcelWriterTransformMeta.CheckResult.FilesNotChecked=File specifications are not checked. ExcelWriterTransformMeta.keyword=excel,writer,transform -TypeExitExcelWriterTransform.Description=Writes or appends data to an Excel file +TypeExitExcelWriterTransform.Description=Writes or appends data to an Excel or OpenDocument Spreadsheet (.xls, .xlsx, .ods) file TypeExitExcelWriterTransform.Name=Microsoft Excel writer ExcelWriterDialog.IgnoreTransformFields.Label=Ignore manual fields diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_it_IT.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_it_IT.properties index 683898ec6f3..ba454a82bb4 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_it_IT.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_it_IT.properties @@ -130,7 +130,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=Crea cartella padre (Y/N)? ExcelWriterMeta.Injection.DateInFilename.Field=Include la data nel nome del file (Y/N)? ExcelWriterMeta.Injection.DateTimeFormat.Field=Campo contenente il formato della data ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=Attendo la prima riga di dati per creare il nuovo file (Y/N)? -ExcelWriterMeta.Injection.Extension.Field=Estensione (xls/xlsx) +ExcelWriterMeta.Injection.Extension.Field=Estensione (xls/xlsx/ods) ExcelWriterMeta.Injection.Field=Campo ExcelWriterMeta.Injection.Fields=Campi di output ExcelWriterMeta.Injection.FileName.Field=Nome del file @@ -155,7 +155,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=Eredita lo stile di header ExcelWriterMeta.Injection.Output.Type.Field=Tipo ExcelWriterMeta.Injection.Password.Field=Password ExcelWriterMeta.Injection.ProtectedBy.Field=Protetto dall''utente -ExcelWriterMeta.Injection.ProtectSheet.Field=Proteggi il foglio (solo per file di formato XLS) (Y/N)? +ExcelWriterMeta.Injection.ProtectSheet.Field=Proteggi il foglio (formati XLS e ODS) (Y/N)? ExcelWriterMeta.Injection.RowWritingMethod.Field=Metodo di scrittura delle righe (overwrite/pus) ExcelWriterMeta.Injection.SheetName.Field=Nome sheet ExcelWriterMeta.Injection.SpecifyFormat.Field=Specifica il formato di data/ora (Y/N)? diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_pt_BR.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_pt_BR.properties index d855379964c..324186d061c 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_pt_BR.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_pt_BR.properties @@ -137,7 +137,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=Criar pasta superior ExcelWriterMeta.Injection.DateInFilename.Field=incluir data no nome de arquivo ? ExcelWriterMeta.Injection.DateTimeFormat.Field=Campo de formato de data e hora ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=Aguarde a primeira linha antes de criar um novo arquivo? -ExcelWriterMeta.Injection.Extension.Field=Extens\u00E3o (xls/xlsx) +ExcelWriterMeta.Injection.Extension.Field=Extens\u00E3o (xls/xlsx/ods) ExcelWriterMeta.Injection.Field=Campo ExcelWriterMeta.Injection.Fields=Campos ExcelWriterMeta.Injection.FileName.Field=Nome do arquivo @@ -162,7 +162,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=estilo de cabe\u00E7alho e ExcelWriterMeta.Injection.Output.Type.Field=Tipo ExcelWriterMeta.Injection.Password.Field=Senha ExcelWriterMeta.Injection.ProtectedBy.Field=Protegido pelo usu\u00E1rio -ExcelWriterMeta.Injection.ProtectSheet.Field=Proteger pasta (somente formato XLS)? +ExcelWriterMeta.Injection.ProtectSheet.Field=Proteger pasta (formatos XLS e ODS)? ExcelWriterMeta.Injection.RowWritingMethod.Field=M\u00E9todo de escrita de linha (substituir/pus) ExcelWriterMeta.Injection.SchemaDefinition.Field=Defini\u00E7\u00E3o do esquema ExcelWriterMeta.Injection.SheetName.Field=Nome da planilha diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_zh_CN.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_zh_CN.properties index 0308c27bb9d..77623fba394 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_zh_CN.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_zh_CN.properties @@ -135,7 +135,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=\u521B\u5EFA\u7236\u6587\u4EF ExcelWriterMeta.Injection.DateInFilename.Field=\u6587\u4EF6\u540D\u4E2D\u662F\u5426\u5305\u542B\u65E5\u671F\uFF1F ExcelWriterMeta.Injection.DateTimeFormat.Field=\u65E5\u671F\u65F6\u95F4\u683C\u5F0F\u5B57\u6BB5 ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=\u5728\u521B\u5EFA\u65B0\u6587\u4EF6\u4E4B\u524D\u7B49\u5F85\u7B2C\u4E00\u884C\uFF1F -ExcelWriterMeta.Injection.Extension.Field=\u6269\u5C55\u540D(xls/xlsx) +ExcelWriterMeta.Injection.Extension.Field=\u6269\u5C55\u540D(xls/xlsx/ods) ExcelWriterMeta.Injection.Field=\u5B57\u6BB5 ExcelWriterMeta.Injection.Fields=\u5B57\u6BB5 ExcelWriterMeta.Injection.FileName.Field=\u6587\u4EF6\u540D @@ -160,7 +160,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=\u6765\u81EA\u5355\u5143\u ExcelWriterMeta.Injection.Output.Type.Field=\u7C7B\u578B ExcelWriterMeta.Injection.Password.Field=\u5BC6\u7801 ExcelWriterMeta.Injection.ProtectedBy.Field=\u7531\u7528\u6237\u4FDD\u62A4 -ExcelWriterMeta.Injection.ProtectSheet.Field=\u4FDD\u62A4\u5DE5\u4F5C\u8868(\u4EC5\u9650XLS)? +ExcelWriterMeta.Injection.ProtectSheet.Field=\u4FDD\u62A4\u5DE5\u4F5C\u8868(XLS\u548CODS\u683C\u5F0F)? ExcelWriterMeta.Injection.RowWritingMethod.Field=\u884C\u5199\u5165\u65B9\u6CD5(\u8986\u76D6/\u8FFD\u52A0) ExcelWriterMeta.Injection.SchemaDefinition.Field=\u6A21\u5F0F\u5B9A\u4E49 ExcelWriterMeta.Injection.SheetName.Field=\u5DE5\u4F5C\u8868\u540D\u79F0 diff --git a/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriterIntegrationTest.java b/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriterIntegrationTest.java new file mode 100644 index 00000000000..c372d3f49be --- /dev/null +++ b/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriterIntegrationTest.java @@ -0,0 +1,217 @@ +/* + * 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.excelwriter.ods; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.File; +import org.apache.hop.core.HopEnvironment; +import org.apache.hop.core.logging.ILoggingObject; +import org.apache.hop.core.row.value.ValueMetaInteger; +import org.apache.hop.core.row.value.ValueMetaString; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterOutputField; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterOutputFormat; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransform; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransformData; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransformMeta; +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.io.TempDir; +import org.odftoolkit.odfdom.doc.OdfSpreadsheetDocument; +import org.odftoolkit.odfdom.doc.table.OdfTable; +import org.odftoolkit.odfdom.doc.table.OdfTableCell; +import org.odftoolkit.odfdom.dom.element.office.OfficeAnnotationElement; +import org.odftoolkit.odfdom.dom.element.text.TextAElement; + +/** + * End-to-end style test exercising the main ODS writer features together in one workflow: file + * template, header, data types, formula, hyperlink, comment, append, and active sheet. + */ +class OdsExcelWriterIntegrationTest { + + private static final String SHEET_NAME = "Report"; + + @TempDir File tempDir; + + private TransformMockHelper mockHelper; + private ExcelWriterTransform transform; + private ExcelWriterTransformMeta meta; + private ExcelWriterTransformData data; + + @BeforeAll + static void setUpBeforeClass() throws Exception { + HopEnvironment.init(); + } + + @BeforeEach + void setUp() throws Exception { + mockHelper = + new TransformMockHelper<>( + "ODS Excel Writer Integration", + ExcelWriterTransformMeta.class, + ExcelWriterTransformData.class); + when(mockHelper.logChannelFactory.create( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(ILoggingObject.class))) + .thenReturn(mockHelper.iLogChannel); + + meta = new ExcelWriterTransformMeta(); + meta.setDefault(); + meta.getFile().setExtension(ExcelWriterOutputFormat.EXT_ODS); + meta.getFile().setSheetname(SHEET_NAME); + meta.setHeaderEnabled(true); + meta.setMakeSheetActive(true); + meta.setForceFormulaRecalculation(true); + meta.getFile().setAutosizecolums(true); + + data = new ExcelWriterTransformData(); + data.realSheetname = SHEET_NAME; + data.createNewFile = true; + data.createNewSheet = true; + + data.inputRowMeta = new org.apache.hop.core.row.RowMeta(); + data.inputRowMeta.addValueMeta(new ValueMetaString("label")); + data.inputRowMeta.addValueMeta(new ValueMetaInteger("amount")); + data.inputRowMeta.addValueMeta(new ValueMetaString("totalFormula")); + data.inputRowMeta.addValueMeta(new ValueMetaString("url")); + data.inputRowMeta.addValueMeta(new ValueMetaString("note")); + + ExcelWriterOutputField labelField = new ExcelWriterOutputField(); + labelField.setName("label"); + ExcelWriterOutputField amountField = new ExcelWriterOutputField(); + amountField.setName("amount"); + ExcelWriterOutputField totalField = new ExcelWriterOutputField(); + totalField.setName("totalFormula"); + totalField.setFormula(true); + ExcelWriterOutputField urlField = new ExcelWriterOutputField(); + urlField.setName("url"); + urlField.setHyperlinkField("url"); + ExcelWriterOutputField noteField = new ExcelWriterOutputField(); + noteField.setName("note"); + noteField.setCommentField("note"); + noteField.setCommentAuthorField("label"); + meta.setOutputFields( + java.util.List.of(labelField, amountField, totalField, urlField, noteField)); + + data.fieldnrs = new int[] {0, 1, 2, 3, 4}; + data.linkfieldnrs = new int[] {-1, -1, -1, 3, -1}; + data.commentfieldnrs = new int[] {-1, -1, -1, -1, 4}; + data.commentauthorfieldnrs = new int[] {0, -1, -1, -1, -1}; + + transform = + spy( + new ExcelWriterTransform( + mockHelper.transformMeta, + meta, + data, + 0, + mockHelper.pipelineMeta, + mockHelper.pipeline)); + } + + @AfterEach + void cleanUp() { + mockHelper.cleanUp(); + } + + @Test + void testFullOdsWorkflow() throws Exception { + File templateFile = new File(tempDir, "workbook-template.ods"); + try (OdfSpreadsheetDocument template = OdfSpreadsheetDocument.newSpreadsheetDocument()) { + template.getTableList().get(0).setTableName("Unused"); + template.save(templateFile); + } + + File outputFile = new File(tempDir, "report.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.getTemplate().setTemplateEnabled(true); + meta.getTemplate().setTemplateFileName(templateFile.getAbsolutePath()); + data.realTemplateFileName = templateFile.getAbsolutePath(); + + assertTrue(transform.init()); + Object[] row1 = + new Object[] {"Alpha", 10L, "=A2+B2", "https://hop.apache.org", "first row comment"}; + Object[] row2 = new Object[] {"Beta", 20L, "=A3+B3", "https://apache.org", "second row"}; + + transform.prepareNextOutputFile(row1); + transform.writeNextLine(data.currentWorkbookDefinition, row1); + transform.writeNextLine(data.currentWorkbookDefinition, row2); + transform.closeFiles(); + + meta.setAppendLines(true); + meta.setHeaderEnabled(false); + meta.getFile().setIfFileExists(ExcelWriterTransformMeta.IF_FILE_EXISTS_REUSE); + meta.getFile().setIfSheetExists(ExcelWriterTransformMeta.IF_SHEET_EXISTS_REUSE); + data.createNewFile = false; + data.createNewSheet = false; + data.usedFiles.clear(); + + transform.prepareNextOutputFile( + new Object[] {"Gamma", 30L, "=A4+B4", "https://example.com", ""}); + transform.writeNextLine( + data.currentWorkbookDefinition, + new Object[] {"Gamma", 30L, "=A4+B4", "https://example.com", ""}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + assertNotNull(table); + assertEquals(SHEET_NAME, OdsTableHelper.getActiveTableName(document)); + + assertEquals("label", cellText(table, 0, 0)); + assertEquals("amount", cellText(table, 1, 0)); + assertEquals("Alpha", cellText(table, 0, 1)); + assertEquals("10.0", cellText(table, 1, 1)); + assertEquals("Beta", cellText(table, 0, 2)); + assertEquals("Gamma", cellText(table, 0, 3)); + + OdfTableCell formulaCell = table.getCellByPosition(2, 1); + assertEquals("of:=.[A2]+.[B2]", formulaCell.getFormula()); + + TextAElement link = + (TextAElement) + table.getCellByPosition(3, 1).getOdfElement().getElementsByTagName("text:a").item(0); + assertNotNull(link); + assertEquals("https://hop.apache.org", link.getXlinkHrefAttribute()); + + OfficeAnnotationElement annotation = + (OfficeAnnotationElement) + table + .getCellByPosition(4, 1) + .getOdfElement() + .getElementsByTagName("office:annotation") + .item(0); + assertNotNull(annotation); + assertTrue(annotation.getTextContent().contains("first row comment")); + + assertTrue(table.getColumnByIndex(0).isOptimalWidth()); + } + } + + private String cellText(OdfTable table, int col, int row) throws Exception { + return table.getCellByPosition(col, row).getDisplayText(); + } +} diff --git a/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriterTest.java b/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriterTest.java new file mode 100644 index 00000000000..d1959c5df64 --- /dev/null +++ b/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsExcelWriterTest.java @@ -0,0 +1,480 @@ +/* + * 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.excelwriter.ods; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import org.apache.hop.core.HopEnvironment; +import org.apache.hop.core.logging.ILoggingObject; +import org.apache.hop.core.row.value.ValueMetaInteger; +import org.apache.hop.core.row.value.ValueMetaNumber; +import org.apache.hop.core.row.value.ValueMetaString; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterOutputField; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterOutputFormat; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransform; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransformData; +import org.apache.hop.pipeline.transforms.excelwriter.ExcelWriterTransformMeta; +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.io.TempDir; +import org.odftoolkit.odfdom.doc.OdfSpreadsheetDocument; +import org.odftoolkit.odfdom.doc.table.OdfTable; +import org.odftoolkit.odfdom.doc.table.OdfTableCell; +import org.odftoolkit.odfdom.dom.element.office.OfficeAnnotationElement; +import org.odftoolkit.odfdom.dom.element.text.TextAElement; +import org.odftoolkit.odfdom.type.Color; + +class OdsExcelWriterTest { + + private static final String SHEET_NAME = "Output"; + + @TempDir File tempDir; + + private TransformMockHelper mockHelper; + private ExcelWriterTransform transform; + private ExcelWriterTransformMeta meta; + private ExcelWriterTransformData data; + + @BeforeAll + static void setUpBeforeClass() throws Exception { + HopEnvironment.init(); + } + + @BeforeEach + void setUp() throws Exception { + mockHelper = + new TransformMockHelper<>( + "ODS Excel Writer Test", + ExcelWriterTransformMeta.class, + ExcelWriterTransformData.class); + when(mockHelper.logChannelFactory.create( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(ILoggingObject.class))) + .thenReturn(mockHelper.iLogChannel); + + meta = new ExcelWriterTransformMeta(); + meta.setDefault(); + meta.getFile().setExtension(ExcelWriterOutputFormat.EXT_ODS); + meta.getFile().setSheetname(SHEET_NAME); + meta.setHeaderEnabled(true); + + data = new ExcelWriterTransformData(); + data.realSheetname = SHEET_NAME; + data.createNewFile = true; + data.createNewSheet = true; + data.inputRowMeta = new org.apache.hop.core.row.RowMeta(); + data.inputRowMeta.addValueMeta(new ValueMetaString("name")); + data.inputRowMeta.addValueMeta(new ValueMetaInteger("count")); + + ExcelWriterOutputField nameField = new ExcelWriterOutputField(); + nameField.setName("name"); + ExcelWriterOutputField countField = new ExcelWriterOutputField(); + countField.setName("count"); + List fields = new ArrayList<>(); + fields.add(nameField); + fields.add(countField); + meta.setOutputFields(fields); + + data.fieldnrs = new int[] {0, 1}; + data.linkfieldnrs = new int[] {-1, -1}; + data.commentfieldnrs = new int[] {-1, -1}; + data.commentauthorfieldnrs = new int[] {-1, -1}; + + transform = + spy( + new ExcelWriterTransform( + mockHelper.transformMeta, + meta, + data, + 0, + mockHelper.pipelineMeta, + mockHelper.pipeline)); + } + + @AfterEach + void cleanUp() { + mockHelper.cleanUp(); + } + + @Test + void testWriteOdsWithHeaderAndData() throws Exception { + File outputFile = new File(tempDir, "output.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + assertTrue(transform.init()); + + transform.prepareNextOutputFile(new Object[] {"alpha", 42L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"alpha", 42L}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + assertEquals("name", getCellText(table, 0, 0)); + assertEquals("count", getCellText(table, 1, 0)); + assertEquals("alpha", getCellText(table, 0, 1)); + assertEquals("42.0", getCellText(table, 1, 1)); + } + } + + @Test + void testAppendToExistingOds() throws Exception { + File outputFile = new File(tempDir, "append.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + assertTrue(transform.init()); + + transform.prepareNextOutputFile(new Object[] {"first", 1L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"first", 1L}); + transform.closeFiles(); + + meta.setAppendLines(true); + meta.getFile().setIfFileExists(ExcelWriterTransformMeta.IF_FILE_EXISTS_REUSE); + meta.getFile().setIfSheetExists(ExcelWriterTransformMeta.IF_SHEET_EXISTS_REUSE); + meta.setHeaderEnabled(false); + data.createNewFile = false; + data.createNewSheet = false; + data.usedFiles.clear(); + + transform.prepareNextOutputFile(new Object[] {"second", 2L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"second", 2L}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + assertEquals("name", getCellText(table, 0, 0)); + assertEquals("first", getCellText(table, 0, 1)); + assertEquals("second", getCellText(table, 0, 2)); + } + } + + @Test + void testStyleCellReference() throws Exception { + File templateFile = new File(tempDir, "styled-template.ods"); + try (OdfSpreadsheetDocument template = OdfSpreadsheetDocument.newSpreadsheetDocument()) { + OdfTable templateTable = template.getTableList().get(0); + templateTable.setTableName(SHEET_NAME); + OdfTableCell styleSource = templateTable.getCellByPosition(1, 0); + styleSource.setCellBackgroundColor(Color.RED); + styleSource.setStringValue("style-source"); + template.save(templateFile); + } + + File outputFile = new File(tempDir, "styled-output.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + meta.getTemplate().setTemplateEnabled(true); + meta.getTemplate().setTemplateFileName(templateFile.getAbsolutePath()); + data.realTemplateFileName = templateFile.getAbsolutePath(); + meta.getOutputFields().get(0).setStyleCell("B1"); + + assertTrue(transform.init()); + transform.prepareNextOutputFile(new Object[] {"styled-value", 1L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"styled-value", 1L}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + OdfTableCell writtenCell = table.getCellByPosition(0, 0); + OdfTableCell referenceCell = table.getCellByPosition(1, 0); + assertEquals("styled-value", writtenCell.getDisplayText()); + assertEquals(referenceCell.getStyleName(), writtenCell.getStyleName()); + assertNotNull(writtenCell.getStyleName()); + } + } + + @Test + void testHyperlinkAndComment() throws Exception { + File outputFile = new File(tempDir, "link-comment.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + + data.inputRowMeta = new org.apache.hop.core.row.RowMeta(); + data.inputRowMeta.addValueMeta(new ValueMetaString("label")); + data.inputRowMeta.addValueMeta(new ValueMetaString("url")); + data.inputRowMeta.addValueMeta(new ValueMetaString("note")); + data.inputRowMeta.addValueMeta(new ValueMetaString("author")); + + ExcelWriterOutputField labelField = new ExcelWriterOutputField(); + labelField.setName("label"); + labelField.setHyperlinkField("url"); + labelField.setCommentField("note"); + labelField.setCommentAuthorField("author"); + meta.setOutputFields(List.of(labelField)); + + data.fieldnrs = new int[] {0}; + data.linkfieldnrs = new int[] {1}; + data.commentfieldnrs = new int[] {2}; + data.commentauthorfieldnrs = new int[] {3}; + + assertTrue(transform.init()); + transform.prepareNextOutputFile( + new Object[] {"Hop", "https://hop.apache.org", "A note", "Tester"}); + transform.writeNextLine( + data.currentWorkbookDefinition, + new Object[] {"Hop", "https://hop.apache.org", "A note", "Tester"}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + OdfTableCell cell = table.getCellByPosition(0, 0); + TextAElement link = + (TextAElement) cell.getOdfElement().getElementsByTagName("text:a").item(0); + assertNotNull(link); + assertEquals("Hop", link.getTextContent()); + assertEquals("https://hop.apache.org", link.getXlinkHrefAttribute()); + + OfficeAnnotationElement annotation = + (OfficeAnnotationElement) + cell.getOdfElement().getElementsByTagName("office:annotation").item(0); + assertNotNull(annotation); + assertFalse(annotation.getTextContent().isBlank()); + } + } + + @Test + void testFormatMask() throws Exception { + File outputFile = new File(tempDir, "format.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + + data.inputRowMeta = new org.apache.hop.core.row.RowMeta(); + data.inputRowMeta.addValueMeta(new ValueMetaNumber("amount")); + + ExcelWriterOutputField amountField = new ExcelWriterOutputField(); + amountField.setName("amount"); + amountField.setFormat("0.00"); + meta.setOutputFields(List.of(amountField)); + data.fieldnrs = new int[] {0}; + data.linkfieldnrs = new int[] {-1}; + data.commentfieldnrs = new int[] {-1}; + data.commentauthorfieldnrs = new int[] {-1}; + + assertTrue(transform.init()); + transform.prepareNextOutputFile(new Object[] {12.3}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {12.3}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + OdfTableCell cell = table.getCellByPosition(0, 0); + assertEquals("12.30", cell.getDisplayText()); + } + } + + @Test + void testFormulaField() throws Exception { + File outputFile = new File(tempDir, "formula.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + meta.setForceFormulaRecalculation(true); + + data.inputRowMeta = new org.apache.hop.core.row.RowMeta(); + data.inputRowMeta.addValueMeta(new ValueMetaNumber("left")); + data.inputRowMeta.addValueMeta(new ValueMetaNumber("right")); + data.inputRowMeta.addValueMeta(new ValueMetaString("totalFormula")); + + ExcelWriterOutputField leftField = new ExcelWriterOutputField(); + leftField.setName("left"); + ExcelWriterOutputField rightField = new ExcelWriterOutputField(); + rightField.setName("right"); + ExcelWriterOutputField totalField = new ExcelWriterOutputField(); + totalField.setName("totalFormula"); + totalField.setFormula(true); + meta.setOutputFields(List.of(leftField, rightField, totalField)); + + data.fieldnrs = new int[] {0, 1, 2}; + data.linkfieldnrs = new int[] {-1, -1, -1}; + data.commentfieldnrs = new int[] {-1, -1, -1}; + data.commentauthorfieldnrs = new int[] {-1, -1, -1}; + + assertTrue(transform.init()); + transform.prepareNextOutputFile(new Object[] {10.0, 20.0, "=A1+B1"}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {10.0, 20.0, "=A1+B1"}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + OdfTableCell formulaCell = table.getCellByPosition(2, 0); + assertEquals("of:=.[A1]+.[B1]", formulaCell.getFormula()); + assertNull(formulaCell.getOdfElement().getOfficeValueAttribute()); + } + } + + @Test + void testPushDownExistingCells() throws Exception { + File outputFile = new File(tempDir, "pushdown.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + assertTrue(transform.init()); + + transform.prepareNextOutputFile(new Object[] {"existing", 1L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"existing", 1L}); + transform.closeFiles(); + + meta.setHeaderEnabled(false); + meta.setRowWritingMethod(ExcelWriterTransformMeta.ROW_WRITE_PUSH_DOWN); + meta.getFile().setIfFileExists(ExcelWriterTransformMeta.IF_FILE_EXISTS_REUSE); + meta.getFile().setIfSheetExists(ExcelWriterTransformMeta.IF_SHEET_EXISTS_REUSE); + data.createNewFile = false; + data.createNewSheet = false; + data.shiftExistingCells = true; + data.usedFiles.clear(); + + transform.prepareNextOutputFile(new Object[] {"inserted", 2L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"inserted", 2L}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + assertEquals("inserted", getCellText(table, 0, 0)); + assertEquals("existing", getCellText(table, 0, 1)); + } + } + + @Test + void testTemplateSheetCloneWithPushDown() throws Exception { + File templateFile = new File(tempDir, "sheet-template.ods"); + String templateSheetName = "TemplateSheet"; + try (OdfSpreadsheetDocument template = OdfSpreadsheetDocument.newSpreadsheetDocument()) { + OdfTable templateTable = template.getTableList().get(0); + templateTable.setTableName(templateSheetName); + templateTable.getCellByPosition(0, 0).setStringValue("header"); + templateTable.getCellByPosition(0, 1).setStringValue("footer"); + template.save(templateFile); + } + + File outputFile = new File(tempDir, "cloned-sheet.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + meta.setRowWritingMethod(ExcelWriterTransformMeta.ROW_WRITE_PUSH_DOWN); + meta.getTemplate().setTemplateEnabled(true); + meta.getTemplate().setTemplateFileName(templateFile.getAbsolutePath()); + meta.getTemplate().setTemplateSheetEnabled(true); + meta.getTemplate().setTemplateSheetHidden(true); + meta.getTemplate().setTemplateSheetName(templateSheetName); + data.realTemplateFileName = templateFile.getAbsolutePath(); + data.realTemplateSheetName = templateSheetName; + + assertTrue(transform.init()); + data.shiftExistingCells = true; + transform.prepareNextOutputFile(new Object[] {"row-data", 5L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"row-data", 5L}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable outputTable = document.getTableByName(SHEET_NAME); + assertNotNull(outputTable); + assertEquals("row-data", getCellText(outputTable, 0, 0)); + assertEquals("header", getCellText(outputTable, 0, 1)); + assertEquals("footer", getCellText(outputTable, 0, 2)); + + OdfTable hiddenTemplate = document.getTableByName(templateSheetName); + assertNotNull(hiddenTemplate); + assertEquals( + "collapse", + hiddenTemplate + .getOdfElement() + .getAttributeNS( + org.odftoolkit.odfdom.dom.OdfDocumentNamespace.TABLE.getUri(), "visibility")); + } + } + + @Test + void testMakeActiveSheet() throws Exception { + File outputFile = new File(tempDir, "active.ods"); + try (OdfSpreadsheetDocument seed = OdfSpreadsheetDocument.newSpreadsheetDocument()) { + seed.getTableList().get(0).setTableName("Other"); + OdfTable outputSeed = org.odftoolkit.odfdom.doc.table.OdfTable.newTable(seed, 1, 1); + outputSeed.setTableName(SHEET_NAME); + seed.save(outputFile); + } + + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + meta.setMakeSheetActive(true); + meta.getFile().setIfFileExists(ExcelWriterTransformMeta.IF_FILE_EXISTS_REUSE); + meta.getFile().setIfSheetExists(ExcelWriterTransformMeta.IF_SHEET_EXISTS_REUSE); + data.createNewFile = false; + data.createNewSheet = false; + + assertTrue(transform.init()); + transform.prepareNextOutputFile(new Object[] {"active", 1L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"active", 1L}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + assertEquals(SHEET_NAME, OdsTableHelper.getActiveTableName(document)); + } + } + + @Test + void testAutoSizeColumns() throws Exception { + File outputFile = new File(tempDir, "autosize.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + meta.getFile().setAutosizecolums(true); + + assertTrue(transform.init()); + transform.prepareNextOutputFile(new Object[] {"short", 1L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"much-longer-value", 2L}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + assertTrue(table.getColumnByIndex(0).isOptimalWidth()); + assertTrue(table.getColumnByIndex(1).isOptimalWidth()); + } + } + + @Test + void testProtectSheet() throws Exception { + File outputFile = new File(tempDir, "protected.ods"); + doReturn(outputFile.getAbsolutePath()).when(transform).buildFilename(0); + meta.setHeaderEnabled(false); + meta.getFile().setProtectsheet(true); + meta.getFile().setPassword("secret"); + + assertTrue(transform.init()); + transform.prepareNextOutputFile(new Object[] {"locked", 1L}); + transform.writeNextLine(data.currentWorkbookDefinition, new Object[] {"locked", 1L}); + transform.closeFiles(); + + try (OdfSpreadsheetDocument document = OdfSpreadsheetDocument.loadDocument(outputFile)) { + OdfTable table = document.getTableByName(SHEET_NAME); + assertTrue(table.getOdfElement().getTableProtectedAttribute()); + assertEquals( + "5en6G6MezRroT3XKqkdPOmY/BfQ=", table.getOdfElement().getTableProtectionKeyAttribute()); + } + } + + private String getCellText(OdfTable table, int col, int row) throws Exception { + OdfTableCell cell = table.getCellByPosition(col, row); + return cell.getDisplayText(); + } +} diff --git a/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormatConverterTest.java b/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormatConverterTest.java new file mode 100644 index 00000000000..7b355303588 --- /dev/null +++ b/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormatConverterTest.java @@ -0,0 +1,30 @@ +/* + * 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.excelwriter.ods; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class OdsFormatConverterTest { + + @Test + void convertsCommonExcelDateTokens() { + assertEquals("YYYY-MM-DD", OdsFormatConverter.toOdfFormat("yyyy-MM-dd")); + } +} diff --git a/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaConverterTest.java b/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaConverterTest.java new file mode 100644 index 00000000000..5709a3cc5fd --- /dev/null +++ b/plugins/transforms/excel/src/test/java/org/apache/hop/pipeline/transforms/excelwriter/ods/OdsFormulaConverterTest.java @@ -0,0 +1,45 @@ +/* + * 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.excelwriter.ods; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class OdsFormulaConverterTest { + + @Test + void convertsExcelFormulaPrefix() { + assertEquals("of:=.[A1]+.[B2]", OdsFormulaConverter.toOdfFormula("=A1+B2")); + } + + @Test + void convertsSumRange() { + assertEquals("of:=SUM([.A1:.A2])", OdsFormulaConverter.toOdfFormula("=SUM(A1:A2)")); + } + + @Test + void keepsOpenFormulaUntouched() { + assertEquals("of:=.[A1]+.[A2]", OdsFormulaConverter.toOdfFormula("of:=.[A1]+.[A2]")); + } + + @Test + void convertsSheetReference() { + assertEquals("of:=Data.[A1]+.[A2]", OdsFormulaConverter.toOdfFormula("=Data!A1+A2")); + } +} From 7600116a381e4681b05ebf1058a3a7009bc84834 Mon Sep 17 00:00:00 2001 From: Bart Maertens Date: Mon, 1 Jun 2026 11:55:21 +0200 Subject: [PATCH 2/2] reverted translation updates. #7191 --- .../transforms/excelwriter/messages/messages_de_DE.properties | 4 ++-- .../transforms/excelwriter/messages/messages_it_IT.properties | 4 ++-- .../transforms/excelwriter/messages/messages_pt_BR.properties | 4 ++-- .../transforms/excelwriter/messages/messages_zh_CN.properties | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_de_DE.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_de_DE.properties index a3490c03520..e9da6613c3d 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_de_DE.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_de_DE.properties @@ -136,7 +136,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=\u00DCbergeordneten Ordner an ExcelWriterMeta.Injection.DateInFilename.Field=Datum zu Dateinamen hinzuf\u00FCgen ? ExcelWriterMeta.Injection.DateTimeFormat.Field=Datetime Format Feld ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=Mit Erzeugung der Datei auf erste Zeile warten ? -ExcelWriterMeta.Injection.Extension.Field=Dateierweiterung (xls/xlsx/ods) +ExcelWriterMeta.Injection.Extension.Field=Dateierweiterung (xls/xlsx) ExcelWriterMeta.Injection.Field=Feld ExcelWriterMeta.Injection.Fields=Felder ExcelWriterMeta.Injection.FileName.Field=Dateiname @@ -161,7 +161,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=Kopf-/Fu\u00DFzeilenstil a ExcelWriterMeta.Injection.Output.Type.Field=Typ ExcelWriterMeta.Injection.Password.Field=Passwort ExcelWriterMeta.Injection.ProtectedBy.Field=gesch\u00FCtzt durch Benutzer -ExcelWriterMeta.Injection.ProtectSheet.Field=Blatt sch\u00FCtzen (XLS- und ODS-Format)? +ExcelWriterMeta.Injection.ProtectSheet.Field=Blatt sch\u00FCtzen ( nur XLS Format ) ? ExcelWriterMeta.Injection.RowWritingMethod.Field=Schreibmethode f\u00FCr Zeilen (\u00FCberschreiben/push) ExcelWriterMeta.Injection.SchemaDefinition.Field=Schemadefinition ExcelWriterMeta.Injection.SheetName.Field=Blattname diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_it_IT.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_it_IT.properties index ba454a82bb4..683898ec6f3 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_it_IT.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_it_IT.properties @@ -130,7 +130,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=Crea cartella padre (Y/N)? ExcelWriterMeta.Injection.DateInFilename.Field=Include la data nel nome del file (Y/N)? ExcelWriterMeta.Injection.DateTimeFormat.Field=Campo contenente il formato della data ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=Attendo la prima riga di dati per creare il nuovo file (Y/N)? -ExcelWriterMeta.Injection.Extension.Field=Estensione (xls/xlsx/ods) +ExcelWriterMeta.Injection.Extension.Field=Estensione (xls/xlsx) ExcelWriterMeta.Injection.Field=Campo ExcelWriterMeta.Injection.Fields=Campi di output ExcelWriterMeta.Injection.FileName.Field=Nome del file @@ -155,7 +155,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=Eredita lo stile di header ExcelWriterMeta.Injection.Output.Type.Field=Tipo ExcelWriterMeta.Injection.Password.Field=Password ExcelWriterMeta.Injection.ProtectedBy.Field=Protetto dall''utente -ExcelWriterMeta.Injection.ProtectSheet.Field=Proteggi il foglio (formati XLS e ODS) (Y/N)? +ExcelWriterMeta.Injection.ProtectSheet.Field=Proteggi il foglio (solo per file di formato XLS) (Y/N)? ExcelWriterMeta.Injection.RowWritingMethod.Field=Metodo di scrittura delle righe (overwrite/pus) ExcelWriterMeta.Injection.SheetName.Field=Nome sheet ExcelWriterMeta.Injection.SpecifyFormat.Field=Specifica il formato di data/ora (Y/N)? diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_pt_BR.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_pt_BR.properties index 324186d061c..d855379964c 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_pt_BR.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_pt_BR.properties @@ -137,7 +137,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=Criar pasta superior ExcelWriterMeta.Injection.DateInFilename.Field=incluir data no nome de arquivo ? ExcelWriterMeta.Injection.DateTimeFormat.Field=Campo de formato de data e hora ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=Aguarde a primeira linha antes de criar um novo arquivo? -ExcelWriterMeta.Injection.Extension.Field=Extens\u00E3o (xls/xlsx/ods) +ExcelWriterMeta.Injection.Extension.Field=Extens\u00E3o (xls/xlsx) ExcelWriterMeta.Injection.Field=Campo ExcelWriterMeta.Injection.Fields=Campos ExcelWriterMeta.Injection.FileName.Field=Nome do arquivo @@ -162,7 +162,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=estilo de cabe\u00E7alho e ExcelWriterMeta.Injection.Output.Type.Field=Tipo ExcelWriterMeta.Injection.Password.Field=Senha ExcelWriterMeta.Injection.ProtectedBy.Field=Protegido pelo usu\u00E1rio -ExcelWriterMeta.Injection.ProtectSheet.Field=Proteger pasta (formatos XLS e ODS)? +ExcelWriterMeta.Injection.ProtectSheet.Field=Proteger pasta (somente formato XLS)? ExcelWriterMeta.Injection.RowWritingMethod.Field=M\u00E9todo de escrita de linha (substituir/pus) ExcelWriterMeta.Injection.SchemaDefinition.Field=Defini\u00E7\u00E3o do esquema ExcelWriterMeta.Injection.SheetName.Field=Nome da planilha diff --git a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_zh_CN.properties b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_zh_CN.properties index 77623fba394..0308c27bb9d 100644 --- a/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_zh_CN.properties +++ b/plugins/transforms/excel/src/main/resources/org/apache/hop/pipeline/transforms/excelwriter/messages/messages_zh_CN.properties @@ -135,7 +135,7 @@ ExcelWriterMeta.Injection.CreateParentFolder.Field=\u521B\u5EFA\u7236\u6587\u4EF ExcelWriterMeta.Injection.DateInFilename.Field=\u6587\u4EF6\u540D\u4E2D\u662F\u5426\u5305\u542B\u65E5\u671F\uFF1F ExcelWriterMeta.Injection.DateTimeFormat.Field=\u65E5\u671F\u65F6\u95F4\u683C\u5F0F\u5B57\u6BB5 ExcelWriterMeta.Injection.DoNotOpenNewFileInit.Field=\u5728\u521B\u5EFA\u65B0\u6587\u4EF6\u4E4B\u524D\u7B49\u5F85\u7B2C\u4E00\u884C\uFF1F -ExcelWriterMeta.Injection.Extension.Field=\u6269\u5C55\u540D(xls/xlsx/ods) +ExcelWriterMeta.Injection.Extension.Field=\u6269\u5C55\u540D(xls/xlsx) ExcelWriterMeta.Injection.Field=\u5B57\u6BB5 ExcelWriterMeta.Injection.Fields=\u5B57\u6BB5 ExcelWriterMeta.Injection.FileName.Field=\u6587\u4EF6\u540D @@ -160,7 +160,7 @@ ExcelWriterMeta.Injection.Output.TitleStyleCell.Field=\u6765\u81EA\u5355\u5143\u ExcelWriterMeta.Injection.Output.Type.Field=\u7C7B\u578B ExcelWriterMeta.Injection.Password.Field=\u5BC6\u7801 ExcelWriterMeta.Injection.ProtectedBy.Field=\u7531\u7528\u6237\u4FDD\u62A4 -ExcelWriterMeta.Injection.ProtectSheet.Field=\u4FDD\u62A4\u5DE5\u4F5C\u8868(XLS\u548CODS\u683C\u5F0F)? +ExcelWriterMeta.Injection.ProtectSheet.Field=\u4FDD\u62A4\u5DE5\u4F5C\u8868(\u4EC5\u9650XLS)? ExcelWriterMeta.Injection.RowWritingMethod.Field=\u884C\u5199\u5165\u65B9\u6CD5(\u8986\u76D6/\u8FFD\u52A0) ExcelWriterMeta.Injection.SchemaDefinition.Field=\u6A21\u5F0F\u5B9A\u4E49 ExcelWriterMeta.Injection.SheetName.Field=\u5DE5\u4F5C\u8868\u540D\u79F0