AD-005: Format System
Summary
Formats are pluggable readers and writers that convert between on-disk
representations and the Part stream. The framework ships a broad set of
built-in formats under core/formats/, each implementing DataFormatReader
and DataFormatWriter on top of shared BaseFormatReader /
BaseFormatWriter embeds. A single FormatRegistry exposes a factory-based
lookup that
serves native Go formats, plugin formats, and Okapi-bridge formats
uniformly. Format detection cascades through MIME type, extension, magic
bytes, and content sniffing. Roundtrip fidelity is supported by three
interchangeable skeleton strategies.
Context
The framework must read a large variety of file formats and write them back with byte-exact fidelity — every newline, every entity reference, every attribute quote style. Formats vary widely in structure: linear text (plain text, Markdown), tree-structured markup (HTML, XML, DOCX), line-oriented key-value (Java properties, iOS strings), grid-based (CSV, XLSX), and translation-specific (XLIFF, TMX, TBX, Gettext).
At the same time, formats frequently contain embedded content in other formats (HTML inside JSON, Markdown inside CSV), and the reader/writer contract must accommodate this recursion without special cases.
Decision
Reader and writer interfaces
These interfaces implement the file source and sink binding in
AD-026: Flow I/O Binding. Other bindings — the project
store, a .kpz workspace, interchange import/export — feed and drain the same
Part stream without a reader or writer, so a flow is agnostic to where its
content enters and leaves.
type DataFormatReader interface {
Open(ctx context.Context, doc *RawDocument) error
Read(ctx context.Context) <-chan PartResult
Close() error
}
type DataFormatWriter interface {
SetOutput(path string) error
Write(ctx context.Context, in <-chan *Part) error
Close() error
}
The reader lifecycle is Open → Read → Close. Open attaches the reader
to a RawDocument (raw bytes plus metadata such as source locale and file
path). Read returns a channel of PartResult{Part, Error} — the reader
produces Parts until the document is exhausted or an error occurs, then
closes the channel. Close releases any held resources.
The writer lifecycle is SetOutput → Write → Close. SetOutput sets the
destination path. Write consumes a channel of *Part until the channel
closes, producing output on the writer's destination.
BaseFormatReader and BaseFormatWriter
BaseFormatReader and BaseFormatWriter provide shared behavior that
concrete formats embed:
- Document-level Layer bracketing (
PartLayerStart/PartLayerEndfor the root document layer) - Locale metadata propagation
- Source/target locale accessors
- Consistent error handling and channel lifecycle
A concrete format implements the format-specific parsing/serialization and delegates lifecycle to the base embed.
BaseFormatWriter also owns the shared byte-level output options
(format.OutputOptions): output.bom (add|remove|keep), output.newline
(lf|crlf|keep), and output.encoding (any charset in core/encoding;
default UTF-8 passthrough). Readers already normalize BOM/charset/newlines at
parse time, so these exist only to control output style — writer
configuration, not a pipeline stage. They are set under the reserved output
key of the ordinary per-format config (defaults.formats[<id>].config in a
kapi.yaml recipe); format.SplitOutputConfig strips the key before per-format
reader/writer config is applied, and the base writer wraps its output stream
with the post-encode chain (newline conversion → BOM policy → charset
encoding), so every writer that embeds the base inherits the behavior with no
per-format code.
Built-in formats
The built-in formats under core/formats/ span several families:
- Markup — HTML, XML, Markdown / MDX, and structured-document formats.
- Translation exchange — XLIFF 1.2 / 2.0, TMX, Gettext PO/MO.
- Structured data — JSON, YAML, CSV, and design-token / app message-catalog
variants (
xcstrings,arb,i18next,resx, Android strings, iOS strings, …). - Office and publishing — OpenXML (
.docx,.xlsx,.pptx), ODF, IDML, and related packaged formats. - Subtitle / media — SRT, VTT, TTML, and similar.
The full, authoritative list of registered formats — with extensions, MIME types, and per-format options — is the generated Format Reference. It is derived from the live registry, so it never drifts from the code.
Each format package under core/formats/<name>/ contains reader.go,
writer.go, and config.go. Formats register both the reader factory
and writer factory in core/formats/register.go via init().
FormatRegistry
A single *FormatRegistry (a concrete struct in core/registry) exposes
factory lookup. Names are the FormatID string type; registration takes a
factory plus static metadata, so no reader instance is built at startup:
func (r *FormatRegistry) RegisterReader(name FormatID, factory FormatReaderFactory, sig format.FormatSignature, displayName string)
func (r *FormatRegistry) RegisterWriter(name FormatID, factory FormatWriterFactory)
func (r *FormatRegistry) NewReader(name FormatID) (format.DataFormatReader, error)
func (r *FormatRegistry) NewWriter(name FormatID) (format.DataFormatWriter, error)
func (r *FormatRegistry) FormatInfos() []FormatInfo
Detection is delegated to a *format.Detector, reachable via r.Detector().
The registry's DetectByExtension(ext) (and the source-scoped
DetectByExtensionForSources) wrap it, falling back to the lazy plugin-load
onMiss hook on a first miss.
Tiered registration makes native, plugin, and bridge formats indistinguishable to callers:
- Native built-ins — registered at program start via
init()hooks incore/formats/register.go. - Plugin formats — registered from the
formatscapability declared in each plugin'smanifest.json, read from disk during plugin discovery (cli/pluginhost) without launching a subprocess. - Bridge formats — served by a Mode-C daemon plugin (the Okapi bridge) over a Unix-socket gRPC connection; the host registers proxy factories that dial the daemon on demand (see AD-007: Plugin System and Okapi Bridge).
A format reference in user-facing configuration uses the syntax
name[@version][:preset], e.g. okf_html@1.46.0:wellFormed. The registry
resolves the reference to the appropriate factory.
Format detection
Detector.Detect(path, reader, mimeType) returns the best-matching format
name using a cascade:
- MIME type — explicit declaration wins if present.
- File extension —
.html,.xliff,.json, etc. resolve deterministically. - Magic bytes — binary signatures (BOM, XML declaration, ZIP signature for OpenXML).
- Content sniffing — heuristic analysis for formats that share extensions (e.g., distinguishing XLIFF 1.2 from XLIFF 2.0).
Each format registers a FormatMeta record that declares the MIME types
and extensions it claims, so the cascade is data-driven rather than
hardcoded.
kapi's own on-disk conventions use compound suffixes — .kbf.json,
.memory.json, .terms.json, .overlays.jsonl — so the marker survives while
the file still reads as the JSON it is. path/filepath.Ext reports .json for
all of them, so extension-driven code goes through format.Ext / TrimExt /
Stem instead, which return the most specific registered suffix. A suffix kapi
does not read resolves through format.RetiredExtHint to a diagnostic naming the
suffix that supersedes it and the command that rewrites the file, rather than
failing as an unknown format.
Skeleton strategies
Three interchangeable strategies preserve non-translatable content for roundtrip writing. A format picks the one that fits its structure:
-
SkeletonStore streaming (HTML, XML). A temp-file-backed binary store. The reader writes non-translatable bytes and block references during extraction; the writer reads entries sequentially to reconstruct the document with byte-exact fidelity. Peak memory is ~100 KB per document regardless of document size. Preferred for new formats. See Skeleton Store for the binary format and wiring.
-
Re-parse (JSON, YAML, PO, Plaintext). The writer re-opens the source document and replaces translatable content in place. Simple but holds the document in memory twice during writing.
-
Fragment-based (XLIFF, some XML dialects). Interleaved skeleton of non-translatable markup plus references to translatable blocks, carried inline on the
Data/Blockresources. Suits formats whose translatable content is sparse.
All three strategies present the same DataFormatWriter interface to the
pipeline.
Streaming readers and bounded-memory I/O
The read → process → write path streams end-to-end so peak memory tracks a bounded window, not the document size. Three edges cooperate:
- Source edge. The file-run path
(
core/flow.FileRunner) hands the reader a streaming, byte-budgetedio.ReadCloserover the file (core/safeioenforced uniformly) instead of reading the whole file into a buffer up front. A line/record reader pulls bytes on demand and never holds the whole input; a whole-document reader stillio.ReadAlls, but only once. - Reader → executor. When the reader declares the
StreamingReadercapability, itsReadchannel is fed straight into the executor rather than being collected into a[]*Partslice between reader and tools, so the reader runs concurrently with the writer. This is gated on the capability because it overlaps the read and the write: only in-process, pure-Go readers may opt in, never a daemon-backed plugin (those keep the read-fully-then-write order their one-Process-stream-at-a-time contract requires). - Skeleton. A byte-exact round-trip needs the skeleton, but the buffered
skeleton writer collects every block into a map and replays the skeleton only
after it is fully written — O(blocks) memory. A reader and writer that both
declare streaming (
StreamingReader+StreamingWriter) instead share a concurrent (channel-backed) skeleton store: the reader appends entries while the writer pops them, consuming eachSkeletonRef's block from the Part stream on demand (format.StreamSkeletonWrite). Because a streaming reader emits refs and their blocks in the same order, the pending-block window stays small. Output is byte-identical to the buffered skeleton path — the same entries in the same order, just consumed interleaved instead of after aFlush. The two capabilities are markers (StreamingReader()/StreamingWriter()); a writer signals it took the streaming path by checkingSkeletonStore.IsStreaming()inWrite.
The line- and record-oriented formats are the adopters: a reader emits each
record as it is parsed (typically via a range-over-func line/record iterator),
holding only the in-progress unit, and its writer's SkeletonRef rendering is
factored into a shared renderRef so the buffered and streaming skeleton paths
are byte-identical. The converted formats are splicedlines, versifiedtext,
properties, srt, fixedwidth, paraplaintext, and mosestext. Two
line-oriented formats stay buffered for a concrete reason rather than the marker:
plaintext transcodes the whole buffer up front (UTF-16/BOM detection), and
vtt's reader is a multi-function state machine with backward random access into
a fully-materialised line slice — both are tracked as follow-ups.
A whole-document format (JSON tree, XML, OOXML/zip, HTML/Markdown DOM) parses
the entire input and keeps the buffered path unchanged — it simply does not
declare the capability, and a uniform fallback keeps its output byte-identical.
The container binding drives one archive entry through FileRunner.RunStream
(bytes in, bytes out, no temp file); a streaming-capable inner format is not even
buffered whole (AD-026 §6).
Writer output modes — generative vs skeleton-bound
A skeleton is format-specific — it is the non-translatable scaffolding of one file, captured by that format's reader. So a writer's ability to produce output depends on whether it can build a whole document from the content model alone, or only by injecting translated text back into a skeleton it was given. Two capabilities, deliberately orthogonal, capture this:
- Generative — the writer can serialize a complete, valid document from the content model (roles, runs, structure) with no skeleton. Markdown, HTML, DocLang, AsciiDoc, plain text, XLIFF / PO / TMX, and the data/catalog formats are generative.
- Skeleton-consuming — the writer uses a skeleton when given one (for
byte-exact fidelity), via the
SkeletonStoreConsumerinterface. This is about using a skeleton, not requiring one.
These compose into three writer classes:
- Generative document/data writers (
generative, not interchange). HTML is the archetype: with the source file's skeleton it round-trips losslessly, and without one it still writes a clean document — so it is also a target for content that arrived from a different format. Markdown, DocLang, AsciiDoc, plain text, and the data/catalog formats behave the same. These are theconverttargets. - Bilingual interchange writers (
generativeandinterchange). XLIFF, PO, TMX, MO, and KBF are generative files, but they belong to the extract→translate→merge loop, not to document conversion:kapi extractcaptures the source skeleton (in the project/batch cache, with a batch-id note in the file) sokapi mergecan round-trip translations back into the original format. Aconvert-produced interchange file carries no skeleton and cannot be merged back — a dead end — so interchange formats are excluded asconverttargets and reached viaextract/merge(AD-017). - Skeleton-bound writers (not generative). OpenXML (
.docx), ODF, IDML, ICML, MIF, EPUB, and image wrap content in a fixed package that cannot be regenerated from the model; they only ever write back into their own skeleton. Same-format / merge writers, never a cross-format target.
Cross-format conversion (AD-023: Toolbox — kconv)
reconstructs the target from the content model and never carries a foreign
skeleton into the writer. A writer is a valid conversion target iff it is
generative and not interchange. Both are declared writer capabilities —
the writer states "what I can write" via GenerativeWriter.Generative() (the
inverse of BaseFormatWriter.RequiresSkeleton) and
InterchangeWriter.IsInterchange(). The registry records them on
FormatInfo.Generative / FormatInfo.Interchange: probed once from the built-in
writer at registration, and for plugin formats taken from the cached manifest's
generative / interchange capabilities — so kconv, the
Conversion Lab, and kapi formats read one authoritative source
without loading any plugin. Neither is derived from SkeletonStoreConsumer
(nearly every writer consumes a skeleton if offered, so that bit does not
distinguish a target) nor probed empirically.
Skeletons are typed per format. A SkeletonStore carries an OriginFormat
stamp, and format.WireSkeleton(store, reader, writer) connects a reader's
skeleton emission to a writer only when they are the same format — so the
"a skeleton from format A is foreign to format B's writer" rule is enforced
centrally, not left to each call site. A cross-format conversion therefore never
feeds a foreign skeleton into the target writer; that writer takes the generative
content-model route every writer shares.
Reader output policy: skeleton vs surfaced content
The skeleton is not the only home for non-translatable content. A reader classifies each fragment three ways, not two:
- Translatable → a
Block(Translatable: true) the pipeline processes. - Pure structure (delimiters, quoting, whitespace) → skeleton bytes.
- Non-translatable but meaningful context (code, captions, alt-text,
formulas, do-not-translate strings, config-excluded values, comments) →
surfaced as a
Block{Translatable:false}carrying aSemanticRole, or as aData/note, so downstream LLM/RAG ingestion sees it while MT skips it.
This surfacing is a default-ON, per-format opt-out
(extractNonTranslatableContent) and is the subject of
AD-031: Content-Fidelity Surfacing. It does
not weaken round-trip fidelity: a surfaced block's verbatim bytes still live in
the skeleton, with a skeleton ref standing in for the rendered body, so the
writer reproduces the original exactly. With the flag off, such content stays in
the skeleton as before — which is the configuration parity pins
(AD-018).
The SkeletonStore also supports a sub-skeleton: verbatim segments of an
otherwise-opaque payload interleaved with refs to translatable spans inside it.
This is how translatable prose embedded in an opaque structure — the
natural-language <m:nor/> text inside a Word equation — is translated while the
surrounding math is replayed byte-for-byte
(AD-032: Math and Equations; see
Skeleton Store).
Subfilters and nested layers
Format readers can emit child Layers when they encounter embedded content
in a different format (HTML inside JSON, Markdown inside CSV). The child
reader is resolved via a SubfilterResolver injected by the
FormatRegistry. This mechanism is defined in
AD-002: Content Model — format readers just
implement SubfilterAware and declare patterns in their config.
Implementing a new format
To add a new format:
- Create
core/formats/<name>/withreader.go,writer.go, andconfig.go. - Implement
DataFormatReaderby embeddingBaseFormatReaderand providing the format-specific parse logic. - Implement
DataFormatWriterby embeddingBaseFormatWriterand providing the format-specific serialize logic. - Populate every field on each inline-code run for any inline markup —
ID,Type/SubType,Data,Disp,Equiv,Constraints(AD-002: Content Model). - Pick a skeleton strategy appropriate to the format's structure.
- Register the reader and writer factories in
core/formats/register.govia aninit()call. - If the format can host embedded content, implement
SubfilterAwareand acceptSubfilters []SubfilterMappingin the config.
See Implementing Formats for a walkthrough, and Skeleton Store for the preferred skeleton strategy details.
Consequences
- Format readers emit the same streaming Part protocol regardless of source format, so tools never need format-specific code.
- Format writers replay
Run.Dataverbatim viaRenderRunsWithData(AD-002: Content Model), so roundtrip fidelity is inherited from the content model. - Native, plugin, and bridge formats coexist in one registry; the pipeline treats them identically.
- MIME/extension/magic/content cascade resolves most files without user configuration; ambiguous cases fall back to explicit format flags.
- Three skeleton strategies cover the full span of file formats from streaming text to zip-packaged markup.
- New formats plug in by adding a directory and registering in
init(); no core changes needed. - SkeletonStore gives bounded memory for large markup documents, at the cost of a temp file and a binary protocol between reader and writer.
Related
- AD-002: Content Model — Parts that readers produce and writers consume; the Run model that drives roundtrip fidelity
- AD-004: Processing Engine — how readers and writers plug into the pipeline
- AD-006: Tool System — the tools that sit between reader and writer
- AD-026: Flow I/O Binding — readers/writers as the
filebinding; other bindings (store,.kpz, interchange) feed the same stream - AD-007: Plugin System and Okapi Bridge — how plugin and bridge formats register
- AD-031: Content-Fidelity Surfacing — surfacing non-translatable context as content for ingestion; the
extractNonTranslatableContentopt-out - AD-032: Math and Equations — the OMML sub-skeleton extension to the skeleton strategies
- Implementing Formats — implementation walkthrough
- Skeleton Store — binary skeleton format and wiring