Library to fill out Libre-/OpenOffice (ODT
) and Microsoft Word Documents (DOCX
; starting with Word 2007) containing place holders and nested Tables. The data model is independent of the resulting document format.
The library is not intended to create documents from scratch, instead to work with templates containing placeholders, tables with placeholders and nested sub tables (tables containing tables).
By using an abstract model layer (com.mz.solutions.office.model
), both formats (DOCX
/ODT
) can be used by the same code base.
For Microsoft Word there are some extensions to use Custom XML Parts and using the functionality of w:altChunk
to insert foreign documents inside the main document.
Affero General Public License Version 3 (AGPL v3)
- Runtime: No dependencies at runtime needed. (cool, right?)
- Compile: JSR 305 (Annotations for Software Defect Detection)
- Test: JUnit 5
The project needs Java 1.8. It is usable up to Java 13. (tested)
In the directory examples
there are some example projects. Further information can be found in the java doc.
Jeder Vorgang zum Befüllen von Textdokumenten (*.odt, *.docx, *.doct) bedarf eines Dokumenten-Daten-Modelles das im Unterpackage 'model' zu finden ist. Nach dem Erstellen/ Zusammensetzen des Modelles oder laden aus externen Quellen, kann in weiteren Schritten mit diesem Package ein beliebiges Dokument befüllt werden.
Alle Dokumente werden auf dem selben Weg geöffnet. Ist das zugrunde liegende Office-Format (ODT oder DOCX) bereits vor dem Öffnen bekannt, kann die konrekte Implementierung gewählt werden.
// for Libre/ Apache OpenOffice
OfficeDocumentFactory docFactory = OfficeDocumentFactory.newOpenOfficeInstance();
// for Microsoft Office (starting with 2007)
OfficeDocumentFactory docFactory = OfficeDocumentFactory.newMicrosoftOfficeInstance();
Ist das Dokumentformat unbekannt, können die Implementierung selbst erkennen ob es sich um ihr eigenes Format handelt.
Path anyDocument = Paths.get("unknownFormat.blob");
Optional<Office> docFactory = OfficeDocumentFactory.newInstanceByDocumentType(anyDocument);
if (docFactory.isPresent() == false) {
throw new IllegalStateException("unknown format");
}
Die Wahl zur passenden Implementierung bedarf dabei keiner korrekten Angabe der Dateinamenserweiterung. Die OfficeDocumentFactory und deren konkrete Implementierung erfüllt immer das Dokumenten-Daten-Modell vollständig.
Mögliche Konfigurationseigenschaften die für alle Implementierungen gültig sind finden sich in
OfficeProperty
; Implementierungs-spezifische in eigenen Klassen (z.B. MicrosoftProperty
). Die
Factory ist nicht thread-safe und eine Änderung der Konfiguration wikt sich auf alle bereits
geöffneten, zukünftig zu öffnenden und derzeit in Bearbeitung befindlichen Dokumente aus.
// für OpenOffice und Microsoft Office gemeinsame Einstellung
// (Boolean.TRUE ist bereits die Standardbelegung und muss eigentlich
// nicht explizit gesetzt werden)
docFactory.setProperty(OfficeProperty.ERR_ON_MISSING_VAL, Boolean.TRUE);
// Selbst wenn die aktuell hinter docFactory stehende Implementierung für
// OpenOffice ist, können für den Fall auch die Microsoft Office
// Einstellungen hinterlegt/ eingerichtet werden. Jede Implementierung
// achtet dabei lediglich auf die eigenen Einstellungen.
// (dieses Beispiel ist ebenfalls eine bereits bestehende Voreinstellung)
docFactory.setProperty(MicrosoftProperty.INS_HARD_PAGE_BREAKS, Boolean.TRUE);
Alle Einstellungen sollten einmalig nach dem Erstellen der Factory eingerichtet werden. Für Dokumente bei denen die Einstellungen abweichen, sollte eine eigene Factory verwendet werden. Nach der Konfiguration können alle Dokumente vom Typ der Implementierung der Factory geöffnet werden.
Path invoiceDocumentPath = Paths.get("invoice-template.any");
Path invoiceOutput = Paths.get("INVOICE-2015-SCHMIDT.DOCX");
DataPage reportData = .... // siehe 'model' Package
OfficeDocumentFactory docFactory = OfficeDocumentFactory
.newInstanceByDocumentType(invoiceDocumentPath)
.orElseThrow(IllegalStateException::new);
OfficeDocument invoiceDoc = docFactory.openDocument(invoiceDocumentPath);
invoiceDoc.generate(reportData, ResultFactory.toFile(invoiceOutput));
Platzhalter sind in OpenOffice "Varibalen" vom Typ "Benutzerdefinierte Felder" (Variables "User Fields") und findet sich im Dialog "Felder", unter dem Tab "Variablen". Die einfachen (unbenannten!) Platzhalter von OpenOffice werden nicht unterstützt. Jeder Platzhalter (Benutzerdefiniertes Feld) muss in Grußbuchstaben geschrieben sein und kann (optional) einen vordefinierten Wert enthalten. Der Typ wird beim Ersetzungsvorgang ignoriert und zu Text umgewandelt, ebenso wie die daraus resultierenden Formatierungsangaben - z.B. das Datumsformat. Wenn in der Factory eingerichtet ist, das fehlende Platzhalter ignoriert werden sollen, werden diese Platzhalter nicht mit dem Wert belassen sondern aus dem Dokument entfernt. Die umgebende Schriftformatierung von Platzhaltern wird im Ausgabedokument beibehalten.
Seitenumbrüche sind in OpenOffice nie hart-kodiert (im Gegensatz zu Microsoft Office) und ergeben sich lediglich aus den Formatierungsangaben von Absätzen. Soll nach jeder DataPage
ein Seitenumbruch erfolgen, muss im aller ersten Absatz des Dokumentes eingestellt sein, das (Paragraph -> Text Flow -> Breaks) vor dem Absatz immer ein Seitenumbruch zu erfolgen hat. Ansonsten werden alle DataPage
s nur hintereinander angefügt ohne gewünschten Seitenumbruch.
Tabellenbezeichung wird in OpenOffice direkt in den Eigenschaften der Tabelle unter "Name" hinterlegt. Der Name muss in Großbuchstaben eingetragen werden.
Kopf- und Fußzeilen werden beim normalen Ersetzungsvorgang nicht berücksichtigt. Mit Dokumenten-Anweisungen können Kopf- und Fußzeilen bei ODT Dokumenten ersetzt werden. Siehe dazu Abschnitt Dokumenten-Anweisungen.
Es werden ODF Dateien ab Version 1.1 unterstützt; nicht zu verwechseln mit der OpenOffice Versionummerierung.
Platzhalter sind Feldbefehle und ensprechen den normalen Platzhaltern in Word (MERGEFIELD
). Die Groß- und Kleinschreibung ist dabei unrelevant, sollte aber am Besten groß sein. Ein mögliches MERGEFIELD
ohne Beispieltext sieht wie folgt aus:
{ MERGEFIELD RECHNUNGSNR \* MERGEFORMAT }
Die geschweiften Klammern sollen dabei die Feld-Klammern von Word repräsentieren. Alternativ können auch Dokumenten-Variablen als Platzhalter missbraucht werden. Wichtig: Nicht in Word einfach geschweifte Klammern schreiben! Die geschweiften Klammern werden mit STRG+F9
erzeugt und sind in Word besondere Feldbefehle.
{ DOCVARIABLE RECHNUNGSNR }
Dokumentvariablen sollten dann jedoch keinerlei Verwendung in VBA (Visual Basic for Applications) Makros finden. Letzte Möglichkeit ist die Angabe eines unbekannten Feld-Befehls der nur den Namen des Platzhalters trägt. Diese Form sollte vermieden werden und ist nur implementiert um Fehlanwendung zu tolerieren. Ein vollständiges MERGEFIELD
ist grundsätzlich die beste Option! Erweiterte MERGEFIELD
Formatierungen/ Parameter (Datumsformatierungen, Ersatzwerte, ...) werden ignoriert und entfernt. Platzhalter übernehmen die umgebende Formatierung; im Feldbefehl selbst angegebene Formatierung, was eigentlich untypisch ist und nur durch spezielle Word-Kenntnisse möglich ist, werden ignoriert und aus dem Dokument entfernt.
Seitenumbrüche können weich (per Absatzformatierung) oder hart (per Einstellung nach jeder DataPage
) in Word gesetzt werden. Harte Seitenumbrüche sollten (wenn Seitenumbrüche erwünscht sind) bevorzugt werden.
Tabellenbezeichner können in Word nicht direkt vergeben werden. Um einer Tabelle eine Bezeichnung/ Name zu vergeben, muss in der ersten Zelle ein unsichtbarer Textmarker hinterlegt werden, dessen Name in Großbuchstaben den Namen der Tabelle markiert.
Kopf- und Fußzeilen werden beim normalen Ersetzungsvorgang nicht berücksichtigt. Mit Dokumenten-Anweisungen können Kopf- und Fußzeilen bei DOCX Dokumenten ersetzt werden.
Word-Dokumente ab Version 2007 im Open XML Document Format (DOCX) werden unterstützt.
Kurzlebiges Daten-Modell zur Strukturierung und späteren Befüllung von Office Dokumenten/Reports.
Grundlegend:
Das zur Befüllung von Dokumenten verwendete Daten-Modell ist ein kurzlebiges Modell das nur dem Zweck der Strukturierung dient. Langfristige Datenhaltung und Modellierung stehen dabei nicht im Vordergrund. Alle Modell-Klassen implementieren lediglich Funktionalität zum Hinzufügen von Bezeichner-Werte- Paaren, Tabellen und Tabellenzeilen. Eine Mutation dieser (Entfernen, Neusortieren, ...) ist nicht vorgesehen und sollte im eigenen Domain-Modell bereits erfolgt sein. Datenmodell können manuell zusammengestellt werden oder aus einer externen Quelle bezogen werden. Die Unter-Packages json
und xml
(in der GitHub Version noch nicht verfügbar, da noch im Beta-Status) dienen als Implementierung zum Laden von vorbereiteten externen Datenmodellen. Die Serialisierung (und damit auch das Laden und Speichern) können alternativ ebenfalls verwendet werden.
Wurzelelement DataPage des Dokumenten-Objekt-Modells:
Jedes Dokument (oder jede Serie von Dokumenten/Seiten) beginnen immer mit einer Instanz von DataPage
. Eine Instanz von DataPage
entspricht immer einem einzelnen Dokumentes oder einer Seite oder mehrerer Seiten. Die entsprechende Interpretation ist dabei abhängig, wie das Datenmodell später übergeben wird.
Bezeichner-Werte-Paare / Untermengen (Tabellen / Tabellenzeilen): Die unterschiedlichen Modell-Klassen übernehmen unterschiedliche Einträge und Strukturen auf. Die folgende Auflistung sollte dabei verdeutlichen wie die Struktur von Dokumenten ist.
x.add(y) | DataPage DataTable DataTableRow DataValue
-------------+--------------------------------------------------------------
DataPage | NEIN JA NEIN JA
DataTable | NEIN NEIN JA JA*
DataTableRow | NEIN JA NEIN JA
DataValue | NEIN NEIN NEIN NEIN
* Einfache DataValue's in DataTable, werden zum Ersetzen der Kopf und
der Fußzeile in der Tabelle verwendet; nicht zum Ersetzen von Platzhalter in den Zeilen.
Dabei ist zu beachten, dass DataTable
und DataValue
benannte Objekte sind und entsprechend eine Bezeichnung besitzen. Jede Instanz von DataValue
besitzt einen Bezeichner (den Platzhalter) und jede Tabelle DataTable
besitzt eine unsichtbare Tabellenbezeichnung die entweder direkt von Office als Name angegeben wird (so bei Open-Office) oder indirekt per Textmarker in der ersten Tabellenzelle versteckt angegeben wird (so bei Microsoft Office) da keine offizielle Tabellenbenennung möglich ist. Zu den genauen Unterschieden im Umgang mit Platzhaltern und Tabellennamen sollte die Package-Dokumentation von com.mz.solutions.office
herangezogen werden.
Beispiel:
// Angaben im Hauptdokument
DataPage invoiceDocument = new DataPage();
invoiceDocument.addValue(new DataValue("Nachname", "Mustermann"));
invoiceDocument.addValue(new DataValue("Vorname", "Max"));
invoiceDocument.addValue(new DataValue("ReDatum", "2015-01-01"));
// Tabelle mit den Rechnungsposten
DataTable invoiceItems = new DataTable("Rechnungsposten");
DataTableRow invItemRow1 = new DataTableRow();
invItemRow1.addValue(new DataValue("PostenNr", "1"));
invItemRow1.addValue(new DataValue("Artikel", "Pepsi Cola 1L"));
invItemRow1.addValue(new DataValue("Preis", "1.70"))
DataTableRow invItemRow2 = new DataTableRow();
invItemRow2.addValues( // Es gibt auch Vereinfachungen
new DataValue("PostenNr", "2"),
new DataValue("Artikel", "Kondensmilch"),
new DataValue("Preis", "0.65"));
// Hinzufügen der Zeilen; Reichenfolge ist relevant
invoiceItems.addTableRow(invItemRow1);
invoiceItems.addTableRow(invItemRow2);
// Tabelle dem Dokument/ der Seite hinzufügen
invoiceDocument.addTable(invoiceItems);
// Steuerung des Zahlungstextes
if (invoice.isDueIn14Days()) {
// less than 14 days to pay
invoiceDocument.addValues(
new DataValue("INV_PAYMENT_TEXT_0", StandardFormatHint.PARAGRAPH_REMOVE),
new DataValue("INV_PAYMENT_TEXT_1", StandardFormatHint.PARAGRAPH_KEEP));
} else {
// more than 14 days to pay
invoiceDocument.addValues(
new DataValue("INV_PAYMENT_TEXT_0", StandardFormatHint.PARAGRAPH_KEEP),
new DataValue("INV_PAYMENT_TEXT_1", StandardFormatHint.PARAGRAPH_REMOVE));
}
// Tabelle Skonto ausblenden bei Privatkunden
invoiceDocument.addValue(new DataValue("INV_TBL_SKONTO",
invoice.isCompanyInvoice() ? StandardFormatHint.TABLE_KEEP
: StandardFormatHint.TABLE_REMOVE));
// ...
Der API wird eigentlich nicht direkt gesagt wo die Datei gespeichert werden soll. Wir haben dies, da die Bibliothek auch server-seitig eingesetzt wird, soweit es geht umgangen.
Ziel und Art der Speicherung können selbst implementiert werden, indem die Schnittstelle Result implementiert wird. Alternativ können vordefinierte Implementierungen genutzt werden.
// Nutzen von fertigen Implementierungen
Result resultToFile = ResultFactory.toFile(Paths.get("output.docx"));
// oder per Lambda-Ausdruck
Result resultToLambda = (data) -> System.out.println(Arrays.toString(data));
// oder klassisch
Result resultToInnerClass = new Result() {
public void writeResult(byte[] dataToWrite) throws IOException {
System.out.println(Arrays.toString(dataToWrite));
};
};
Bilder können in Vorlage-Dokumenten eingesetzt sowie durch andere ersetzt werden. Bei
Text-Platzhaltern (MergeFields bei Microsoft, User-Def-Fields bei Libre/Openoffice), wird bei
einem Bild-Wert com.mz.solutions.office.model.images.ImageValue
an jene Stelle das
als com.mz.solutions.office.model.images.ImageResource
geladene Bild eingesetzt unter
Verwendung der angegebenen Abmaße aus dem Bild-Wert.
Bestehende Bilder können ersetzt/ausgetauscht werden und, wenn gewünscht, deren bestehenden Abmaße in der Vorlage mit eigenen überschrieben/ersetzt werden. Bild-Platzhalter, also in der Vorlage bereits existierende Bilder, werden als Platzhalter erkannt, wenn dem Bild in der Vorlage in den Eigenschaften (Titel, Name, Beschreibung, Alt-Text) ein bekannter Platzhalter mit Bild-Wert angegeben wurde. Genaueres ist den folgenden Klassen zu entnehmen:
com.mz.solutions.office.model.images.ImageResource
Bild-Datei/-Resource (Bild als Byte-Array mit Angabe des Formates)
com.mz.solutions.office.model.images.ImageValue
Bild-Wert (Resource) mit weiteren Angaben wie Titel (optional), Beschreibung (optional)
und anzuwendende Abmaße.
Ein Bild-Wert (com.mz.solutions.office.model.images.ImageValue
) besitzt eine
zugeordnete Bild-Resource (com.mz.solutions.office.model.images.ImageResource
). Eine
Bild-Resource kann mehrfach/gleichzeitig in mehreren Bild-Werten verwendet werden.
Das Wiederverwenden von Bild-Resourcen führt zu deutlich kleineren Ergebnis-Dokumenten. Jene
Bild-Resource wird dann nur einmalig im Ergebnis-Dokument eingebettet.
// Create or load Image-Resources. Try to reuse resources to reduce the file size of the
// result documents. Internally image resources cache the file content.
ImageResource imageData1 = ImageResource.loadImage(
Paths.get("image_1.png"), StandardImageResourceType.PNG);
ImageResource imageData2 = ImageResource.loadImage(
Paths.get("image_2.bmp"), StandardImageResourceType.BMP);
ImageValue image1Small = new ImageValue(imageData1)
.setDimension(0.5D, 0.5D, UnitOfLength.CENTIMETERS) // default 3cm x 1cm
.setTitle("Image Title") // optional
.setDescription("Alternative Text Description"); // optional
ImageValue image1Large = new ImageValue(imageData1) // same image as image1Small (sharing res.)
.setDimension(15, 15, UnitOfLength.CENTIMETERS);
ImageValue image2 = new ImageValue(imageData2)
.setDimension(40, 15, UnitOfLength.MILLIMETERS)
.setOverrideDimension(true);
// Assigning ImageValue's to DataValue's
final DataPage page = new DataPage();
page.addValue(new DataValue("IMAGE_1_SMALL", image1Small));
page.addValue(new DataValue("IMAGE_1_LARGE", image1Large));
page.addValue(new DataValue("IMAGE_2", image2));
page.addValue(new DataValue("IMAGE_B", image2)); // ImageValue's are reusable
Dem Ersetzungs-Vorgang können weitere Anweisungen/Callbacks mit übergeben werden. Derzeit mögliche Anweisungen ist das Abfangen (oder gezielte Laden) von Dokumenten-Teilen (also XML Dateien im ZIP Container) und der Bearbeitung des XML-Baumes vor und/oder nach Ausführung des Ersetzungs-Vorganges.
Bei LibreOffice/Apache-OpenOffice sowie Microsoft Office können dazu bei ODT und DOCX Dateien die Kopf- und Fußzeilen im Ersetzungs-Prozess mit einbezogen werden.
Alle Anweisungen können erstellt werden über die vereinfachten Factory-Methoden in
com.mz.solutions.office.instruction.DocumentProcessingInstruction
oder händisch durch
Implementieren der jeweiligen Klassen.
Kopf- und Fußzeilen werden ODT
und DOCX
Dokumente unterstützt.
// A document containing (maybe) a header and a footer
final OfficeDocument anyDocument = ...
final DataPage documentData = ...
final DataPage headerData = ... // Values for header placeholders
final DataPage footerData = ... // Values for footer placeholders
anyDocument.generate(documentData, ResultFactory.toFile(invoiceOutput),
DocumentProcessingInstruction.replaceHeaderWith(headerData),
DocumentProcessingInstruction.replaceFooterWith(footerData));
// a shorter way to replace header and footer if the same model is used:
final DataPage headerFooterSameData = ... // (same) values for header AND footer
anyDocument.generate(documentData, ResultFactory.toFile(invoiceOutput),
DocumentProcessingInstruction.replaceHeaderFooterWith(headerFooterSameData));
Document-Interceptors werden bei beiden Office-Implementierungen unterstützt.
final DocumentInterceptorFunction interceptorFunction = ...
final DocumentInterceptorFunction changeCustomXml = (DocumentInterceptionContext context) -> {
final Document xmlDocument = context.getXmlDocument();
final NodeList styleNodes = xmlDocument.getElementsByTagName("custXml:customers");
// add/remove/change XML document
// 'context' should contain all data and access you will need
};
anyDocument.generate(documentData, ResultFactory.toFile(invoiceOutput),
// Intercept main document part (document body)
DocumentProcessingInstruction.interceptDocumentBody(
DocumentInterceptorType.BEFORE_GENERATION, // invoke interceptor before
interceptorFunction, // change low level document function (Callback-Method)
dataMapForInterceptorFunctionHere), // data is optional
// Intercept styles part of this document, maybe to change font-scaling afterwards
DocumentProcessingInstruction.interceptDocumentStylesAfter(
interceptorFunction), // no data for this callback function
// let us change the Custom XML Document Part (only MS Word) und fill with our data
DocumentProcessingInstruction.interceptXmlDocumentPart(
"word/custom/custPropItem.xml", // our Custom XML data
DocumentInterceptorType.AFTER_GENERATION, // before or after doesn't matter
changeCustomXml));
Using StandardFormatHint.*
placeholder values you are able to keep, remove or hide a paragraph. There are also placeholder values to keep or remove full tables.
Most templates contain paragraphs and/or tables that are only useful in some circumstances (conditional). Using these special placeholder values you are able to control the existence of these.
Imagine there is a document with the following content and structure of placeholders (in this case MERGEFIELD
s):
[Paragraph 1] Please pay the course registration fee now. The course fee is due two weeks before
the course starts. { MERGEFIELD INV_P_NORMAL }
[Paragraph 2] Registration fee and course fee are due two weeks before the course starts. { MERGEFIELD INV_P_TWO_WEEKS_ONLY }
[Paragraph 3] Please pay the registration fee and course fee as quick as possible. { MERGEFIELD INV_P_LESS_THAN_TWO_WEEKS }
Depending on your data (e.g. invoice) there is the possibility to control which paragraph should remain in the generated document.
final DataPage invPage = new DataPage();
final LocalDate courseBeginDate = ...
final LocalDate invDate = ...
final long dueDays = DAYS.between(invDate, courseBeginDate);
if (dueDays > 21) {
invPage.addValue(new DataValue("INV_P_NORMAL", StandardFormatHint.PARAGRAPH_KEEP));
invPage.addValue(new DataValue("INV_P_TWO_WEEKS_ONLY", StandardFormatHint.PARAGRAPH_REMOVE));
invPage.addValue(new DataValue("INV_P_LESS_THAN_TWO_WEEKS", StandardFormatHint.PARAGRAPH_REMOVE));
} else if (dueDays > 14) {
invPage.addValue(new DataValue("INV_P_NORMAL", StandardFormatHint.PARAGRAPH_REMOVE));
invPage.addValue(new DataValue("INV_P_TWO_WEEKS_ONLY", StandardFormatHint.PARAGRAPH_KEEP));
invPage.addValue(new DataValue("INV_P_LESS_THAN_TWO_WEEKS", StandardFormatHint.PARAGRAPH_REMOVE));
} else {
invPage.addValue(new DataValue("INV_P_NORMAL", StandardFormatHint.PARAGRAPH_REMOVE));
invPage.addValue(new DataValue("INV_P_TWO_WEEKS_ONLY", StandardFormatHint.PARAGRAPH_REMOVE));
invPage.addValue(new DataValue("INV_P_LESS_THAN_TWO_WEEKS", StandardFormatHint.PARAGRAPH_KEEP));
}
The same is applicable with tables using StandardFormatHint.TABLE_KEEP
to indicate that a table should remain in a document and that this table should be normal replaced. Using StandardFormatHint.TABLE_REMOVE
instructs the process to fully remove a table without further replacement.