Repository navigation
Releases: bsnsoft/esj
Release list
EN 16931 Semantic JSON 1.0.0-rc.4
Release candidate 4 of 1.0.0 — the software of rc.3, whose tag has no release (its macOS archive did not build), with the fix below. Format 1.0 (see rc.1 and rc.2 in CHANGELOG.md).
Fixed
ESJ Viewer.appis built on themacos-15runner again: the script that turns the compiled
AppleScript into the viewer removed a property-list key the applet template of that macOS does not
write, and stopped. Every key it sets or removes is now handled whichever keys the template
carries. 1.0.0-rc.3 has no release for that reason; its tag exists, nothing of it was published.
Changes since 1.0.0-rc.2 (the section of 1.0.0-rc.3)
Added
esj view <file>shows an invoice in the browser: one page in a directory of its own, with
the facts of the invoice and the verdict at the top and a tab each for the PDF as it came, the
letteresj renderdraws from its data, the KoSIT rendering and, with--report, the
validation report, kept beside the page as an HTML file and a PDF; every file is the bytes its
own command writes. Every outcome is a page — a PDF without an invoice, several candidates (a
table with the command that opens each), ZUGFeRD 1.0, an encrypted PDF, a file that is not
read, a limit — saying what the file is and what to ask the sender for, with the exit code
renderleaves with, or with--reportvalidate. The page runs no script of its own (the
tabs are CSS), fetches nothing and says so in a content security policy.--out <dir>,
--lang(the default from the locale),--template,--no-payment-code,--output json;
--textprints the invoice in the terminal instead, and with--reportthe lines of
esj validateunder it (docs/view.md).esj render --textwrites the invoice as plain text for a terminal: the facts and names of the
letter layout top to bottom, the lines as a table, UTF-8 without colour or box drawing, in
--widthcolumns (defaultCOLUMNSwhere it is 60 or more, otherwise 100), no line longer and
nothing cut (docs/cli.md).- The deb and the rpm install a desktop entry, ESJ Viewer, that offers
esj view --reportunder
Open with for XML and PDF files, with its icon; the macOS archive carriesESJ Viewer.app
beside the executable, an alternate viewer of the same two types, signed ad hoc;
docs/install.mdgives the line that does it
on Windows. None of them makes itself the default program of a type.
Changed
- The macOS executable and its archives are built on GitHub's
macos-15runner;macos-14, which
built them until 1.0.0-rc.2, is being retired by GitHub. The pages and the Homebrew formula name
the release candidate as the version to use. - A release candidate is published like a release: the image tag
latestfollows every tag, and
the apt and dnf repositories have one suite,stable, with every version, a candidate as
1.2.3~rc.1, which sorts before1.2.3. The suitetestingandrpm/esj-testing.repoare
gone (a source line or.repofile added for them is to be removed); the next run of
dist/repositories.shmoves the rpm packages oftestingintostable.release.ymlstarted
withpublish_versionsetslatestand regenerates the repositories for a version already
released, and builds nothing (docs/releasing.md).
Fixed
- The native executables — the release archives, the Homebrew formula, the
.deband.rpm
packages — ended with exit code 5 andinternal error: java.util.MissingResourceException: Could not load any resource bundle by com.sun.org.apache.xerces.internal.impl.msg.XMLMessages
on every XML document that is not well-formed — cut short, with a wrong end tag, an undeclared
entity, a control character or markup after the root element — in every command that reads
one, and on a hybrid PDF whose attachment or XMP packet is not well-formed; and in
esj validateon every document its schema refuses (…XMLSchemaMessages). The jar answers
exit code 2,the document is not well-formed XML that this reader accepts, or reports the
finding with exit code 1, and so do they now. The parser of the Java runtime formats each error
from a resource bundle even where the tool prints a sentence of its own, and no case of
dist/cases.shhad fed it such a document, so the GraalVM agent never recorded the two
bundles: every native executable released up to 1.0.0-rc.2 lacks them. The jar, the runtime
image and the container image were not affected. The casesinput-*now take every kind of
input a reader refuses or reads the hard way, fromdist/inputs/,
throughvalidate,inspect,convertandrender --html, so the agent records what they
reach and the smoke test compares every packaged artefact's answers with the jar's. - The native executables refused an invoice in Windows-1252, ISO-8859-15, Shift_JIS or any other
encoding beyond UTF-8, UTF-16, ISO-8859-1 and US-ASCII with exit code 2 (the document is written in, or declares, the encoding …, which this reader does not read), and the repair of
a document declared UTF-8 whose bytes are Windows-1252, both of which the jar reads: an image
carries only those charsets unless it is built with-H:+AddAllCharsets, which it now is. The
runtime image refused the encodings of the modulejdk.charsets, Shift_JIS among them, and
now carries it. Cases ofdist/inputs/in each of these encodings keep both answering as the
jar does. - The Linux packages of a pre-release are attached to the GitHub release with a dot in place of
the~of their version (esj_1.2.3.rc.1_amd64.deb), which is the name GitHub stores them
under anyway, and their checksum files name them so: those of 1.0.0-rc.2 name
esj_1.0.0~rc.2_amd64.deb, which a download does not have, sosha256sum -cfails on them
(compare the digest by hand). The packages in the apt and dnf repositories keep the~, and a
release, which has none, is attached as before. The release job also publishes a pre-release
only when GitHub lists every asset under the name it was delivered with
(docs/install.md).
EN 16931 Semantic JSON 1.0.0-rc.2
Release candidate of 1.0.0. The format is 1.0 (SPEC.md 1.0, release candidate); a reader of this version reads documents of 1.0 and 0.1, and a 0.1 document keeps its version, its canonical bytes and both digests. Channels: Maven Central de.bsnsoft.esj:*:1.0.0-rc.2, the archives and Linux packages below, ghcr.io/bsnsoft/esj:1.0.0-rc.2 (latest stays 0.9.8), the apt and dnf suite testing at https://apt.bsnsoft.de as 1.0.0~rc.2; the Homebrew tap stays on 0.9.8. The tag v1.0.0-rc.1 carries the same software: its release was published without assets and is deleted, and it is on no other channel.
Fixed
release.ymlcreates a pre-release as a draft, attaches the assets and publishes it then:
releases are immutable once published, so assets can be attached only before publication, and
the release action publishes a pre-release before it uploads them. The tagv1.0.0-rc.1was
published that way, got no assets, and is on no other channel; its release is deleted, and
1.0.0-rc.2 is the same software with this fix (docs/releasing.md).
Changes since 0.9.8 (the section of 1.0.0-rc.1 in the changelog)
Format
- The format is 1.0, and the specification is
SPEC.mdversion 1.0, a release candidate: a
document written now carries"version": "1.0". Nothing else of the format changed — the path
grammar, the value model, the canonical form, the digests, the finding codes, the registries
and the limits are those of 0.1 (SPEC.mdappendix D). - A reader of 1.0 reads documents of version
1.0and of0.1, the release candidate, both by
the rules of 1.0, and refuses every other value withESJ-L1-ENVELOPE-VALUE, whose message
names the two; in general a reader of 1.x reads 1.y for y ≤ x and 0.1 (section 4.3). - A document keeps the version it was read in. The canonical and the pretty form write it, so a
stored 0.1 document keeps its canonical bytes and both digests, and a document derived from one
— edited, given derived totals, written up to another edition and back — stays 0.1. A document
built from nothing, by the domain API or the typed builder, or imported from UBL, CII or PDF
carries 1.0. The same values as 0.1 and as 1.0 share the semantic digest and differ in the
document digest (sections 7.3 and 8.3); no command rewrites a 0.1 document as 1.0. schema/esj.schema.jsontakesversionfrom["1.0", "0.1"], and the schema identifiers move
fromtag:bsnsoft.de,2026:esj/schema/0.1/…to…/schema/1.0/…; the generated schemata of the
two editions follow.- The fixture manifest records documents of version 0.1 with the canonical bytes they had in a
new sectionearlierVersions(conformance/fixtures/version-0.1/), a case of the canonical
order whose bytes carry 0.1, and rejected documents with the versions2.0,1and1.1.
Every other document of the repository — examples, corpus, fixtures, appendix B — is written in
1.0, and its document digest moved with it; no semantic digest did.
Added
Esj.acceptedFormatVersions(),SemanticDocument.formatVersion()and
SemanticDocument.Builder.formatVersion(String), which accepts only a version this
implementation reads.Esj.formatVersion()answers1.0. Two documents that differ in their
format version alone are notequals, as their document digests differ.
Changed
- The validation report says under its verdict, in German and in English, that the verdict is
technical conformance with EN 16931 and that the German VAT classification is a separate question
(BMF letter of 15 October 2026, Rn. 22 f.). The documentation is checked against that letter, the
third on mandatory e-invoicing in Germany; nothing in the format, the registries, the bindings or
the rule packs had to change.docs/legal-de.mdmaps it paragraph by paragraph
to what ESJ offers,docs/validation.mdseparates the verdict from the VAT
view,docs/b2c.mdcovers prices agreed gross between businesses as well, and both
FAQ pages answer for a final invoice after partial invoices, construction work with a bill of
quantities and a correction.
EN 16931 Semantic JSON 0.9.8
Added
- Linux packages and package repositories: the native executable as
esj_<version>_<arch>.deb
andesj-<version>-1.<arch>.rpmfor x86_64 and arm64, built with nfpm (dist/packages.sh,
nfpm 2.47.0 pinned by SHA-256 indist/nfpm.sh), attached to the release with their checksums
and build provenance attestations, and published in an apt repository and a dnf repository at
https://apt.bsnsoft.de, served by GitHub Pages from the branchgh-pages:
apt-get install esjanddnf install esjafter adding a keyring and a source line or a
.repofile (docs/install.md). The executable is
installed under/usr/lib/esjbeside its shared libraries, with/usr/bin/esja link to it;
the packages require glibc 2.34 and zlib and nothing else, measured ondebian:12-slim,
ubuntu:22.04,ubuntu:24.04,fedora:latestandrockylinux:9(dist/packages-smoke.sh).
A release goes to the suitestable, a pre-release totestingas1.2.3~rc.1, which sorts
before1.2.3. The indices are signed with the release key, the key the artefacts on Maven
Central are signed with, inline and detached (InRelease,Release.gpg,repomd.xml.asc),
and so is every rpm; apt reads the indices by hash.dist/repositories.shregenerates both
repositories from the packages on the branch, which is their whole state; the job Package
repositories ofrelease.ymlruns it on a tag, anddist/repositories-test.shputs both
through apt and dnf end to end with a throwaway key and the commands of the install page, in
the CI and before a release attaches anything. dist/checks.shfails a native Linux executable, or a library beside it, that needs a glibc
symbol version newer thanESJ_GLIBC_FLOOR(2.34), the floor the packages require; the native
build anddist/packages.shrun the check.
Fixed
esj servekept the connection of an early refusal alive — an answer sent before the request
body was read to its end — though it closes that connection where the lingering read of the
rest ends at its time bound or at the bound of the door; a client that had already sent its
next request on it lost that answer (HTTP/1.1 header parser received no bytes: once in 420
requests on a CI runner, in a test run some 3.5 s longer than usual, past the test's 3 s
bounds; every time behind a chunked body past the bound). Such an answer now says
Connection: close, so the next request goes on a new connection; a request whose body was
read to its end keeps its connection.- The native executables ended
esj render --templatewith exit 5 on a JPEG letterhead or logo
(NoSuchMethodError: …JPEGImageReader.readInputData) and on every PNG that is not plain RGB or
a palette — with an ICC profile or ansRGBchunk, in grey, interlaced or with an alpha channel
(sun/java2d/Disposer,CMMException): no case had used such an image, so the native image
lacked the JNI and resource metadata of the JPEG decoder, of the rasters and colour models of
ImageIO and of the colour management of the Java runtime. Four template cases over
dist/artwork/record it, and the smoke compares their renderings with the jar's.
EN 16931 Semantic JSON 0.9.7
Added
esj serve(preview): the tools of the command line over HTTP — a REST API under/api, its
OpenAPI 3.1 description at/openapi.json, and an MCP server over Streamable HTTP at/mcp—
andesj mcp(preview), the same tools as an MCP server over the standard streams. Seven tools
from one table:validate,summary,get,convert,render,extractandinspect, and
uploadover HTTP. The server reads no document itself: every call runsesjas a child process
with--job-heap,--job-timeoutand an environment of its own, at most--max-jobsat once.
POST /api/validateanswers with the report ofesj validate --output json, byte for byte.
Loopback by default and0.0.0.0in both container images (ESJ_SERVE_BIND, port 8080
exposed), a bearer token from--token-file,Originchecked,pathonly below
--allow-dir, uploads and artefacts kept for--ttlin one temporary directory thatSIGTERM
removes. The server holds no document and no answer whole: it reads what a child wrote from
its file, keeps what a result carries and writes the answer into a spool before the call gives
back its child's place, so a call costs its heap at most 6 MiB whatever--limitslets a
child read and a client that reads slowly costs none of it, and it starts only with the
--max-jobsits heap holds (ESJ_SERVE_HEAPin the container image, besideESJ_MAX_HEAP
for the children); an MCP batch carries at most 16 messages and onetools/call. Links are
built from--public-urlor aHostthe server answers to (--allow-host) and are paths
otherwise; a file with a second name below--allow-diris refused; the texts a model reads
escape every invisible character;notifications/cancelledends the child of its call.
Everything the server holds on disk — request bodies and uploads as they are read, artefacts,
answers that wait for their client, and 64 MiB for each running child from its start, the
most it may write — is held to--max-disk(448M, within the tmpfs ofdist/compose.yaml;
--max-stored-bytes256M within it): a call that does not fit is refused with 503 and
Retry-After, never cut, and a call whose child the server ends as it stops is told so with
503, not 502. A request refused before its body was read is answered first, and the rest of
its body is read and dropped, for at most 5 s, before the connection is closed, so that the
client reads the answer and not a TCP reset.dist/compose.yamlruns it as the service
esj-api;dist/serve-smoke.shholds every packaged artefact's server against the jar's.
docs/serve.md.
Fixed
- The image stage of
dist/Dockerfile.nativecarries the runtime libraries beside the executable,
without which a rendering in it failed to load the AWT library.
EN 16931 Semantic JSON 0.9.6
The Java implementation and the TypeScript and C# bindings now read, report and load registries
alike. An entry without a prefix holds for all three; a prefix names the implementation it is
about.
Added
- The fixture manifest records the whole answer of a validation (contract version 2): the status,
the layers not evaluated with their reasons, and every finding with its path, code, subject —
the empty one included — and severity, information findings such asESJ-L2-NOT-CHECKEDand
ESJ-L2-EDITION-UNKNOWNamong them.run.pycompares the findings of the reader and those of
one path at layer L2 in order, asSPEC.mdsections 9.6 and 9.2 fix it, and the rest as a set,
and a rejected document by its whole list of findings rather than its first. - Fixture manifest: a section
boundsreads documents under bounds below the defaults, each at a
bound and one past it; a sectionregistryChecksasks a loader to read and combine registry
files and records whether it takes them; the grammar tables cover the edition grammar of
semanticModeland the owner token, and more decimal, date, time and base64 candidates; a
rejected document the reader reads carries its digests; a canonical order case carries its
document digest and its number of values. Avalidaterequest may name the registries to
validate with (registries), which the fixture bindings of TypeScript and C# answer. - Fixtures: 13 documents in
examples/invalid/(the encoding, the JSON text, a missing envelope
member, a wrong version, the edition grammar, an undefined member ofsource, a duplicate in
the envelope and below an owner token, a parent chain, a missing index, a missing group) and,
underconformance/fixtures/, 64 variants layer L1 refuses, 10 documents whose findings at
layers L2 and L3 are pinned as a list, 4 documents a reader reads for what a validator says of
them, 69 cases under bounds, 30 sets of registries, 4 documents not in canonical form, and the
two documents ofSPEC.mdappendix B with their canonical bytes. A test holds the bytes,
lengths and digests the appendix prints to those files and to the manifest.
Changed
- Java:
EsjReaderreads the bytes with a scanner of its own instead ofjackson-core: it reads
UTF-8 and nothing else, and it judges every member name before the value written under it. It is
faster than before and holds less while it reads. - The
subjectof a reader finding is a member access with one grammar: a name the
specification defines at that place is dotted (source.syntax,values["/BT-1"].scheme),
every name the document chose is written in brackets (["profile"],source["origin"],
values["/BT-1"]["note"],extensions["a.b"]["x"][0]), and the name is never cut to an
excerpt. The bindings wrote such a name dotted (profile,source.foo,values["/BT-1"].foo). - A duplicate member name is named by its own member access (
values["/BT-1"],format,
source.syntax,extensions["a.b"]["x"]), not by the object it occurs in; inside a value
object, a repeated name or a name with a lone surrogate names the value (values["/BT-1"]). - Every missing required envelope member is a finding of its own, with the member's name as its
subject, in the orderformat,version,semanticModel,values(was the first alone). - Every member name is held to
maxStringBytes, counted in UTF-8 bytes (the bindings counted
UTF-16 code units), also below a value the reader walks past, and so areformat,version,
semanticModelandsource.sha256assource.syntaxwas: past the bound each is
ESJ-L1-LIMITrather than the code of its grammar (ESJ-L1-OWNER-TOKEN,ESJ-L1-PATH-SYNTAX,
ESJ-L1-ENVELOPE-MEMBER), which still applies inside the bound. A bound shorter than the
envelope's own strings refuses every document; the edition string of 2017 is 30 bytes long. - A number token longer than
maxStringBytesisESJ-L1-LIMITwherever it stands, measured
before it is built; invaluesit wasESJ-L1-JSON-TYPEwhen written with a fraction. ESJ-L1-LIMITcarries the path of the member ofvaluesit was met in and, as its subject,
the member whose name or value reached the bound —values["/BT-1"],values["/BT-1"].value,
extensions["o"][1]; for a member name past the string bound, the object the name stands in —
also for a bound the parser used to apply, which named nothing. Inside a
structure the reader walks past, that is the member walked past; names, numbers and the depth
are bounded there, strings are not.- A message that places a defect in the byte sequence names the byte offset at which the token
begins, counted from zero, rather than where the parser stopped (Java) or a UTF-16 index (the
bindings). ESJ-L1-JSONis a finding about the document: its path and subject are empty, its message
names the byte offset. A token where a value belongs that is no complete JSON value —tru,
truex,01,1.— isESJ-L1-JSON, before anything is said about its type, also where the
envelope expects another type (the bindings saidESJ-L1-ENVELOPE-VALUEthere).- An exception carries the path and the subject of the finding it stands for beside its code:
path()andsubject()ofEsjFormatExceptionandEsjLimitExceptionin Java;Pathand
Subjectin C#, whereEsjLimitException.Codeis now an instance property and
EsjLimitException.LimitCodethe constant;subjectofEsjErrorin TypeScript, whosepath
is the empty string where the finding names none (wasundefined). esj validate --output jsonwritessubjectas a string in every finding,""where it
names nothing beyond the path, and nevernull.- Layer L2 checks every path independently (
SPEC.md9.2): a core identifier the registry does not
contain isESJ-L2-UNKNOWN-TERMonce per segment, also beside an extension segment no loaded
registry defines, which made the whole pathESJ-L2-NOT-CHECKEDand the resultINDETERMINATE
before; the segments above an undefined one are held to the index rule; an unknown identifier of
a loaded extension namespace staysESJ-L2-NOT-CHECKED, and its message no longer says that the
registry is not loaded. Of a path whose terms are all known, the index rule, the chain, the
content and the components are checked and every failure is reported (TypeScript reported the
first alone), and the findings of a path stand in the order of its segments. - The findings of layer L2 name in
subjectwhat the path cannot tell apart: the identifier of the
segment forESJ-L2-UNKNOWN-TERM,-INDEX-REQUIREDand-INDEX-FORBIDDEN, the term whose group
chain is wrong forESJ-L2-PARENT-CHAIN, the component forESJ-L2-COMPONENT-NOT-ALLOWEDand
-COMPONENT-MISSING. The subject was empty in Java and C#. - Java and C#:
ValidationResult.merge(Merge) keepsNOT-REQUESTEDfor a layer the caller did
not ask for: aLIMITorPRECEDING-LAYER-FAILEDthe other result names for it no longer
displaces it (SPEC.md9.5).EDITION-UNKNOWNof a validator that was asked for the layer is
still kept over theNOT-REQUESTEDof a clean read, whichMergeof C# answered with
NOT-REQUESTED; both now compose every pair of reasons by one table. C#:
Validator.Validate(byte[])composes the read and the structural run withMerge, so an unknown
edition readsEDITION-UNKNOWN. - Java:
Registry.loadrefuses a registry that names terms it does not define — as a parent, in a
chain or inreusesTerms— and imports nothing, and reports a cardinality below zero or a path
that does not end at its term asEsjFormatExceptionrather thanIllegalArgumentException.
Registry.withExtensionrefuses such an extension where itsimportsdo not name this model
with this edition, which it combined silently before; its refusal of a redefined term no longer
calls a term of an earlier extension a core term.Registry.admits(extension)asks the edition
question before combining. - TypeScript:
registryOfandnew Structurerefuse a registry the way section 10 does —
RegistryErrorfor components no value can satisfy, an identifier listed twice or defined by
core and extension (was: the later one won), and an extension combined with an edition it does
not import, or naming core terms with noimportsat all. esj validate --extensionfor a document of an edition the extension does not import says so:
theESJ-L2-NOT-CHECKEDfinding names the option, the edition the registry imports and that it
is not this one, and the cause is the newextension-for-another-editionrather than
extension-registry-missing.- An extension registry defines identifiers of its own namespace only: one that defines an
identifier without a namespace —BT-999as well asBT-1— or identifiers of two namespaces
is refused when it is read, where it imports a core, and where it is combined with one.
model/registry.schema.jsonstates the same for a registry that carriesimports. - C#:
Registry.Loadrefuses a registry that names an identifier it does not define and imports
nothing, andWithExtensionasks for the imports wherever an extension names an identifier it
does not define, as Java and TypeScript do, not only one of the core. - TypeScript and C#: a member name is judged before the colon after it, as in Java:
{"a" 1}is
ESJ-L1-ENVELOPE-MEMBERrather thanESJ-L1-JSON, and a name ofvaluesthat is no path draws
ESJ-L1-PATH-SYNTAXbefore the missing colon ends the read. - A string past its bound is read on to its end for a lone surrogate, which comes first:
TypeScript and C# refused a string of the envelope, ofsourceor ofextensionspast the
larger of the two string bounds, and a string ofvaluespast twice the string bound, for its
length alone. Strings of the envelope, ofsource, ofextensionsand ofvaluesare he...
EN 16931 Semantic JSON 0.9.5
Added
- Six negative fixtures under
examples/invalid/, run by the fixture manifest over the Java, the
TypeScript and the C# reader:extension-number-exponent-overflowand-underflow
(ESJ-L1-EXT-NUMBER),value-depth-32andvalue-depth-33(ESJ-L1-JSON-TYPE, then
ESJ-L1-LIMIT: the walk past a structure insidevalues),path-syntax-with-array-value
(ESJ-L1-PATH-SYNTAXandESJ-L1-JSON-TYPE) andvalues-deep-array(ESJ-L1-ENVELOPE-VALUE). de.bsnsoft.esj.Preview, an annotation for what is published to be used and judged and may
change in any minor release. It marks the packages…typed.v2026,…rules.en16931.v2026,
…upgradeand…b2c, the typesMinorUnits,PackFetcher,PackRecipe,PackRecipes,
ContainerChecks,InvoiceAttachmentsandEn16931V2026Pack, and the method
RulePackSource.currencyMinorUnits(). The generator writes the package comment of the 2026
view with it.Registry.isPreview(): whether a registry describes an edition this project ships as a preview.de.bsnsoft.esj.imports.InvoiceReader,ImportResult read(byte[] xml):StreamingReaderand
XrImporterimplement it, andPdfInvoiceImporter.importPdfhands the attachment to one.- The package
de.bsnsoft.esj.xmlinesj-core, the front door of an XML invoice:
InvoiceSyntaxwithof(namespace, localName),EncodingMode,XmlEncodingReportwith
of(byte[]), andXmlEncodingException. Both readers, the syntax engine and the PDF container
name a syntax and an encoding with these types. Registry.en16931WithXrechnung(): the core model with the XRechnung extension, built once; the
readers, the renderers andXrExporteruse it by default.EsjLimitException.bound()names the bound that was reached as anEsjLimitException.Bound:
the setting (maxInputBytes,maxPages,maxRuntime, …), its value and its unit. Every bound
of every module names it.RenderEngineException, the failure of the rendering machinery, whichRenderExceptionstood
for itself before.CiiWriter.semanticModel(),CiiWriter.supports(String),UblWriter.semanticModel()and
UblWriter.supports(String).- Every jar names its module:
Automatic-Module-Nameisde.bsnsoft.esj.followed by the
artifact identifier withoutesj-(de.bsnsoft.esj.core, …);MavenCoordinatesTestholds every
published jar to it. - The Maven profile
api-checkcompares the API of every library module with the release
esj.api-baselinenames, with japicmp, leaving out internal packages, the preview packages and
everything marked@Preview. It reports and does not fail before 1.0.0; the CI job API compared
with the last release keeps the report (docs/releasing.md). Against 0.9.4 it names 124 classes
with incompatible changes, all of them the moves, renames and removals of the migration table and
the preview marks. - The rule files of both bundled packs, as their manifests name them, are validated against
rules/rule.schema.jsonon every build; the 2026 pack's test is part of what
bin/without-edition-2026.shremoves. - Three negative fixtures carry, in a member name, every class of character
SPEC.mdsection 9.5
escapes:path-syntax-terminal-characters(ESJ-L1-PATH-SYNTAX),
owner-token-terminal-characters(ESJ-L1-OWNER-TOKEN) and
envelope-member-terminal-characters(ESJ-L1-ENVELOPE-MEMBER). The fixture manifest records
thesubjectof a finding where section 9.5 requires one — where the path is empty, and on a
finding of layer L3 — andrun.pyand the manifest tests of both bindings compare it there; a
binding'svalidateanswer carriessubjectbesidepathandcode. docs/compatibility.md: what a release may change from 1.0.0 — the format, the command line,
the Java libraries, the data they ship — and the rules the Java API keeps;docs/java-api.md
lists every public package as API, preview or internal.Registry.editionKey(),Registry.defaultEditionKey()andRegistry.editionKeys(): an edition
is named three ways and each has its method — the key (2017),edition()(the title) and
semanticModel()(the spelling of a document).de.bsnsoft.esj.render.PaymentCode(TEMPLATE,DRAW,OMIT), whatRenderOptionssays
about the EPC QR code of the letter.
Changed
esj render --htmlis held to--max-output-bytes(64 MiB, 1 GiB under--limits large), as
every XML document the tool writes is: the page is measured in bytes of UTF-8 while it is
written, and one past the bound leaves with exit code 7 and nothing written, rather than being
finished in memory first. In the library the bound isRenderOptions.maxHtmlBytes()
(withMaxHtmlBytes(long), 1 GiB by default) and is reached with anEsjLimitException.esj-renderreplaces U+007F and the C1 controls with a space in every form — the HTML
rendering and the report carried them as they stood, the PDF printed a question mark — and
turns U+0085, U+2028 and U+2029 into line feeds in all of them; one class holds the sets for
every form.dist/package.sh smokecompares a container image only whendist/package.sh dockerbuilt it
from the jar it is compared with, and fails when the image that target built is gone or stale;
an older image of the same tag is named and not compared.dist/smoke.shtakes a relative path
in a command relative to the directory it was run from.bin/without-edition-2026.shmakes its copy under$TMPDIRand removes it when it ends;
--keepkeeps it.-Dsurefire.failIfNoSpecifiedTests=falseletsmvn -Dtest=<class> -pl <module> -ampass the
modules without that test; without it, a pattern that matches no test still fails.esj-render's sRGB profile is recorded as compared byte for byte with the file the
International Color Consortium publishes (icc/README.md,docs/sources.md).- The bindings carry their publisher's names and the project's version: the npm package is
@bsnsoft/esj(wasen16931-semantic-json), the C# package, assembly and namespace
BSNSoft.Esj(wasEn16931.SemanticJson, projects underbindings/csharp/BSNSoft.Esj*). The C#
build reads the version frompom.xml;npm run sync-versionwrites it intopackage.jsonand
package-lock.json, and a test fails while they differ. Nothing is published yet. - EN 16931-1:2026 is a preview, and says so:
esj --versionprints
semantic model registries 2017, 2026 (preview),esj inspectandesj validateprint
Semantic model: EN16931-1:2026 (preview), the validation report writes (preview) or
(Vorschau) beside the edition,esj upgradewrites aninfo:line where the edition it reads
or writes is one, andesj --list-packsmarks the packen16931-2026/0.1. The JSON output is
unchanged.README.md,SPEC.md(section 1; the format stays 0.1),docs/editions.md,
docs/java-api.mdand the FAQ say so too. - The Javadoc leaves out every package whose name contains
internal. esj-syntaxandesj-renderdeclare Saxon-HE, which they call directly;esj-clitakes
esj-typedin test scope only, so the self-contained jar no longer carries the typed view it
never loads.- One set of characters that steer a terminal,
Esj.steersATerminal(int)and
Esj.isBidiControl(int)inesj-core, is used by every text output: the messages of
esj-core,esj-rulesandesj-pdf, the lines ofesj list,esj inspect,esj diff,
esj extract --listand of every report, and the error stream. The JSON reports write the same
characters as JSON escapes. Until nowlist,inspectanddiffwrote C1 controls (CSI,
NEXT LINE), U+061C, U+2028 and U+2029 as they stood, and a message of a registry quoted its
content unescaped.esj getstill writes a value raw.esj-rendertakes the bidirectional
controls from there as well and adds the interlinear annotation characters U+FFF9–U+FFFB,
which only a rendering replaces; the messages ofesj-rulesandesj-pdfwrite\uwith
lowercase digits, asesj-coredoes. - Every command that reads a document holds the five-minute deadline where
--max-runtimenames
none:convert,upgrade,inspect,extract,get,list,diff,canonicalize, and
embedwithout--verapdf, asvalidateandrenderdid. Exit code 7 and no verdict. Over
conformance/scale(80 MB of UBL and CII, rich and dense lines,--limits large) each of them
ends in 2 to 22 s;--importer xslttakes 2 minutes over 25 MB and needs a larger
--max-runtimefrom about 40 MB. examples/smallest-valid.esj.jsonstates BT-110 (0): it isVALIDthrough both syntaxes, where
--via ublsaidINDETERMINATE(exit code 9) because UBL requires the tax amount beside a VAT
breakdown. It has 28 terms, and removing any of them ends the verdict through one of the two.XmlEncodingReporthasdecodes()andXmlEncodingExceptionhasrepairable(); the earlier
constructors stay.- One exception hierarchy:
EsjExceptionis the abstract root of every exception the libraries
throw, no longer sealed.XrException,BindingException,SyntaxException,PdfException,
RenderException,PackException,RulePackException,DerivationException,
MissingValueException,ValueTypeException,BuildExceptionand
PolicyPreconditionExceptionextend it.RenderExceptionis abstract and sealed, its kinds
final:RenderEngineException,TemplateException,RenderContentException. - Every bound of every module raises
EsjLimitException, which is no longer final and no longer
belongs to a module's hierarchy:catch (PdfException e)orcatch (BindingException e)no
longer catches a limit. The messages are the ones of 0.9.4. PdfInvoiceImporter.importPdf(byte[])reads the attachment with theStreamingReader, as the
command line does; it was the importer ofesj-xr. The document is the oneesj convertwrites
for t...
EN 16931 Semantic JSON 0.9.4
Added
- Pack directories:
esj validateandesj inspecttake--packs <directory>(repeatable) and
read the environment variableESJ_PACKS(a path list); every<id>/<version>/<release>/pack.json
found there joins the bundled packs and is chosen by the profile of the document. A pack with the
identity of a bundled one, and a profile two packs recognize, are refused by name — except where
those packs are releases of one pack (same id and version): then the newest release is taken,
compared part by part (3.0.10 after 3.0.9), and--packtakes another. Every report
names the origin of the pack beside its identity:syntax.pack.sourceisdirectoryand the new
syntax.pack.locationthe directory it was read from (nullfor a bundled pack), and
esj --list-packsprintsoriginfor every pack. esj packs listandesj packs fetch <recipe> --into <directory> [--replace].fetchfollows a
recipe this build carries for artefacts that may be used but not redistributed: it downloads the
files over https, refuses any whose SHA-256 is not the one the recipe pins, compiles the
Schematron to XSLT in process with the ISO Schematron XSLT 2 skeleton (MIT, now in
esj-syntax), copies the schema modules out of the bundled pack and writes a pack whose
manifest records the source URLs, the fetch date and the SHA-256 of every file. It is the only
command that opens a network connection.- The recipes
peppol-bis-billing-3.0.21andpeppol-bis-billing-3.0.20: the Peppol BIS
Billing 3.0 validation artefacts, as published by OpenPeppol — the Peppol rules for UBL and CII
and the EN 16931 Schematron that release ships (1.3.16 and 1.3.15). 3.0.21 is the May 2026
release, mandatory from 2026-08-17; OpenPeppol tagged no 3.0.21, so its recipe fetches below
the commit806866bd2bd91d7e9623b68f08164e8fbe9e67a0, never by a branch name, and a recipe
without a tag may fetch only below the commit it names.esj packs fetch peppol-bis-billing
follows the newest recipe this build carries (3.0.21);esj packs listmarks it. None of those
files is in this repository or in any artefact;esj validateon a Peppol invoice without the
pack names the command that makes it. With 3.0.21, OpenPeppol's 10 examples validateVALID
and all 62 rules its own unit tests cover fire as those tests expect; with 3.0.20, 9 examples
and 58 rules (conformance/peppol/README.md, run with-Desj.network=trueand in the JDK 21
job of the continuous integration).
Changed
-
A recipe's
source.tagis optional, with a new optionalsource.branch; the manifest of a
fetched pack records them as the recipe has them, andesj packs listnames the revision a
recipe fetches by (at tag v3.0.20,at commit … (branch 2026-Q2-QA2)). -
A PDF written by 0.9.0 to 0.9.3 still carries
invoice.esj.json. It is read as an attachment
that is not the invoice, as a logo would be:esj inspectandesj extract --listlist it
(not XML,application/json,Supplement),esj extract --attachment invoice.esj.json
hands out its bytes, andesj validatereports nothing about it. A PDF whose one attachment it
is carries no invoice (exit code 2). -
docs/java-api.mdnames Saxon-HE 13.0 as the minimum: with 12.x the XML Schema, Schematron
and XSLT path still run, and every HTML page ofesj-render, the HTML report included, fails
withAbstractMethodError(UnparsedTextURIResolver).docs/install.mdnotes that the jar
carries Saxon-HE 13.0 unrelocated.
Removed
- The ESJ document inside the hybrid PDF.
esj embedandesj render --embed ciiwrite one
embedded file, the invoice XML. Gone with the second fileinvoice.esj.json: the rule that it
and the XML are two accounts of one invoice, the option--no-esjand the line saying the
attachment went in, the container rowESJ document attachedofesj validate(lines, the
JSON membercontainer.esj, the HTML and the PDF report), the findingsPDF-ESJ-DISAGREES,
PDF-ESJ-UNSOUND,PDF-ESJ-UNREADABLE,PDF-ESJ-UNCHECKED,PDF-EMBEDDED-SEVERAL-ESJand
PDF-EMBEDDED-ESJ-LABEL, and section 15 ofSPEC.md(informative). A receiver of two
representations of one invoice has to parse and compare both; the XML is the invoice, and the
ESJ document derives from it at any time (docs/design-decisions.md). - The API of that feature, which breaks callers of it: in
esj-pdfthe classesEsjAgreement
andEsjAttachment,EmbedOptions.withEsjand theesjcomponent ofEmbedOptions(its
constructors take one argument fewer),EmbedResult.EsjOutcome,EmbedResult.esj(),
esjAttached()andesjOmitted()(its constructor takes the file and the report),
AttachmentKind.ESJ_DOCUMENT,ContainerFinding.Category.PDF_ESJand the three-argument
ContainerChecks.run; inesj-corethe phrasesPhrase.ROW_PDF_ESJ,ESJ_ATTACHMENTand
ESJ_ATTACHMENT_PARTLY.
Fixed
- UBL Credit Notes keep the sub lines of the XRechnung extension. The extension's source binds
them for UBL Invoice only, so the streaming reader dropped everycac:SubCreditNoteLine
without an observation, and the writer had no place for BG-DEX-01.ubl-creditnote.jsonnow
binds BG-DEX-01 to BG-DEX-08 and the 36 core terms a sub line reuses below
cac:CreditNoteLine//cac:SubCreditNoteLine, quantity fromcbc:CreditedQuantity(two
corrections,model/bindings/README.md). BG-DEX-09 and BT-DEX-001 to BT-DEX-003 stay unbound:
UBL 2.1 gives the credit note nocac:PrepaidPayment. Measured on
conformance/creditnote/04.01a-CREDITNOTE_ubl.xml, the KoSIT extension instance 04.01a as a
credit note: read, written, read again and accepted by the pack, with the values of the
invoice. The XSLT path (--importer xslt) still gives every sub credit note line the values
of the first credit note line, a defect of the vendored KoSIT stylesheet
(conformance/creditnote/README.md). esj-bomno longer hands on the versions the build of this project manages. The deployed POM
is flattened: no parent, no properties, and only the modules of this project in
dependencyManagement. Importing 0.9.0 to 0.9.3 also imported JUnit 6.1.3, Saxon-HE, PDFBox,
Jackson and the PostgreSQL test binaries, and so decided the JUnit version of the importing
build.
Full Changelog: v0.9.3...v0.9.4
v0.9.3
EN 16931 Semantic JSON 0.9.2
Added
- The business rule pack of EN 16931-1:2026,
rules/en16931-2026/0.1: 252 rules, of which 199
are those of clause 6.4 (194 implemented, 5 not decidable, each with its reason), the fraction
digit rules of Table 28 and the code list rules. No official validation artefact is published
for the edition, so every rule names inoraclewhat stands behind it —downgradefor the
rules the edition leaves unchanged, measured against the Schematron of release 1.3.16 over the
document written back withesj upgrade,casesfor the rules it changed or adds — and a
document of that edition validatesINVALIDorINDETERMINATE, neverVALID, with the new
causeno-artefacts-for-edition(conformance/rules-2026/coverage.md,
ledger.md). The pack is separable with the edition. derive()for the typed view of EN 16931-1:2026:…typed.v2026.Totals.of(minorUnits)writes
the line net amounts, one VAT breakdown per category, rate, exemption reason and goods/services
code, and the totals, with the third party charges (BG-34) added into the amount due.
Each amount is rounded once, half up, to the minor unit of its currency;MinorUnitsof
esj-coreholds those numbers andEn16931V2026.minorUnits()reads them from the pack's
currency snapshot. A currency the snapshot gives no minor unit is refused, never guessed; a
rule of the pack that asks for one reports that it was not decided. The
pack writes outBR-CO-10,-11,-12and-15, which round to the same minor unit.- The rule language has three more operators —
minorUnit,unitandatRoot— and the scale
ofroundand ofdecimalsmay be an expression;decimalsapplies to every numeric term.
A rule may name inundecideda case a document may state and the rule has no answer for;
there it reports that it was not decided, atinfo, instead of holding silently — the 2026
pack does so for a price base quantity of zero inBR-CO-32andBR-67.
A pack manifest names itsedition, which the engine checks against the registry, may take
rules of another pack over by identifier (shares) and read a code list snapshot of another
pack ({"day", "from"}). The TypeScript and C# bindings read all of it and run the 2026 pack;
the fixture manifest carries its cases in the part of that edition. esj validateruns the pack of the edition a document names. The row, the JSON report
(rules.pack.edition,rules.pack.corroborated) and the report file name the pack and, for
the 2026 one, say it is not corroborated by an official artefact.esj --list-packslists
the rule packs with their edition and the count of rules per oracle.esj upgrade --to 2026
evaluates a decimal bound over the minor unit of the currency a value is written in — BT-5,
BT-6 for BT-111, BT-184 in a VAT breakdown — against the currency snapshot of that pack
(UpgradeOptions.minorUnits) and reports a value beyond it, unrounded.- The container image is published with every release at
ghcr.io/bsnsoft/esj, for linux/amd64
and linux/arm64, tagged with the version andlatest
(docs/install.md). esj convert --to cii|ubl --fail-on-loss: where the target syntax has no place for part of the
document, nothing is written — neither to--outnor to the standard output — and the run
leaves with exit code 8. The exit code table did not grow: 8 was reserved for exactly this
case and is now used, and every printing of the table says so. A value written by convention
and a term left behind by design are not losses. Without the option a loss is warned about and
the exit code stays 0 (docs/cli.md).- The CI runs the TypeScript and C# bindings on every push, in the job
bindings:npm ciand
npm test,dotnet test, and the fixture runner over each binding. - The fixture manifest pins division in the rule language for every implementation: the section
arithmeticruns the packconformance/fixtures/arithmetic/pack.json, whose quotients are
worked out by hand, and arulesrequest of the runner may name that pack. The C# binding
reads a pack from a stream withRulePacks.Read.
Changed
- Every rule of a pack, and every entry of
javaRules, which is now an object{class, oracle},
names itsoracle; the rules ofen16931/1.3.16sayartefact.verifiedAgainstis optional.
A pack manifest written for 0.9.x — withoutedition, with a rule that names nooracle, or
with ajavaRulesentry written as a string — is refused byRulePacks.read.
no-pack-for-editionnow means only that this build carries no pack for the edition. - The writer's report tells a term left behind by design from a loss. Handed the extension
registries of a document (WriterOptions.builder().extensions(...), andesj convertand
esj validatehand it the ones--extensionloads), the writer notes a value of a term whose
registry declares"transport": "none"— the B2C extension's — asTERM_BY_DESIGNwith the
registry inWriteNote.registry(), and counts it neither as written nor as dropped;
WriteReport.byDesign()gathers those terms by registry.esj convert --extension b2cnames
them on one information line instead of warning that ten values were not written, and the
JSON report carries the registry beside each such note.esj validatereaches the same rows
and exit codes as before. - The letter layout is the default of
esj renderand ofPdfRenderer
(RenderOptions.DEFAULT_LAYOUT), for a template that names none as well;--layout generic,
Layout.GENERICor"layout": "generic"ask for the generic layout, in which the PDF
validation report still draws the invoice it carries. - Letter layout: a page after the first begins as far under the rule of its compact head as the
first section of page one begins under the rule of the title, whatever opens the page. - Letter layout: the seller's details in the foot of page one are written value first — an
identifier followed by the code of its scheme, or by a short word where the document states
none (HRB 12345 (Registernummer)), and the contact point without a label. - Letter layout: the preceding invoices of BG-3 stand under the title, up to three, with their
dates; a credit note (the types the display names call one) labels its number, its date and
its closing amount as a credit note's; neither a credit note nor a self-billed invoice carries
an EPC QR code, since its reader is not the one who pays (docs/letter-layout.md). - The libraries underneath moved on: Saxon-HE 13.0 runs the validation artefacts and the
stylesheets, where 12.10 did, the tests run on JUnit 6, and the PostgreSQL that
docs/storage.mdis executed against is release 18 on every platform the
build runs on, pinned through the bill of materials of its binaries rather than left to the
default of the artefact that starts it. Nothing observable changed: the ledgers, the fixture
manifest and the checked-in renderings hold as they are.
Fixed
- Division in the TypeScript binding is exact where the quotient terminates, as
rules/README.md
says:Decimal.dividecomputed every quotient to 34 fraction digits and stripped the zeros, so
a terminating quotient that needs more digits was rounded —1E-35 / 1came out as0. The
Java and C# implementations were measured and already divided exactly. - A row of a table that fits on a page no longer sends the lines hanging under it to the next
page under a carry-over line: the room for that line is asked for only where the lines go over. - The footer of the PDF validation report no longer runs into the page count for a long input
path: the path is shortened in its middle, keeping its beginning and its file name. - A hybrid PDF with two embedded files under one name is refused instead of validated on the one
a map kept: both are listed, the name is reported (PDF-EMBEDDED-DUPLICATE-NAME), and files a
page refers to count as attachments too (docs/pdf-input.md). - The output intent of a rendering carries the vendored sRGB profile byte for byte on every
runtime. PDFBox was handed the profile as a stream, read it into ajava.awt.color.ICC_Profile
and embedded what that object gave back, and on a runtime whose colour management
re-serializes a profile it was handed — the Ubuntu build of OpenJDK 21, for one — the header
of the embedded profile named that engine as the preferred CMM, so the file was no longer the
ICC's and the rendering no longer the checked-in one. The stream is now filled from the file
itself; the checked-in renderings are unchanged. - The checksum beside the platform-independent archive of a release,
esj-<version>.zip.sha256,
is the checksum of that archive. Each of the three packaging jobs of the release workflow
uploaded the archive and its checksum under the same two names, and the release kept whichever
copy of each came last — for 0.9.1 the archive of one job and the checksum of another, which
does not match it; the six platform archives match theirs. Thelinux-x64job alone delivers
it now, its entries carry fixed times, modes and order, so that the clock, the time zone and the
umask of a build leave no trace in its bytes, and nothing is attached before every checksum has
been checked against the archive beside it, no name comes from two jobs with different
contents, and a tag finds the archive of the version it names. esj embedandesj render --embed ciihand the writer of the attachment the extension
registries--extensionloads, asesj convertdoes: a term whose registry declares
"transport": "none"is named on one information lin...
EN 16931 Semantic JSON 0.9.1
Fixed
- The macOS native executable of 0.9.0 could not render: its archive shipped without the shared
libraries of the Java runtime that a rendering loads (Can't load library: awt). The packaging
now copies them from the GraalVM that built the image wherenative-imageleaves them out and
fails the build where any is missing; a smoke test whose cases differ from the jar fails the
build again instead of being lost in a pipe. The GraalVM build the executables are made with is
now pinned by release and SHA-256 (dist/graalvm.sh): the 25.0.x line of the community builds,
which the release runner had picked, produces a macOS image that cannot loadlibawteven with
the libraries beside it; the 25.3 line does.