From 8f246796f4eb5a4c1015d418b36688ddb2ef7f4c Mon Sep 17 00:00:00 2001 From: jstaerk Date: Sat, 9 Apr 2022 09:28:50 +0200 Subject: [PATCH] first attempts to be able to slap together a deliver-x pdf on the commandline --- .../org/mustangproject/commandline/Main.java | 19 +- .../java/org/mustangproject/EStandard.java | 2 +- .../ZUGFeRD/DXExporterFromA1.java | 140 ++++ .../ZUGFeRD/DXExporterFromA3.java | 696 ++++++++++++++++++ .../org/mustangproject/ZUGFeRD/Profiles.java | 21 +- .../ZUGFeRD/ZUGFeRDImporter.java | 2 +- .../org/mustangproject/ZUGFeRD/DXTest.java | 314 ++++++++ .../validator/XMLValidator.java | 7 + 8 files changed, 1192 insertions(+), 9 deletions(-) create mode 100644 library/src/main/java/org/mustangproject/ZUGFeRD/DXExporterFromA1.java create mode 100644 library/src/main/java/org/mustangproject/ZUGFeRD/DXExporterFromA3.java create mode 100644 library/src/test/java/org/mustangproject/ZUGFeRD/DXTest.java diff --git a/Mustang-CLI/src/main/java/org/mustangproject/commandline/Main.java b/Mustang-CLI/src/main/java/org/mustangproject/commandline/Main.java index f7624a2f..63776d73 100755 --- a/Mustang-CLI/src/main/java/org/mustangproject/commandline/Main.java +++ b/Mustang-CLI/src/main/java/org/mustangproject/commandline/Main.java @@ -560,7 +560,7 @@ public class Main { if (attachmentFilenames == null) { byte attachmentContents[]=null; String attachmentFilename, attachmentMime, attachmentDescription; - attachmentFilename = getFilenameFromUser("Attachment filename (empty for none)", "", "pdf", true, false); + attachmentFilename = getFilenameFromUser("Additional file attachments filename (empty for none)", "", "pdf", true, false); if (attachmentFilename.length()!=0) { attachmentContents=Files.readAllBytes(Paths.get(attachmentFilename)); attachmentMime= Files.probeContentType(Paths.get(attachmentFilename)); @@ -580,7 +580,7 @@ public class Main { if (format == null) { try { - format = getStringFromUser("Format (fx=Factur-X, zf=ZUGFeRD, ox=Order-X)", "zf", "fx|zf|ox"); + format = getStringFromUser("Format (fx=Factur-X, zf=ZUGFeRD, ox=Order-X)", "zf", "fx|zf|ox|dx"); } catch (Exception e) { LOGGER.error(e.getMessage(), e); } @@ -601,7 +601,7 @@ public class Main { if (zfProfile == null) { try { - if (format.equals("zf") && (zfIntVersion == 1) || (format.equals("ox"))) { + if ((format.equals("zf") && (zfIntVersion == 1)) || (format.equals("ox") || (format.equals("dx")))) { zfProfile = getStringFromUser("Profile (b)asic, (c)omfort or ex(t)ended", "t", "B|b|C|c|T|t"); } else { zfProfile = getStringFromUser( @@ -627,11 +627,14 @@ public class Main { throw new Exception("Factur-X is only available in version 1 (roughly corresponding to ZF2)"); } - if (((format.equals("zf")) && (zfIntVersion == 1))||(format.equals("ox"))) { + if (((format.equals("zf")) && (zfIntVersion == 1))||(format.equals("ox")||(format.equals("dx")))) { EStandard standard=EStandard.facturx; if (format.equals("ox")) { standard=EStandard.orderx; } + if (format.equals("dx")) { + standard=EStandard.deliverx; + } if (zfProfile.equals("b")) { zfConformanceLevelProfile = Profiles.getByName(standard, "BASIC", zfIntVersion); @@ -674,6 +677,14 @@ public class Main { ((OXExporterFromA1)ze).ignorePDFAErrors(); } + } else if (format.equals("dx")) { + ze = new DXExporterFromA1().setProducer("Mustang-cli") + .setZUGFeRDVersion(zfIntVersion) + .setCreator(System.getProperty("user.name")).setProfile(zfConformanceLevelProfile); + if (ignoreInputErrors) { + ((DXExporterFromA1)ze).ignorePDFAErrors(); + } + } else { ze = new ZUGFeRDExporterFromA1().setProducer("Mustang-cli") .setZUGFeRDVersion(zfIntVersion) diff --git a/library/src/main/java/org/mustangproject/EStandard.java b/library/src/main/java/org/mustangproject/EStandard.java index 69c43648..0326f7c8 100644 --- a/library/src/main/java/org/mustangproject/EStandard.java +++ b/library/src/main/java/org/mustangproject/EStandard.java @@ -1,6 +1,6 @@ package org.mustangproject; public enum EStandard { - facturx, orderx, zugferd, cii, ubl + facturx, orderx, deliverx, zugferd, cii, ubl } diff --git a/library/src/main/java/org/mustangproject/ZUGFeRD/DXExporterFromA1.java b/library/src/main/java/org/mustangproject/ZUGFeRD/DXExporterFromA1.java new file mode 100644 index 00000000..ab8566cd --- /dev/null +++ b/library/src/main/java/org/mustangproject/ZUGFeRD/DXExporterFromA1.java @@ -0,0 +1,140 @@ +/** ********************************************************************** + * + * Copyright 2020 Jochen Staerk + * + * Use is subject to license terms. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + * + *********************************************************************** */ +package org.mustangproject.ZUGFeRD; + + +import org.apache.pdfbox.preflight.PreflightDocument; +import org.apache.pdfbox.preflight.exception.ValidationException; +import org.apache.pdfbox.preflight.parser.PreflightParser; + +import javax.activation.DataSource; +import java.io.IOException; +import java.io.InputStream; + +public class DXExporterFromA1 extends DXExporterFromA3 implements IZUGFeRDExporter { + protected boolean ignorePDFAErrors = false; + + public DXExporterFromA1 ignorePDFAErrors() { + this.ignorePDFAErrors = true; + return this; + } + + private static boolean isValidA1(DataSource dataSource) throws IOException { + return getPDFAParserValidationResult(new PreflightParser(dataSource)); + } + /*** + * internal helper function: get namespace for order-x + * @param ver the order-x version + * @return the URN of the namespace + */ + public String getNamespaceForVersion(int ver) { + return "urn:factur-x:pdfa:CrossIndustryDocument:1p0#"; + } + /*** + * internal helper: returns the namespace prefix for the given order-x version number + * @param ver the ox version + * @return the namespace prefix as string, without colon + */ + public String getPrefixForVersion(int ver) { + return "fx"; + } + + private static boolean getPDFAParserValidationResult(PreflightParser parser) throws IOException { + /* + * Parse the PDF file with PreflightParser that inherits from the + * NonSequentialParser. Some additional controls are present to check a set of + * PDF/A requirements. (Stream length consistency, EOL after some Keyword...) + */ + parser.parse();// might add a Format.PDF_A1A as parameter and iterate through A1 and A3 + + try (PreflightDocument document = parser.getPreflightDocument()) { + /* + * Once the syntax validation is done, the parser can provide a + * PreflightDocument (that inherits from PDDocument) This document process the + * end of PDF/A validation. + */ + + document.validate(); + + // Get validation result + return document.getResult().isValid(); + } catch (ValidationException e) { + /* + * the parse method can throw a SyntaxValidationException if the PDF file can't + * be parsed. In this case, the exception contains an instance of + * ValidationResult + */ + return false; + } + } + + + public DXExporterFromA1 setProfile(Profile p) { + return (DXExporterFromA1)super.setProfile(p); + } + public DXExporterFromA1 setProfile(String profileName) { + return (DXExporterFromA1)super.setProfile(profileName); + } + + public boolean ensurePDFIsValid(final DataSource dataSource) throws IOException { + if (!ignorePDFAErrors && !isValidA1(dataSource)) { + throw new IOException("File is not a valid PDF/A input file"); + } + return true; + } + + public DXExporterFromA1() { + setZUGFeRDVersion(ZUGFeRDExporterFromA3.DefaultZUGFeRDVersion); + + } + + public DXExporterFromA1 load(String pdfFilename) throws IOException { + return (DXExporterFromA1) super.load(pdfFilename); + } + public DXExporterFromA1 load(byte[] pdfBinary) throws IOException { + return (DXExporterFromA1) super.load(pdfBinary); + } + public DXExporterFromA1 load(InputStream pdfSource) throws IOException{ + return (DXExporterFromA1) super.load(pdfSource); + } + public DXExporterFromA1 setCreator(String creator) { + return (DXExporterFromA1) super.setCreator(creator); + } + public DXExporterFromA1 setConformanceLevel(PDFAConformanceLevel newLevel) { + return (DXExporterFromA1) super.setConformanceLevel(newLevel); + } + public DXExporterFromA1 setProducer(String producer){ + return (DXExporterFromA1) super.setProducer(producer); + } + public DXExporterFromA1 setZUGFeRDVersion(int version){ + return (DXExporterFromA1) super.setZUGFeRDVersion(version); + } + public DXExporterFromA1 setXML(byte[] zugferdData) throws IOException{ + return (DXExporterFromA1) super.setXML(zugferdData); + } + + public DXExporterFromA1 disableAutoClose(boolean disableAutoClose){ + return (DXExporterFromA1) super.disableAutoClose(disableAutoClose); + } + public DXExporterFromA1 convertOnly() { + setAttachZUGFeRDHeaders(false); + return this; + } + +} diff --git a/library/src/main/java/org/mustangproject/ZUGFeRD/DXExporterFromA3.java b/library/src/main/java/org/mustangproject/ZUGFeRD/DXExporterFromA3.java new file mode 100644 index 00000000..eb54d5a1 --- /dev/null +++ b/library/src/main/java/org/mustangproject/ZUGFeRD/DXExporterFromA3.java @@ -0,0 +1,696 @@ +/** + * ********************************************************************* + *

+ * Copyright 2020 Jochen Staerk + *

+ * Use is subject to license terms. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0. + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + *

+ * See the License for the specific language governing permissions and + * limitations under the License. + *

+ * ********************************************************************** + */ +package org.mustangproject.ZUGFeRD; + +import org.apache.pdfbox.cos.*; +import org.apache.pdfbox.io.IOUtils; +import org.apache.pdfbox.pdmodel.*; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification; +import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDMarkInfo; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot; +import org.apache.pdfbox.pdmodel.graphics.color.PDOutputIntent; +import org.apache.pdfbox.preflight.utils.ByteArrayDataSource; +import org.apache.xmpbox.XMPMetadata; +import org.apache.xmpbox.schema.AdobePDFSchema; +import org.apache.xmpbox.schema.DublinCoreSchema; +import org.apache.xmpbox.schema.PDFAIdentificationSchema; +import org.apache.xmpbox.schema.XMPBasicSchema; +import org.apache.xmpbox.type.ArrayProperty; +import org.apache.xmpbox.type.BadFieldValueException; +import org.apache.xmpbox.xml.DomXmpParser; +import org.apache.xmpbox.xml.XmpParsingException; +import org.apache.xmpbox.xml.XmpSerializer; +import org.mustangproject.EStandard; +import org.mustangproject.FileAttachment; + +import javax.activation.DataSource; +import javax.activation.FileDataSource; +import javax.xml.transform.TransformerException; +import java.io.*; +import java.util.*; + +public class DXExporterFromA3 extends ZUGFeRDExporterFromA3 { + + protected PDFAConformanceLevel conformanceLevel = PDFAConformanceLevel.UNICODE; + protected ArrayList fileAttachments = new ArrayList(); + + /** + * This flag controls whether or not the metadata is overwritten, or kind of merged. + * The merging probably needs to be overhauled, but for my purpose it was good enough. + */ + protected boolean overwrite = true; + + private boolean disableAutoClose; + private boolean fileAttached = false; + private Profile profile = null; + private boolean documentPrepared = false; + + /** + * Data (XML invoice) to be added to the ZUGFeRD PDF. It may be externally set, + * in which case passing a IZUGFeRDExportableTransaction is not necessary. By + * default it is null meaning the caller needs to pass a + * IZUGFeRDExportableTransaction for the XML to be populated. + */ + protected PDMetadata metadata = null; + /** + * Producer attribute for PDF + */ + protected String producer = "mustangproject"; + /** + * Author/Creator attribute for PDF + */ + protected String creator = "mustangproject"; + /** + * CreatorTool + */ + protected String creatorTool = "mustangproject"; + + /** + * @deprecated author is never set yet + */ + @Deprecated + protected String author; + /** + * @deprecated title is never set yet + */ + @Deprecated + protected String title; + /** + * @deprecated subject is never set yet + */ + @Deprecated + protected String subject; + + /** + * OrderX document type. As of version 1.0 it may be + * ORDER, ORDER_RESPONSE, or ORDER_CHANGE + */ + protected String orderXDocumentType = "ORDER"; + + + private HashMap additionalXMLs = new HashMap(); + + + private boolean attachZUGFeRDHeaders = true; + + /** + * Makes A PDF/A3a-compliant document from a PDF-A1 compliant document (on the + * metadata level, this will not e.g. convert graphics to JPG-2000) + * + * @param pdfFilename filename of an PDF/A1 compliant document + */ + public DXExporterFromA3 load(String pdfFilename) throws IOException { + + ensurePDFIsValid(new FileDataSource(pdfFilename)); + try (FileInputStream pdf = new FileInputStream(pdfFilename)) { + return load(readAllBytes(pdf)); + } + } + + public IXMLProvider getProvider() { + return xmlProvider; + } + + public DXExporterFromA3 setProfile(Profile p) { + this.profile = p; + if (xmlProvider != null) { + xmlProvider.setProfile(p); + } + return this; + } + + public DXExporterFromA3 setProfile(String profilename) { + this.profile = Profiles.getByName(profilename); + + if (xmlProvider != null) { + xmlProvider.setProfile(this.profile); + } + return this; + } + + public DXExporterFromA3 addAdditionalFile(String name, byte[] content) { + fileAttachments.add(new FileAttachment(name, "text/xml", "Supplement", content).setDescription("ZUGFeRD extension/additional data")); + return this; + } + + + + /** + * Makes A PDF/A3a-compliant document from a PDF-A1 compliant document (on the + * metadata level, this will not e.g. convert graphics to JPG-2000) + * + * @param pdfBinary binary of a PDF/A1 compliant document + */ + public DXExporterFromA3 load(byte[] pdfBinary) throws IOException { + ensurePDFIsValid(new ByteArrayDataSource(new ByteArrayInputStream(pdfBinary))); + doc = PDDocument.load(pdfBinary); + return this; + } + + public DXExporterFromA3() { + super(); + } + + public void attachFile(FileAttachment file) { + fileAttachments.add(file); + } + + public void attachFile(String filename, byte[] data, String mimetype, String relation) { + FileAttachment fa = new FileAttachment(filename, mimetype, relation, data); + fileAttachments.add(fa); + } + + /*** + * Perform the final export to a now ZUGFeRD-enriched PDF file + * @param ZUGFeRDfilename the pdf file name + * @throws IOException if anything is wrong in the target location + */ + public void export(String ZUGFeRDfilename) throws IOException { + if (!documentPrepared) { + prepareDocument(); + } + if ((!fileAttached) && (attachZUGFeRDHeaders)) { + throw new IOException( + "File must be attached (usually with setTransaction) before perfoming this operation"); + } + doc.save(ZUGFeRDfilename); + if (!disableAutoClose) { + close(); + } + } + + @Override + public void close() throws IOException { + if (doc != null) { + doc.close(); + } + } + + + /*** + * Perform the final export to a now ZUGFeRD-enriched PDF file as OutputStream + * @param output the OutputStream + * @throws IOException if anything is wrong in the OutputStream + */ + public void export(OutputStream output) throws IOException { + if (!documentPrepared) { + prepareDocument(); + } + if ((!fileAttached) && (attachZUGFeRDHeaders)) { + throw new IOException( + "File must be attached (usually with setTransaction) before perfoming this operation"); + } + doc.save(output); + if (!disableAutoClose) { + close(); + } + } + + + /** + * Embeds an external file (generic - any type allowed) in the PDF. + * The embedding is done in the default PDF document. + * + * @param filename name of the file that will become attachment name in the PDF + * @param relationship how the file relates to the content, e.g. "Alternative" + * @param description Human-readable description of the file content + * @param subType type of the data e.g. could be "text/xml" - mime like + * @param data the binary data of the file/attachment + * @throws IOException if anything is wrong with filename + */ + public void PDFAttachGenericFile(String filename, String relationship, String description, + String subType, byte[] data) throws IOException { + PDFAttachGenericFile(this.doc, filename, relationship, description, subType, data); + } + + + /** + * Embeds an external file (generic - any type allowed) in the PDF. + * + * @param doc PDDocument to attach the file to. + * @param filename name of the file that will become attachment name in the PDF + * @param relationship how the file relates to the content, e.g. "Alternative" + * @param description Human-readable description of the file content + * @param subType type of the data e.g. could be "text/xml" - mime like + * @param data the binary data of the file/attachment + * @throws IOException if anything is wrong with filename + */ + public void PDFAttachGenericFile(PDDocument doc, String filename, String relationship, String description, + String subType, byte[] data) throws IOException { + fileAttached = true; + + PDComplexFileSpecification fs = new PDComplexFileSpecification(); + fs.setFile(filename); + + COSDictionary dict = fs.getCOSObject(); + dict.setName("AFRelationship", relationship); + dict.setString("UF", filename); + dict.setString("Desc", description); + + ByteArrayInputStream fakeFile = new ByteArrayInputStream(data); + PDEmbeddedFile ef = new PDEmbeddedFile(doc, fakeFile); +// ef.addCompression(); + ef.setSubtype(subType); + ef.setSize(data.length); + ef.setCreationDate(new GregorianCalendar()); + + ef.setModDate(GregorianCalendar.getInstance()); + + fs.setEmbeddedFile(ef); + + // In addition make sure the embedded file is set under /UF + dict = fs.getCOSObject(); + COSDictionary efDict = (COSDictionary) dict.getDictionaryObject(COSName.EF); + COSBase lowerLevelFile = efDict.getItem(COSName.F); + efDict.setItem(COSName.UF, lowerLevelFile); + + // now add the entry to the embedded file tree and set in the document. + PDDocumentNameDictionary names = new PDDocumentNameDictionary(doc.getDocumentCatalog()); + PDEmbeddedFilesNameTreeNode efTree = names.getEmbeddedFiles(); + if (efTree == null) { + efTree = new PDEmbeddedFilesNameTreeNode(); + } + + Map namesMap = new HashMap<>(); + + Map oldNamesMap = efTree.getNames(); + if (oldNamesMap != null) { + for (String key : oldNamesMap.keySet()) { + namesMap.put(key, oldNamesMap.get(key)); + } + } + namesMap.put(filename, fs); + efTree.setNames(namesMap); + + names.setEmbeddedFiles(efTree); + doc.getDocumentCatalog().setNames(names); + + // AF entry (Array) in catalog with the FileSpec + COSBase AFEntry = (COSBase) doc.getDocumentCatalog().getCOSObject().getItem("AF"); + if ((AFEntry == null)) { + COSArray cosArray = new COSArray(); + cosArray.add(fs); + doc.getDocumentCatalog().getCOSObject().setItem("AF", cosArray); + } else if (AFEntry instanceof COSArray) { + COSArray cosArray = (COSArray) AFEntry; + cosArray.add(fs); + doc.getDocumentCatalog().getCOSObject().setItem("AF", cosArray); + } else if ((AFEntry instanceof COSObject) && + ((COSObject) AFEntry).getObject() instanceof COSArray) { + COSArray cosArray = (COSArray) ((COSObject) AFEntry).getObject(); + cosArray.add(fs); + } else { + throw new IOException("Unexpected object type for PDFDocument/Catalog/COSDictionary/Item(AF)"); + } + } + + /** + * Sets the ZUGFeRD XML data to be attached as a single byte array. This is + * useful for use-cases where the XML has already been produced by some external + * API or component. + * + * @param zugferdData XML data to be set as a byte array (XML file in raw form). + * @throws IOException (should not happen) + */ + public DXExporterFromA3 setXML(byte[] zugferdData) throws IOException { + CustomXMLProvider cus = new CustomXMLProvider(); + cus.setXML(zugferdData); + this.setXMLProvider(cus); + prepare(); + return this; + } + + + /** + * Makes A PDF/A3a-compliant document from a PDF-A1 compliant document (on the + * metadata level, this will not e.g. convert graphics to JPG-2000) + * + * @param pdfSource source to read a PDF/A1 compliant document from + */ + public DXExporterFromA3 load(InputStream pdfSource) throws IOException { + return load(readAllBytes(pdfSource)); + } + + public boolean ensurePDFIsValid(final DataSource dataSource) throws IOException { + return true; + } + + private static byte[] readAllBytes(InputStream in) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + IOUtils.copy(in, buffer); + return buffer.toByteArray(); + } + + + /** + * All files are PDF/A-3, setConformance refers to the level conformance. + *

+ * PDF/A-3 has three coformance levels, called "A", "U" and "B". + *

+ * PDF/A-3-B where B means only visually preservable, U -standard for Mustang- + * means visually and unicode preservable and A means full compliance, i.e. + * visually, unicode and structurally preservable and tagged PDF, i.e. useful + * metainformation for blind people. + *

+ * Feel free to pass "A" as new level if you know what you are doing :-) + */ + public DXExporterFromA3 setConformanceLevel(PDFAConformanceLevel newLevel) { + conformanceLevel = newLevel; + return this; + } + + + public DXExporterFromA3 setCreator(String creator) { + this.creator = creator; + return this; + } + + public DXExporterFromA3 setCreatorTool(String creatorTool) { + this.creatorTool = creatorTool; + return this; + } + + public DXExporterFromA3 setProducer(String producer) { + this.producer = producer; + return this; + } + + /** + * Sets the property orderXDocumentType. + * + * @param orderXDocumentType new value. Expected: ORDER, ORDER_RESPONSE, or ORDER_CHANGE + * + * @return this exporter + */ + public DXExporterFromA3 setOrderXDocumentType(String orderXDocumentType) + { + this.orderXDocumentType = orderXDocumentType; + + return this; + } + + protected DXExporterFromA3 setAttachZUGFeRDHeaders(boolean attachHeaders) { + this.attachZUGFeRDHeaders = attachHeaders; + return this; + } + + + /** + * This will add both the RDF-indication which embedded file is Zugferd and the + * neccessary PDF/A schema extension description to be able to add this + * information to RDF + * + * @param metadata the PDFbox XMPMetadata object + */ + protected void addXMP(XMPMetadata metadata) { + + if (attachZUGFeRDHeaders) { + XMPSchemaZugferd zf = new XMPSchemaZugferd(metadata, 1, true, xmlProvider.getProfile(), + "urn:factur-x:pdfa:CrossIndustryDocument:1p0#", "fx", + "deliver-x.xml"); + zf.setType(this.orderXDocumentType); + + metadata.addSchema(zf); + // also add the schema extensions... + XMPSchemaPDFAExtensions pdfaex = new XMPSchemaPDFAExtensions(this, metadata, 1, attachZUGFeRDHeaders, EStandard.orderx); + pdfaex.setZUGFeRDVersion(1); + metadata.addSchema(pdfaex); + } + + } + + + /** + * Embeds the Zugferd XML structure in a file named ZUGFeRD-invoice.xml. + * + * @param trans a IZUGFeRDExportableTransaction that provides the data-model to + * populate the XML. This parameter may be null, if so the XML data + * should hav ebeen set via + * setZUGFeRDXMLData(byte[] zugferdData) + * @throws IOException if anything is wrong with already loaded PDF + */ + public IExporter setTransaction(IExportableTransaction trans) throws IOException { + this.trans = trans; + return prepare(); + } + + public IExporter prepare() throws IOException { + prepareDocument(); + xmlProvider.generateXML(trans); + String filename = "deliver-x.xml"; + PDFAttachGenericFile(doc, filename, "Alternative", + "Order metadata conforming to the Order-X standard (https://www.ferd-net.de/standards/order-x/index.html)", + "text/xml", xmlProvider.getXML()); + + for (FileAttachment attachment : fileAttachments) { + PDFAttachGenericFile(doc, attachment.getFilename(), attachment.getRelation(), attachment.getDescription(), attachment.getMimetype(), attachment.getData()); + } + + return this; + } + + /** + * Reads the XMPMetadata from the PDDocument, if it exists. + * Otherwise creates XMPMetadata. + */ + protected XMPMetadata getXmpMetadata() throws IOException { + PDMetadata meta = doc.getDocumentCatalog().getMetadata(); + if ((meta != null) && (meta.getLength() > 0)) { + try { + DomXmpParser xmpParser = new DomXmpParser(); + return xmpParser.parse(meta.toByteArray()); + } catch (XmpParsingException | IOException e) { + throw new IOException(e); + } + } + return XMPMetadata.createXMPMetadata(); + } + + protected byte[] serializeXmpMetadata(XMPMetadata xmpMetadata) throws TransformerException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + new XmpSerializer().serialize(xmpMetadata, buffer, true); // see https://github.com/ZUGFeRD/mustangproject/issues/44 + return buffer.toByteArray(); + } + + /** + * Sets the producer if the overwrite flag is set or the producer is not already set. + * Sets the PDFVersion to 1.4 if the field is empty. + */ + protected void writeAdobePDFSchema(XMPMetadata xmp) { + AdobePDFSchema pdf = getAdobePDFSchema(xmp); + if (overwrite || isEmpty(pdf.getProducer())) + pdf.setProducer(producer); + } + + /** + * Returns the AdobePDFSchema from the XMPMetadata if it exists. + * If the overwrite flag is set or no AdobePDFSchema exists in the XMPMetadata, it is created, added and returned. + */ + protected AdobePDFSchema getAdobePDFSchema(XMPMetadata xmp) { + AdobePDFSchema pdf = xmp.getAdobePDFSchema(); + if (pdf != null) + if (overwrite) + xmp.removeSchema(pdf); + else + return pdf; + return xmp.createAndAddAdobePDFSchema(); + } + + protected void writePDFAIdentificationSchema(XMPMetadata xmp) { + PDFAIdentificationSchema pdfaid = getPDFAIdentificationSchema(xmp); + if (overwrite || isEmpty(pdfaid.getConformance())) { + try { + pdfaid.setConformance(conformanceLevel.getLetter()); + } catch (BadFieldValueException ex) { + // This should be impossible, because it would occur only if an illegal + // conformance level is supplied, + // however the enum enforces that the conformance level is valid. + throw new RuntimeException(ex); + } + } + pdfaid.setPart(3); + } + + protected PDFAIdentificationSchema getPDFAIdentificationSchema(XMPMetadata xmp) { + PDFAIdentificationSchema pdfaid = xmp.getPDFIdentificationSchema(); + if (pdfaid != null) + if (overwrite) + xmp.removeSchema(pdfaid); + else + return pdfaid; + return xmp.createAndAddPFAIdentificationSchema(); + } + + protected void writeDublinCoreSchema(XMPMetadata xmp) { + DublinCoreSchema dc = getDublinCoreSchema(xmp); + if (dc.getFormat() == null) + dc.setFormat("application/pdf"); + if ((overwrite || dc.getCreators() == null || dc.getCreators().isEmpty()) && creator != null) + dc.addCreator(creator); + if ((overwrite || dc.getDates() == null || dc.getDates().isEmpty()) && creator != null) + dc.addDate(Calendar.getInstance()); + + ArrayProperty titleProperty = dc.getTitleProperty(); + if (titleProperty != null) { + if (overwrite && !isEmpty(title)) { + dc.removeProperty(titleProperty); + dc.setTitle(title); + } else if (titleProperty.getElementsAsString().stream().anyMatch("Untitled"::equalsIgnoreCase)) { + // remove unfitting ghostscript default + dc.removeProperty(titleProperty); + } + } else if (!isEmpty(title)) { + dc.setTitle(title); + } + } + + protected DublinCoreSchema getDublinCoreSchema(XMPMetadata xmp) { + DublinCoreSchema dc = xmp.getDublinCoreSchema(); + if (dc != null) + if (overwrite) + xmp.removeSchema(dc); + else + return dc; + return xmp.createAndAddDublinCoreSchema(); + } + + protected void writeXMLBasicSchema(XMPMetadata xmp) { + XMPBasicSchema xsb = getXmpBasicSchema(xmp); + if (overwrite || isEmpty(xsb.getCreatorTool()) || "UnknownApplication".equals(xsb.getCreatorTool())) + xsb.setCreatorTool(creatorTool); + if (overwrite || xsb.getCreateDate() == null) + xsb.setCreateDate(GregorianCalendar.getInstance()); + } + + protected XMPBasicSchema getXmpBasicSchema(XMPMetadata xmp) { + XMPBasicSchema xsb = xmp.getXMPBasicSchema(); + if (xsb != null) + if (overwrite) + xmp.removeSchema(xsb); + else + return xsb; + return xmp.createAndAddXMPBasicSchema(); + } + + protected void writeDocumentInformation() { + String fullProducer = producer + " (via mustangproject.org " + Version.VERSION + ")"; + PDDocumentInformation info = doc.getDocumentInformation(); + if (overwrite || info.getCreationDate() == null) + info.setCreationDate(Calendar.getInstance()); + if (overwrite || info.getModificationDate() == null) + info.setModificationDate(Calendar.getInstance()); + if (overwrite || (isEmpty(info.getAuthor()) && !isEmpty(author))) + info.setAuthor(author); + if (overwrite || (isEmpty(info.getProducer()) && !isEmpty(fullProducer))) + info.setProducer(fullProducer); + if (overwrite || (isEmpty(info.getCreator()) && !isEmpty(creator))) + info.setCreator(creator); + if (overwrite || (isEmpty(info.getTitle()) && !isEmpty(title))) + info.setTitle(title); + if (overwrite || (isEmpty(info.getSubject()) && !isEmpty(subject))) + info.setSubject(subject); + } + + /** + * Adds an OutputIntent and the sRGB color profile if no OutputIntent exist + */ + protected void addSRGBOutputIntend() throws IOException { + if (!doc.getDocumentCatalog().getOutputIntents().isEmpty()) { + return; + } + + try { + InputStream colorProfile = Thread.currentThread().getContextClassLoader().getResourceAsStream("sRGB.icc"); + if (colorProfile != null) { + PDOutputIntent intent = new PDOutputIntent(doc, colorProfile); + intent.setInfo("sRGB IEC61966-2.1"); + intent.setOutputCondition("sRGB IEC61966-2.1"); + intent.setOutputConditionIdentifier("sRGB IEC61966-2.1"); + intent.setRegistryName("http://www.color.org"); + doc.getDocumentCatalog().addOutputIntent(intent); + } + } catch (IOException e) { + throw e; + } + } + + /** + * Adds a MarkInfo element to the PDF if it doesn't already exist and sets it as marked. + */ + protected void setMarked() { + PDDocumentCatalog catalog = doc.getDocumentCatalog(); + if (catalog.getMarkInfo() == null) { + catalog.setMarkInfo(new PDMarkInfo(doc.getPages().getCOSObject())); + } + catalog.getMarkInfo().setMarked(true); + } + + /** + * Adds a StructureTreeRoot element to the PDF if it doesn't already exist. + */ + protected void addStructureTreeRoot() { + if (doc.getDocumentCatalog().getStructureTreeRoot() == null) { + doc.getDocumentCatalog().setStructureTreeRoot(new PDStructureTreeRoot()); + } + } + + + /** + * @return if pdf file will be automatically closed after adding ZF + */ + public boolean isAutoCloseDisabled() { + return disableAutoClose; + } + + /** + * @param disableAutoClose prevent PDF file from being closed after adding ZF + */ + public DXExporterFromA3 disableAutoClose(boolean disableAutoClose) { + this.disableAutoClose = disableAutoClose; + return this; + } + + protected void setXMLProvider(IXMLProvider p) { + this.xmlProvider = p; + if (profile != null) { + xmlProvider.setProfile(profile); + } + } + + @Override + public DXExporterFromA3 setZUGFeRDVersion(int version) { + OXPullProvider z2p = new OXPullProvider(); + setXMLProvider(z2p); + return this; + } + + /** + * Utility method inspired by apache commons-lang3 StringUtils. + * + * @param string the string to test + * @return true if the string is null or empty + */ + private boolean isEmpty(String string) { + return string == null || string.isEmpty(); + } +} diff --git a/library/src/main/java/org/mustangproject/ZUGFeRD/Profiles.java b/library/src/main/java/org/mustangproject/ZUGFeRD/Profiles.java index 46a185d8..b2be449d 100644 --- a/library/src/main/java/org/mustangproject/ZUGFeRD/Profiles.java +++ b/library/src/main/java/org/mustangproject/ZUGFeRD/Profiles.java @@ -49,19 +49,34 @@ public class Profiles { {"EXTENDED", new Profile("EXTENDED", "urn:order-x.eu:1p0:extended")}, + }).collect(Collectors.toMap(data -> (String) data[0], data -> (Profile) data[1])); + static Map dx1Map = Stream.of(new Object[][]{ + {"BASIC", new Profile("BASIC", "urn:order-x.eu:1p0:basic")}, + {"COMFORT", new Profile("COMFORT", "urn:order-x.eu:1p0:comfort")}, + {"EXTENDED", new Profile("EXTENDED", "urn:order-x.eu:1p0:extended")}, + + }).collect(Collectors.toMap(data -> (String) data[0], data -> (Profile) data[1])); public static Profile getByName(EStandard standard, String name, int version) { - if (standard != EStandard.orderx) { - return getByName(name, version); - } else { + if (standard == EStandard.orderx) { Profile result = null; result = ox1Map.get(name.toUpperCase()); if (result == null) { throw new RuntimeException("Profile not found"); } return result; + } else if (standard == EStandard.deliverx) { + Profile result = null; + result = dx1Map.get(name.toUpperCase()); + if (result == null) { + throw new RuntimeException("Profile not found"); + } + return result; + } else { + return getByName(name, version); + } } diff --git a/library/src/main/java/org/mustangproject/ZUGFeRD/ZUGFeRDImporter.java b/library/src/main/java/org/mustangproject/ZUGFeRD/ZUGFeRDImporter.java index 3168dc2a..c6777343 100644 --- a/library/src/main/java/org/mustangproject/ZUGFeRD/ZUGFeRDImporter.java +++ b/library/src/main/java/org/mustangproject/ZUGFeRD/ZUGFeRDImporter.java @@ -149,7 +149,7 @@ public class ZUGFeRDImporter { /** * filenames for invoice data (ZUGFeRD v1 and v2, Factur-X) */ - if ((filename.equals("ZUGFeRD-invoice.xml") || (filename.equals("zugferd-invoice.xml")) || filename.equals("factur-x.xml")) || filename.equals("xrechnung.xml") || filename.equals("order-x.xml")) { + if ((filename.equals("ZUGFeRD-invoice.xml") || (filename.equals("zugferd-invoice.xml")) || filename.equals("factur-x.xml")) || filename.equals("xrechnung.xml") || filename.equals("order-x.xml") || filename.equals("deliver-x.xml")) { containsMeta = true; final PDEmbeddedFile embeddedFile = fileSpec.getEmbeddedFile(); diff --git a/library/src/test/java/org/mustangproject/ZUGFeRD/DXTest.java b/library/src/test/java/org/mustangproject/ZUGFeRD/DXTest.java new file mode 100644 index 00000000..98db1729 --- /dev/null +++ b/library/src/test/java/org/mustangproject/ZUGFeRD/DXTest.java @@ -0,0 +1,314 @@ + +/** ********************************************************************** + * + * Copyright 2019 Jochen Staerk + * + * Use is subject to license terms. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + * + *********************************************************************** */ +package org.mustangproject.ZUGFeRD; + +import junit.framework.Test; +import junit.framework.TestSuite; +import org.junit.FixMethodOrder; +import org.junit.runners.MethodSorters; +import org.mustangproject.Invoice; + +import javax.xml.xpath.XPathExpressionException; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class DXTest extends MustangReaderTestCase implements IExportableTransaction { + final String TARGET_PDF = "./target/testout-DX.pdf"; + final String TARGET_XML = "./target/testout-DX.xml"; + + protected class EdgeProduct implements IZUGFeRDExportableProduct { + private String description, name, unit; + + public EdgeProduct(String description, String name, String unit) { + super(); + this.description = description; + this.name = name; + this.unit = unit; + } + + @Override + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getUnit() { + return unit; + } + + public void setUnit(String unit) { + this.unit = unit; + } + + @Override + public BigDecimal getVATPercent() { + return BigDecimal.ZERO; + } + + @Override + public boolean isIntraCommunitySupply() { + return true; // simulate intra community supply + } + } + + protected class DebitPayment implements IZUGFeRDTradeSettlementDebit { + + @Override + public String getIBAN() { + return "DE540815"; + } + + @Override + public String getMandate() { + return "DE99XX12345"; + } + + } + + @Override + public IZUGFeRDTradeSettlement[] getTradeSettlement() { + IZUGFeRDTradeSettlement[] payments = new DebitPayment[1]; + payments[0] = new DebitPayment(); + return payments; + } + + @Override + public Date getDeliveryDate() { + return new GregorianCalendar(2017, Calendar.MAY, 7).getTime(); + } + + @Override + public Date getDueDate() { + return new GregorianCalendar(2017, Calendar.MAY, 30).getTime(); + } + + @Override + public Date getIssueDate() { + return new GregorianCalendar(2017, Calendar.MAY, 9).getTime(); + } + + @Override + public String getNumber() { + return "RE-20170509/505"; + } + + @Override + public String getOwnCountry() { + return "DE"; + } + + @Override + public String getOwnLocation() { + return "Stadthausen"; + } + + @Override + public String getOwnOrganisationName() { + return "Bei Spiel GmbH"; + } + + @Override + public String getOwnStreet() { + return "Ecke 12"; + } + + @Override + public IZUGFeRDExportableTradeParty getSender() { + return new SenderTradeParty(); + } + + @Override + public String getOwnTaxID() { + return "22/815/0815/4"; + } + + @Override + public String getOwnVATID() { + return "DE136695976"; + } + + @Override + public String getOwnZIP() { + return "12345"; + } + + @Override + public IZUGFeRDExportableTradeParty getRecipient() { + return new RecipientTradeParty(); + } + + @Override + public IZUGFeRDExportableTradeParty getDeliveryAddress() { + return new RecipientTradeParty(); + } + + @Override + public String getOwnOrganisationFullPlaintextInfo() { + return null; + } + + @Override + public String getCurrency() { + return "USD"; + } + + @Override + public IZUGFeRDExportableItem[] getZFItems() { + Item[] allItems = new Item[3]; + EdgeProduct designProduct = new EdgeProduct("", "Künstlerische Gestaltung (Stunde): Einer Beispielrechnung", + "HUR"); + EdgeProduct balloonProduct = new EdgeProduct("", "Bestellerweiterung für E&F Umbau", "C62");// test for issue + // 103 + EdgeProduct airProduct = new EdgeProduct("", "Heiße Luft pro Liter", "LTR"); + + Item design=new Item(new BigDecimal("160"), new BigDecimal("1"), designProduct); + design.setAddReference("1825"); + allItems[0] = design; + allItems[1] = new Item(new BigDecimal("0.79"), new BigDecimal("400"), balloonProduct); + allItems[2] = new Item(new BigDecimal("0.10"), new BigDecimal("200"), airProduct); + return allItems; + } + + @Override + public String getPaymentTermDescription() { + SimpleDateFormat germanDateFormat = new SimpleDateFormat("dd.MM.yyyy"); + return "Zahlbar ohne Abzug bis zum " + germanDateFormat.format(getDueDate()); + } + + @Override + public IZUGFeRDAllowanceCharge[] getZFAllowances() { + return null; + } + + @Override + public IZUGFeRDAllowanceCharge[] getZFCharges() { + return null; + } + + @Override + public IZUGFeRDAllowanceCharge[] getZFLogisticsServiceCharges() { + return null; + } + + @Override + public String getReferenceNumber() { + return "AB321"; + } + + /** + * Create the test case + * + * @param testName name of the test case + */ + public DXTest(String testName) { + super(testName); + } + + /** + * @return the suite of tests being tested + */ + public static Test suite() { + return new TestSuite(DXTest.class); + } + + // //////// TESTS + // ////////////////////////////////////////////////////////////////////////////////////////// + + /** + * The exporter test bases on @{code + * ./src/test/MustangGnuaccountingBeispielRE-20170509_505PDFA3.pdf}, adds + * metadata, writes to @{code ./target/testout-*} and then imports to check the + * values. + */ + public void testDXExport() { + + // the writing part + + try (InputStream SOURCE_PDF = this.getClass() + .getResourceAsStream("/MustangGnuaccountingBeispielRE-20170509_505blanko.pdf"); + + DXExporterFromA1 oe = new DXExporterFromA1().setProducer("My Application") + .setCreator(System.getProperty("user.name")).setZUGFeRDVersion(1).ignorePDFAErrors() + .load(SOURCE_PDF)) { + oe.setTransaction(this); + String theXML = new String(oe.getProvider().getXML(), StandardCharsets.UTF_8); + assertTrue(theXML.contains("220")); + assertTrue(zi.getUTF8().contains("")); + assertFalse(zi.getUTF8().contains("EUR")); + assertTrue(zi.getUTF8().contains("USD"));//currency should be USD, test for #150 + + // Now also check the "invoice"Importer + assertEquals("496.00", zi.getAmount()); + assertEquals(zi.getHolder(), getOwnOrganisationName()); + ZUGFeRDInvoiceImporter zii = new ZUGFeRDInvoiceImporter(TARGET_PDF); + try { + Invoice i=zii.extractInvoice(); + + assertEquals(new BigDecimal("400.0000"), i.getZFItems()[1].getQuantity()); + /* getting the Quantity is more difficult than usual because in OrderX it's + called requestedQuantity, not BilledQuantity + */ + + + } catch (XPathExpressionException e) { + fail("XPathExpressionException should not be raised in testEdgeExport"); + } catch (ParseException e) { + fail("ParseException should not be raised in testEdgeExport"); + /* a parseException would also be fired if the calculated grand total does not + match the read grand total */ + } + + + } + +} diff --git a/validator/src/main/java/org/mustangproject/validator/XMLValidator.java b/validator/src/main/java/org/mustangproject/validator/XMLValidator.java index 51951b4c..a4157d90 100644 --- a/validator/src/main/java/org/mustangproject/validator/XMLValidator.java +++ b/validator/src/main/java/org/mustangproject/validator/XMLValidator.java @@ -177,6 +177,7 @@ public class XMLValidator extends Validator { context.setProfile(booking.getNodeValue()); } boolean isOrderX = false; + boolean isDeliverX = false; boolean isMiniumum = false; boolean isBasic = false; boolean isBasicWithoutLines = false; @@ -200,6 +201,12 @@ public class XMLValidator extends Validator { validateSchema(zfXML.getBytes(StandardCharsets.UTF_8), "OX_10/comfort/SCRDMCCBDACIOMessageStructure_100pD20B.xsd", 99, EPart.ox); xsltFilename = "/xslt/OX_10/comfort/SCRDMCCBDACIOMessageStructure_100pD20B_COMFORT.xslt"; + } else if (root.getLocalName().equalsIgnoreCase("SCRDMCCBDACIOMessageStructure")) { + context.setGeneration("1"); + isOrderX = true; + validateSchema(zfXML.getBytes(StandardCharsets.UTF_8), "OX_10/comfort/SCRDMCCBDACIOMessageStructure_100pD20B.xsd", 99, EPart.ox); + xsltFilename = "/xslt/OX_10/comfort/SCRDMCCBDACIOMessageStructure_100pD20B_COMFORT.xslt"; + } else if (root.getLocalName().equalsIgnoreCase("CrossIndustryInvoice")) { // ZUGFeRD 2.0 or Factur-X context.setGeneration("2");