` are rendered as lists — this is the one output format
+ where that markup is native, and §7.5 was waiting on somewhere to put it.
+
+**One thing this needed from the model.** `TypeDefinition.getService()` was never set: the field
+existed, the linker never filled it, and nothing had read it. A type declared by a service belongs
+to that service — two services of one area may each declare a type of the same name — so the linker
+now sets it. Nothing else read it, and the Java and document golden trees are unmoved.
+
+The five `svg.*` options of `extraProperties` are not reproduced, per invariant 4: descriptive
+text, indexes, collapsed and expanded messages are all included, and SVG is not split out into
+files of its own, which is what every default already said.
+
+### 10.8 Where the tests live
+
+The main build compiles; testbeds test. That is not a new rule — `testbeds` is commented out of the
+root reactor at `pom.xml:60`, the CI main build runs `-DskipTests clean install`, and every testbed
+is run separately against its own `working-directory`. The library follows it like everything else.
+
+**`api-generator-lib` is in the reactor** (`api-generator/pom.xml`), for compilation. What that
+costs the main build is the compile, because the main build skips tests:
+
+| | Time |
+| --- | --- |
+| `clean compile` | 2.8 s |
+| `clean test`, the 73 unit tests that stayed | 3.5 s |
+
+**`testbeds/testbed-api-generator` holds the 39 tests that read a corpus** — the Java and document
+golden trees, the XML importer and validator corpus walks, the XHTML corpus, and the XML and MOSpec
+round trips, together with the `ModelComparison`, `GoldenTree` and `Corpus` helpers they share. The
+line between the two is whether a test reads anything outside its own module: a test of
+`JumpTable` or `JavaMethodBuilder` stays with the code it tests, a test that walks
+`xml-service-specifications/` or the captured baseline does not.
+
+The point of the split is that building the library is a compile. The point of *not* deleting the
+unit tests along with the rest is that they are what fails when someone breaks a builder, and they
+cost 70 ms.
+
+**What CI runs.** `.github/workflows/testbeds.yml` gains a `testbed-api-generator` job on each of
+the four JDKs, and all 39 tests run there, golden trees included.
+
+That last part took a correction. The baseline was gitignored, on the Phase 0 note that it is
+"~20 MB and regenerable", so on a fresh runner the nine golden-tree tests found nothing to compare
+against and `Assume`-skipped — the strongest check in the suite was the one CI did not perform. The
+proposed fix was to run `golden.sh capture` in the workflow, rebuilding every `apis/*` module with
+the old generators before the testbed built them again with the new.
+
+That was the wrong trade, and the 20 MB was the wrong number. It is repetitive generated Java and
+XML, so it **packs to 1.5 MB** against a repository of 11 MB, and it changes only when the old
+generators change, which is to say almost never while they are frozen through 14.x. Spending
+minutes of CI on four JDKs on every push, forever, to avoid committing 1.5 MB once is not a saving.
+
+So the baseline is committed, at `testbeds/testbed-api-generator/baseline/`, next to the tests that
+read it — deliberately not under `src/test/resources/`, since the tests reach it as files and Maven
+would otherwise copy 20 MB into `target/` on every build. `golden.sh` captures to and compares
+against the same path.
+
+The benefit that decided it is one neither option had been weighed for: a committed baseline makes
+a change in generated output **visible in review**. Change the Java generator and the diff names
+the files that moved. A test that reports "7 files differ" says less than a diff that shows what.
+
+### 10.9 Cut-over — v15.0
+
+The cut-over is **flipping the default**, not proving the new path works — that has been
+demonstrable since Phase 3, by building the whole reactor with `-Pnew-generator` (§3). By this point
+the two implementations should have been producing agreed output in CI for some time, so v15.0 is
+bookkeeping rather than a leap:
+
+- `esa.stubgen.generator` defaults to the new generator, and the profile disappears;
+- `parent/pom.xml` points the plugin's dependency at `api-generator-lib`;
+- `generator-interfaces`, `generator-java` and `generator-docs` are deleted, along with everything
+ in §10.10;
+- the golden tree is re-baselined from the new library, since there is no longer a second
+ implementation to agree with.
+
+If the new path is not producing agreed output by then, the correct response is to leave the default
+alone and ship 15.0 without the deletion — nothing else in the release depends on it.
+
+### 10.10 Removed at cut-over (v15.0)
+
+The new library never carries any of these; they disappear from the repository when the old modules
+are deleted. Until then they stay exactly as they are — no clean-up of the old code during 14.x,
+since the whole point of keeping it is that it goes on working untouched.
+
+- `GeneratorGwt` — dead (§8).
+- `Generator.loadXSD()`, `XsdSpecification`, the `w3c.xsd` package, `src/main/xsd-ref/` and
+ `MOTypeRegistry.loadTypesFromXsdList()`. The only `xsd-ref` directory in the repository is the
+ generator's own, so no module feeds this path. Removing it deletes the second `xjc` execution and
+ the checked-in `XMLSchema.xsd`/DTD copies.
+- `org.reflections` generator discovery.
+- **The plugin's configuration options** — `packageBindings`, `generateStructures`, `generateCOM`,
+ `extraProperties`, `forceGeneration`, `xsdRefDirectory`. Two independent reasons they cannot go
+ earlier:
+ 1. **They are still wired to live code during 14.x.** The plugin goes on driving the old
+ generators, which genuinely read all six (§5.2). They are not vestigial parameters being kept
+ for compatibility — they still do exactly what they do today. Only when the old generators are
+ deleted is there nothing behind them, and that is the cut-over by definition.
+ 2. Removing a Mojo `@Parameter` breaks any external pom that sets one, since Maven fails on
+ unknown plugin configuration. That is a major-version change, announced in the release notes.
+
+ Note the removal is then optional rather than required: the plugin may keep its surface and
+ quietly ignore the options, at no cost to the library, which never had them (§3, invariant 4).
+- `XmlSpecification.modifyFileToBeBackwardsCompatible()`.
+- `ParserMOSDL`'s comment string-replacement.
+- `OperationSummary`, `ServiceSummary`, `FieldInfo`, `TypeKey` — superseded by the model.
+ `CompositeField` is not carried over either — whatever the Java generator needs to render a field
+ is re-derived from the model rather than inherited (§8).
+
+## 11. Decisions taken
+
+No open points outstanding. Decisions taken while writing this document are recorded where they apply, with
+the evidence that settled them, so that they are not silently reopened:
+
+| Decision | Where |
+| --- | --- |
+| One library, not several; the Maven plugin stays separate | §3 |
+| No configuration — invariant 4 | §3 |
+| Java 1.8 | §3 |
+| Area identity keyed on `(name, number, version)`; collisions are a `WARNING` | §4.1 |
+| Type references resolve against the loaded area version, not the schema version | §4.1 |
+| MAL attribute types come from the MAL specification; the Java mapping stays in the generator | §4.2 |
+| `CompositeType.superType` always set — `extends` written explicitly | §4.3 |
+| COM first-class, always generated | §4.4 |
+| The model is the union of v001 and v003; the exporter refuses cross-version constructs | §4.5 |
+| Requirement IDs only where the specification authored them | §4.6 |
+| Documentation markup: existing `DocxBaseWriter` behaviour is normative | §4.6 |
+| `import → link → validate → generate`; ERROR halts, WARNING proceeds | §5.1, §5.3 |
+| Targets are an argument to `generate`, not model state | §5.2 |
+| Schema validation on export, against the real v001/v003 schemas | §6.1 |
+| MOSpec: name, hand-written parser, no MOSDL compatibility | §7.1, §7.2 |
+| A `.mospec` file is a specification, holding one or more areas | §7.1, §7.9 |
+| Diagrams as sidecar SVGs | §7.6 |
+| `BULK` the default doc mode | §7.7 |
+| Type-first field declarations, `replayable`, arrows unchanged | §7.8 |
+| Numbers always explicit; no auto-numbering | §7.7 |
+| MOSpec normalises the older `ObjectRef(X)` spelling; the XML comes back modernised | §7.9 |
+| A comment written empty stays empty, and is shown as one | §7.5 |
+| docx: output unchanged, OOXML layer lifted, styles to resource files | §8 |
+| No third-party dependencies; Batik went with diagram rasterisation | §3, §8.3 |
+| The XHTML generator is ported, not dropped, as Phase 6 | §8, §10.7 |
+| The XHTML page is named after its specification, so two versions of an area no longer collide | §10.7 |
+| A type that contains itself is drawn once and linked, not expanded | §10.7 |
+| A drawing stops at 120 fields, a level at a time | §10.7 |
+| A type knows the service that declares it: the linker now sets it | §10.7 |
+| Rewrite rather than port; no language abstraction before a second language | §8, §8.1 |
+| Parallel generation, with determinism as the binding constraint | §8.2 |
+| Parallel running through 14.x, cut-over at v15.0 | §3, §10.9 |
+| Inline SVG diagrams are not rendered; removed from prototype XML, recorded as a difference for published files | §8.3 |
+| The library compiles in the reactor; its corpus tests live in a testbed | §10.8 |
diff --git a/api-generator/api-generator-lib/pom.xml b/api-generator/api-generator-lib/pom.xml
new file mode 100644
index 00000000..5bb7c642
--- /dev/null
+++ b/api-generator/api-generator-lib/pom.xml
@@ -0,0 +1,120 @@
+
+
+
+
+
+ 4.0.0
+
+
+ int.esa.ccsds.mo
+ parent
+ 14.2
+ ../../parent/pom.xml
+
+
+ api-generator-lib
+ 14.2
+ jar
+
+ ESA MO API Generator - Library
+ Model of MO service specifications, with importers, exporters and generators
+
+
+ ESA
+ http://www.esa.int
+
+
+
+
+ The European Space Agency Public License, Version 2.0
+ https://raw.githubusercontent.com/esa/mo-services-java/master/LICENCE.md
+ repo
+
+
+
+
+ scm:git:git@github.com:esa/mo-services-java.git
+ scm:git:git@github.com:esa/mo-services-java.git
+ https://github.com/esa/mo-services-java
+
+
+
+ GitHub
+ https://github.com/esa/mo-services-java/issues
+
+
+
+
+ false
+ xml-ccsds-mo-standards
+ ${project.build.outputDirectory}
+ **/*.xsd
+
+
+
+
+
+
+ int.esa.ccsds.mo
+ xml-ccsds-mo-standards
+ 14.2
+ provided
+
+
+ junit
+ junit
+ test
+
+
+
+
+
+
+ ${basedir}/..
+
+ LICENCE.md
+
+
+
+
+ src/main/resources
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/ExportException.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/ExportException.java
new file mode 100644
index 00000000..86452c71
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/ExportException.java
@@ -0,0 +1,37 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters;
+
+/**
+ * Thrown when a model cannot be written in the requested format.
+ *
+ * The commonest cause is a construct the target schema version does not have. An exporter
+ * refuses rather than dropping it: writing a v003 model into a v001 file would silently
+ * lose its subscription keys.
+ */
+public class ExportException extends Exception {
+
+ private static final long serialVersionUID = 1L;
+
+ public ExportException(String message) {
+ super(message);
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/Exporter.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/Exporter.java
new file mode 100644
index 00000000..d6d43eb8
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/Exporter.java
@@ -0,0 +1,44 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters;
+
+import esa.mo.apigen.model.Specification;
+import java.io.IOException;
+import java.nio.file.Path;
+
+/**
+ * Writes a specification out.
+ *
+ * Exporters write to a directory rather than to a stream because no export is necessarily
+ * one document: a MOSpec specification is a text file plus a sidecar for each diagram.
+ */
+public interface Exporter {
+
+ /**
+ * Writes a specification.
+ *
+ * @param spec The specification to write.
+ * @param outputDir The directory to write into. Created if it does not exist.
+ * @throws IOException if writing fails.
+ * @throws ExportException if the specification cannot be represented in this format.
+ */
+ void write(Specification spec, Path outputDir) throws IOException, ExportException;
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/DocMode.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/DocMode.java
new file mode 100644
index 00000000..3e019f0c
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/DocMode.java
@@ -0,0 +1,44 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters.mospec;
+
+/**
+ * Where the documentation of an operation's fields and errors is placed.
+ */
+public enum DocMode {
+
+ /**
+ * Hoisted into one block above the signature, tagged by what it describes. The default,
+ * because it keeps the signature scannable in one glance.
+ */
+ BULK,
+
+ /**
+ * Written beside the field or error it describes, inside the signature.
+ */
+ INLINE,
+
+ /**
+ * Left out. The only mode that does not preserve the model, and so the only one the
+ * round-trip test cannot hold to equality.
+ */
+ SUPPRESS
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/MOSpecExporter.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/MOSpecExporter.java
new file mode 100644
index 00000000..5da969ea
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/MOSpecExporter.java
@@ -0,0 +1,720 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters.mospec;
+
+import esa.mo.apigen.exporters.ExportException;
+import esa.mo.apigen.exporters.Exporter;
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.CapabilitySet;
+import esa.mo.apigen.model.ErrorDefinition;
+import esa.mo.apigen.model.ErrorReference;
+import esa.mo.apigen.model.Field;
+import esa.mo.apigen.model.InteractionPattern;
+import esa.mo.apigen.model.InteractionStage;
+import esa.mo.apigen.model.MessageBody;
+import esa.mo.apigen.model.Operation;
+import esa.mo.apigen.model.Service;
+import esa.mo.apigen.model.Specification;
+import esa.mo.apigen.model.com.COMFeatures;
+import esa.mo.apigen.model.com.COMObject;
+import esa.mo.apigen.model.com.ObjectLink;
+import esa.mo.apigen.model.com.ObjectReference;
+import esa.mo.apigen.model.docs.Diagram;
+import esa.mo.apigen.model.docs.DocSection;
+import esa.mo.apigen.model.types.AttributeType;
+import esa.mo.apigen.model.types.CompositeType;
+import esa.mo.apigen.model.types.EnumerationItem;
+import esa.mo.apigen.model.types.EnumerationType;
+import esa.mo.apigen.model.types.FundamentalType;
+import esa.mo.apigen.model.types.TypeDefinition;
+import esa.mo.apigen.model.types.TypeRef;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Writes a specification as MOSpec.
+ *
+ * A MOSpec specification is a directory: one {@code .mospec} file holding the specification,
+ * and one {@code .svg} beside it per diagram. Inlining a diagram would put a hundred lines of
+ * drawing in the middle of a readable file, and dropping it would make the text format the
+ * one place the round trip leaks.
+ *
+ * The exporter defines the dialect: whatever it writes, the parser must read back. That is
+ * what the round-trip test holds it to.
+ */
+public final class MOSpecExporter implements Exporter {
+
+ private static final Charset UTF8 = Charset.forName("UTF-8");
+
+ private final DocMode mode;
+
+ /**
+ * An exporter writing in the default mode, with each operation's documentation gathered
+ * above its signature.
+ */
+ public MOSpecExporter() {
+ this(DocMode.BULK);
+ }
+
+ /**
+ * @param mode Where to put the documentation of fields and errors.
+ */
+ public MOSpecExporter(DocMode mode) {
+ this.mode = mode;
+ }
+
+ @Override
+ public void write(Specification spec, Path outputDir) throws IOException, ExportException {
+ Files.createDirectories(outputDir);
+ SourceFormatter out = new SourceFormatter();
+ writeSpecification(out, spec);
+
+ String name = spec.getAreas().isEmpty() ? "specification"
+ : spec.getAreas().get(0).getName();
+ write(outputDir.resolve(name + ".mospec"), out.toText());
+
+ for (Area area : spec.getAreas()) {
+ writeDiagrams(outputDir, area.getDocumentation().getDiagrams());
+ for (Service service : area.getServices()) {
+ writeDiagrams(outputDir, service.getDocumentation().getDiagrams());
+ if (service.getCom() != null) {
+ writeDiagrams(outputDir, service.getCom().getDocumentation().getDiagrams());
+ }
+ }
+ }
+ }
+
+ /**
+ * @return the text of a specification, for a caller that wants it without the sidecars.
+ */
+ public String toText(Specification spec) {
+ SourceFormatter out = new SourceFormatter();
+ writeSpecification(out, spec);
+ return out.toText();
+ }
+
+ private void writeSpecification(SourceFormatter out, Specification spec) {
+ if (mode == DocMode.SUPPRESS) {
+ out.suppressDocumentation();
+ }
+ out.doc(spec.getComment());
+ out.line("specification [" + spec.getSchemaVersion().name().toLowerCase() + "]");
+ for (Area area : spec.getAreas()) {
+ out.blank();
+ writeArea(out, area);
+ }
+ }
+
+ private void writeArea(SourceFormatter out, Area area) {
+ out.doc(area.getComment());
+ out.line("area " + MOSpecTypes.identifier(area.getName())
+ + " [" + area.getNumber() + "." + area.getVersion() + "]");
+ writeDocSections(out, area.getDocumentation().getSections());
+ writeDiagramReferences(out, area.getDocumentation().getDiagrams());
+ writeDataTypes(out, area, null, area.getDataTypes());
+ writeErrorDefinitions(out, area.getErrors());
+
+ for (Service service : area.getServices()) {
+ out.blank();
+ writeService(out, area, service);
+ }
+ }
+
+ private void writeService(SourceFormatter out, Area area, Service service) {
+ out.doc(service.getComment());
+ // A service can be a COM extended service and declare nothing at all under com, so
+ // being one is said on the service rather than inferred from what follows.
+ out.line((service.isExtended() ? "extended service " : "service ")
+ + MOSpecTypes.identifier(service.getName())
+ + " [" + service.getNumber() + "] {");
+ out.in();
+
+ writeDocSections(out, service.getDocumentation().getSections());
+ writeDiagramReferences(out, service.getDocumentation().getDiagrams());
+
+ for (CapabilitySet set : service.getCapabilitySets()) {
+ out.blank();
+ writeCapabilitySet(out, area, service, set);
+ }
+
+ writeDataTypes(out, area, service, service.getDataTypes());
+ writeErrorDefinitions(out, service.getErrors());
+
+ if (service.getCom() != null) {
+ out.blank();
+ writeCom(out, area, service, service.getCom());
+ }
+
+ out.out();
+ out.line("}");
+ }
+
+ private void writeCapabilitySet(SourceFormatter out, Area area, Service service,
+ CapabilitySet set) {
+ out.doc(set.getComment());
+ out.line("capability [" + set.getNumber() + "] {");
+ out.in();
+ for (Operation operation : set.getOperations()) {
+ out.blank();
+ writeOperation(out, area, service, operation);
+ }
+ out.out();
+ out.line("}");
+ }
+
+ /**
+ * One operation: its documentation, then the pattern, the name, the number, and the
+ * messages it exchanges in the order they travel.
+ */
+ private void writeOperation(SourceFormatter out, Area area, Service service,
+ Operation operation) {
+ if (mode == DocMode.BULK) {
+ out.doc(operation.getComment(), bulkTags(area, operation));
+ } else if (mode == DocMode.INLINE) {
+ // A message's own comment stays in the block even inline, tagged by its stage.
+ // Written inside the parentheses it could not be told from the documentation of
+ // the first field, which sits in the same place and looks the same.
+ out.doc(operation.getComment(), messageTags(operation));
+ }
+ writeDocSections(out, operation.getDocumentation().getSections());
+
+ StringBuilder head = new StringBuilder();
+ if (operation.isSupportInReplay()) {
+ head.append("replayable ");
+ }
+ head.append(patternOf(operation)).append(' ')
+ .append(MOSpecTypes.identifier(operation.getName()))
+ .append(" [").append(operation.getNumber()).append(']');
+ // The opening message is left out where the operation declares none: a
+ // publish-subscribe operation that keys its subscription on nothing is not the same
+ // as one that keys it on an empty list.
+ InteractionStage first = firstStageOf(operation);
+ boolean opened = operation.getMessage(first) != null;
+
+ if (mode == DocMode.INLINE) {
+ if (opened) {
+ writeInlineMessage(out, area, service, operation, first, head + " ");
+ } else {
+ out.line(head.toString());
+ }
+ out.in();
+ for (InteractionStage stage : replyStagesOf(operation)) {
+ writeInlineMessage(out, area, service, operation, stage, "-> ");
+ if (repeats(operation, stage)) {
+ out.line("*");
+ }
+ }
+ writeThrows(out, area, service, operation);
+ out.out();
+ return;
+ }
+
+ if (opened) {
+ head.append(' ').append(message(area, service, operation, first));
+ }
+ out.line(head.toString());
+
+ out.in();
+ for (InteractionStage stage : replyStagesOf(operation)) {
+ out.line("-> " + message(area, service, operation, stage)
+ + (repeats(operation, stage) ? "*" : ""));
+ }
+ writeThrows(out, area, service, operation);
+ out.out();
+ }
+
+ /**
+ * The errors an operation may answer with, each with the type of whatever extra
+ * information it carries.
+ */
+ private void writeThrows(SourceFormatter out, Area area, Service service,
+ Operation operation) {
+ if (operation.getErrors().isEmpty()) {
+ return;
+ }
+ if (mode == DocMode.INLINE) {
+ out.line("throws");
+ out.in();
+ List errors = operation.getErrors();
+ for (int i = 0; i < errors.size(); i++) {
+ ErrorReference error = errors.get(i);
+ out.doc(error.getComment());
+ String written = errorName(area, error) + (i == errors.size() - 1 ? "" : ",");
+ if (error.getExtraInformation() == null) {
+ out.line(written);
+ continue;
+ }
+ out.line(errorName(area, error) + ":");
+ out.in();
+ out.doc(error.getExtraInformation().getComment());
+ out.line(MOSpecTypes.of(area, service, error.getExtraInformation().getType())
+ + (i == errors.size() - 1 ? "" : ","));
+ out.out();
+ }
+ out.out();
+ return;
+ }
+
+ List raised = new ArrayList();
+ for (ErrorReference error : operation.getErrors()) {
+ String written = errorName(area, error);
+ if (error.getExtraInformation() != null) {
+ written += ": " + MOSpecTypes.of(area, service,
+ error.getExtraInformation().getType());
+ }
+ raised.add(written);
+ }
+ out.line("throws " + join(raised, ", "));
+ }
+
+ /**
+ * @return the fields of one message, in parentheses.
+ */
+ private String message(Area area, Service service, Operation operation,
+ InteractionStage stage) {
+ if (stage == null) {
+ return "()";
+ }
+ List fields = fieldsOf(operation, stage);
+ List written = new ArrayList();
+ for (Field field : fields) {
+ written.add(MOSpecTypes.field(area, service, field));
+ }
+ return "(" + join(written, ", ") + ")";
+ }
+
+ /**
+ * Writes a message with the documentation of each field beside it, which is what inline
+ * mode is for: the signature is longer, and nothing about a field is anywhere else.
+ *
+ * @return true if anything was written, false where the message is empty and the
+ * caller should write it on the line it is already on.
+ */
+ private boolean writeInlineMessage(SourceFormatter out, Area area, Service service,
+ Operation operation, InteractionStage stage, String opening) {
+ List fields = stage == null ? new ArrayList()
+ : fieldsOf(operation, stage);
+
+ if (fields.isEmpty()) {
+ out.line(opening + "()");
+ return true;
+ }
+ out.line(opening + "(");
+ out.in();
+ for (int i = 0; i < fields.size(); i++) {
+ Field field = fields.get(i);
+ out.doc(field.getComment());
+ out.line(MOSpecTypes.field(area, service, field)
+ + (i == fields.size() - 1 ? "" : ","));
+ }
+ out.out();
+ out.line(")");
+ return true;
+ }
+
+ /**
+ * The tagged lines of a bulk documentation block: one per documented field, then one
+ * per error and its extra information.
+ */
+ /**
+ * @return one tag per message that documents itself, and nothing else.
+ */
+ private List messageTags(Operation operation) {
+ List tags = new ArrayList();
+ for (InteractionStage stage : allStagesOf(operation)) {
+ MessageBody body = operation.getMessage(stage);
+ if (body != null && body.getComment() != null) {
+ tags.add("@" + tagOf(stage) + ": " + body.getComment());
+ }
+ }
+ return tags;
+ }
+
+ private List bulkTags(Area area, Operation operation) {
+ List tags = new ArrayList();
+ for (InteractionStage stage : allStagesOf(operation)) {
+ MessageBody body = operation.getMessage(stage);
+ if (body != null && body.getComment() != null) {
+ tags.add("@" + tagOf(stage) + ": " + body.getComment());
+ }
+ for (Field field : fieldsOf(operation, stage)) {
+ // Written whenever there is a comment at all, empty or not: the empty one
+ // is a thing the specification says, and it has to come back.
+ if (field.getComment() != null) {
+ tags.add("@" + tagOf(stage) + "param " + field.getName() + ": "
+ + field.getComment());
+ }
+ }
+ }
+ for (ErrorReference error : operation.getErrors()) {
+ String name = errorName(area, error);
+ if (error.getComment() != null) {
+ tags.add("@error " + name + ": " + error.getComment());
+ }
+ Field extra = error.getExtraInformation();
+ if (extra != null && extra.getComment() != null) {
+ tags.add("@errorinfo " + name + ": " + extra.getComment());
+ }
+ }
+ return tags;
+ }
+
+ private void writeDataTypes(SourceFormatter out, Area area, Service service,
+ List types) {
+ for (TypeDefinition type : types) {
+ out.blank();
+ if (type instanceof FundamentalType) {
+ writeFundamental(out, area, service, (FundamentalType) type);
+ } else if (type instanceof AttributeType) {
+ writeAttribute(out, (AttributeType) type);
+ } else if (type instanceof EnumerationType) {
+ writeEnumeration(out, (EnumerationType) type);
+ } else if (type instanceof CompositeType) {
+ writeComposite(out, area, service, (CompositeType) type);
+ }
+ }
+ }
+
+ private void writeFundamental(SourceFormatter out, Area area, Service service,
+ FundamentalType type) {
+ out.doc(type.getComment());
+ String head = "fundamental " + MOSpecTypes.identifier(type.getName());
+ if (type.getSuperType() != null) {
+ head += " extends " + MOSpecTypes.of(area, service, type.getSuperType());
+ }
+ out.line(head);
+ }
+
+ private void writeAttribute(SourceFormatter out, AttributeType type) {
+ out.doc(type.getComment());
+ out.line("attribute " + MOSpecTypes.identifier(type.getName())
+ + " [" + type.getShortFormPart() + "]");
+ }
+
+ private void writeEnumeration(SourceFormatter out, EnumerationType type) {
+ out.doc(type.getComment());
+ out.line("enumeration " + MOSpecTypes.identifier(type.getName())
+ + " [" + type.getShortFormPart() + "] {");
+ out.in();
+ for (EnumerationItem item : type.getItems()) {
+ out.doc(item.getComment());
+ out.line(MOSpecTypes.identifier(item.getValue())
+ + " [" + item.getNumericValue() + "]");
+ }
+ out.out();
+ out.line("}");
+ }
+
+ private void writeComposite(SourceFormatter out, Area area, Service service,
+ CompositeType type) {
+ out.doc(type.getComment());
+ StringBuilder head = new StringBuilder();
+ if (type.isAbstract()) {
+ head.append("abstract ");
+ }
+ head.append("composite ").append(MOSpecTypes.identifier(type.getName()));
+ if (type.getShortFormPart() != null) {
+ head.append(" [").append(type.getShortFormPart()).append(']');
+ }
+ if (type.getSuperType() != null) {
+ head.append(" extends ").append(MOSpecTypes.of(area, service, type.getSuperType()));
+ }
+ out.line(head.append(" {").toString());
+ out.in();
+ for (Field field : type.getFields()) {
+ out.doc(field.getComment());
+ out.line(MOSpecTypes.field(area, service, field));
+ }
+ out.out();
+ out.line("}");
+ }
+
+ private void writeErrorDefinitions(SourceFormatter out, List errors) {
+ for (ErrorDefinition error : errors) {
+ out.blank();
+ out.doc(error.getComment());
+ out.line("error " + MOSpecTypes.identifier(error.getName())
+ + " [" + error.getNumber() + "]");
+ }
+ }
+
+ /**
+ * What the service adds to the COM: the objects it stores, the events it raises, and
+ * how it uses the archive and the activity tracking.
+ */
+ private void writeCom(SourceFormatter out, Area area, Service service, COMFeatures com) {
+ out.line("com {");
+ out.in();
+ writeDiagramReferences(out, com.getDocumentation().getDiagrams());
+ writeDocSections(out, com.getDocumentation().getSections());
+
+ if (com.declaresObjects()) {
+ out.blank();
+ out.doc(com.getObjectsComment());
+ out.line("objects {");
+ out.in();
+ for (COMObject object : com.getObjects()) {
+ out.blank();
+ writeComObject(out, area, service, "object", object);
+ }
+ out.out();
+ out.line("}");
+ }
+ if (com.declaresEvents()) {
+ out.blank();
+ out.doc(com.getEventsComment());
+ out.line("events {");
+ out.in();
+ for (COMObject event : com.getEvents()) {
+ out.blank();
+ writeComObject(out, area, service, "event", event);
+ }
+ out.out();
+ out.line("}");
+ }
+ if (com.declaresArchiveUsage()) {
+ out.blank();
+ out.doc(com.getArchiveUsage());
+ out.line("archiveUsage");
+ }
+ if (com.declaresActivityUsage()) {
+ out.blank();
+ out.doc(com.getActivityUsage());
+ out.line("activityUsage");
+ }
+ out.out();
+ out.line("}");
+ }
+
+ /**
+ * One COM object or event. The braces are left off where it links to nothing, which is
+ * most of them.
+ */
+ private void writeComObject(SourceFormatter out, Area area, Service service,
+ String keyword, COMObject object) {
+ out.doc(object.getComment());
+ StringBuilder head = new StringBuilder(keyword);
+ head.append(' ').append(MOSpecTypes.identifier(object.getName()))
+ .append(" [").append(object.getNumber()).append(']');
+ if (object.getBodyType() != null) {
+ head.append(" (").append(MOSpecTypes.of(area, service, object.getBodyType()))
+ .append(')');
+ }
+
+ boolean hasLinks = object.getRelated() != null || object.getSource() != null;
+ if (!hasLinks) {
+ out.line(head.toString());
+ return;
+ }
+ out.line(head.append(" {").toString());
+ out.in();
+ writeLink(out, area, "related", object.getRelated());
+ writeLink(out, area, "source", object.getSource());
+ out.out();
+ out.line("}");
+ }
+
+ /**
+ * A link to another COM object, by name where the target is known and by number where
+ * it is not - a specification whose target area is absent still has to round-trip.
+ */
+ private void writeLink(SourceFormatter out, Area area, String keyword, ObjectLink link) {
+ if (link == null) {
+ return;
+ }
+ out.doc(link.getComment());
+ ObjectReference target = link.getTarget();
+ if (target == null) {
+ out.line(keyword + " -");
+ return;
+ }
+ StringBuilder written = new StringBuilder(keyword).append(' ');
+ if (!area.getName().equals(target.getArea())) {
+ written.append(target.getArea()).append("::");
+ }
+ if (target.getService() != null && !target.getService().isEmpty()) {
+ written.append(target.getService());
+ }
+ out.line(written.append('#').append(target.getNumber()).toString());
+ }
+
+ private void writeDocSections(SourceFormatter out, List sections) {
+ if (mode == DocMode.SUPPRESS) {
+ return;
+ }
+ for (DocSection section : sections) {
+ out.blank();
+ String head = "doc \"" + section.getName() + "\" [" + section.getOrder() + "]";
+ if (section.getContent() == null || section.getContent().isEmpty()) {
+ out.line(head);
+ continue;
+ }
+ out.line(head + " \"\"\"");
+ out.in();
+ for (String written : section.getContent().split("\n", -1)) {
+ out.line(written);
+ }
+ out.out();
+ out.line("\"\"\"");
+ }
+ }
+
+ private void writeDiagramReferences(SourceFormatter out, List diagrams) {
+ for (Diagram diagram : diagrams) {
+ out.blank();
+ out.line("diagram " + MOSpecTypes.identifier(diagram.getName())
+ + " \"" + diagram.getName() + ".svg\"");
+ }
+ }
+
+ private void writeDiagrams(Path outputDir, List diagrams) throws IOException {
+ for (Diagram diagram : diagrams) {
+ write(outputDir.resolve(diagram.getName() + ".svg"), diagram.getSvg());
+ }
+ }
+
+ private static void write(Path file, String content) throws IOException {
+ Writer out = new OutputStreamWriter(Files.newOutputStream(file), UTF8);
+ try {
+ out.write(content == null ? "" : content);
+ } finally {
+ out.close();
+ }
+ }
+
+ // ------------------------------------------------------------- the stages
+
+ private static String patternOf(Operation operation) {
+ return operation.getPattern().name().toLowerCase();
+ }
+
+ /**
+ * @return the stage the consumer starts the interaction with.
+ */
+ private static InteractionStage firstStageOf(Operation operation) {
+ switch (operation.getPattern()) {
+ case SEND:
+ return InteractionStage.SEND;
+ case SUBMIT:
+ return InteractionStage.SUBMIT;
+ case REQUEST:
+ return InteractionStage.REQUEST;
+ case INVOKE:
+ return InteractionStage.INVOKE;
+ case PROGRESS:
+ return InteractionStage.PROGRESS;
+ case PUBSUB:
+ return InteractionStage.SUBSCRIPTION_KEYS;
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * @return the stages the provider answers with, in the order they travel.
+ */
+ private static List replyStagesOf(Operation operation) {
+ List stages = new ArrayList();
+ switch (operation.getPattern()) {
+ case REQUEST:
+ stages.add(InteractionStage.RESPONSE);
+ break;
+ case INVOKE:
+ stages.add(InteractionStage.ACK);
+ stages.add(InteractionStage.RESPONSE);
+ break;
+ case PROGRESS:
+ stages.add(InteractionStage.ACK);
+ stages.add(InteractionStage.UPDATE);
+ stages.add(InteractionStage.RESPONSE);
+ break;
+ case PUBSUB:
+ stages.add(InteractionStage.PUBLISH_NOTIFY);
+ break;
+ default:
+ break;
+ }
+ return stages;
+ }
+
+ private static List allStagesOf(Operation operation) {
+ List stages = new ArrayList();
+ InteractionStage first = firstStageOf(operation);
+ if (first != null) {
+ stages.add(first);
+ }
+ stages.addAll(replyStagesOf(operation));
+ return stages;
+ }
+
+ /**
+ * @return true where the stage may be sent more than once, which only the update of a
+ * progress is.
+ */
+ private static boolean repeats(Operation operation, InteractionStage stage) {
+ return operation.getPattern() == InteractionPattern.PROGRESS
+ && stage == InteractionStage.UPDATE;
+ }
+
+ /**
+ * @return the tag that documents a field of this stage, which is the stage's own name.
+ */
+ private static String tagOf(InteractionStage stage) {
+ if (stage == InteractionStage.PUBLISH_NOTIFY) {
+ return "publish";
+ }
+ if (stage == InteractionStage.SUBSCRIPTION_KEYS) {
+ return "subscriptionkeys";
+ }
+ return stage.name().toLowerCase();
+ }
+
+ private static String errorName(Area area, ErrorReference error) {
+ TypeRef ref = error.getError();
+ StringBuilder buf = new StringBuilder();
+ if (ref.getArea() != null && !ref.getArea().equals(area.getName())) {
+ buf.append(ref.getArea()).append("::");
+ }
+ if (ref.getService() != null && !ref.getService().isEmpty()) {
+ buf.append(ref.getService()).append('.');
+ }
+ return buf.append(MOSpecTypes.identifier(ref.getName())).toString();
+ }
+
+ private static List fieldsOf(Operation operation, InteractionStage stage) {
+ MessageBody body = operation.getMessage(stage);
+ return body == null ? new ArrayList() : body.getFields();
+ }
+
+ private static String join(List parts, String separator) {
+ StringBuilder buf = new StringBuilder();
+ for (int i = 0; i < parts.size(); i++) {
+ buf.append(i == 0 ? "" : separator).append(parts.get(i));
+ }
+ return buf.toString();
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/MOSpecTypes.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/MOSpecTypes.java
new file mode 100644
index 00000000..d1a360d2
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/MOSpecTypes.java
@@ -0,0 +1,171 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters.mospec;
+
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.Field;
+import esa.mo.apigen.model.Service;
+import esa.mo.apigen.model.types.TypeDefinition;
+import esa.mo.apigen.model.types.TypeRef;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * How a type is written in MOSpec.
+ *
+ * Type-first, with the list and object-reference wrappers around the name and nullability
+ * postfix on the whole thing: {@code List>? refs}. The area and service
+ * are left off only when both match where the type is being mentioned, so a name is never
+ * ambiguous about where it comes from.
+ */
+public final class MOSpecTypes {
+
+ /**
+ * Words the grammar gives a meaning to. An identifier that collides with one is quoted,
+ * so that a specification is never prevented from naming something what it likes.
+ */
+ private static final Set KEYWORDS = new HashSet(Arrays.asList(
+ "specification", "area", "service", "capability", "com", "objects", "events",
+ "object", "event", "related", "source", "archiveUsage", "activityUsage",
+ "diagram", "doc", "composite", "enumeration", "attribute", "fundamental",
+ "abstract", "extends", "extended", "error", "errors", "throws", "replayable",
+ "send", "submit", "request", "invoke", "progress", "pubsub", "subscriptionKeys"));
+
+ private MOSpecTypes() {
+ }
+
+ /**
+ * Writes a type as it is referred to from somewhere.
+ *
+ * @param area The area doing the referring.
+ * @param service The service doing the referring, or null at area level.
+ * @param reference The type referred to.
+ * @return the type, written.
+ */
+ public static String of(Area area, Service service, TypeRef reference) {
+ TypeRef ref = reference.unwrapped();
+ StringBuilder buf = new StringBuilder(qualified(area, service, ref));
+ if (ref.isObjectRef()) {
+ buf.insert(0, "ObjectRef<").append('>');
+ }
+ if (ref.isList()) {
+ buf.insert(0, "List<").append('>');
+ }
+ return buf.toString();
+ }
+
+ /**
+ * Writes a field: its type, then its name, with the nullable marker between them.
+ *
+ * @param area The area the field is declared in.
+ * @param service The service it is declared in, or null at area level.
+ * @param field The field.
+ * @return the declaration.
+ */
+ public static String field(Area area, Service service, Field field) {
+ return of(area, service, field.getType())
+ + (field.isCanBeNull() ? "? " : " ") + identifier(field.getName());
+ }
+
+ /**
+ * Writes a name.
+ *
+ * Quoted where the grammar would otherwise read it as a keyword, and where it is not a
+ * bare name at all: the MAL's own errors are called things like "Authentication Failed",
+ * and a specification is not going to be told what it may call things.
+ *
+ * @param name An identifier out of a specification.
+ * @return the name, quoted where it has to be.
+ */
+ public static String identifier(String name) {
+ if (name == null) {
+ return null;
+ }
+ return KEYWORDS.contains(name) || !isBare(name) ? "\"" + name + "\"" : name;
+ }
+
+ /**
+ * @return true if the name is one the lexer would read as a single word.
+ */
+ private static boolean isBare(String name) {
+ if (name.isEmpty() || !(Character.isLetter(name.charAt(0)) || name.charAt(0) == '_')) {
+ return false;
+ }
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ if (!Character.isLetterOrDigit(c) && c != '_') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Names a type from where it is mentioned.
+ *
+ * The area is left off when it is the area doing the mentioning, and the service
+ * likewise - a type of another service in the same area still says which service. MAL
+ * is left off as well: its types are named in every specification and the language
+ * imports them implicitly, exactly as the format it descends from did. The one case
+ * that would be ambiguous - an area declaring a type of its own with the same name as a
+ * MAL type - is written out in full.
+ */
+ /**
+ * @return true if the area or the service being written declares a type of this name,
+ * in which case an unqualified name would mean that one rather than the MAL's.
+ */
+ private static boolean declaresLocally(Area area, Service service, String name) {
+ if (area != null) {
+ for (TypeDefinition type : area.getDataTypes()) {
+ if (name.equals(type.getName())) {
+ return true;
+ }
+ }
+ }
+ if (service != null) {
+ for (TypeDefinition type : service.getDataTypes()) {
+ if (name.equals(type.getName())) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static String qualified(Area area, Service service, TypeRef ref) {
+ StringBuilder buf = new StringBuilder();
+ boolean sameArea = area != null && area.getName().equals(ref.getArea());
+ boolean implicitMal = !sameArea && "MAL".equals(ref.getArea())
+ && !declaresLocally(area, service, ref.getName());
+ if (!sameArea && !implicitMal) {
+ buf.append(ref.getArea()).append("::");
+ }
+ if (ref.getService() != null && !ref.getService().isEmpty()) {
+ boolean sameService = sameArea && service != null
+ && ref.getService().equals(service.getName());
+ if (!sameService) {
+ buf.append(ref.getService()).append('.');
+ }
+ }
+ return buf.append(identifier(ref.getName())).toString();
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/SourceFormatter.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/SourceFormatter.java
new file mode 100644
index 00000000..87053db0
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/mospec/SourceFormatter.java
@@ -0,0 +1,188 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters.mospec;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * How a MOSpec file is laid out, apart from what it says.
+ *
+ * The exporter decides what to write; this decides how it reads - where the indentation
+ * goes, how a doc comment is written, where the blank lines fall. Keeping them apart means
+ * the layout can be changed without touching the traversal, and the round-trip test says
+ * whether the change was safe.
+ */
+public final class SourceFormatter {
+
+ private final StringBuilder buffer = new StringBuilder();
+
+ private int indent = 0;
+
+ /**
+ * True where documentation is left out entirely. The one mode that does not preserve
+ * the model, and the reason the round-trip test holds it to a weaker standard.
+ */
+ private boolean suppressed = false;
+
+ /**
+ * Stops anything being written as documentation.
+ */
+ public void suppressDocumentation() {
+ this.suppressed = true;
+ }
+
+ /**
+ * Steps in. Everything written until the matching {@link #out} is indented one further.
+ */
+ public void in() {
+ indent++;
+ }
+
+ public void out() {
+ if (indent > 0) {
+ indent--;
+ }
+ }
+
+ /**
+ * Writes one line at the current indentation.
+ *
+ * @param text The line, which may be empty for a blank one.
+ */
+ public void line(String text) {
+ if (text.isEmpty()) {
+ buffer.append('\n');
+ return;
+ }
+ for (int i = 0; i < indent; i++) {
+ buffer.append('\t');
+ }
+ buffer.append(text).append('\n');
+ }
+
+ /**
+ * Writes a blank line, unless the last thing written was already one. A traversal that
+ * separates everything from everything else would otherwise leave gaps of two and three
+ * lines wherever two rules meet.
+ */
+ public void blank() {
+ int length = buffer.length();
+ if (length == 0 || (length >= 2 && buffer.charAt(length - 1) == '\n'
+ && buffer.charAt(length - 2) == '\n')) {
+ return;
+ }
+ buffer.append('\n');
+ }
+
+ /**
+ * Writes a documentation comment, as one line where it fits and as a block where it
+ * does not.
+ *
+ * @param text The documentation, may be null or empty for none.
+ */
+ public void doc(String text) {
+ doc(text, new ArrayList());
+ }
+
+ /**
+ * Writes a documentation comment followed by tagged lines, which is how an operation
+ * documents its fields and errors in bulk mode.
+ *
+ * @param text The documentation of the thing itself, may be null.
+ * @param tags The tagged lines, each already beginning with its tag.
+ */
+ public void doc(String text, List tags) {
+ if (suppressed) {
+ return;
+ }
+ // Nothing is re-wrapped. A comment is written with the line breaks it has and no
+ // others: wrapping a long paragraph would turn one paragraph into several, and the
+ // text that came back would say something the model no longer does. Long lines are
+ // the price of a round trip that returns what it was given.
+ List body = new ArrayList(paragraphs(text));
+ if (!tags.isEmpty() && !body.isEmpty()) {
+ body.add("");
+ }
+ for (String tag : tags) {
+ body.addAll(paragraphs(tag));
+ }
+ if (body.isEmpty()) {
+ // An empty comment is written as an empty one. The specifications are full of
+ // comment="", which says nothing but is not the same as saying nothing, and a
+ // format that could not tell them apart would not round-trip.
+ if (text != null) {
+ line("///");
+ }
+ return;
+ }
+ // One line is written as one line; anything longer becomes a block, so that a short
+ // comment does not cost three lines to say one thing.
+ if (body.size() == 1) {
+ line("/// " + body.get(0));
+ return;
+ }
+ line("/**");
+ for (String written : body) {
+ line(written.isEmpty() ? " *" : " * " + written);
+ }
+ line(" **/");
+ }
+
+ /**
+ * Writes text that has to survive exactly as it is - a requirement, or anything else
+ * whose own line breaks carry meaning.
+ *
+ * @param text The text.
+ */
+ public void verbatim(String text) {
+ line("\"\"\"");
+ in();
+ for (String written : text.split("\n", -1)) {
+ line(written);
+ }
+ out();
+ line("\"\"\"");
+ }
+
+ /**
+ * @return what has been written.
+ */
+ public String toText() {
+ return buffer.toString();
+ }
+
+ /**
+ * Splits documentation where the writer put a line break, so that a comment written as
+ * several paragraphs stays several paragraphs.
+ */
+ private static List paragraphs(String text) {
+ List found = new ArrayList();
+ if (text == null || text.isEmpty()) {
+ return found;
+ }
+ for (String paragraph : text.split("\n", -1)) {
+ found.add(paragraph);
+ }
+ return found;
+ }
+
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/SchemaValidation.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/SchemaValidation.java
new file mode 100644
index 00000000..ef196526
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/SchemaValidation.java
@@ -0,0 +1,145 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters.xml;
+
+import esa.mo.apigen.model.SchemaVersion;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.util.EnumMap;
+import java.util.Map;
+import javax.xml.XMLConstants;
+import javax.xml.transform.Source;
+import javax.xml.transform.stream.StreamSource;
+import javax.xml.validation.Schema;
+import javax.xml.validation.SchemaFactory;
+import javax.xml.validation.Validator;
+import org.xml.sax.ErrorHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+
+/**
+ * Validates exported XML against the normative schema, using JAXP - part of the JDK, so
+ * this adds no dependency.
+ *
+ * The schema encodes constraints a hand-written validator would otherwise have to
+ * duplicate, so anything the XSD can state is left to it. Each specification is checked
+ * against its own version rather than against the backwards-compatible hybrid, which
+ * accepts documents neither version does.
+ *
+ * The schemas are unpacked into this library's resources at build time from
+ * {@code xml-ccsds-mo-standards}. Copying rather than keeping a hand-maintained copy
+ * matters: a hand-kept copy is how a divergent snapshot of this schema came to be shipped
+ * elsewhere.
+ */
+public final class SchemaValidation {
+
+ private static final Map CACHE
+ = new EnumMap(SchemaVersion.class);
+
+ private SchemaValidation() {
+ }
+
+ /**
+ * Validates a document.
+ *
+ * @param xml The document.
+ * @param version The schema version to check it against.
+ * @return null if the document is valid, otherwise a description of the first problem.
+ */
+ public static synchronized String check(String xml, SchemaVersion version) {
+ Schema schema;
+ try {
+ schema = schemaFor(version);
+ } catch (SAXException ex) {
+ return "the " + version + " schema could not be loaded: " + ex.getMessage();
+ } catch (IOException ex) {
+ return "the " + version + " schema could not be read: " + ex.getMessage();
+ }
+ if (schema == null) {
+ return null; // no schema available; nothing to check against
+ }
+ final StringBuilder first = new StringBuilder();
+ try {
+ Validator validator = schema.newValidator();
+ validator.setErrorHandler(new ErrorHandler() {
+ @Override
+ public void warning(SAXParseException ex) {
+ // not a failure
+ }
+
+ @Override
+ public void error(SAXParseException ex) throws SAXException {
+ record(ex);
+ }
+
+ @Override
+ public void fatalError(SAXParseException ex) throws SAXException {
+ record(ex);
+ }
+
+ private void record(SAXParseException ex) throws SAXException {
+ if (first.length() == 0) {
+ first.append("line ").append(ex.getLineNumber())
+ .append(", column ").append(ex.getColumnNumber())
+ .append(": ").append(ex.getMessage());
+ }
+ throw ex;
+ }
+ });
+ validator.validate(new StreamSource(new StringReader(xml)));
+ } catch (SAXException ex) {
+ return first.length() > 0 ? first.toString() : ex.getMessage();
+ } catch (IOException ex) {
+ return "could not read the document: " + ex.getMessage();
+ }
+ return null;
+ }
+
+ private static Schema schemaFor(SchemaVersion version) throws SAXException, IOException {
+ Schema cached = CACHE.get(version);
+ if (cached != null) {
+ return cached;
+ }
+ // v001 documents may carry COM features; no v003 specification does, which is
+ // consistent with COMSchema importing the v001 service schema.
+ String[] resources = version == SchemaVersion.V001
+ ? new String[]{"/xsd/ServiceSchema.xsd", "/xsd/COMSchema.xsd"}
+ : new String[]{"/xsd/ServiceSchema-v003.xsd"};
+
+ Source[] sources = new Source[resources.length];
+ for (int i = 0; i < resources.length; i++) {
+ InputStream in = SchemaValidation.class.getResourceAsStream(resources[i]);
+ if (in == null) {
+ return null; // schemas not on the classpath in this build
+ }
+ StreamSource source = new StreamSource(in);
+ // A system id so that COMSchema's relative import of the service schema
+ // resolves against its sibling.
+ source.setSystemId(SchemaValidation.class.getResource(resources[i]).toExternalForm());
+ sources[i] = source;
+ }
+ SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
+ Schema schema = factory.newSchema(sources);
+ CACHE.put(version, schema);
+ return schema;
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/XmlExporter.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/XmlExporter.java
new file mode 100644
index 00000000..c4fc4345
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/XmlExporter.java
@@ -0,0 +1,444 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters.xml;
+
+import esa.mo.apigen.exporters.ExportException;
+import esa.mo.apigen.exporters.Exporter;
+import esa.mo.apigen.importers.xml.XmlNames;
+import esa.mo.apigen.model.*;
+import esa.mo.apigen.model.com.COMFeatures;
+import esa.mo.apigen.model.com.COMObject;
+import esa.mo.apigen.model.com.ObjectLink;
+import esa.mo.apigen.model.docs.Diagram;
+import esa.mo.apigen.model.docs.DocSection;
+import esa.mo.apigen.model.docs.Documentation;
+import esa.mo.apigen.model.types.*;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Writes a specification back to the CCSDS service schema XML, in the version the
+ * specification declares.
+ *
+ * Constructs the target version does not have are refused rather than dropped: writing a
+ * v003 model into a v001 file would silently lose its subscription keys, and a silent
+ * loss is worse than a failed export.
+ */
+public final class XmlExporter implements Exporter {
+
+ @Override
+ public void write(Specification spec, Path outputDir) throws IOException, ExportException {
+ Files.createDirectories(outputDir);
+ String name = spec.getSource() == null ? "specification.xml" : spec.getSource().getName();
+ Writer out = new OutputStreamWriter(
+ Files.newOutputStream(outputDir.resolve(name)), Charset.forName("UTF-8"));
+ try {
+ writeTo(spec, out);
+ } finally {
+ out.close();
+ }
+ }
+
+ /**
+ * Writes a specification to any writer. The directory form above is a wrapper over
+ * this, so that in-memory use and file use are the same code.
+ *
+ * @param spec The specification to write.
+ * @param out Where to write it.
+ * @throws IOException if writing fails.
+ * @throws ExportException if the model cannot be represented in its schema version.
+ */
+ public void writeTo(Specification spec, Writer out) throws IOException, ExportException {
+ SchemaVersion version = spec.getSchemaVersion();
+ if (version == null) {
+ throw new ExportException("The specification does not say which schema it uses");
+ }
+ StringWriter buffered = new StringWriter();
+ XmlWriter xml = new XmlWriter(buffered);
+ xml.start("mal:specification");
+ xml.attr("xmlns:mal", version.getNamespace());
+ xml.attr("xmlns:com", XmlNames.COM);
+ xml.attr("xmlns:svg", XmlNames.SVG);
+ xml.attr("xmlns:xsi", XmlNames.XSI);
+ xml.attr("comment", spec.getComment());
+ for (Area area : spec.getAreas()) {
+ writeArea(xml, version, area);
+ }
+ xml.end();
+ xml.flush();
+
+ // Validated before it is written, so a failed export cannot leave an invalid
+ // document behind (design section 6.1).
+ String document = buffered.toString();
+ String problem = SchemaValidation.check(document, version);
+ if (problem != null) {
+ throw new ExportException("The exported document does not satisfy the "
+ + version + " schema - " + problem);
+ }
+ out.write(document);
+ out.flush();
+ }
+
+ /**
+ * Returns the specification as a string.
+ *
+ * @param spec The specification to write.
+ * @return the XML document.
+ * @throws ExportException if the model cannot be represented in its schema version.
+ */
+ public String toXml(Specification spec) throws ExportException {
+ StringWriter out = new StringWriter();
+ try {
+ writeTo(spec, out);
+ } catch (IOException ex) {
+ throw new IllegalStateException("a StringWriter cannot fail", ex);
+ }
+ return out.toString();
+ }
+
+ // ----------------------------------------------------------------- area
+
+ private void writeArea(XmlWriter xml, SchemaVersion version, Area area)
+ throws IOException, ExportException {
+ xml.start("mal:area");
+ xml.attr("name", area.getName());
+ xml.attr("number", area.getNumber());
+ xml.attr("version", area.getVersion());
+ xml.attr("comment", area.getComment());
+ writeDocumentation(xml, area.getDocumentation());
+ for (Service service : area.getServices()) {
+ writeService(xml, version, service);
+ }
+ writeDataTypes(xml, version, area.getDataTypes(), true);
+ writeErrorDefinitions(xml, area.getErrors());
+ xml.end();
+ }
+
+ // -------------------------------------------------------------- service
+
+ private void writeService(XmlWriter xml, SchemaVersion version, Service service)
+ throws IOException, ExportException {
+ if (version == SchemaVersion.V003) {
+ if (!service.getDataTypes().isEmpty()) {
+ throw new ExportException("Service '" + service.getName() + "' declares data "
+ + "types, which the v003 schema does not have");
+ }
+ if (!service.getErrors().isEmpty()) {
+ throw new ExportException("Service '" + service.getName() + "' declares errors, "
+ + "which the v003 schema does not have");
+ }
+ }
+ xml.start("mal:service");
+ xml.attr("name", service.getName());
+ xml.attr("number", service.getNumber());
+ xml.attr("comment", service.getComment());
+ if (service.isExtended()) {
+ xml.attr("xsi:type", "com:ExtendedServiceType");
+ }
+ writeDocumentation(xml, service.getDocumentation());
+ for (CapabilitySet set : service.getCapabilitySets()) {
+ writeCapabilitySet(xml, version, set);
+ }
+ writeDataTypes(xml, version, service.getDataTypes(), false);
+ writeErrorDefinitions(xml, service.getErrors());
+ if (service.getCom() != null) {
+ writeCom(xml, service.getCom());
+ }
+ xml.end();
+ }
+
+ private void writeCapabilitySet(XmlWriter xml, SchemaVersion version, CapabilitySet set)
+ throws IOException, ExportException {
+ xml.start("mal:capabilitySet");
+ xml.attr("number", set.getNumber());
+ xml.attr("comment", set.getComment());
+ for (Operation op : set.getOperations()) {
+ writeOperation(xml, version, op);
+ }
+ xml.end();
+ }
+
+ // ------------------------------------------------------------ operation
+
+ private void writeOperation(XmlWriter xml, SchemaVersion version, Operation op)
+ throws IOException, ExportException {
+ if (op.getPattern() == null) {
+ throw new ExportException("Operation '" + op.getName() + "' has no interaction pattern");
+ }
+ if (version == SchemaVersion.V003 && op.isSupportInReplay()) {
+ throw new ExportException("Operation '" + op.getName() + "' sets supportInReplay, "
+ + "which the v003 schema does not have");
+ }
+ if (version == SchemaVersion.V001 && !op.getDocumentation().isEmpty()) {
+ throw new ExportException("Operation '" + op.getName() + "' carries documentation, "
+ + "which the v001 schema does not have");
+ }
+ String element = "mal:" + op.getPattern().name().toLowerCase() + "IP";
+ xml.start(element);
+ xml.attr("name", op.getName());
+ xml.attr("number", op.getNumber());
+ if (version == SchemaVersion.V001) {
+ xml.attr("supportInReplay", op.isSupportInReplay());
+ }
+ xml.attr("comment", op.getComment());
+ writeDocumentation(xml, op.getDocumentation());
+
+ xml.start("mal:messages");
+ for (InteractionStage stage : op.getPattern().getStages()) {
+ MessageBody body = op.getMessage(stage);
+ if (body == null) {
+ continue;
+ }
+ if (stage == InteractionStage.SUBSCRIPTION_KEYS && version == SchemaVersion.V001) {
+ throw new ExportException("Operation '" + op.getName() + "' declares subscription "
+ + "keys, which the v001 schema does not have");
+ }
+ xml.start("mal:" + stage.getXmlName());
+ xml.attr("comment", body.getComment());
+ for (Field field : body.getFields()) {
+ writeField(xml, field);
+ }
+ xml.end();
+ }
+ xml.end();
+
+ if (!op.getErrors().isEmpty()) {
+ xml.start("mal:errors");
+ for (ErrorReference ref : op.getErrors()) {
+ xml.start("mal:errorRef");
+ xml.attr("comment", ref.getComment());
+ writeTypeRef(xml, ref.getError());
+ writeExtraInformation(xml, ref.getExtraInformation());
+ xml.end();
+ }
+ xml.end();
+ }
+ xml.end();
+ }
+
+ private void writeField(XmlWriter xml, Field field) throws IOException {
+ xml.start("mal:field");
+ xml.attr("name", field.getName());
+ xml.attr("canBeNull", field.isCanBeNull());
+ xml.attr("comment", field.getComment());
+ writeTypeRef(xml, field.getType());
+ xml.end();
+ }
+
+ private void writeTypeRef(XmlWriter xml, TypeRef ref) throws IOException {
+ if (ref == null) {
+ return;
+ }
+ xml.start("mal:type");
+ xml.attr("area", ref.getArea());
+ if (ref.getService() != null) {
+ xml.attr("service", ref.getService());
+ }
+ xml.attr("name", ref.getName());
+ xml.attr("list", ref.isList());
+ if (ref.isObjectRef()) {
+ xml.attr("objectRef", true);
+ }
+ xml.end();
+ }
+
+ private void writeExtraInformation(XmlWriter xml, Field extra) throws IOException {
+ if (extra == null) {
+ return;
+ }
+ xml.start("mal:extraInformation");
+ xml.attr("comment", extra.getComment());
+ writeTypeRef(xml, extra.getType());
+ xml.end();
+ }
+
+ // ---------------------------------------------------------------- types
+
+ private void writeDataTypes(XmlWriter xml, SchemaVersion version,
+ List types, boolean areaLevel) throws IOException, ExportException {
+ if (types.isEmpty()) {
+ return;
+ }
+ xml.start("mal:dataTypes");
+ for (TypeDefinition type : types) {
+ if (type instanceof FundamentalType) {
+ FundamentalType fundamental = (FundamentalType) type;
+ xml.start("mal:fundamental");
+ xml.attr("name", type.getName());
+ xml.attr("comment", type.getComment());
+ writeExtends(xml, fundamental.getSuperType());
+ xml.end();
+ } else if (type instanceof AttributeType) {
+ xml.start("mal:attribute");
+ xml.attr("name", type.getName());
+ xml.attr("shortFormPart", ((AttributeType) type).getShortFormPart());
+ xml.attr("comment", type.getComment());
+ xml.end();
+ } else if (type instanceof CompositeType) {
+ CompositeType composite = (CompositeType) type;
+ xml.start("mal:composite");
+ xml.attr("name", type.getName());
+ if (composite.getShortFormPart() != null) {
+ xml.attr("shortFormPart", composite.getShortFormPart().intValue());
+ }
+ xml.attr("comment", type.getComment());
+ writeExtends(xml, composite.getSuperType());
+ for (Field field : composite.getFields()) {
+ writeField(xml, field);
+ }
+ xml.end();
+ } else if (type instanceof EnumerationType) {
+ EnumerationType enumeration = (EnumerationType) type;
+ xml.start("mal:enumeration");
+ xml.attr("name", type.getName());
+ xml.attr("shortFormPart", enumeration.getShortFormPart());
+ xml.attr("comment", type.getComment());
+ for (EnumerationItem item : enumeration.getItems()) {
+ xml.start("mal:item");
+ xml.attr("value", item.getValue());
+ xml.attr("nvalue", item.getNumericValue());
+ xml.attr("comment", item.getComment());
+ xml.end();
+ }
+ xml.end();
+ }
+ }
+ xml.end();
+ }
+
+ private void writeExtends(XmlWriter xml, TypeRef superType) throws IOException {
+ if (superType == null) {
+ return;
+ }
+ xml.start("mal:extends");
+ writeTypeRef(xml, superType);
+ xml.end();
+ }
+
+ private void writeErrorDefinitions(XmlWriter xml, List errors)
+ throws IOException {
+ if (errors.isEmpty()) {
+ return;
+ }
+ xml.start("mal:errors");
+ for (ErrorDefinition error : errors) {
+ xml.start("mal:error");
+ xml.attr("name", error.getName());
+ xml.attr("number", error.getNumber());
+ xml.attr("comment", error.getComment());
+ writeExtraInformation(xml, error.getExtraInformation());
+ xml.end();
+ }
+ xml.end();
+ }
+
+ // ------------------------------------------------------------------ COM
+
+ private void writeCom(XmlWriter xml, COMFeatures com) throws IOException {
+ xml.start("com:features");
+ writeDocumentation(xml, com.getDocumentation());
+ if (!com.getObjects().isEmpty() || com.getObjectsComment() != null) {
+ xml.start("com:objects");
+ xml.attr("comment", com.getObjectsComment());
+ writeComObjects(xml, "object", com.getObjects());
+ xml.end();
+ }
+ if (!com.getEvents().isEmpty() || com.getEventsComment() != null) {
+ xml.start("com:events");
+ xml.attr("comment", com.getEventsComment());
+ writeComObjects(xml, "event", com.getEvents());
+ xml.end();
+ }
+ if (com.declaresArchiveUsage()) {
+ xml.start("com:archiveUsage");
+ xml.attr("comment", com.getArchiveUsage());
+ xml.end();
+ }
+ if (com.declaresActivityUsage()) {
+ xml.start("com:activityUsage");
+ xml.attr("comment", com.getActivityUsage());
+ xml.end();
+ }
+ xml.end();
+ }
+
+ private void writeComObjects(XmlWriter xml, String element, List objects)
+ throws IOException {
+ for (COMObject object : objects) {
+ xml.start("com:" + element);
+ xml.attr("name", object.getName());
+ xml.attr("number", object.getNumber());
+ xml.attr("comment", object.getComment());
+ if (object.getBodyType() != null) {
+ xml.start("com:objectType");
+ writeTypeRef(xml, object.getBodyType());
+ xml.end();
+ }
+ writeObjectLink(xml, "relatedObject", object.getRelated());
+ writeObjectLink(xml, "sourceObject", object.getSource());
+ xml.end();
+ }
+ }
+
+ private void writeObjectLink(XmlWriter xml, String element, ObjectLink link)
+ throws IOException {
+ if (link == null) {
+ return;
+ }
+ xml.start("com:" + element);
+ xml.attr("comment", link.getComment());
+ if (link.getTarget() != null) {
+ xml.start("com:objectType");
+ xml.attr("area", link.getTarget().getArea());
+ xml.attr("service", link.getTarget().getService());
+ xml.attr("number", link.getTarget().getNumber());
+ xml.end();
+ }
+ xml.end();
+ }
+
+ // -------------------------------------------------------- documentation
+
+ private void writeDocumentation(XmlWriter xml, Documentation docs) throws IOException {
+ if (docs == null) {
+ return;
+ }
+ for (DocSection section : docs.getSections()) {
+ xml.start("mal:documentation");
+ xml.attr("name", section.getName());
+ xml.attr("order", section.getOrder());
+ xml.text(section.getContent());
+ xml.end();
+ }
+ for (Diagram diagram : docs.getDiagrams()) {
+ xml.start("mal:diagram");
+ xml.attr("name", diagram.getName());
+ xml.raw(diagram.getSvg());
+ xml.end();
+ }
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/XmlWriter.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/XmlWriter.java
new file mode 100644
index 00000000..7408742a
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/exporters/xml/XmlWriter.java
@@ -0,0 +1,150 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.exporters.xml;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * A small indenting XML writer.
+ *
+ * Hand-written rather than StAX because the output has to be laid out predictably: the
+ * round-trip test compares documents, and a writer that decides its own line breaks makes
+ * every comparison a negotiation.
+ */
+public final class XmlWriter {
+
+ private final Writer out;
+ private final Deque open = new ArrayDeque();
+ private boolean elementOpen;
+ private boolean hasChildren;
+
+ public XmlWriter(Writer out) throws IOException {
+ this.out = out;
+ out.write("\n");
+ }
+
+ public void start(String name) throws IOException {
+ closeStartTag(true);
+ indent(open.size());
+ out.write("<" + name);
+ open.push(name);
+ elementOpen = true;
+ hasChildren = false;
+ }
+
+ public void attr(String name, String value) throws IOException {
+ if (value != null) {
+ out.write(" " + name + "=\"" + escapeAttr(value) + "\"");
+ }
+ }
+
+ public void attr(String name, int value) throws IOException {
+ out.write(" " + name + "=\"" + value + "\"");
+ }
+
+ public void attr(String name, long value) throws IOException {
+ out.write(" " + name + "=\"" + value + "\"");
+ }
+
+ public void attr(String name, boolean value) throws IOException {
+ out.write(" " + name + "=\"" + value + "\"");
+ }
+
+ /**
+ * Writes the element's text content. Must be called before any child element.
+ *
+ * @param text The content, written escaped.
+ * @throws IOException if writing fails.
+ */
+ public void text(String text) throws IOException {
+ if (text == null) {
+ return;
+ }
+ closeStartTag(false);
+ out.write(escapeText(text));
+ hasChildren = true;
+ }
+
+ /**
+ * Writes pre-serialised markup as-is - used for diagrams, whose SVG the model carries
+ * as text.
+ *
+ * @param markup The markup to write verbatim.
+ * @throws IOException if writing fails.
+ */
+ public void raw(String markup) throws IOException {
+ if (markup == null) {
+ return;
+ }
+ closeStartTag(false);
+ out.write(markup);
+ hasChildren = true;
+ }
+
+ public void end() throws IOException {
+ String name = open.pop();
+ if (elementOpen) {
+ out.write("/>\n");
+ elementOpen = false;
+ } else {
+ if (!hasChildren) {
+ indent(open.size());
+ }
+ out.write("" + name + ">\n");
+ }
+ hasChildren = false;
+ }
+
+ private void closeStartTag(boolean newline) throws IOException {
+ if (elementOpen) {
+ out.write(">");
+ if (newline) {
+ out.write("\n");
+ }
+ elementOpen = false;
+ }
+ }
+
+ private void indent(int depth) throws IOException {
+ for (int i = 0; i < depth; i++) {
+ out.write(" ");
+ }
+ }
+
+ public void flush() throws IOException {
+ out.flush();
+ }
+
+ private static String escapeText(String text) {
+ return text.replace("&", "&").replace("<", "<").replace(">", ">");
+ }
+
+ private static String escapeAttr(String text) {
+ return escapeText(text)
+ .replace("\"", """)
+ .replace("\n", "
")
+ .replace("\r", "
")
+ .replace("\t", " ");
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/Generator.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/Generator.java
new file mode 100644
index 00000000..0bc0bd75
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/Generator.java
@@ -0,0 +1,59 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators;
+
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.MOModel;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Turns a model into output of some kind.
+ *
+ * The caller says which areas to generate: the model itself holds every specification
+ * loaded, including those present only so that references resolve, and which of them are
+ * wanted is a property of the build rather than of the model.
+ *
+ * There is no configuration phase. A generator is constructed and then called.
+ */
+public interface Generator {
+
+ /**
+ * @return the short name used to select this generator, for example "Java".
+ */
+ String getShortName();
+
+ /**
+ * @return a one-line description.
+ */
+ String getDescription();
+
+ /**
+ * Generates output for the given areas.
+ *
+ * @param model Every loaded specification, linked and validated.
+ * @param targets The areas to generate. Others are available for reference only.
+ * @param outputDir The directory to write into.
+ * @throws IOException if writing fails.
+ */
+ void generate(MOModel model, List targets, Path outputDir) throws IOException;
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxBody.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxBody.java
new file mode 100644
index 00000000..1cf60c1b
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxBody.java
@@ -0,0 +1,320 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+/**
+ * The body of a Word document, built up as it is written.
+ *
+ * Everything a document is made of goes through here - titles, paragraphs, tables,
+ * captions - and comes out as the WordprocessingML that says it. Nothing in this class
+ * knows anything about MO: it is told what to write, not what it means.
+ */
+public final class DocxBody {
+
+ /**
+ * The numbering of the document this body belongs to, so that a numbered list can ask
+ * for one of its own. Null for a body that is built up on the side and appended later,
+ * which is how the data type sections are assembled.
+ */
+ private final DocxNumbering numbering;
+
+ /**
+ * A caption is the same construction whether it names a table or a figure: the word,
+ * the chapter number, a sequence number, and an entry for the table of contents. Only
+ * the word and the two sequence names differ, so the block is written once.
+ */
+ private static final String CAPTION
+ = ""
+ + "${kind} ";
+
+ private final StringBuilder buffer = new StringBuilder();
+
+ /**
+ * @param numbering The numbering of the document, which numbered lists draw from.
+ */
+ public DocxBody(DocxNumbering numbering) {
+ this.numbering = numbering;
+ }
+
+ /**
+ * A body that cannot number a list, for text assembled on the side.
+ */
+ public DocxBody() {
+ this(null);
+ }
+
+ /**
+ * Writes a heading.
+ *
+ * @param level How deep the heading is, 1 being a chapter.
+ * @param name What the heading says.
+ */
+ public void title(int level, String name) {
+ title(level, "", name, null);
+ }
+
+ /**
+ * Writes a heading that can be linked to from elsewhere in the document.
+ *
+ * @param level How deep the heading is, 1 being a chapter.
+ * @param section The number in front of the name, empty for none.
+ * @param name What the heading says.
+ * @param bookmark What to call the anchor, or null not to place one.
+ */
+ public void title(int level, String section, String name, String bookmark) {
+ append(2, "");
+ if (bookmark != null) {
+ append(3, "");
+ }
+ append(3, "" + section + name + "");
+ append(2, "");
+ }
+
+ /**
+ * Writes a comment out of a specification as running text.
+ *
+ * The comments carry a little markup of their own: a line inside {@code _li_} becomes a
+ * bullet, and the {@code _ul_} around a group of them says nothing a Word document
+ * needs, so it is dropped.
+ *
+ * @param text The comment, may be null.
+ */
+ public void comment(String text) {
+ for (String line : DocxText.split(text)) {
+ if ("
".equals(line)) {
+ continue;
+ }
+ if (line.contains("")) {
+ String item = line.substring(line.indexOf("") + 4, line.indexOf(""));
+ append(2, ""
+ + ""
+ + DocxText.escape(item) + "");
+ } else {
+ append(2, ""
+ + DocxText.escape(line) + "");
+ }
+ }
+ }
+
+ /**
+ * Writes a numbered list, which gets a numbering of its own so that it starts from the
+ * top rather than carrying on from the last list.
+ *
+ * @param lines The items. An {@code _ol_} among them opens a nested list and the
+ * matching close ends it.
+ * @throws java.io.IOException if the numbering template is missing from the build.
+ */
+ public void numberedComment(java.util.List lines) throws java.io.IOException {
+ if (lines == null || lines.isEmpty()) {
+ return;
+ }
+ int instance = numbering == null ? 0 : numbering.nextInstance();
+ numbered(instance, 0, lines.iterator());
+ }
+
+ /**
+ * Writes the items of a list, descending into a nested one where it starts.
+ */
+ private void numbered(int instance, int level, java.util.Iterator lines) {
+ while (lines.hasNext()) {
+ String line = lines.next();
+ if (line == null || line.trim().isEmpty()) {
+ continue;
+ }
+ if ("".equalsIgnoreCase(line)) {
+ numbered(instance, level + 1, lines);
+ } else if ("
".equalsIgnoreCase(line)) {
+ return;
+ } else {
+ numberedItem(instance, level, line);
+ }
+ }
+ }
+
+ /**
+ * Writes one item, which may itself be several lines, and which is written as a note
+ * instead where it announces itself as one.
+ */
+ private void numberedItem(int instance, int level, String text) {
+ java.util.List parts = DocxText.split(text);
+ if (parts.size() > 1) {
+ for (String part : parts) {
+ numberedItem(instance, level, part);
+ }
+ return;
+ }
+ if (text.toLowerCase().contains("note:")) {
+ note(text);
+ return;
+ }
+ for (String part : parts) {
+ if (!part.isEmpty()) {
+ append(2, "" + DocxText.escape(part)
+ + "");
+ }
+ }
+ }
+
+ /**
+ * Writes a note, which the documents set apart from the text around it.
+ *
+ * @param text The note, with or without the word that announces it.
+ */
+ public void note(String text) {
+ String body = text.replace("Note: ", "").replace("NOTE: ", "");
+ append(2, "");
+ append(3, ""
+ + ""
+ + ""
+ + ""
+ + "");
+ append(3, "NOTE");
+ append(3, "\u2013");
+ append(3, "" + body + "");
+ append(2, "");
+ }
+
+ /**
+ * Writes the one field a message carries, named and described.
+ *
+ * @param fieldName The name of the field.
+ * @param fieldComment What the field holds.
+ */
+ public void singleTypeSignature(String fieldName, String fieldComment) {
+ append(2, ""
+ + "" + DocxText.escape(fieldName)
+ + " - "
+ + DocxText.escape(fieldComment) + "");
+ }
+
+ /**
+ * Starts a table.
+ *
+ * @param widths The width of each column, in twentieths of a point.
+ * @return the table, to add rows to.
+ */
+ public DocxTable table(int[] widths) {
+ return new DocxTable(this, widths);
+ }
+
+ /**
+ * Starts a table under a caption that names it.
+ *
+ * @param widths The width of each column, in twentieths of a point.
+ * @param caption What the table is called.
+ * @return the table, to add rows to.
+ */
+ public DocxTable table(int[] widths, String caption) {
+ if (caption != null) {
+ caption("Table", caption);
+ }
+ return new DocxTable(this, widths);
+ }
+
+ /**
+ * Writes the caption of a figure, which follows the figure rather than preceding it.
+ *
+ * @param caption What the figure is called.
+ */
+ public void figureCaption(String caption) {
+ if (caption != null) {
+ caption("Figure", caption);
+ }
+ }
+
+ /**
+ * Writes a caption: the word, the chapter it is in, its number within the chapter, and
+ * the field that puts it in the table of contents.
+ *
+ * @param kind "Table" or "Figure", which is both the word shown and the sequence the
+ * number comes from.
+ * @param caption What is being named.
+ */
+ private void caption(String kind, String caption) {
+ String bookmark = "Table".equals(kind) ? "T_" : "F_";
+ append(2, "");
+ append(3, CAPTION.replace("${kind}", kind));
+ append(3, "");
+ append(3, " STYLEREF \"Heading 1\"\\l \\n \\t \\* MERGEFORMAT"
+ + " "
+ + "1"
+ + ""
+ + " SEQ " + kind + " \\s 1 "
+ + "1"
+ + ""
+ + "tc \\f T \""
+ + "");
+ append(3, "1"
+ + "");
+ append(3, "- SEQ "
+ + kind + "_TOC \\s 1 1"
+ + "");
+ append(3, "" + caption + "");
+ append(3, "\""
+ + "");
+ append(3, ": " + caption + "");
+ append(2, "");
+ }
+
+ /**
+ * Starts the next page.
+ */
+ public void pageBreak() {
+ buffer.append("");
+ }
+
+ /**
+ * Ends a section without starting a new page, which is what separates one data type
+ * from the next.
+ */
+ public void sectionBreak() {
+ buffer.append("");
+ }
+
+ /**
+ * @return everything written so far.
+ */
+ public String toXml() {
+ return buffer.toString();
+ }
+
+ /**
+ * Appends a body built up on the side, as it stands. The data types are gathered while
+ * the services are walked and land at the end of the document.
+ *
+ * @param other The body to append.
+ */
+ public void append(DocxBody other) {
+ buffer.append(other.buffer);
+ }
+
+ public void append(int indent, String text) {
+ buffer.append(DocxText.line(indent, text, DocxText.BODY_SEPARATOR));
+ }
+
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxCell.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxCell.java
new file mode 100644
index 00000000..4d9b4a61
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxCell.java
@@ -0,0 +1,166 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+/**
+ * One cell of a table, described before it is written.
+ *
+ * The generator this replaces reached a cell through nine overloads of one method, taking
+ * a column index, the widths of every column, and four flags in a fixed order. Here a cell
+ * says what it is: the row knows which column it is in and how wide that is, and anything
+ * unusual about the cell is named at the point it is asked for.
+ */
+public final class DocxCell {
+
+ /**
+ * How a cell takes part in a run of cells merged down the page.
+ */
+ enum Merge {
+ /**
+ * Not merged with anything.
+ */
+ NONE,
+ /**
+ * The top of a merged run, which is where the content goes.
+ */
+ START,
+ /**
+ * Continues the run above it, and so is written empty.
+ */
+ CONTINUE
+ }
+
+ private final DocxRow row;
+
+ private final String content;
+
+ private String shade = null;
+
+ private boolean centered = false;
+
+ private int span = 1;
+
+ private Merge merge = Merge.NONE;
+
+ DocxCell(DocxRow row, String content) {
+ this.row = row;
+ this.content = content;
+ }
+
+ /**
+ * Fills the cell with a background colour.
+ *
+ * @param colour The colour, as six hexadecimal digits.
+ * @return this cell.
+ */
+ public DocxCell shaded(String colour) {
+ this.shade = colour;
+ return this;
+ }
+
+ /**
+ * @return this cell, with its content centred.
+ */
+ public DocxCell centered() {
+ this.centered = true;
+ return this;
+ }
+
+ /**
+ * Widens the cell over the columns that follow it.
+ *
+ * @param columns How many columns the cell covers, including its own.
+ * @return this cell.
+ */
+ public DocxCell spanning(int columns) {
+ this.span = columns;
+ return this;
+ }
+
+ /**
+ * @return this cell, as the top of a run merged down the page.
+ */
+ public DocxCell mergeStart() {
+ this.merge = Merge.START;
+ return this;
+ }
+
+ /**
+ * @return this cell, continuing the merged run above it.
+ */
+ public DocxCell mergeContinue() {
+ this.merge = Merge.CONTINUE;
+ return this;
+ }
+
+ /**
+ * Finishes the cell and returns to the row, so that cells read one after another.
+ *
+ * @return the row this cell belongs to.
+ */
+ public DocxRow next() {
+ row.write(this);
+ return row;
+ }
+
+ /**
+ * Finishes the cell and the row.
+ */
+ public void endRow() {
+ row.write(this);
+ row.end();
+ }
+
+ String toXml(int width) {
+ StringBuilder buf = new StringBuilder("");
+ buf.append("");
+
+ if (merge == Merge.START) {
+ buf.append("");
+ } else if (merge == Merge.CONTINUE) {
+ buf.append("");
+ }
+ if (span > 1) {
+ buf.append("");
+ }
+ if (shade != null) {
+ buf.append("");
+ }
+ buf.append("");
+
+ // A cell that only continues the run above it holds nothing: the content sits in
+ // the cell that started the run.
+ if (merge == Merge.CONTINUE) {
+ buf.append("");
+ } else {
+ buf.append("");
+ if (centered) {
+ buf.append("");
+ }
+ buf.append(content).append("");
+ }
+ return buf.append("").toString();
+ }
+
+ int getSpan() {
+ return span;
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxDocument.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxDocument.java
new file mode 100644
index 00000000..0dcaae8b
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxDocument.java
@@ -0,0 +1,131 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.Charset;
+
+/**
+ * One Word document: its body, its numbering, and the parts that go around them.
+ *
+ * A .docx is a zip of XML parts. Everything fixed comes out of the module's resources; what
+ * is left is the body, the numbering, and the relationships. This writes the parts into a
+ * directory, which is what the comparison against the reference output reads and what a zip
+ * is made from.
+ */
+public final class DocxDocument {
+
+ private static final Charset UTF8 = Charset.forName("UTF-8");
+
+ /**
+ * How wide a diagram is drawn, in English Metric Units: the width of the text on the
+ * page. The height follows from the proportions of the diagram.
+ */
+ private static final int WIDTH_EMU = 5722620;
+
+ private static final String HEADER
+ = ""
+ + DocxText.BODY_SEPARATOR
+ + "" + DocxText.BODY_SEPARATOR;
+
+ private static final String RELATIONSHIPS
+ = ""
+ + ""
+ + ""
+ + "";
+
+ private final File folder;
+
+ private final DocxNumbering numbering;
+
+ private final DocxBody body;
+
+ /**
+ * @param folder The directory the parts are written into.
+ * @throws IOException if the fixed parts are missing from the build.
+ */
+ public DocxDocument(File folder) throws IOException {
+ this.folder = folder;
+ this.numbering = new DocxNumbering();
+ this.body = new DocxBody(numbering);
+ }
+
+ /**
+ * @return the body of the document, to write into.
+ */
+ public DocxBody getBody() {
+ return body;
+ }
+
+ /**
+ * Writes every part of the document.
+ *
+ * @throws IOException if a part cannot be written.
+ */
+ public void write() throws IOException {
+ write(new File(folder, "[Content_Types].xml"),
+ DocxResources.read(DocxResources.CONTENT_TYPES));
+ write(new File(folder, "_rels/.rels"),
+ DocxResources.read(DocxResources.PACKAGE_RELS));
+ write(new File(folder, "word/styles.xml"), DocxResources.read(DocxResources.STYLES));
+ write(new File(folder, "word/numbering.xml"), numbering.toXml());
+ write(new File(folder, "word/document.xml"), HEADER
+ + DocxText.line(1, "", DocxText.BODY_SEPARATOR)
+ + body.toXml()
+ + DocxText.line(1, "", DocxText.BODY_SEPARATOR)
+ + DocxText.line(0, "", DocxText.BODY_SEPARATOR));
+
+ write(new File(folder, "word/_rels/document.xml.rels"),
+ RELATIONSHIPS + "");
+ }
+
+ private static void write(File file, String content) throws IOException {
+ file.getParentFile().mkdirs();
+ Writer out = new OutputStreamWriter(new FileOutputStream(file), UTF8);
+ try {
+ out.write(content);
+ } finally {
+ out.close();
+ }
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxGenerator.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxGenerator.java
new file mode 100644
index 00000000..74f827ee
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxGenerator.java
@@ -0,0 +1,911 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+import esa.mo.apigen.generators.Generator;
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.CapabilitySet;
+import esa.mo.apigen.model.ErrorDefinition;
+import esa.mo.apigen.model.ErrorReference;
+import esa.mo.apigen.model.MOModel;
+import esa.mo.apigen.model.Operation;
+import esa.mo.apigen.model.Service;
+import esa.mo.apigen.model.Field;
+import esa.mo.apigen.model.InteractionPattern;
+import esa.mo.apigen.model.InteractionStage;
+import esa.mo.apigen.model.MessageBody;
+import esa.mo.apigen.model.com.COMFeatures;
+import esa.mo.apigen.model.com.COMObject;
+import esa.mo.apigen.model.com.ObjectLink;
+import esa.mo.apigen.model.com.ObjectReference;
+import esa.mo.apigen.model.docs.DocSection;
+import esa.mo.apigen.model.types.AttributeType;
+import esa.mo.apigen.model.types.CompositeType;
+import esa.mo.apigen.model.types.EnumerationItem;
+import esa.mo.apigen.model.types.EnumerationType;
+import esa.mo.apigen.model.types.FundamentalType;
+import esa.mo.apigen.model.types.TypeDefinition;
+import esa.mo.apigen.model.types.TypeRef;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Writes the Word document that describes a specification.
+ *
+ * One document per area: what the area is, then a chapter per service and a section per
+ * operation, then the data types and the errors. The data types are built up in a second
+ * body as the services are walked and appended at the end, which is why the document is
+ * assembled rather than written straight through.
+ */
+public final class DocxGenerator implements Generator {
+
+ /**
+ * The blue every table header is filled with.
+ */
+ static final String HEADER_COLOUR = "00CCFF";
+
+ /**
+ * The grey a fixed part of a message is filled with, as against a field the
+ * specification chose.
+ */
+ static final String FIXED_COLOUR = "E0E0E0";
+
+ private static final int[] SERVICE_OVERVIEW_WIDTHS = {2250, 2801, 1382, 1185, 1382};
+
+ private static final int[] ERROR_TABLE_WIDTHS = {2302, 1430, 5268};
+
+ private static final int[] OPERATION_OVERVIEW_WIDTHS = {2200, 1700, 800, 4300};
+
+ private static final int[] OPERATION_ERROR_WIDTHS = {1500, 1000, 2500, 4000};
+
+ /**
+ * What is shown where an error carries no extra information.
+ */
+ private static final String NOT_USED = "Not Used";
+
+ private static final int[] LIST_WIDTHS = {2302, 6698};
+
+ private static final int[] ENUM_WIDTHS = {2302, 2430, 4268};
+
+ private static final int[] COMPOSITE_WIDTHS = {2302, 1830, 1100, 3768};
+
+ private static final int[] COM_TYPES_WIDTHS = {1010, 2250, 2500, 1150, 2196};
+
+ @Override
+ public String getShortName() {
+ return "docx";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Generates the Word document of a set of MO service specifications";
+ }
+
+ @Override
+ public void generate(MOModel model, List targets, Path outputDir) throws IOException {
+ for (Area area : targets) {
+ generateArea(model, area, outputDir);
+ }
+ }
+
+ /**
+ * @return the name of the document of an area, which says what it documents.
+ */
+ public static String documentNameOf(Area area) {
+ return String.format("Service_Specification_area%03d-v%03d-%s",
+ area.getNumber(), area.getVersion(), area.getName());
+ }
+
+ private void generateArea(MOModel model, Area area, Path outputDir) throws IOException {
+ File folder = new File(outputDir.toFile(), documentNameOf(area));
+ DocxDocument document = new DocxDocument(folder);
+ DocxBody body = document.getBody();
+ // The data types are described after everything else, but which of them matter is
+ // only known once the services have been walked, so they are gathered as we go.
+ DocxBody types = new DocxBody();
+
+ body.title(1, "Specification: " + area.getName());
+ body.title(2, "General");
+ body.comment("This section contains the specifications for the "
+ + area.getName() + " services.");
+ body.comment(area.getComment());
+ body.comment("This area contains the following services:");
+
+ List names = new ArrayList();
+ for (Service service : area.getServices()) {
+ names.add(service.getName());
+ }
+ body.numberedComment(names);
+
+ for (DocSection section : area.getDocumentation().getSections()) {
+ body.title(2, section.getName());
+ body.numberedComment(DocxText.split(section.getContent()));
+ }
+
+ for (Service service : area.getServices()) {
+ writeService(body, document, model, area, service);
+ }
+
+ writeDataTypes(types, model, area);
+ writeErrors(types, area);
+
+ body.append(types);
+ document.write();
+ }
+
+ /**
+ * A chapter per service: what it is, what it must do, and a section per operation.
+ */
+ private void writeService(DocxBody body, DocxDocument document, MOModel model, Area area,
+ Service service) throws IOException {
+ body.pageBreak();
+ body.title(2, "Service: ", service.getName(), "SERVICE");
+ body.title(3, "Overview");
+ body.comment(service.getComment());
+ writeServiceTable(body, area, service);
+ body.title(3, "Service-level Requirements");
+ writeRequirements(body, service.getDocumentation().getSections());
+ writeComUsage(body, document, area, service);
+
+ for (CapabilitySet set : service.getCapabilitySets()) {
+ for (Operation operation : set.getOperations()) {
+ body.pageBreak();
+ // The bookmark is named after the service number rather than its name: a
+ // long bookmark name is cut short and the link stops working.
+ body.title(3, "Operation: ", operation.getName(),
+ "OPERATION_" + service.getNumber());
+ body.title(4, "Overview");
+ body.comment(operation.getComment());
+ writeOperationTable(body, area, service, operation);
+ writeSignatureDetails(body, operation);
+ body.title(4, "Requirements");
+ writeRequirements(body, operation.getDocumentation().getSections());
+ writeOperationErrors(body, model, area, service, operation);
+ }
+ }
+ }
+
+ /**
+ * What the service is numbered, and every operation it offers.
+ */
+ private void writeServiceTable(DocxBody body, Area area, Service service) {
+ DocxTable table = body.table(SERVICE_OVERVIEW_WIDTHS,
+ service.getName() + " Service Operations");
+ table.row()
+ .cell("Area Identifier").shaded(HEADER_COLOUR).centered().next()
+ .cell("Service Identifier").shaded(HEADER_COLOUR).centered().next()
+ .cell("Area Number").shaded(HEADER_COLOUR).centered().next()
+ .cell("Service Number").shaded(HEADER_COLOUR).centered().next()
+ .cell("Area Version").shaded(HEADER_COLOUR).centered().endRow();
+ table.row()
+ .cell(area.getName()).centered().next()
+ .cell(service.getName()).centered().next()
+ .cell(String.valueOf(area.getNumber())).centered().next()
+ .cell(String.valueOf(service.getNumber())).centered().next()
+ .cell(String.valueOf(area.getVersion())).centered().endRow();
+ table.row()
+ .cell("Interaction Pattern").shaded(HEADER_COLOUR).centered().next()
+ .cell("Operation Identifier").shaded(HEADER_COLOUR).centered().spanning(2).next()
+ .cell("Operation Number").shaded(HEADER_COLOUR).centered().next()
+ .cell("Capability Set").shaded(HEADER_COLOUR).centered().endRow();
+
+ for (CapabilitySet set : service.getCapabilitySets()) {
+ // The capability set number is written once for the run of operations that
+ // belong to it, so the cell of the first operation is merged over the rest.
+ boolean first = true;
+ for (Operation operation : set.getOperations()) {
+ String linkTo = "OPERATION_" + service.getNumber() + "_" + operation.getName();
+ DocxRow row = table.row();
+ row.cell(shortPatternNameOf(operation)).centered().next()
+ .markedUp(DocxTypeLink.hyperlink("", operation.getName(), "", linkTo, true))
+ .centered().spanning(2).next()
+ .cell(String.valueOf(operation.getNumber())).centered().next();
+ DocxCell capability = row.cell(String.valueOf(set.getNumber())).centered();
+ if (first) {
+ capability.mergeStart();
+ } else {
+ capability.mergeContinue();
+ }
+ capability.endRow();
+ first = false;
+ }
+ }
+ table.end();
+ }
+
+ /**
+ * What the service adds to the COM: the objects it stores, the events it raises, and
+ * how it uses the archive and the activity tracking.
+ */
+ private void writeComUsage(DocxBody body, DocxDocument document, Area area, Service service)
+ throws IOException {
+ COMFeatures com = service.getCom();
+ if (com == null) {
+ return;
+ }
+
+ // An objects section counts as saying something only when it lists something; an
+ // events section counts as soon as it is written at all. The difference is in the
+ // existing generator and decides which relationships the figure is said to show.
+ boolean hasObjects = !com.getObjects().isEmpty();
+ boolean hasEvents = com.declaresEvents();
+
+ if (com.declaresObjects()) {
+ body.title(3, "COM usage");
+ body.numberedComment(DocxText.split(com.getObjectsComment()));
+ if (hasObjects) {
+ writeComObjects(body, area, service, com.getObjects(),
+ service.getName() + " Service Object Types", "Object Name");
+ }
+ }
+ if (hasEvents) {
+ body.title(3, "COM Event Service usage");
+ body.numberedComment(DocxText.split(com.getEventsComment()));
+ writeComObjects(body, area, service, com.getEvents(),
+ service.getName() + " Service Events", "Event Name");
+ }
+
+ if (hasObjects || hasEvents) {
+ String what = "COM" + (hasObjects ? " object" : "")
+ + (hasEvents ? (hasObjects ? " and event" : " event") : "") + " relationships";
+ body.title(3, "COM Object Relationships");
+ body.comment("The Figure below shows the " + what + " for this service:");
+ body.figureCaption(service.getName() + " Service " + what);
+ }
+
+ if (com.getArchiveUsage() != null) {
+ body.title(3, "COM Archive Service usage");
+ body.numberedComment(DocxText.split(com.getArchiveUsage()));
+ }
+ if (com.getActivityUsage() != null) {
+ body.title(3, "COM Activity Service usage");
+ body.numberedComment(DocxText.split(com.getActivityUsage()));
+ }
+ }
+
+ /**
+ * The objects or the events of a service, which are described the same way.
+ */
+ private void writeComObjects(DocxBody body, Area area, Service service,
+ List objects, String caption, String nameColumn) {
+ DocxTable table = body.table(COM_TYPES_WIDTHS, caption);
+ table.row()
+ .cell("Object Number").shaded(HEADER_COLOUR).centered().next()
+ .cell(nameColumn).shaded(HEADER_COLOUR).centered().next()
+ .cell("Object Body Type").shaded(HEADER_COLOUR).centered().next()
+ .cell("Related link").shaded(HEADER_COLOUR).centered().next()
+ .cell("Source link").shaded(HEADER_COLOUR).centered().endRow();
+
+ for (COMObject object : objects) {
+ DocxRow row = table.row();
+ row.cell(String.valueOf(object.getNumber())).centered().next()
+ .cell(object.getName()).centered().next();
+ if (object.getBodyType() == null) {
+ row.cell("No body").centered().next();
+ } else {
+ row.markedUp(DocxTypeLink.forType(area, service, object.getBodyType()))
+ .centered().next();
+ }
+ row.cell(linkText(area, service, object.getRelated())).centered().next()
+ .cell(linkText(area, service, object.getSource())).centered().endRow();
+ }
+ table.end();
+ }
+
+ /**
+ * What a link to another object says: the object it points at, whatever the
+ * specification said instead, or that there is nothing there.
+ */
+ private static String linkText(Area area, Service service, ObjectLink link) {
+ if (link == null) {
+ return "Set to NULL";
+ }
+ if (link.getTarget() != null) {
+ ObjectReference target = link.getTarget();
+ StringBuilder buf = new StringBuilder();
+ if (!area.getName().equalsIgnoreCase(target.getArea())) {
+ buf.append(target.getArea()).append("::");
+ }
+ String owning = service == null ? "" : service.getName();
+ if (target.getService() != null && !target.getService().isEmpty()
+ && !target.getService().equalsIgnoreCase(owning)) {
+ buf.append(target.getService()).append("::");
+ }
+ return buf.append(target.getNumber()).toString();
+ }
+ return link.getComment() != null ? link.getComment() : "Not specified";
+ }
+
+ /**
+ * What one operation exchanges: the pattern it follows, and a row per message with the
+ * fields it carries.
+ */
+ private void writeOperationTable(DocxBody body, Area area, Service service,
+ Operation operation) {
+ DocxTable table = body.table(OPERATION_OVERVIEW_WIDTHS);
+ table.row()
+ .cell("Operation Identifier").shaded(HEADER_COLOUR).centered().next()
+ .cell(operation.getName()).centered().spanning(3).endRow();
+
+ table.row()
+ .cell("Interaction Pattern").shaded(HEADER_COLOUR).centered().next()
+ .cell(patternNameOf(operation)).shaded(FIXED_COLOUR).centered().spanning(3)
+ .endRow();
+
+ // Only where the operation says something about its keys: an operation that
+ // declares none at all is not the same as one that declares an empty set.
+ if (operation.getPattern() == InteractionPattern.PUBSUB
+ && operation.getMessage(InteractionStage.SUBSCRIPTION_KEYS) != null) {
+ writeSubscriptionKeys(table, area, service, operation);
+ }
+
+ table.row()
+ .cell("Pattern Sequence").shaded(HEADER_COLOUR).centered().next()
+ .cell("Message").shaded(HEADER_COLOUR).centered().next()
+ .cell("Nullable").shaded(HEADER_COLOUR).centered().next()
+ .cell("Type Signature").shaded(HEADER_COLOUR).centered().endRow();
+
+ for (Message message : messagesOf(operation)) {
+ writeMessage(table, area, service, message);
+ }
+ table.end();
+ }
+
+ /**
+ * The names a subscription is keyed by, which a publish-subscribe operation states
+ * before the messages themselves.
+ */
+ private void writeSubscriptionKeys(DocxTable table, Area area, Service service,
+ Operation operation) {
+ List keys = fieldsOf(operation, InteractionStage.SUBSCRIPTION_KEYS);
+ DocxRow row = table.row();
+ row.cell("Subscription Keys").shaded(HEADER_COLOUR).centered().next();
+ if (keys.isEmpty()) {
+ row.cell("Empty").shaded(FIXED_COLOUR).centered().spanning(3).endRow();
+ } else {
+ row.markedUp(signature(area, service, keys)).centered().spanning(3).endRow();
+ }
+ }
+
+ /**
+ * One message of an interaction: which way it goes, what it is called, and what it
+ * carries.
+ */
+ private void writeMessage(DocxTable table, Area area, Service service, Message message) {
+ DocxRow row = table.row();
+ row.cell(message.incoming ? "IN" : "OUT").shaded(FIXED_COLOUR).centered().next()
+ .cell(message.name).shaded(FIXED_COLOUR).centered().next();
+ if (message.fields.isEmpty()) {
+ row.cell("-").shaded(FIXED_COLOUR).centered().next()
+ .cell("-").shaded(FIXED_COLOUR).centered().endRow();
+ } else {
+ row.markedUp(DocxTypeLink.nullability(message.fields)).centered().next()
+ .markedUp(signature(area, service, message.fields)).centered().endRow();
+ }
+ }
+
+ /**
+ * @return the fields of a message, each named and linked to its type.
+ */
+ private static String signature(Area area, Service service, List fields) {
+ List parts = new ArrayList();
+ for (Field field : fields) {
+ parts.add(DocxTypeLink.forField(area, service, field));
+ }
+ return DocxTypeLink.join(parts);
+ }
+
+ /**
+ * The messages of an interaction, in the order they are exchanged, each knowing which
+ * way it travels and what the document calls it.
+ */
+ private static List messagesOf(Operation operation) {
+ List messages = new ArrayList();
+ switch (operation.getPattern()) {
+ case SEND:
+ messages.add(new Message(true, "SEND", InteractionStage.SEND,
+ fieldsOf(operation, InteractionStage.SEND)));
+ break;
+ case SUBMIT:
+ messages.add(new Message(true, "SUBMIT", InteractionStage.SUBMIT,
+ fieldsOf(operation, InteractionStage.SUBMIT)));
+ break;
+ case REQUEST:
+ messages.add(new Message(true, "REQUEST", InteractionStage.REQUEST,
+ fieldsOf(operation, InteractionStage.REQUEST)));
+ messages.add(new Message(false, "RESPONSE", InteractionStage.RESPONSE,
+ fieldsOf(operation, InteractionStage.RESPONSE)));
+ break;
+ case INVOKE:
+ messages.add(new Message(true, "INVOKE", InteractionStage.INVOKE,
+ fieldsOf(operation, InteractionStage.INVOKE)));
+ messages.add(new Message(false, "ACK", InteractionStage.ACK,
+ fieldsOf(operation, InteractionStage.ACK)));
+ messages.add(new Message(false, "RESPONSE", InteractionStage.RESPONSE,
+ fieldsOf(operation, InteractionStage.RESPONSE)));
+ break;
+ case PROGRESS:
+ messages.add(new Message(true, "PROGRESS", InteractionStage.PROGRESS,
+ fieldsOf(operation, InteractionStage.PROGRESS)));
+ messages.add(new Message(false, "ACK", InteractionStage.ACK,
+ fieldsOf(operation, InteractionStage.ACK)));
+ messages.add(new Message(false, "UPDATE", InteractionStage.UPDATE,
+ fieldsOf(operation, InteractionStage.UPDATE)));
+ messages.add(new Message(false, "RESPONSE", InteractionStage.RESPONSE,
+ fieldsOf(operation, InteractionStage.RESPONSE)));
+ break;
+ case PUBSUB:
+ messages.add(new Message(false, "PUBLISH", InteractionStage.PUBLISH_NOTIFY,
+ fieldsOf(operation, InteractionStage.PUBLISH_NOTIFY)));
+ break;
+ default:
+ break;
+ }
+ return messages;
+ }
+
+ /**
+ * @return what the operation's own table calls this interaction pattern.
+ */
+ private static String patternNameOf(Operation operation) {
+ return operation.getPattern() == InteractionPattern.PUBSUB
+ ? "PUBLISH-SUBSCRIBE" : operation.getPattern().name();
+ }
+
+ /**
+ * @return what the service overview table calls this interaction pattern, which is the
+ * same as above except that the column is narrow enough to want it shortened.
+ */
+ private static String shortPatternNameOf(Operation operation) {
+ return operation.getPattern() == InteractionPattern.PUBSUB
+ ? "PUB-SUB" : operation.getPattern().name();
+ }
+
+ private static List fieldsOf(Operation operation, InteractionStage stage) {
+ MessageBody body = operation.getMessage(stage);
+ return body == null ? new ArrayList() : body.getFields();
+ }
+
+ /**
+ * One error an operation may answer with, gathered from the reference that names it.
+ *
+ * The specifications only ever reference an error from an operation, never define one
+ * there, so the definition is looked up to find its number - and an error of another
+ * area is named rather than numbered, since its number is that area's business.
+ */
+ private static final class OperationError implements Comparable {
+
+ private final String name;
+ private final String number;
+
+ /**
+ * What the error sorts on. Usually its number, but an error of another area sorts
+ * as zero and one whose number could not be found sorts under the text that says
+ * so - which is how the reference output orders them.
+ */
+ private final String order;
+ private final List comments;
+ private final String extraType;
+ private final String extraDescription;
+
+ private OperationError(String name, String number, String order, List comments,
+ String extraType, String extraDescription) {
+ this.name = name;
+ this.number = number;
+ this.order = order;
+ this.comments = comments;
+ this.extraType = extraType;
+ this.extraDescription = extraDescription;
+ }
+
+ private static OperationError of(MOModel model, Area area, Service service,
+ ErrorReference reference) {
+ String name = reference.getError().getName();
+ String number = "UNKNOWN ERROR NUMBER!";
+ String order;
+
+ String declaredIn = reference.getError().getArea();
+ if (declaredIn == null || declaredIn.equals(area.getName())) {
+ ErrorDefinition definition = model.resolveError(reference.getError());
+ if (definition != null) {
+ number = String.valueOf(definition.getNumber());
+ }
+ order = number;
+ } else {
+ number = "Defined in " + declaredIn;
+ order = "0";
+ }
+
+ String extraType = NOT_USED;
+ String extraDescription = "-";
+ Field extra = reference.getExtraInformation();
+ if (extra != null) {
+ extraType = qualify(area, service, extra.getType());
+ if (extra.getComment() != null) {
+ extraDescription = extra.getComment();
+ }
+ }
+ return new OperationError(name, number, order,
+ DocxText.split(reference.getComment()), extraType, extraDescription);
+ }
+
+ /**
+ * @return the error named and described, a line per thing said about it.
+ */
+ private List describe() {
+ List lines = new ArrayList();
+ if (comments.isEmpty()) {
+ lines.add(name + ": Not described");
+ return lines;
+ }
+ for (String comment : comments) {
+ lines.add(name + ": " + comment);
+ }
+ return lines;
+ }
+
+ @Override
+ public int compareTo(OperationError other) {
+ try {
+ return Long.valueOf(order).compareTo(Long.valueOf(other.order));
+ } catch (NumberFormatException ex) {
+ // One of them is not a number at all, so they are ordered as text.
+ return order.compareTo(other.order);
+ }
+ }
+ }
+
+ /**
+ * Names a type from where it is mentioned, leaving off the area and service that are
+ * being written about.
+ */
+ private static String qualify(Area area, Service service, esa.mo.apigen.model.types.TypeRef type) {
+ StringBuilder buf = new StringBuilder();
+ if (!area.getName().equalsIgnoreCase(type.getArea())) {
+ buf.append(type.getArea()).append("::");
+ }
+ String owning = service == null ? "" : service.getName();
+ if (type.getService() != null && !type.getService().isEmpty()
+ && !type.getService().equalsIgnoreCase(owning)) {
+ buf.append(type.getService()).append("::");
+ }
+ buf.append(type.getName());
+ // The extra information of an error is named in full, list wrapper and all: unlike
+ // a message field, there is no separate column to put it in.
+ return type.isList() ? "List<" + buf + ">" : buf.toString();
+ }
+
+ /**
+ * One message of an interaction, as the table describes it.
+ */
+ private static final class Message {
+
+ private final boolean incoming;
+ private final String name;
+ private final InteractionStage stage;
+ private final List fields;
+
+ private Message(boolean incoming, String name, InteractionStage stage,
+ List fields) {
+ this.incoming = incoming;
+ this.name = name;
+ this.stage = stage;
+ this.fields = fields;
+ }
+ }
+
+ /**
+ * The errors an operation may answer with: named and described, then tabulated with
+ * whatever extra information each one carries.
+ */
+ private void writeOperationErrors(DocxBody body, MOModel model, Area area, Service service,
+ Operation operation) throws IOException {
+ body.title(4, "MO Errors");
+
+ if (operation.getPattern() == InteractionPattern.SEND) {
+ // A send is not answered at all, so there is nowhere to put an error.
+ body.comment("The operation cannot return any errors.");
+ return;
+ }
+ if (operation.getErrors().isEmpty()) {
+ body.comment("The operation does not return any errors.");
+ return;
+ }
+
+ body.comment(operation.getErrors().size() == 1
+ ? "The operation may return the following error:"
+ : "The operation may return one of the following errors:");
+
+ List errors = new ArrayList();
+ for (ErrorReference reference : operation.getErrors()) {
+ errors.add(OperationError.of(model, area, service, reference));
+ }
+ java.util.Collections.sort(errors);
+
+ List described = new ArrayList();
+ for (OperationError error : errors) {
+ described.addAll(error.describe());
+ }
+ body.numberedComment(described);
+
+ DocxTable table = body.table(OPERATION_ERROR_WIDTHS);
+ table.row()
+ .cell("Error").shaded(HEADER_COLOUR).centered().next()
+ .cell("Error #").shaded(HEADER_COLOUR).centered().next()
+ .cell("ExtraInfo Type").shaded(HEADER_COLOUR).centered().next()
+ .cell("ExtraInfo description").shaded(HEADER_COLOUR).centered().endRow();
+
+ for (OperationError error : errors) {
+ DocxRow row = table.row();
+ row.cell(error.name).centered().next()
+ .cell(error.number).centered().next();
+ // A type of this area is linked to where it is defined; one that names its own
+ // area, or none at all, is only shown.
+ if (error.extraType.contains("::") || NOT_USED.equals(error.extraType)) {
+ row.cell(error.extraType.replace("MAL::", "")).centered().next();
+ } else {
+ row.markedUp(DocxTypeLink.hyperlink("", error.extraType, "",
+ "DATATYPE_" + error.extraType, true)).centered().next();
+ }
+ row.cell(error.extraDescription).centered().endRow();
+ }
+ table.end();
+ }
+
+ /**
+ * Describes every field of every message, so that the table above can stay to the
+ * types and the names.
+ */
+ private void writeSignatureDetails(DocxBody body, Operation operation) {
+ body.title(4, "Type Signature Details");
+ for (InteractionStage stage : detailedStagesOf(operation)) {
+ for (Field field : fieldsOf(operation, stage)) {
+ body.singleTypeSignature(field.getName(), field.getComment());
+ }
+ }
+ }
+
+ /**
+ * The stages whose fields are described one by one. A publish-subscribe operation
+ * describes its subscription keys here as well, which the table above only lists.
+ */
+ private static List detailedStagesOf(Operation operation) {
+ List stages = new ArrayList();
+ if (operation.getPattern() == InteractionPattern.PUBSUB) {
+ stages.add(InteractionStage.SUBSCRIPTION_KEYS);
+ stages.add(InteractionStage.PUBLISH_NOTIFY);
+ return stages;
+ }
+ for (Message message : messagesOf(operation)) {
+ stages.add(message.stage);
+ }
+ return stages;
+ }
+
+ /**
+ * The requirements a service states, numbered as one list however many sections they
+ * are written in: they are the requirements of the service, not of its sections.
+ */
+ private void writeRequirements(DocxBody body, List sections) throws IOException {
+ List requirements = new ArrayList();
+ for (DocSection section : sections) {
+ requirements.addAll(DocxText.split(section.getContent()));
+ }
+ body.numberedComment(requirements);
+ }
+
+ /**
+ * Every type the specification declares, area level first and then service by service.
+ */
+ private void writeDataTypes(DocxBody types, MOModel model, Area area) throws IOException {
+ types.pageBreak();
+ types.title(1, "Data types");
+ boolean any = !area.getDataTypes().isEmpty();
+
+ if (any) {
+ types.title(2, "Area data types: " + area.getName());
+ for (TypeDefinition type : area.getDataTypes()) {
+ writeDataType(types, model, area, null, type);
+ }
+ }
+ for (Service service : area.getServices()) {
+ if (!service.getDataTypes().isEmpty()) {
+ any = true;
+ types.title(2, "Service data types: " + service.getName());
+ for (TypeDefinition type : service.getDataTypes()) {
+ writeDataType(types, model, area, service, type);
+ }
+ }
+ }
+ if (!any) {
+ types.comment("No data types are defined in this specification.");
+ }
+ }
+
+ /**
+ * One declared type, described according to what kind of thing it is.
+ */
+ private void writeDataType(DocxBody types, MOModel model, Area area, Service service,
+ TypeDefinition type) {
+ if (type instanceof FundamentalType) {
+ types.title(3, "Fundamental: ", type.getName(), "DATATYPE");
+ if (type.getComment() != null && !type.getComment().isEmpty()) {
+ types.comment(type.getComment());
+ }
+ } else if (type instanceof AttributeType) {
+ writeAttribute(types, (AttributeType) type);
+ } else if (type instanceof EnumerationType) {
+ writeEnumeration(types, (EnumerationType) type);
+ } else if (type instanceof CompositeType) {
+ writeComposite(types, model, area, service, (CompositeType) type);
+ }
+ }
+
+ /**
+ * An attribute is one of the types everything else is built from, so there is little to
+ * say beyond what it is called and numbered.
+ */
+ private void writeAttribute(DocxBody types, AttributeType attribute) {
+ types.title(3, "Attribute: ", attribute.getName(), "DATATYPE");
+ if (attribute.getComment() != null && !attribute.getComment().isEmpty()) {
+ types.comment(attribute.getComment());
+ }
+
+ DocxTable table = types.table(LIST_WIDTHS);
+ table.row().cell("Name").shaded(HEADER_COLOUR).centered().next()
+ .cell(attribute.getName()).centered().endRow();
+ table.row().cell("Extends").shaded(HEADER_COLOUR).centered().next()
+ .cell("Attribute").centered().endRow();
+ table.row().cell("Short Form Part").shaded(HEADER_COLOUR).centered().next()
+ .cell(String.valueOf(attribute.getShortFormPart())).centered().endRow();
+ table.end();
+ types.sectionBreak();
+ }
+
+ /**
+ * An enumeration is its values, each with the number it travels as.
+ */
+ private void writeEnumeration(DocxBody types, EnumerationType enumeration) {
+ types.title(3, "Enumeration: ", enumeration.getName(), "DATATYPE");
+ if (enumeration.getComment() != null && !enumeration.getComment().isEmpty()) {
+ types.comment(enumeration.getComment());
+ }
+
+ DocxTable table = types.table(ENUM_WIDTHS);
+ table.row().cell("Name").shaded(HEADER_COLOUR).centered().next()
+ .cell(enumeration.getName()).centered().spanning(2).endRow();
+ table.row().cell("Short Form Part").shaded(HEADER_COLOUR).centered().next()
+ .cell(String.valueOf(enumeration.getShortFormPart())).centered().spanning(2)
+ .endRow();
+ table.row()
+ .cell("Enumeration Value").shaded(HEADER_COLOUR).centered().next()
+ .cell("Numerical Value").shaded(HEADER_COLOUR).centered().next()
+ .cell("Comment").shaded(HEADER_COLOUR).centered().endRow();
+
+ for (EnumerationItem item : enumeration.getItems()) {
+ table.row()
+ .cell(item.getValue()).centered().next()
+ .cell(String.valueOf(item.getNumericValue())).centered().next()
+ .cell(item.getComment()).endRow();
+ }
+ table.end();
+ types.sectionBreak();
+ }
+
+ /**
+ * A composite is its fields, and the fields of whatever it extends before them.
+ */
+ private void writeComposite(DocxBody types, MOModel model, Area area, Service service,
+ CompositeType composite) {
+ TypeRef parent = composite.getSuperType() != null ? composite.getSuperType()
+ : new TypeRef("MAL", area.getVersion(), null, "Composite", false, false);
+ // A composite extending the MAL Object is an object in the COM sense: something
+ // that is stored and referred to, rather than only carried in a message.
+ boolean isObject = "Object".equals(parent.getName());
+
+ types.title(3, isObject ? "MO Object: " : "Composite: ", composite.getName(), "DATATYPE");
+ if (composite.getComment() != null && !composite.getComment().isEmpty()) {
+ types.comment(composite.getComment());
+ }
+
+ DocxTable table = types.table(COMPOSITE_WIDTHS);
+ table.row().cell("Name").shaded(HEADER_COLOUR).centered().next()
+ .cell(composite.getName()).centered().spanning(3).endRow();
+ table.row().cell("Extends").shaded(HEADER_COLOUR).centered().next()
+ .markedUp(DocxTypeLink.forType(area, service, parent)).centered().spanning(3)
+ .endRow();
+
+ if (composite.getShortFormPart() == null) {
+ table.row().cell("Abstract").shaded(HEADER_COLOUR).centered().spanning(4).endRow();
+ } else {
+ table.row().cell("Short Form Part").shaded(HEADER_COLOUR).centered().next()
+ .cell(String.valueOf(composite.getShortFormPart())).centered().spanning(3)
+ .endRow();
+ }
+
+ List inherited = model.inheritedFields(composite);
+ List own = composite.getFields();
+
+ if (!own.isEmpty()) {
+ table.row()
+ .cell("Field").shaded(HEADER_COLOUR).centered().next()
+ .cell("Type").shaded(HEADER_COLOUR).centered().next()
+ .cell("Nullable").shaded(HEADER_COLOUR).centered().next()
+ .cell("Comment").shaded(HEADER_COLOUR).centered().endRow();
+
+ for (Field field : inherited) {
+ writeCompositeField(table, area, service, field, FIXED_COLOUR);
+ }
+ for (Field field : own) {
+ writeCompositeField(table, area, service, field, null);
+ }
+ }
+ table.end();
+ types.sectionBreak();
+ }
+
+ /**
+ * One field of a composite. A field it inherits is filled in, to set it apart from the
+ * ones the composite declares itself.
+ */
+ private void writeCompositeField(DocxTable table, Area area, Service service, Field field,
+ String shade) {
+ DocxRow row = table.row();
+ row.cell(field.getName()).shaded(shade).centered().next()
+ .markedUp(DocxTypeLink.forType(area, service, field.getType())).shaded(shade)
+ .centered().next()
+ .cell(field.isCanBeNull() ? "Yes" : "No").shaded(shade).centered().next()
+ .cell(field.getComment()).shaded(shade).endRow();
+ }
+
+ /**
+ * Every error the specification declares, wherever it declares it.
+ */
+ private void writeErrors(DocxBody types, Area area) throws IOException {
+ types.title(1, "MO Errors");
+ List errors = new ArrayList(area.getErrors());
+ for (Service service : area.getServices()) {
+ errors.addAll(service.getErrors());
+ }
+
+ if (errors.isEmpty()) {
+ types.comment("No errors are defined in this specification.");
+ return;
+ }
+
+ types.comment("The following table lists the errors defined in this specification:");
+ DocxTable table = types.table(ERROR_TABLE_WIDTHS, area.getName() + " MO Errors");
+ table.row()
+ .cell("Error").shaded(HEADER_COLOUR).centered().next()
+ .cell("Error #").shaded(HEADER_COLOUR).centered().next()
+ .cell("Comment").shaded(HEADER_COLOUR).centered().endRow();
+ for (ErrorDefinition error : errors) {
+ table.row()
+ .cell(error.getName()).centered().next()
+ .cell(String.valueOf(error.getNumber())).centered().next()
+ .cell(error.getComment()).endRow();
+ }
+ table.end();
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxNumbering.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxNumbering.java
new file mode 100644
index 00000000..2dc40e48
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxNumbering.java
@@ -0,0 +1,80 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+import java.io.IOException;
+
+/**
+ * The numbering definitions of one document.
+ *
+ * A Word document keeps its list numbering apart from its body: the body says which
+ * numbering an item belongs to, and the numbering part says what that looks like. Two are
+ * defined up front - a dash bullet and a symbol bullet - and a further one is defined each
+ * time a numbered list is started, so that lists number from the top rather than continuing
+ * one another. That is why this is not a fixed resource like the style sheet, and why every
+ * document ends up with a numbering part of its own.
+ */
+public final class DocxNumbering {
+
+ private final StringBuilder definitions = new StringBuilder();
+
+ private final StringBuilder instances = new StringBuilder();
+
+ /**
+ * Counts the numberings handed out. The preamble defines 0 and 1, so the first one
+ * asked for is 2.
+ */
+ private int lastInstance = 1;
+
+ /**
+ * @throws IOException if the fixed part of the numbering is missing from the build.
+ */
+ public DocxNumbering() throws IOException {
+ definitions.append(DocxResources.read(DocxResources.NUMBERING_PREAMBLE))
+ .append(DocxText.SEPARATOR);
+ instances.append(DocxText.line(2,
+ "", DocxText.SEPARATOR));
+ }
+
+ /**
+ * Defines a numbering for a list that is about to start.
+ *
+ * @return the identifier the body refers to it by.
+ * @throws IOException if the template is missing from the build.
+ */
+ public int nextInstance() throws IOException {
+ int instance = ++lastInstance;
+ String id = String.valueOf(instance);
+ definitions.append(DocxResources.read(DocxResources.NUMBERING_INSTANCE)
+ .replace("${id}", id)).append(DocxText.SEPARATOR);
+ instances.append(DocxText.line(2, "", DocxText.SEPARATOR));
+ return instance;
+ }
+
+ /**
+ * @return the numbering part of the document: every definition, then every instance
+ * that refers to one.
+ */
+ public String toXml() {
+ return definitions.toString() + instances + "" + DocxText.SEPARATOR;
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxResources.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxResources.java
new file mode 100644
index 00000000..8f48214a
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxResources.java
@@ -0,0 +1,84 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+
+/**
+ * Reads the fixed parts of a Word document out of the module's resources.
+ *
+ * A .docx carries a good deal of XML that says nothing about the specification being
+ * documented - the style sheet, the content types, the package relationships, the numbering
+ * definitions. It was 270 000 characters of escaped string literal in the generator this
+ * replaces; here it is the files it actually is, so that a style can be read, diffed and
+ * edited as XML.
+ */
+public final class DocxResources {
+
+ private static final Charset UTF8 = Charset.forName("UTF-8");
+
+ /**
+ * The style sheet in use: Times New Roman at 12 point, with the MO table style. Four
+ * others are kept beside it - Calibri, plain Times, Times with the MO table style, and
+ * the CCSDS sheet - none of them selected by anything. They are carried because they
+ * were carried before, not because anything reaches them.
+ */
+ public static final String STYLES = "styles.xml";
+
+ public static final String CONTENT_TYPES = "content-types.xml";
+
+ public static final String PACKAGE_RELS = "package.rels";
+
+ public static final String NUMBERING_PREAMBLE = "numbering-preamble.xml";
+
+ public static final String NUMBERING_INSTANCE = "numbering-instance.xml";
+
+ private DocxResources() {
+ }
+
+ /**
+ * Reads one of the fixed parts.
+ *
+ * @param name The resource name, one of the constants here.
+ * @return the contents.
+ * @throws IOException if the resource is missing from the build.
+ */
+ public static String read(String name) throws IOException {
+ InputStream in = DocxResources.class.getResourceAsStream("/docx/" + name);
+ if (in == null) {
+ throw new IOException("The docx resource '" + name + "' is not on the classpath");
+ }
+ try {
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ byte[] chunk = new byte[8192];
+ int read;
+ while ((read = in.read(chunk)) != -1) {
+ buf.write(chunk, 0, read);
+ }
+ return new String(buf.toByteArray(), UTF8);
+ } finally {
+ in.close();
+ }
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxRow.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxRow.java
new file mode 100644
index 00000000..19b377d6
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxRow.java
@@ -0,0 +1,74 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+/**
+ * One row of a table, which knows where it has got to across the columns.
+ *
+ * A cell is as wide as the column it sits in, and a cell that spans is as wide as the
+ * columns it covers together. The row keeps the count, so that nothing has to be told its
+ * own position.
+ */
+public final class DocxRow {
+
+ private final DocxTable table;
+
+ private int column = 0;
+
+ DocxRow(DocxTable table) {
+ this.table = table;
+ table.append(3, "");
+ }
+
+ /**
+ * Adds a cell holding text, in the table's own style.
+ *
+ * @param text The text, which is escaped.
+ * @return the cell, for saying anything else about it.
+ */
+ public DocxCell cell(String text) {
+ return new DocxCell(this, ""
+ + DocxText.escape(text) + "");
+ }
+
+ /**
+ * Adds a cell holding content that is already marked up - a hyperlink, or a signature
+ * built out of several runs.
+ *
+ * @param xml The content of the cell, inside its paragraph.
+ * @return the cell, for saying anything else about it.
+ */
+ public DocxCell markedUp(String xml) {
+ return new DocxCell(this, xml);
+ }
+
+ /**
+ * Ends the row.
+ */
+ public void end() {
+ table.append(3, "");
+ }
+
+ void write(DocxCell cell) {
+ table.append(4, cell.toXml(table.widthOf(column, cell.getSpan())));
+ column += cell.getSpan();
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxTable.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxTable.java
new file mode 100644
index 00000000..c5cdfc90
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxTable.java
@@ -0,0 +1,99 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+/**
+ * One table: the widths of its columns, and the rows written into it.
+ *
+ * The borders and the sizing are the same for every table in these documents, so they are
+ * not something a caller says anything about.
+ */
+public final class DocxTable {
+
+ /**
+ * The borders every table in these documents has. Written through the same indent
+ * helper as everything else rather than as a block of pre-indented text, so that one
+ * place decides what a level of indentation is.
+ */
+ private static final String[] BORDERS = {"top", "left", "bottom", "right", "insideH", "insideV"};
+
+ private final DocxBody body;
+
+ private final int[] widths;
+
+ DocxTable(DocxBody body, int[] widths) {
+ this.body = body;
+ this.widths = widths;
+ body.append(2, "");
+ body.append(3, "");
+ body.append(4, "");
+ body.append(4, "");
+ for (String edge : BORDERS) {
+ body.append(5, "");
+ }
+ body.append(4, "");
+ body.append(3, "");
+ if (widths != null) {
+ body.append(3, "");
+ for (int width : widths) {
+ body.append(4, "");
+ }
+ body.append(3, "");
+ }
+ }
+
+ /**
+ * Starts a row.
+ *
+ * @return the row, to add cells to.
+ */
+ public DocxRow row() {
+ return new DocxRow(this);
+ }
+
+ /**
+ * Ends the table.
+ */
+ public void end() {
+ body.append(2, "");
+ }
+
+ /**
+ * @param column The column the cell starts in.
+ * @param span How many columns it covers.
+ * @return how wide the cell is, which is the columns it covers taken together.
+ */
+ int widthOf(int column, int span) {
+ if (widths == null) {
+ return 0;
+ }
+ int width = 0;
+ for (int i = column; i < column + span && i < widths.length; i++) {
+ width += widths[i];
+ }
+ return width;
+ }
+
+ void append(int indent, String text) {
+ body.append(indent, text);
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxText.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxText.java
new file mode 100644
index 00000000..42647c3f
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxText.java
@@ -0,0 +1,90 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+/**
+ * The text side of a document: what has to be escaped, and how a line is indented.
+ */
+public final class DocxText {
+
+ private DocxText() {
+ }
+
+ /**
+ * Prepares text written by a specification for a document.
+ *
+ * The list item markup a few comments carry is dropped rather than escaped: the
+ * documents show those comments as running text, so a literal {@code _li_} in the middle
+ * of a sentence would be worse than nothing. Everything else XML reserves is escaped,
+ * and the order matters - the ampersands have to go first, or the escapes escape
+ * themselves.
+ *
+ * @param text The text to put in the document, may be null.
+ * @return the text, ready to sit inside an element.
+ */
+ public static String escape(String text) {
+ if (text == null) {
+ return "";
+ }
+ return text.replace("
", "").replace("", "")
+ .replace("&", "&").replace("<", "<").replace(">", ">");
+ }
+
+ /**
+ * The body of a document ends its lines the way Word does, and the numbering part ends
+ * them the way everything else here does. That is not a decision anyone took - the two
+ * writers of the existing generator were built with different separators, one passing
+ * "\r\n" and the other taking the default - but the documents are compared byte for
+ * byte, so both are reproduced as they are.
+ */
+ public static final String BODY_SEPARATOR = "\r\n";
+
+ public static final String SEPARATOR = "\n";
+
+ /**
+ * Splits a comment where a document starts a new paragraph: at a line break, or at the
+ * double space the specifications use to mean one.
+ *
+ * @param text The comment, may be null.
+ * @return the paragraphs, empty if there is no text.
+ */
+ public static java.util.List split(String text) {
+ java.util.List parts = new java.util.ArrayList();
+ if (text != null) {
+ parts.addAll(java.util.Arrays.asList(text.split("( |\n)")));
+ }
+ return parts;
+ }
+
+ /**
+ * @param indent How many steps in, at four spaces a step.
+ * @param text The line.
+ * @param separator How the line ends.
+ * @return the line, indented and ended.
+ */
+ public static String line(int indent, String text, String separator) {
+ StringBuilder buf = new StringBuilder();
+ for (int i = 0; i < indent; i++) {
+ buf.append(" ");
+ }
+ return buf.append(text).append(separator).toString();
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxTypeLink.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxTypeLink.java
new file mode 100644
index 00000000..2c3325c7
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/docx/DocxTypeLink.java
@@ -0,0 +1,206 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.docx;
+
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.Field;
+import esa.mo.apigen.model.Service;
+import esa.mo.apigen.model.types.TypeRef;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Names a type inside a table cell, linking to where it is defined when the document that
+ * mentions it is also the document that defines it.
+ *
+ * The generator this replaces took two flags through every call - whether to show the name
+ * of the field and whether to use the older layout - and branched on them here. Neither has
+ * ever been set to anything but its default, so neither is carried over: a field is always
+ * shown as {@code List_Type_ name}, never as {@code name : (List_Type_)}.
+ */
+public final class DocxTypeLink {
+
+ /**
+ * Separates two type names sharing a cell: the cell holds a paragraph each.
+ */
+ private static final String NEXT_LINE
+ = "";
+
+ private DocxTypeLink() {
+ }
+
+ /**
+ * Names the type of a field, with the field's own name after it.
+ *
+ * @param area The area of the document being written.
+ * @param service The service being written, or null at area level.
+ * @param field The field to name.
+ * @return the content of a cell.
+ */
+ public static String forField(Area area, Service service, Field field) {
+ TypeRef type = field.getType();
+ String prefix = type.isList() ? "List<" : "";
+ String postfix = (type.isList() ? "> " : " ") + field.getName();
+ return link(area, service, type, prefix, postfix);
+ }
+
+ /**
+ * Names a type on its own.
+ *
+ * @param area The area of the document being written.
+ * @param service The service being written, or null at area level.
+ * @param type The type to name.
+ * @return the content of a cell.
+ */
+ public static String forType(Area area, Service service, TypeRef type) {
+ String prefix = type.isList() ? "List<" : "";
+ String postfix = type.isList() ? ">" : "";
+ return link(area, service, type, prefix, postfix);
+ }
+
+ /**
+ * Says of each field whether it may be left out.
+ *
+ * @param fields The fields of a message.
+ * @return the content of a cell, one answer per line.
+ */
+ public static String nullability(List fields) {
+ List answers = new ArrayList();
+ for (Field field : fields) {
+ answers.add(""
+ + (field.isCanBeNull() ? "Yes" : "No") + "");
+ }
+ return join(answers);
+ }
+
+ /**
+ * @param fragments What each line of the cell holds.
+ * @return the fragments as the lines of one cell.
+ */
+ public static String join(List fragments) {
+ StringBuilder buf = new StringBuilder();
+ for (int i = 0; i < fragments.size(); i++) {
+ buf.append(fragments.get(i));
+ if (i != fragments.size() - 1) {
+ buf.append(NEXT_LINE);
+ }
+ }
+ return buf.toString();
+ }
+
+ /**
+ * Writes the name, linked to its definition when that definition is in this document.
+ */
+ private static String link(Area area, Service service, TypeRef type,
+ String prefix, String postfix) {
+ String name = qualify(area, service, type);
+ // The MAL types are mentioned everywhere and defined in a document of their own, so
+ // naming their area every time would say nothing.
+ if (name.startsWith("MAL::")) {
+ name = name.substring("MAL::".length());
+ }
+ boolean sameArea = area.getName().equalsIgnoreCase(type.getArea());
+ return hyperlink(prefix, name, postfix, "DATATYPE_" + name, sameArea);
+ }
+
+ /**
+ * Names a type from where it is being mentioned: the area is left off when it is the
+ * area being written, and so is the service.
+ */
+ private static String qualify(Area area, Service service, TypeRef reference) {
+ // The older spelling of an object reference, ObjectRef(Product), means the same as
+ // the newer one; unwrapping lets both be named the same way.
+ TypeRef type = reference.unwrapped();
+ StringBuilder buf = new StringBuilder();
+ if (!area.getName().equalsIgnoreCase(type.getArea())) {
+ buf.append(type.getArea()).append("::");
+ }
+ String owning = service == null ? "" : service.getName();
+ if (type.getService() != null && !type.getService().isEmpty()
+ && !type.getService().equalsIgnoreCase(owning)) {
+ buf.append(type.getService()).append("::");
+ }
+ return buf.append(type.isObjectRef()
+ ? "ObjectRef<" + type.getName() + ">" : type.getName()).toString();
+ }
+
+ /**
+ * Writes a name, as a link to a bookmark where there is one to link to.
+ *
+ * @param prefix What comes before the name.
+ * @param typeName The name shown.
+ * @param postfix What comes after the name.
+ * @param linkTo The bookmark to link to.
+ * @param withHyperlink Whether to link at all.
+ * @return the content of a cell.
+ */
+ public static String hyperlink(String prefix, String typeName, String postfix,
+ String linkTo, boolean withHyperlink) {
+ // An object reference is shown as ObjectRef_Product_, but what is linked to is
+ // Product: the reference is not a type anyone documents.
+ boolean isObjectRef = typeName.startsWith("ObjectRef");
+ String target = linkTo.replace("ObjectRef<", "").replace(">", "")
+ .replace("ObjectRef(", "").replace(")", "");
+ String shown = isObjectRef ? insideObjectRef(typeName) : typeName;
+
+ StringBuilder buf = new StringBuilder();
+ buf.append("");
+ buf.append(DocxText.escape(prefix));
+ if (isObjectRef) {
+ buf.append(DocxText.escape("ObjectRef<"));
+ }
+ buf.append("");
+
+ if (withHyperlink) {
+ buf.append("");
+ buf.append(" HYPERLINK \\l \"");
+ buf.append(DocxText.escape(isObjectRef ? target : linkTo));
+ buf.append("\" ");
+ buf.append("");
+ }
+
+ buf.append("");
+ if (withHyperlink) {
+ buf.append("");
+ }
+ buf.append("").append(DocxText.escape(shown)).append("");
+
+ if (withHyperlink) {
+ buf.append("");
+ }
+
+ buf.append("");
+ if (isObjectRef) {
+ buf.append(DocxText.escape(">"));
+ }
+ return buf.append(DocxText.escape(postfix)).append("").toString();
+ }
+
+ /**
+ * @param typeName A name of the form ObjectRef_Something_.
+ * @return what the reference points at.
+ */
+ private static String insideObjectRef(String typeName) {
+ int open = typeName.indexOf('<');
+ int close = typeName.lastIndexOf('>');
+ return open >= 0 && close > open ? typeName.substring(open + 1, close) : typeName;
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaClassBuilder.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaClassBuilder.java
new file mode 100644
index 00000000..c9d96950
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaClassBuilder.java
@@ -0,0 +1,196 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * One Java class, from its package statement to its closing brace.
+ *
+ * The outermost of the three builders. {@link #open} writes the package statement, the
+ * javadoc and the declaration and hands back the source to write the members into;
+ * {@link #close} writes the brace that ends the class and returns the finished text. The
+ * members in between are written with {@link JavaFieldBuilder} and
+ * {@link JavaMethodBuilder}, which is why the class does not hold them itself: a writer
+ * decides what goes in and in what order, and this decides how the class around them reads.
+ *
+ * Every class this generator writes is public, so there is nothing to say about scope.
+ */
+public final class JavaClassBuilder {
+
+ private final String name;
+
+ private final List interfaces = new ArrayList();
+
+ private final JavaSource out = new JavaSource();
+
+ private String packageName = null;
+
+ private String comment = null;
+
+ private String superClass = null;
+
+ private boolean isFinal = false;
+
+ private boolean isAbstract = false;
+
+ private boolean isInterface = false;
+
+ private JavaClassBuilder(String name) {
+ this.name = name;
+ }
+
+ /**
+ * @param name The name of the class.
+ * @return a public class, extending nothing and implementing nothing.
+ */
+ public static JavaClassBuilder named(String name) {
+ return new JavaClassBuilder(name);
+ }
+
+ /**
+ * Sets the package the class belongs to.
+ *
+ * @param name The package name.
+ * @return this class.
+ */
+ public JavaClassBuilder inPackage(String name) {
+ this.packageName = name;
+ return this;
+ }
+
+ /**
+ * Sets the comment of the class.
+ *
+ * @param text The comment, may be null.
+ * @return this class.
+ */
+ public JavaClassBuilder comment(String text) {
+ this.comment = text;
+ return this;
+ }
+
+ /**
+ * Sets the class this one extends.
+ *
+ * @param type The super class.
+ * @return this class.
+ */
+ public JavaClassBuilder extending(String type) {
+ this.superClass = type;
+ return this;
+ }
+
+ /**
+ * Adds an interface the class implements, in order.
+ *
+ * @param type The interface.
+ * @return this class.
+ */
+ public JavaClassBuilder implementing(String type) {
+ interfaces.add(type);
+ return this;
+ }
+
+ /**
+ * @return this class, marked final.
+ */
+ public JavaClassBuilder asFinal() {
+ this.isFinal = true;
+ return this;
+ }
+
+ /**
+ * @return this class, marked abstract.
+ */
+ public JavaClassBuilder asAbstract() {
+ this.isAbstract = true;
+ return this;
+ }
+
+ /**
+ * Marks this as an interface rather than a class. Its members are declarations, so they
+ * are written packed together rather than a blank line apart, and the blank line that
+ * separates them from the declaration is written here instead.
+ *
+ * @return this class.
+ */
+ public JavaClassBuilder asInterface() {
+ this.isInterface = true;
+ return this;
+ }
+
+ /**
+ * Writes everything down to the brace that opens the class.
+ *
+ * @return the source to write the members into.
+ */
+ public JavaSource open() {
+ out.line("package " + packageName + ";");
+ out.blank();
+
+ List lines = JavaComment.normaliseInClass(comment);
+ if (!lines.isEmpty()) {
+ out.line("/**");
+ for (String line : lines) {
+ out.line(" * " + line);
+ }
+ out.line(" */");
+ }
+
+ StringBuilder buf = new StringBuilder("public ");
+ if (isFinal) {
+ buf.append("final ");
+ }
+ if (isAbstract) {
+ buf.append("abstract ");
+ }
+ buf.append(isInterface ? "interface " : "class ").append(name);
+ if (superClass != null) {
+ buf.append(" extends ").append(superClass);
+ }
+ for (int i = 0; i < interfaces.size(); i++) {
+ // An interface extends what a class implements.
+ buf.append(i == 0 ? (isInterface ? " extends " : " implements ") : ", ")
+ .append(interfaces.get(i));
+ }
+ out.line(buf.append(" {").toString());
+ if (isInterface) {
+ out.blank();
+ }
+ return out;
+ }
+
+ /**
+ * Writes the brace that ends the class, under the blank line that separates it from the
+ * last member. An interface has no such line: its declarations are packed.
+ *
+ * @return the finished source.
+ */
+ public String close() {
+ if (!isInterface) {
+ out.blank();
+ }
+ out.line("}");
+ return out.toString();
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaComment.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaComment.java
new file mode 100644
index 00000000..6fb5bb73
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaComment.java
@@ -0,0 +1,124 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Turns a specification comment into javadoc lines.
+ *
+ * This reproduces the existing generator's behaviour exactly, including where that
+ * behaviour is odd, because the generated API is compiled against by every api module and
+ * its javadoc is part of what people read. Two quirks are deliberate:
+ *
+ * - an apostrophe becomes {@code "} rather than {@code '};
+ * - an angle bracket becomes an underscore, so a comment mentioning {@code >=} reads
+ * {@code _=};
+ * - the line-length check happens before a word is added rather than after, so lines
+ * routinely run past the threshold - up to 92 characters in the current output.
+ *
+ * Changing either would be an improvement, and would show up in the golden tree as a
+ * deliberate difference to be agreed rather than something to slip in here.
+ */
+public final class JavaComment {
+
+ private static final int LENGTH_THRESHOLD = 70;
+
+ private JavaComment() {
+ }
+
+ /**
+ * Normalises a comment into the lines of a javadoc block.
+ *
+ * @param comment The comment, may be null.
+ * @return the lines, empty if there is no comment.
+ */
+ public static List normalise(String comment) {
+ List output = new ArrayList();
+ if (comment == null || comment.isEmpty()) {
+ return output;
+ }
+ String text = comment;
+ if (!text.endsWith(".")) {
+ text += ".";
+ }
+ text = text.replace("&", "&");
+ text = text.replace("'", """);
+ output.addAll(wrap(text));
+ return output;
+ }
+
+ /**
+ * Normalises a comment for a javadoc block inside a class, where angle brackets would
+ * be read as markup. Package documentation goes through {@link #normalise} instead and
+ * keeps its {@code } and {@code - } tags.
+ *
+ * @param comment The comment, may be null.
+ * @return the lines, empty if there is no comment.
+ */
+ public static List normaliseInClass(String comment) {
+ List output = new ArrayList();
+ for (String line : normalise(comment)) {
+ output.add(escape(line));
+ }
+ return output;
+ }
+
+ /**
+ * Removes the angle brackets that would otherwise be read as javadoc markup.
+ *
+ * @param text The comment text.
+ * @return the text with angle brackets replaced by underscores.
+ */
+ public static String escape(String text) {
+ return text == null ? null : text.replace("<", "_").replace(">", "_");
+ }
+
+ /**
+ * Breaks text into lines at roughly the threshold, paragraph by paragraph.
+ *
+ * @param text The text to wrap.
+ * @return the lines.
+ */
+ public static List wrap(String text) {
+ List output = new ArrayList();
+ if (text.length() < LENGTH_THRESHOLD) {
+ output.add(text);
+ return output;
+ }
+ for (String paragraph : text.split("\n")) {
+ StringBuilder line = new StringBuilder();
+ int counter = 0;
+ for (String word : paragraph.split(" ")) {
+ if (counter > LENGTH_THRESHOLD) {
+ output.add(line.toString().trim());
+ line.setLength(0);
+ counter = 0;
+ }
+ line.append(word).append(' ');
+ counter += 1 + word.length();
+ }
+ output.add(line.toString().trim());
+ }
+ return output;
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaFieldBuilder.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaFieldBuilder.java
new file mode 100644
index 00000000..131074d8
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaFieldBuilder.java
@@ -0,0 +1,190 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import java.util.List;
+
+/**
+ * One Java field, from its javadoc to its semicolon.
+ *
+ * The counterpart of {@link JavaMethodBuilder}, and built the same way: the comment is held
+ * with the declaration it documents, and {@code write} is the only thing that produces
+ * text. A value spanning more than one line is written as it is given, so the caller that
+ * lays out an array lays out its own continuation, as it does for the body of a method.
+ */
+public final class JavaFieldBuilder {
+
+ /**
+ * The step a field takes past the class.
+ */
+ private static final String INDENT = " ";
+
+ private final String name;
+
+ private String scope = "public";
+
+ private String type = null;
+
+ private String value = null;
+
+ private String comment = null;
+
+ private boolean isStatic = false;
+
+ private boolean isFinal = false;
+
+ private boolean isDeprecated = false;
+
+ private boolean joined = false;
+
+ private JavaFieldBuilder(String name) {
+ this.name = name;
+ }
+
+ /**
+ * @param name The name of the field.
+ * @return a public field, with no type yet and no value.
+ */
+ public static JavaFieldBuilder named(String name) {
+ return new JavaFieldBuilder(name);
+ }
+
+ /**
+ * Sets the scope. Public unless said otherwise.
+ *
+ * @param scope The scope keyword.
+ * @return this field.
+ */
+ public JavaFieldBuilder scope(String scope) {
+ this.scope = scope;
+ return this;
+ }
+
+ /**
+ * Sets the declared type.
+ *
+ * @param typeName The type of the field.
+ * @return this field.
+ */
+ public JavaFieldBuilder ofType(String typeName) {
+ this.type = typeName;
+ return this;
+ }
+
+ /**
+ * Sets what the field is initialised to. A field with no value is declared and left
+ * unset.
+ *
+ * @param expression The initialiser, which may span several lines.
+ * @return this field.
+ */
+ public JavaFieldBuilder value(String expression) {
+ this.value = expression;
+ return this;
+ }
+
+ /**
+ * Sets the comment of the field.
+ *
+ * @param text The comment, may be null.
+ * @return this field.
+ */
+ public JavaFieldBuilder comment(String text) {
+ this.comment = text;
+ return this;
+ }
+
+ /**
+ * @return this field, marked static.
+ */
+ public JavaFieldBuilder asStatic() {
+ this.isStatic = true;
+ return this;
+ }
+
+ /**
+ * @return this field, marked final.
+ */
+ public JavaFieldBuilder asFinal() {
+ this.isFinal = true;
+ return this;
+ }
+
+ /**
+ * @return this field, marked deprecated.
+ */
+ public JavaFieldBuilder asDeprecated() {
+ this.isDeprecated = true;
+ return this;
+ }
+
+ /**
+ * Writes the field directly under the one before it, with no blank line between them.
+ * The reference output keeps the fields that carry a type's identity together as one
+ * block, and separates everything else.
+ *
+ * @return this field.
+ */
+ public JavaFieldBuilder joinedToPrevious() {
+ this.joined = true;
+ return this;
+ }
+
+ /**
+ * Writes the field, preceded by the blank line that separates it from what came before
+ * it unless it was asked to stay with it.
+ *
+ * @param out The source to write to.
+ */
+ public void write(JavaSource out) {
+ if (!joined) {
+ out.blank();
+ }
+
+ List lines = JavaComment.normaliseInClass(comment);
+ if (!lines.isEmpty()) {
+ out.line(INDENT + "/**");
+ for (String line : lines) {
+ out.line(INDENT + " * " + line);
+ }
+ out.line(INDENT + " */");
+ }
+
+ if (isDeprecated) {
+ out.line(INDENT + "@Deprecated");
+ }
+
+ StringBuilder buf = new StringBuilder(INDENT);
+ buf.append(scope).append(' ');
+ if (isStatic) {
+ buf.append("static ");
+ }
+ if (isFinal) {
+ buf.append("final ");
+ }
+ buf.append(type).append(' ').append(name);
+ if (value != null) {
+ buf.append(" = ").append(value);
+ }
+ buf.append(';');
+ out.line(buf.toString());
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaGenerator.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaGenerator.java
new file mode 100644
index 00000000..19ffe2fb
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaGenerator.java
@@ -0,0 +1,315 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import esa.mo.apigen.generators.Generator;
+import esa.mo.apigen.generators.java.writers.CompositeWriter;
+import esa.mo.apigen.generators.java.writers.ConsumerAdapterWriter;
+import esa.mo.apigen.generators.java.writers.ConsumerStubWriter;
+import esa.mo.apigen.generators.java.writers.ElementFactoryWriter;
+import esa.mo.apigen.generators.java.writers.EnumerationWriter;
+import esa.mo.apigen.generators.java.writers.ExceptionWriter;
+import esa.mo.apigen.generators.java.writers.HelperWriter;
+import esa.mo.apigen.generators.java.writers.ListWriter;
+import esa.mo.apigen.generators.java.writers.MultiReturnBodyWriter;
+import esa.mo.apigen.generators.java.writers.ProviderHandlerWriter;
+import esa.mo.apigen.generators.java.writers.ProviderInheritanceSkeletonWriter;
+import esa.mo.apigen.generators.java.writers.ProviderInteractionWriter;
+import esa.mo.apigen.generators.java.writers.ProviderPublisherWriter;
+import esa.mo.apigen.generators.java.writers.ProviderSkeletonWriter;
+import esa.mo.apigen.generators.java.writers.ServiceInfoWriter;
+import esa.mo.apigen.generators.java.writers.SubscriptionKeysWriter;
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.MOModel;
+import esa.mo.apigen.model.Service;
+import esa.mo.apigen.model.types.CompositeType;
+import esa.mo.apigen.model.types.EnumerationType;
+import esa.mo.apigen.model.types.TypeDefinition;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Generates the Java API.
+ *
+ * Written against the model directly. The old generator is the reference for what comes
+ * out - the generated API is compiled against by every api module and by user code, so
+ * its shape is a compatibility surface - but not for how it is produced.
+ *
+ * Incomplete: it writes the structures, the exceptions, the helpers, the element factories
+ * and the package documentation - verified byte for byte against the reference output. The
+ * provider skeletons, the consumer stubs, the {@code ServiceInfo} classes and the
+ * multiple-return bodies are still to come; the last of those also brings the two
+ * {@code package-info.java} files that the old generator writes as a side effect of it.
+ */
+public final class JavaGenerator implements Generator {
+
+ private static final Charset UTF8 = Charset.forName("UTF-8");
+
+ @Override
+ public String getShortName() {
+ return "Java";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Generates the Java API for a set of MO service specifications";
+ }
+
+ @Override
+ public void generate(MOModel model, List targets, Path outputDir) throws IOException {
+ for (Area area : targets) {
+ generateArea(model, area, outputDir);
+ }
+ }
+
+ private void generateArea(MOModel model, Area area, Path outputDir) throws IOException {
+ PackageInfoWriter packages = new PackageInfoWriter(outputDir);
+ // An area without a comment still gets one: the package documentation would
+ // otherwise be an empty javadoc block.
+ String areaComment = area.getComment() != null && !area.getComment().isEmpty()
+ ? area.getComment() : "The " + area.getName() + " area";
+ packages.write(JavaNaming.packageOf(area), areaComment);
+ if (!area.getDataTypes().isEmpty()) {
+ packages.write(JavaNaming.packageOf(area, JavaNaming.STRUCTURES),
+ "Package containing types defined in the " + area.getName() + " area");
+ }
+ writeTypes(model, area, null, area.getDataTypes(), outputDir);
+ writeExceptions(area, outputDir);
+ writeHelpers(area, outputDir);
+ writeElementFactory(area, outputDir);
+ for (Service service : area.getServices()) {
+ writeTypes(model, area, service, service.getDataTypes(), outputDir);
+ writeServiceInfo(model, area, service, outputDir);
+ writeSubscriptionKeys(model, service, outputDir);
+ writeConsumer(model, area, service, outputDir);
+ writeProvider(model, service, outputDir);
+ writeMultiReturnBodies(model, service, packages, outputDir);
+ packages.write(JavaNaming.packageOf(service), service.getComment());
+ packages.write(JavaNaming.packageOf(service, JavaNaming.CONSUMER),
+ "Package containing the consumer stubs for the "
+ + service.getName() + " service");
+ packages.write(JavaNaming.packageOf(service, JavaNaming.PROVIDER),
+ "Package containing the provider skeletons for the "
+ + service.getName() + " service");
+ if (!service.getDataTypes().isEmpty()) {
+ packages.write(JavaNaming.packageOf(service, JavaNaming.STRUCTURES),
+ "Package containing types defined in the "
+ + service.getName() + " service");
+ }
+ }
+ }
+
+ /**
+ * Every error the area declares becomes an exception in the area's own package,
+ * whether it was declared by the area or by one of its services.
+ */
+ private void writeExceptions(Area area, Path outputDir) throws IOException {
+ java.util.List errors
+ = new java.util.ArrayList(area.getErrors());
+ for (Service service : area.getServices()) {
+ errors.addAll(service.getErrors());
+ }
+ if (errors.isEmpty()) {
+ return;
+ }
+ Path dir = outputDir.resolve(JavaNaming.directoryOf(JavaNaming.packageOf(area)));
+ Files.createDirectories(dir);
+ for (esa.mo.apigen.model.ErrorDefinition error : errors) {
+ Files.write(dir.resolve(ExceptionWriter.classNameOf(error.getName()) + ".java"),
+ ExceptionWriter.write(area, error).getBytes(UTF8));
+ }
+ }
+
+ /**
+ * The area's helper, and one for each of its services.
+ */
+ private void writeHelpers(Area area, Path outputDir) throws IOException {
+ Path areaDir = outputDir.resolve(JavaNaming.directoryOf(JavaNaming.packageOf(area)));
+ Files.createDirectories(areaDir);
+ Files.write(areaDir.resolve(area.getName() + "Helper.java"),
+ HelperWriter.writeArea(area).getBytes(UTF8));
+ for (Service service : area.getServices()) {
+ Path dir = outputDir.resolve(JavaNaming.directoryOf(JavaNaming.packageOf(service)));
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(service.getName() + "Helper.java"),
+ HelperWriter.writeService(service).getBytes(UTF8));
+ }
+ }
+
+ /**
+ * The factory that creates the Elements of the area, one per area.
+ */
+ private void writeElementFactory(Area area, Path outputDir) throws IOException {
+ Path dir = outputDir.resolve(JavaNaming.directoryOf(JavaNaming.packageOf(area)));
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(area.getName() + "ElementFactory.java"),
+ ElementFactoryWriter.write(area).getBytes(UTF8));
+ }
+
+ /**
+ * The ServiceInfo of a service, which is what its stubs and skeletons name their
+ * operations through.
+ */
+ private void writeServiceInfo(MOModel model, Area area, Service service, Path outputDir)
+ throws IOException {
+ Path dir = outputDir.resolve(JavaNaming.directoryOf(JavaNaming.packageOf(service)));
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(service.getName() + "ServiceInfo.java"),
+ ServiceInfoWriter.write(model, area, service).getBytes(UTF8));
+ }
+
+ /**
+ * One class per publish-subscribe operation, for reading the keys of a notify message.
+ */
+ private void writeSubscriptionKeys(MOModel model, Service service, Path outputDir)
+ throws IOException {
+ Path dir = outputDir.resolve(
+ JavaNaming.directoryOf(JavaNaming.packageOf(service, JavaNaming.CONSUMER)));
+ for (esa.mo.apigen.model.Operation operation : service.getOperations()) {
+ if (operation.getPattern() != esa.mo.apigen.model.InteractionPattern.PUBSUB) {
+ continue;
+ }
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(SubscriptionKeysWriter.classNameOf(operation) + ".java"),
+ SubscriptionKeysWriter.write(model, service, operation).getBytes(UTF8));
+ }
+ }
+
+ /**
+ * The consumer stub of a service, and the adapter its answers arrive through.
+ */
+ private void writeConsumer(MOModel model, Area area, Service service, Path outputDir)
+ throws IOException {
+ Path dir = outputDir.resolve(
+ JavaNaming.directoryOf(JavaNaming.packageOf(service, JavaNaming.CONSUMER)));
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(service.getName() + "Stub.java"),
+ ConsumerStubWriter.write(model, service).getBytes(UTF8));
+ Files.write(dir.resolve(service.getName() + "Adapter.java"),
+ ConsumerAdapterWriter.write(model, area, service).getBytes(UTF8));
+ }
+
+ /**
+ * The provider side of a service: what a provider implements, and how it reaches its
+ * publishers.
+ */
+ private void writeProvider(MOModel model, Service service, Path outputDir)
+ throws IOException {
+ Path dir = outputDir.resolve(
+ JavaNaming.directoryOf(JavaNaming.packageOf(service, JavaNaming.PROVIDER)));
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(service.getName() + "Handler.java"),
+ ProviderHandlerWriter.write(model, service).getBytes(UTF8));
+ Files.write(dir.resolve(service.getName() + "Skeleton.java"),
+ ProviderSkeletonWriter.write(service).getBytes(UTF8));
+ Files.write(dir.resolve(service.getName() + "InheritanceSkeleton.java"),
+ ProviderInheritanceSkeletonWriter.write(model, service).getBytes(UTF8));
+ // An operation that reports back over time is answered through an object of its
+ // own, so that what it can send is named and typed.
+ for (esa.mo.apigen.model.Operation operation : service.getOperations()) {
+ if (operation.getPattern() == esa.mo.apigen.model.InteractionPattern.INVOKE
+ || operation.getPattern() == esa.mo.apigen.model.InteractionPattern.PROGRESS) {
+ Files.write(dir.resolve(ProviderInteractionWriter.classNameOf(operation) + ".java"),
+ ProviderInteractionWriter.write(model, service, operation).getBytes(UTF8));
+ }
+ if (operation.getPattern() == esa.mo.apigen.model.InteractionPattern.PUBSUB) {
+ Files.write(dir.resolve(ProviderPublisherWriter.classNameOf(operation) + ".java"),
+ ProviderPublisherWriter.write(model, service, operation).getBytes(UTF8));
+ }
+ }
+ }
+
+ /**
+ * An operation that answers with more than one field needs a class to hold the answer,
+ * since a Java method can only return one thing.
+ */
+ private void writeMultiReturnBodies(MOModel model, Service service,
+ PackageInfoWriter packages, Path outputDir) throws IOException {
+ java.util.List operations
+ = MultiReturnBodyWriter.operationsOf(service);
+ if (operations.isEmpty()) {
+ return;
+ }
+ String pkg = JavaNaming.packageOf(service, MultiReturnBodyWriter.BODY);
+ packages.write(pkg, "Package containing the types for holding compound messages");
+ Path dir = outputDir.resolve(JavaNaming.directoryOf(pkg));
+ Files.createDirectories(dir);
+ for (esa.mo.apigen.model.Operation operation : operations) {
+ Files.write(dir.resolve(MultiReturnBodyWriter.classNameOf(operation) + ".java"),
+ MultiReturnBodyWriter.write(model, service, operation).getBytes(UTF8));
+ }
+ }
+
+ private void writeTypes(MOModel model, Area area, Service service,
+ java.util.List types, Path outputDir) throws IOException {
+ String pkg = service == null
+ ? JavaNaming.packageOf(area, JavaNaming.STRUCTURES)
+ : JavaNaming.packageOf(service, JavaNaming.STRUCTURES);
+ Path dir = outputDir.resolve(JavaNaming.directoryOf(pkg));
+ for (TypeDefinition type : types) {
+ if (type instanceof EnumerationType) {
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(type.getName() + ".java"),
+ EnumerationWriter.write(area, service, (EnumerationType) type)
+ .getBytes(UTF8));
+ }
+ if (type instanceof CompositeType) {
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(type.getName() + ".java"),
+ CompositeWriter.write(model, area, service, (CompositeType) type)
+ .getBytes(UTF8));
+ }
+ String list = ListWriter.write(area, service, type);
+ if (list != null) {
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(type.getName() + "List.java"), list.getBytes(UTF8));
+ }
+ }
+ }
+
+ /**
+ * Writes the package-info.java files.
+ */
+ private static final class PackageInfoWriter {
+
+ private final Path root;
+
+ private PackageInfoWriter(Path root) {
+ this.root = root;
+ }
+
+ private void write(String packageName, String comment) throws IOException {
+ Path dir = root.resolve(JavaNaming.directoryOf(packageName));
+ Files.createDirectories(dir);
+ StringBuilder buf = new StringBuilder();
+ buf.append("/**\n");
+ for (String line : JavaComment.normalise(comment)) {
+ buf.append(" * ").append(line).append('\n');
+ }
+ buf.append("*/\n");
+ buf.append("package ").append(packageName).append(";\n");
+ Files.write(dir.resolve("package-info.java"), buf.toString().getBytes(UTF8));
+ }
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaMethodBuilder.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaMethodBuilder.java
new file mode 100644
index 00000000..121311d3
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaMethodBuilder.java
@@ -0,0 +1,378 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * One Java method, from its javadoc to its closing brace.
+ *
+ * Written out in the order the pieces are declared here rather than in the order they end
+ * up on the page: an argument is given with the description that documents it, so the
+ * {@code @param} block and the signature cannot drift apart, and the body is held with the
+ * signature so that a method is one expression from end to end.
+ *
+ * Everything a method can be is set through the same object, so a writer never has to
+ * decide between "the one with the comment" and "the one that overrides". A method the
+ * language has no word for - a static override, say - is simply never asked for.
+ */
+public final class JavaMethodBuilder {
+
+ /**
+ * The step a method takes past the class, and the step its body takes past it.
+ */
+ private static final String INDENT = " ";
+
+ private static final String BODY_INDENT = INDENT + INDENT;
+
+ /**
+ * Arguments past the first are carried onto their own line, at the step the reference
+ * output uses regardless of how long the signature is.
+ */
+ private static final String ARGUMENT_INDENT = " ";
+
+ private final String name;
+
+ private final List arguments = new ArrayList();
+
+ private final List thrown = new ArrayList();
+
+ private final List body = new ArrayList();
+
+ private String scope = "public";
+
+ private String returnType = null;
+
+ private String returnComment = null;
+
+ private String comment = null;
+
+ private boolean constructor = false;
+
+ private boolean isStatic = false;
+
+ private boolean isFinal = false;
+
+ private boolean isOverride = false;
+
+ private boolean isDeprecated = false;
+
+ private boolean declarationOnly = false;
+
+ private JavaMethodBuilder(String name) {
+ this.name = name;
+ }
+
+ /**
+ * @param name The name of the method.
+ * @return a method that returns nothing, is public, and has no arguments.
+ */
+ public static JavaMethodBuilder named(String name) {
+ return new JavaMethodBuilder(name);
+ }
+
+ /**
+ * @param className The name of the class being constructed.
+ * @return a constructor, which is written without a return type.
+ */
+ public static JavaMethodBuilder constructor(String className) {
+ JavaMethodBuilder method = new JavaMethodBuilder(className);
+ method.constructor = true;
+ return method;
+ }
+
+ /**
+ * Sets the scope. Public unless said otherwise.
+ *
+ * @param scope The scope keyword.
+ * @return this method.
+ */
+ public JavaMethodBuilder scope(String scope) {
+ this.scope = scope;
+ return this;
+ }
+
+ /**
+ * Sets what the method returns. Void unless said otherwise.
+ *
+ * @param type The returned type.
+ * @param description What is returned, or null to leave it undocumented.
+ * @return this method.
+ */
+ public JavaMethodBuilder returns(String type, String description) {
+ this.returnType = type;
+ this.returnComment = description;
+ return this;
+ }
+
+ /**
+ * Adds an argument, in declaration order, with the description that documents it.
+ *
+ * @param type The type of the argument.
+ * @param argumentName The name of the argument.
+ * @param description What the argument is, or null for a plain description of it.
+ * @return this method.
+ */
+ public JavaMethodBuilder argument(String type, String argumentName,
+ String description) {
+ arguments.add(new Argument(type, argumentName, description));
+ return this;
+ }
+
+ /**
+ * Adds a thrown type, in order, with the description that documents it.
+ *
+ * @param type The thrown type.
+ * @param description When it is thrown, or null to leave it undocumented.
+ * @return this method.
+ */
+ public JavaMethodBuilder throwing(String type, String description) {
+ thrown.add(new Thrown(type, description));
+ return this;
+ }
+
+ /**
+ * Sets the comment of the method.
+ *
+ * @param text The comment, may be null.
+ * @return this method.
+ */
+ public JavaMethodBuilder comment(String text) {
+ this.comment = text;
+ return this;
+ }
+
+ /**
+ * @return this method, marked static.
+ */
+ public JavaMethodBuilder asStatic() {
+ this.isStatic = true;
+ return this;
+ }
+
+ /**
+ * @return this method, marked final.
+ */
+ public JavaMethodBuilder asFinal() {
+ this.isFinal = true;
+ return this;
+ }
+
+ /**
+ * Marks the method as overriding another, which carries the annotation.
+ *
+ * @return this method.
+ */
+ public JavaMethodBuilder asOverride() {
+ this.isOverride = true;
+ return this;
+ }
+
+ /**
+ * @return this method, marked deprecated.
+ */
+ public JavaMethodBuilder asDeprecated() {
+ this.isDeprecated = true;
+ return this;
+ }
+
+ /**
+ * Marks the method as a declaration without a body, for an interface. The scope goes
+ * unwritten: an interface method has no keyword in front of it.
+ *
+ * @return this method.
+ */
+ public JavaMethodBuilder asDeclaration() {
+ this.declarationOnly = true;
+ return this;
+ }
+
+ /**
+ * Adds a line to the body, indented past the method. A line that steps further in says
+ * so itself, as the reference output does.
+ *
+ * @param text The line, without the indentation of the body.
+ * @return this method.
+ */
+ public JavaMethodBuilder line(String text) {
+ body.add(text);
+ return this;
+ }
+
+ /**
+ * Adds the lines of the body, in order.
+ *
+ * @param lines The lines, without the indentation of the body.
+ * @return this method.
+ */
+ public JavaMethodBuilder lines(List lines) {
+ body.addAll(lines);
+ return this;
+ }
+
+ /**
+ * Writes the method, preceded by the blank line that separates it from what came
+ * before it. A declaration has no such line.
+ *
+ * @param out The source to write to.
+ */
+ public void write(JavaSource out) {
+ // A declaration follows the one before it without a gap, the way an interface reads
+ // in the reference output.
+ if (!declarationOnly) {
+ out.blank();
+ }
+ writeComment(out);
+ if (isDeprecated) {
+ out.line(INDENT + "@Deprecated");
+ }
+ if (isOverride) {
+ out.line(INDENT + "@Override");
+ }
+ out.line(INDENT + signature());
+ if (declarationOnly) {
+ return;
+ }
+ for (String line : body) {
+ out.line(line.isEmpty() ? "" : BODY_INDENT + line);
+ }
+ out.line(INDENT + "}");
+ }
+
+ /**
+ * Writes the javadoc, which is left out entirely when there would be nothing in it.
+ *
+ * An override is never documented: it says the same thing as the method it overrides,
+ * and javadoc carries that documentation down on its own.
+ */
+ private void writeComment(JavaSource out) {
+ if (isOverride) {
+ return;
+ }
+
+ List lines = JavaComment.normaliseInClass(comment);
+ List tags = new ArrayList();
+
+ for (Argument argument : arguments) {
+ tags.add("@param " + argument.name + " " + argument.describe());
+ }
+ if (returnComment != null && !returnComment.isEmpty()) {
+ tags.add("@return " + returnComment);
+ }
+ for (Thrown item : thrown) {
+ if (item.description != null && !item.description.isEmpty()) {
+ tags.add("@throws " + item.type + " " + item.description);
+ }
+ }
+ if (lines.isEmpty() && tags.isEmpty()) {
+ return;
+ }
+
+ out.line(INDENT + "/**");
+ for (String line : lines) {
+ out.line(INDENT + " * " + line);
+ }
+ // The blank line stands between the description and the tags whether or not there
+ // is a description above it, which is what the reference output does.
+ out.line(INDENT + " * ");
+ for (String tag : tags) {
+ out.line(INDENT + " * " + JavaComment.escape(tag));
+ }
+ out.line(INDENT + " */");
+ }
+
+ /**
+ * @return the signature, from the scope to the brace that opens the body.
+ */
+ private String signature() {
+ StringBuilder buf = new StringBuilder();
+
+ if (!declarationOnly) {
+ // An override is public whatever else it is: so is the method it overrides
+ buf.append(isOverride ? "public" : scope).append(' ');
+ }
+ if (isStatic) {
+ buf.append("static ");
+ }
+ if (isFinal) {
+ buf.append("final ");
+ }
+ if (!constructor) {
+ buf.append(returnType == null ? "void" : returnType).append(' ');
+ }
+
+ buf.append(name).append('(');
+ for (int i = 0; i < arguments.size(); i++) {
+ if (i != 0) {
+ buf.append(",\n").append(ARGUMENT_INDENT);
+ }
+ Argument argument = arguments.get(i);
+ buf.append(argument.type).append(' ').append(argument.name);
+ }
+ buf.append(')');
+
+ for (int i = 0; i < thrown.size(); i++) {
+ buf.append(i == 0 ? " throws " : ", ").append(thrown.get(i).type);
+ }
+
+ buf.append(declarationOnly ? ";" : " {");
+ return buf.toString();
+ }
+
+ /**
+ * One argument, held with the description that documents it.
+ */
+ private static final class Argument {
+
+ private final String type;
+ private final String name;
+ private final String description;
+
+ private Argument(String type, String name, String description) {
+ this.type = type;
+ this.name = name;
+ this.description = description;
+ }
+
+ /**
+ * @return the description, or a plain one naming the argument.
+ */
+ private String describe() {
+ return (description == null || description.isEmpty())
+ ? "The " + name + " field." : description;
+ }
+ }
+
+ /**
+ * One thrown type, held with the description that documents it.
+ */
+ private static final class Thrown {
+
+ private final String type;
+ private final String description;
+
+ private Thrown(String type, String description) {
+ this.type = type;
+ this.description = description;
+ }
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaNaming.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaNaming.java
new file mode 100644
index 00000000..270de2a6
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaNaming.java
@@ -0,0 +1,85 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.Service;
+
+/**
+ * Where generated Java lands: package names and their directories.
+ */
+public final class JavaNaming {
+
+ /**
+ * The root package of every generated API.
+ */
+ public static final String ROOT = "org.ccsds.moims.mo.";
+ /**
+ * The folder that holds an area's or a service's data types.
+ */
+ public static final String STRUCTURES = "structures";
+ /**
+ * The MAL area's package. Generated code refers to it constantly - every element,
+ * encoder and type id lives there - so it is named once here.
+ */
+ public static final String MAL = ROOT + "mal.";
+ /**
+ * The package holding the MAL's own data types.
+ */
+ public static final String MAL_STRUCTURES = MAL + STRUCTURES + ".";
+ /**
+ * The folder that holds a service's consumer stubs.
+ */
+ public static final String CONSUMER = "consumer";
+ /**
+ * The folder that holds a service's provider skeletons.
+ */
+ public static final String PROVIDER = "provider";
+
+ private JavaNaming() {
+ }
+
+ public static String packageOf(Area area) {
+ return ROOT + area.getName().toLowerCase();
+ }
+
+ public static String packageOf(Area area, String folder) {
+ return packageOf(area) + "." + folder;
+ }
+
+ public static String packageOf(Service service) {
+ return packageOf(service.getArea()) + "." + service.getName().toLowerCase();
+ }
+
+ public static String packageOf(Service service, String folder) {
+ return packageOf(service) + "." + folder;
+ }
+
+ /**
+ * Returns the directory a package maps to, relative to the output root.
+ *
+ * @param packageName The package.
+ * @return the relative path, using '/' as separator.
+ */
+ public static String directoryOf(String packageName) {
+ return packageName.replace('.', '/');
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaSource.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaSource.java
new file mode 100644
index 00000000..17d473e8
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaSource.java
@@ -0,0 +1,85 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+/**
+ * Accumulates the text of a Java source file.
+ */
+public final class JavaSource {
+
+ private final StringBuilder buf = new StringBuilder();
+
+ public void line(String text) {
+ buf.append(text).append('\n');
+ }
+
+ public void blank() {
+ buf.append('\n');
+ }
+
+ /**
+ * Writes a single-line javadoc block at the given indentation.
+ *
+ * @param indent Number of four-space levels.
+ * @param text The comment, already normalised.
+ */
+ public void javadoc(int indent, String text) {
+ String pad = pad(indent);
+ line(pad + "/**");
+ line(pad + " * " + JavaComment.escape(text));
+ line(pad + " */");
+ }
+
+ private static String pad(int indent) {
+ StringBuilder p = new StringBuilder();
+ for (int i = 0; i < indent; i++) {
+ p.append(" ");
+ }
+ return p.toString();
+ }
+
+ /**
+ * @return the last line written, without its line separator.
+ */
+ public String lastLine() {
+ int end = buf.length() - 1;
+ int start = buf.lastIndexOf("\n", end - 1) + 1;
+ return buf.substring(start, end);
+ }
+
+ /**
+ * Replaces the last line written, for the rare case where a line can only be finished
+ * once the next is known - a list that closes on the line of its last entry.
+ *
+ * @param text The replacement line.
+ */
+ public void replaceLast(String text) {
+ int end = buf.length() - 1;
+ int start = buf.lastIndexOf("\n", end - 1) + 1;
+ buf.setLength(start);
+ line(text);
+ }
+
+ @Override
+ public String toString() {
+ return buf.toString();
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaTypeName.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaTypeName.java
new file mode 100644
index 00000000..4b819904
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaTypeName.java
@@ -0,0 +1,210 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import esa.mo.apigen.model.MOModel;
+import esa.mo.apigen.model.types.AttributeType;
+import esa.mo.apigen.model.types.EnumerationType;
+import esa.mo.apigen.model.types.TypeDefinition;
+import esa.mo.apigen.model.types.TypeRef;
+
+/**
+ * Renders a type reference as the Java type that represents it.
+ */
+public final class JavaTypeName {
+
+ private JavaTypeName() {
+ }
+
+ /**
+ * @return the Java type for a field or parameter of this type.
+ */
+ public static String of(MOModel model, TypeRef reference) {
+ if (reference == null) {
+ return "java.lang.Object";
+ }
+ // The older spelling of an object reference, ObjectRef(Product), means the same as
+ // objectRef="true"; unwrapping here lets everything below see just the one form.
+ TypeRef ref = reference.unwrapped();
+ if (ref.isList()) {
+ // A list of object references is a list of references, not of the target type.
+ return ref.isObjectRef()
+ ? JavaNaming.MAL_STRUCTURES + "ObjectRefList" : qualifiedList(ref);
+ }
+ if (ref.isObjectRef()) {
+ return JavaNaming.MAL_STRUCTURES + "ObjectRef<" + qualified(ref) + ">";
+ }
+ if (isNativeAttribute(model, ref)) {
+ return JavaTypes.nativeName(ref.getName());
+ }
+ return qualified(ref);
+ }
+
+ /**
+ * @return the expression that creates an empty instance, for decoding.
+ */
+ public static String newInstance(MOModel model, TypeRef ref) {
+ // An enumeration cannot be constructed empty, so its first singleton stands in.
+ if (ref != null && !ref.isList() && !ref.isObjectRef()) {
+ TypeDefinition definition = model.resolve(ref);
+ if (definition instanceof EnumerationType) {
+ EnumerationType enumeration = (EnumerationType) definition;
+ if (!enumeration.getItems().isEmpty()) {
+ return qualified(ref) + "." + enumeration.getItems().get(0).getValue();
+ }
+ }
+ }
+ return "new " + of(model, ref) + "()";
+ }
+
+ /**
+ * @return the fully qualified name of the type itself, ignoring list and reference
+ * wrapping.
+ */
+ public static String qualified(TypeRef ref) {
+ StringBuilder pkg = new StringBuilder("org.ccsds.moims.mo.");
+ pkg.append(ref.getArea().toLowerCase());
+ if (ref.getService() != null) {
+ pkg.append('.').append(ref.getService().toLowerCase());
+ }
+ pkg.append('.').append(JavaNaming.STRUCTURES).append('.')
+ .append(JavaTypes.className(ref.getArea(), ref.getName()));
+ return pkg.toString();
+ }
+
+ /**
+ * Returns the Java type of a list of this type. Usually the element's name with
+ * "List" appended, but a list of MAL Elements is the hand-written HeterogeneousList
+ * rather than a generated ElementList.
+ */
+ private static String qualifiedList(TypeRef ref) {
+ String listName = JavaTypes.className(ref.getArea(), ref.getName() + "List");
+ int lastDot = qualified(ref).lastIndexOf('.');
+ return qualified(ref).substring(0, lastDot + 1) + listName;
+ }
+
+ /**
+ * Returns true if the reference names a MAL attribute type that is represented by a
+ * plain Java type.
+ */
+ public static boolean isNativeAttribute(MOModel model, TypeRef ref) {
+ return !ref.isList() && !ref.isObjectRef()
+ && JavaTypes.isNative(ref.getArea(), ref.getName())
+ && isAttribute(model, ref);
+ }
+
+ /**
+ * Returns the MAL attribute type this reference names, or null if it names something
+ * else. Attributes have their own encoder methods; everything else is encoded as an
+ * element.
+ */
+ public static String attributeName(MOModel model, TypeRef ref) {
+ if (ref == null || ref.isList() || ref.isObjectRef()) {
+ return null;
+ }
+ // The abstract Attribute type has its own encoder method, like the concrete ones.
+ if ("MAL".equals(ref.getArea()) && "Attribute".equals(ref.getName())) {
+ return "Attribute";
+ }
+ return isAttribute(model, ref) ? ref.getName() : null;
+ }
+
+ /**
+ * Returns true if the reference names a type that cannot be instantiated, so the
+ * encoder has to record which concrete type it is writing.
+ */
+ public static boolean isAbstractElement(MOModel model, TypeRef ref) {
+ if (ref == null || ref.isList()) {
+ return false;
+ }
+ // A reference to an abstract type is itself abstract: ObjectRef can hold
+ // a reference to anything, so the encoder has to record what it wrote.
+ TypeDefinition definition = model.resolve(ref);
+ return definition != null && definition.isAbstract();
+ }
+
+ /**
+ * Returns the expression that names the short form of a type, as a message body needs
+ * it to say what it carries. An abstract type has none - what is on the wire is decided
+ * per message - and neither has the base Element.
+ *
+ * @param model The model the reference is resolved against.
+ * @param reference The type, may be null.
+ * @return the short form expression, or the literal null.
+ */
+ public static String shortFormOf(MOModel model, TypeRef reference) {
+ if (reference == null) {
+ return "null";
+ }
+ TypeRef ref = reference.unwrapped();
+ // What is on the wire is decided per message, whether the field holds one of them
+ // or a list of them.
+ TypeDefinition definition = model.resolve(ref);
+ if (definition != null && definition.isAbstract()) {
+ return "null";
+ }
+ // An attribute carries the short form of the attribute type, not of a class: the
+ // concrete ones are constants on Attribute itself.
+ if (!ref.isList() && isAttribute(model, ref)) {
+ return JavaNaming.MAL_STRUCTURES + "Attribute."
+ + ref.getName().toUpperCase() + "_SHORT_FORM";
+ }
+ if (ref.isObjectRef()) {
+ return ref.isList()
+ ? JavaNaming.MAL_STRUCTURES + "ObjectRefList.SHORT_FORM"
+ : JavaNaming.MAL_STRUCTURES + "ObjectRef.OBJECTREF_SHORT_FORM";
+ }
+ String java = of(model, ref);
+ return (JavaNaming.MAL_STRUCTURES + "Element").equals(java) ? "null" : java + ".SHORT_FORM";
+ }
+
+ /**
+ * Returns what a decoder is handed so it knows what to build. An abstract type carries
+ * its own identity on the wire, so nothing is handed over for it - except a list of
+ * abstract things, which is still a list and can be built empty. A list of the base
+ * Element is the one exception to that exception: the reference output leaves it
+ * undecided, because before the Java mapping renames it the type is called ElementList
+ * and the rule matches on the name.
+ *
+ * @param model The model the reference is resolved against.
+ * @param reference The type of the field being decoded.
+ * @return the expression handed to the decoder, or the literal null.
+ */
+ public static String expectedTypeOf(MOModel model, TypeRef reference) {
+ TypeRef ref = reference.unwrapped();
+ String raw = qualified(ref) + (ref.isList() ? "List" : "");
+ if (raw.contains(".MOObject")) {
+ return "null";
+ }
+ TypeDefinition definition = model.resolve(ref);
+ boolean isAbstract = definition != null && definition.isAbstract();
+ if (isAbstract) {
+ return raw.contains("List") && !raw.contains(".Element")
+ ? "new " + of(model, reference) + "()" : "null";
+ }
+ return newInstance(model, reference);
+ }
+
+ private static boolean isAttribute(MOModel model, TypeRef ref) {
+ TypeDefinition definition = model.resolve(ref);
+ return definition instanceof AttributeType;
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaTypes.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaTypes.java
new file mode 100644
index 00000000..9d833a9f
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JavaTypes.java
@@ -0,0 +1,127 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * How the MAL's attribute types are represented in Java.
+ *
+ * The model takes the attribute types themselves from the MAL specification; this holds
+ * only the Java side of the mapping, which belongs to this generator and nowhere else.
+ *
+ * Seven of them are plain Java types rather than generated classes, and the name is not
+ * always the MAL one - an Octet is a {@code Byte}.
+ */
+public final class JavaTypes {
+
+ private static final Map NATIVE = new HashMap();
+
+ static {
+ NATIVE.put("Boolean", "Boolean");
+ NATIVE.put("Double", "Double");
+ NATIVE.put("Float", "Float");
+ NATIVE.put("Integer", "Integer");
+ NATIVE.put("Long", "Long");
+ NATIVE.put("Octet", "Byte");
+ NATIVE.put("Short", "Short");
+ NATIVE.put("String", "String");
+ }
+
+ /**
+ * The value a native attribute is created with where one has to be created without
+ * knowing anything about it - the Union that carries it has no empty constructor.
+ */
+ private static final Map NATIVE_DEFAULT = new HashMap();
+
+ static {
+ NATIVE_DEFAULT.put("Boolean", "Boolean.FALSE");
+ NATIVE_DEFAULT.put("Double", "Double.MAX_VALUE");
+ NATIVE_DEFAULT.put("Float", "Float.MAX_VALUE");
+ NATIVE_DEFAULT.put("Integer", "Integer.MAX_VALUE");
+ NATIVE_DEFAULT.put("Long", "Long.MAX_VALUE");
+ NATIVE_DEFAULT.put("Octet", "Byte.MAX_VALUE");
+ NATIVE_DEFAULT.put("Short", "Short.MAX_VALUE");
+ NATIVE_DEFAULT.put("String", "\"\"");
+ }
+
+ /**
+ * MAL types whose Java class is named differently, to avoid colliding with a Java
+ * built-in: MAL's Object would otherwise shadow java.lang.Object.
+ */
+ private static final Map RENAMED = new HashMap();
+
+ static {
+ RENAMED.put("Object", "MOObject");
+ RENAMED.put("ElementList", "HeterogeneousList");
+ }
+
+ private JavaTypes() {
+ }
+
+ /**
+ * Returns the Java class name for a MAL type, which is usually its own name.
+ *
+ * @param malArea The area the type belongs to.
+ * @param typeName The MAL type name.
+ * @return the Java class name.
+ */
+ public static String className(String malArea, String typeName) {
+ if ("MAL".equals(malArea) && RENAMED.containsKey(typeName)) {
+ return RENAMED.get(typeName);
+ }
+ return typeName;
+ }
+
+ /**
+ * Returns true if the MAL attribute type is a plain Java type rather than a class this
+ * generator produces.
+ *
+ * @param malArea The area the type belongs to.
+ * @param typeName The MAL type name.
+ * @return true if it maps to a Java built-in.
+ */
+ public static boolean isNative(String malArea, String typeName) {
+ return "MAL".equals(malArea) && NATIVE.containsKey(typeName);
+ }
+
+ /**
+ * Returns the Java name of a native MAL attribute type.
+ *
+ * @param typeName The MAL type name.
+ * @return the Java type name.
+ */
+ public static String nativeName(String typeName) {
+ return NATIVE.get(typeName);
+ }
+
+ /**
+ * Returns the value a native MAL attribute type is created with when nothing is known
+ * about the value it is going to carry.
+ *
+ * @param typeName The MAL type name.
+ * @return the Java expression of the default value.
+ */
+ public static String nativeDefault(String typeName) {
+ return NATIVE_DEFAULT.get(typeName);
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JumpTable.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JumpTable.java
new file mode 100644
index 00000000..480de6f2
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/JumpTable.java
@@ -0,0 +1,177 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/**
+ * Decides how the type numbers of an element factory are laid out, so that a lookup
+ * compiles to a jump rather than to a binary search.
+ *
+ * Carried over from the generator this replaces, rule for rule. A type and the list that
+ * holds it share a number save for the sign, so numbers far from zero leave the whole
+ * distance across zero empty between them, and a switch spanning that emptiness falls back
+ * to a binary search. Two things follow: numbers that lie past the widest table worth
+ * building are answered by a second method, and where each side of zero would answer with
+ * a table of its own but the two together would not, the sides are switched apart.
+ */
+public final class JumpTable {
+
+ private JumpTable() {
+ }
+
+ /**
+ * Returns true when a switch over this many numbers, spanning this far, is worth
+ * compiling to a jump table rather than to a binary search.
+ *
+ * @param count How many numbers the switch answers for.
+ * @param span The distance from the lowest number to the highest, inclusive.
+ * @return true if a jump table pays.
+ */
+ public static boolean compilesToJumpTable(int count, long span) {
+ return (4 + span) + 3L * 3L <= (3 + 2L * count) + 3L * count;
+ }
+
+ /**
+ * Returns the widest distance from zero within which the numbers still pay for a jump
+ * table. A type and its list share a number save for the sign, so the band widens by
+ * the same step on both sides at once.
+ *
+ * @param numbers The type numbers.
+ * @return the widest band, or zero if no table pays.
+ */
+ public static int widestJumpTableBand(Collection numbers) {
+ SortedSet magnitudes = new TreeSet();
+ for (Integer number : numbers) {
+ magnitudes.add(Math.abs(number));
+ }
+
+ int widest = 0;
+ for (Integer magnitude : magnitudes) {
+ int count = 0;
+ for (Integer number : numbers) {
+ if (Math.abs(number) <= magnitude) {
+ count++;
+ }
+ }
+ if (compilesToJumpTable(count, 2L * magnitude + 1L)) {
+ widest = magnitude;
+ }
+ }
+ return widest;
+ }
+
+ /**
+ * Returns true when the numbers are better switched on one side of zero at a time than
+ * all together: neither side is worth it unless both sides answer with a table of
+ * their own and the two together would not.
+ *
+ * @param numbers The type numbers of the switch.
+ * @return true if each side of zero should be switched on its own.
+ */
+ public static boolean shouldSplitOnSign(Collection numbers) {
+ int positives = 0;
+ int negatives = 0;
+ int highest = Integer.MIN_VALUE;
+ int lowest = Integer.MAX_VALUE;
+ int highestPositive = Integer.MIN_VALUE;
+ int lowestPositive = Integer.MAX_VALUE;
+ int highestNegative = Integer.MIN_VALUE;
+ int lowestNegative = Integer.MAX_VALUE;
+
+ for (Integer number : numbers) {
+ highest = Math.max(highest, number);
+ lowest = Math.min(lowest, number);
+ if (number > 0) {
+ positives++;
+ highestPositive = Math.max(highestPositive, number);
+ lowestPositive = Math.min(lowestPositive, number);
+ } else {
+ negatives++;
+ highestNegative = Math.max(highestNegative, number);
+ lowestNegative = Math.min(lowestNegative, number);
+ }
+ }
+
+ if (positives == 0 || negatives == 0) {
+ return false; // There is only one side to switch on
+ }
+
+ return !compilesToJumpTable(numbers.size(), (long) highest - lowest + 1L)
+ && compilesToJumpTable(positives, (long) highestPositive - lowestPositive + 1L)
+ && compilesToJumpTable(negatives, (long) highestNegative - lowestNegative + 1L);
+ }
+
+ /**
+ * The types of one switch, split into the ones a jump table can hold and the ones
+ * whose numbers lie too far out for it.
+ */
+ public static final class Split {
+
+ private final Map inBand = new TreeMap();
+ private final Map outOfBand = new TreeMap();
+
+ /**
+ * @return true if the types have to be reached by two switches.
+ */
+ public boolean isSplit() {
+ return !inBand.isEmpty() && !outOfBand.isEmpty();
+ }
+
+ public Map getInBand() {
+ return inBand;
+ }
+
+ public Map getOutOfBand() {
+ return outOfBand;
+ }
+
+ /**
+ * @return every type, whether or not the jump table can hold it.
+ */
+ public Map all() {
+ Map every = new TreeMap(inBand);
+ every.putAll(outOfBand);
+ return every;
+ }
+ }
+
+ /**
+ * Splits the types into the ones the widest jump table can hold and the ones past it.
+ *
+ * @param types The types, by number, in declaration order.
+ * @return the split.
+ */
+ public static Split splitTypes(Map types) {
+ Map byNumber = new TreeMap(types);
+ int band = widestJumpTableBand(byNumber.keySet());
+ Split split = new Split();
+ for (Map.Entry entry : byNumber.entrySet()) {
+ boolean fits = Math.abs(entry.getKey()) <= band;
+ (fits ? split.inBand : split.outOfBand).put(entry.getKey(), entry.getValue());
+ }
+ return split;
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/ShortForm.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/ShortForm.java
new file mode 100644
index 00000000..bca55708
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/ShortForm.java
@@ -0,0 +1,76 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java;
+
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.Service;
+
+/**
+ * The absolute short form of a type: the number that identifies it on the wire.
+ *
+ * Area number, service number, area version and the type's own short form part, packed
+ * into one long.
+ */
+public final class ShortForm {
+
+ private ShortForm() {
+ }
+
+ /**
+ * Computes the absolute short form.
+ *
+ * @param area The area defining the type.
+ * @param service The service defining it, or null for an area-level type.
+ * @param shortFormPart The type's short form part.
+ * @return the absolute short form.
+ */
+ public static long of(Area area, Service service, long shortFormPart) {
+ long serviceNumber = service == null ? 0 : service.getNumber();
+ return ((long) area.getNumber() << 48)
+ + (serviceNumber << 32)
+ + ((long) area.getVersion() << 24)
+ + shortFormPart;
+ }
+
+ /**
+ * Writes the three fields that carry a type's identity: the version the class was
+ * serialised under, the short form as a Long, and the TypeId built from it. Composites,
+ * enumerations and lists all open with them, written as one block with no blank line
+ * between them.
+ *
+ * @param out The source to write to.
+ * @param shortForm The absolute short form of the type.
+ */
+ public static void writeIdentity(JavaSource out, long shortForm) {
+ JavaFieldBuilder.named("serialVersionUID").scope("private").asStatic().asFinal()
+ .ofType("long").value(shortForm + "L")
+ .write(out);
+ JavaFieldBuilder.named("SHORT_FORM").asStatic().asFinal()
+ .ofType("Long").value(shortForm + "L")
+ .comment("The TypeId of this Element as a long.")
+ .joinedToPrevious().write(out);
+ JavaFieldBuilder.named("TYPE_ID").asStatic().asFinal()
+ .ofType(JavaNaming.MAL + "TypeId")
+ .value("new " + JavaNaming.MAL + "TypeId(SHORT_FORM)")
+ .comment("The TypeId of this Element.")
+ .joinedToPrevious().write(out);
+ }
+}
diff --git a/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/writers/CompositeWriter.java b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/writers/CompositeWriter.java
new file mode 100644
index 00000000..82488bfc
--- /dev/null
+++ b/api-generator/api-generator-lib/src/main/java/esa/mo/apigen/generators/java/writers/CompositeWriter.java
@@ -0,0 +1,375 @@
+/* ----------------------------------------------------------------------------
+ * Copyright (C) 2026 European Space Agency
+ * European Space Operations Centre
+ * Darmstadt
+ * Germany
+ * ----------------------------------------------------------------------------
+ * System : CCSDS MO API Generator
+ * ----------------------------------------------------------------------------
+ * Licensed under the European Space Agency Public License, Version 2.0
+ * You may not use this file except in compliance with the License.
+ *
+ * Except as expressly set forth in this License, the Software is provided to
+ * You on an "as is" basis and without warranties of any kind, including without
+ * limitation merchantability, fitness for a particular purpose, absence of
+ * defects or errors, accuracy or non-infringement of intellectual property rights.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ----------------------------------------------------------------------------
+ */
+package esa.mo.apigen.generators.java.writers;
+
+import esa.mo.apigen.generators.java.JavaClassBuilder;
+import esa.mo.apigen.generators.java.JavaFieldBuilder;
+import esa.mo.apigen.generators.java.JavaMethodBuilder;
+import esa.mo.apigen.generators.java.JavaNaming;
+import esa.mo.apigen.generators.java.JavaSource;
+import esa.mo.apigen.generators.java.JavaTypeName;
+import esa.mo.apigen.generators.java.ShortForm;
+import esa.mo.apigen.model.Area;
+import esa.mo.apigen.model.Field;
+import esa.mo.apigen.model.MOModel;
+import esa.mo.apigen.model.Service;
+import esa.mo.apigen.model.types.CompositeType;
+import esa.mo.apigen.model.types.TypeRef;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Writes the Java class for a composite.
+ */
+public final class CompositeWriter {
+
+ private static final String COMPOSITE = JavaNaming.MAL_STRUCTURES + "Composite";
+
+ private CompositeWriter() {
+ }
+
+ public static String write(MOModel model, Area area, Service service, CompositeType type) {
+ String name = type.getName();
+ String pkg = service == null
+ ? JavaNaming.packageOf(area, JavaNaming.STRUCTURES)
+ : JavaNaming.packageOf(service, JavaNaming.STRUCTURES);
+ String fq = pkg + "." + name;
+ List own = type.getFields();
+ List inherited = model.inheritedFields(type);
+ List all = new ArrayList(inherited);
+ all.addAll(own);
+
+ String superFq = superClassOf(type);
+ boolean isAbstract = type.isAbstract();
+
+ JavaClassBuilder clazz = JavaClassBuilder.named(name).inPackage(pkg)
+ .comment(isBlank(type.getComment())
+ ? "The " + name + " structure." : type.getComment());
+ if (isAbstract) {
+ clazz.asAbstract();
+ } else {
+ clazz.asFinal();
+ }
+ // A composite that extends the base Composite implements the interface instead;
+ // anything else extends its super type.
+ if (superFq == null) {
+ clazz.implementing(COMPOSITE);
+ } else {
+ clazz.extending(superFq);
+ }
+ JavaSource out = clazz.open();
+
+ if (type.getShortFormPart() != null) {
+ ShortForm.writeIdentity(out, ShortForm.of(area, service, type.getShortFormPart()));
+ }
+
+ for (Field field : own) {
+ JavaFieldBuilder.named(field.getName()).scope("private")
+ .ofType(JavaTypeName.of(model, field.getType()))
+ .comment(isBlank(field.getComment())
+ ? "The " + field.getName() + " field." : field.getComment())
+ .write(out);
+ }
+
+ JavaMethodBuilder.constructor(name).comment("Default constructor for " + name + ".")
+ .write(out);
+
+ if (!all.isEmpty()) {
+ writeConstructor(out, model, name, all, inherited, own,
+ "Constructor that initialises the values of the structure.");
+ // The second constructor is suppressed in two cases: with no non-nullable
+ // fields it would take no arguments and collide with the default constructor,
+ // and with nothing but non-nullable fields it would repeat the first one.
+ List required = requiredOf(all);
+ if (!required.isEmpty() && required.size() != all.size()) {
+ writeConstructor(out, model, name, required,
+ retain(inherited, required), own,
+ "Constructor that initialises the non-nullable values of the structure.",
+ omitted(own, required));
+ }
+ }
+
+ if (!isAbstract) {
+ JavaMethodBuilder.named("createElement").asOverride()
+ .returns(JavaNaming.MAL_STRUCTURES + "Element", null)
+ .line("return new " + fq + "();")
+ .write(out);
+ }
+
+ for (Field field : own) {
+ JavaMethodBuilder.named("get" + capitalise(field.getName()))
+ .comment("Returns the field " + field.getName() + ".")
+ .returns(JavaTypeName.of(model, field.getType()),
+ "The field " + field.getName())
+ .line("return " + field.getName() + ";")
+ .write(out);
+ }
+
+ writeEquals(out, name, own, superFq != null);
+ writeHashCode(out, own, superFq != null);
+ writeToString(out, name, own, superFq != null);
+ writeEncode(out, model, own, superFq != null);
+ writeDecode(out, model, own, superFq != null);
+
+ if (type.getShortFormPart() != null) {
+ JavaMethodBuilder.named("getTypeId").asOverride()
+ .returns(JavaNaming.MAL + "TypeId", null)
+ .line("return TYPE_ID;")
+ .write(out);
+ }
+
+ return clazz.close();
+ }
+
+ // --------------------------------------------------------- constructors
+
+ private static void writeConstructor(JavaSource out, MOModel model, String name,
+ List