Media
Media — images first, audio and video beside them — enters the model through
two doors, both already in the format’s grain: a media block is a
void-shaped block whose attributes carry
the source, and an inline image is an atomic
object run inside a text block. The bytes
themselves are never in the model: the format stores references
(paths), resolved relative to the work file.
Media blocks
A media block is a block type with shape: "void" whose instances carry a
src. There is no new shape and no new BlockDef field: image, audio, and
video are ordinary block types, declared in config.blocks
like any other, with their identity in their selector.
The recommended kinds — just data:
{ "id": "image", "shape": "void", "selector": "img" },
{ "id": "audio", "shape": "void", "selector": "audio" },
{ "id": "video", "shape": "void", "selector": "video" }
with their presentation in the sheet:
img, video { max-width: 100%; height: auto; display: block; }
audio { width: 100%; display: block; }
Three kinds, not one media kind — they style differently, edit
differently (only video takes a poster), and degrade differently. But a
renderer never keys on the kind ids: what a media block is resolves,
in order, from
- its selector’s write tag (
img/audio/video), - else the major type of its
typeattribute (image/…/audio/…/video/…), - else media-type inference from the
srcextension.
So a format that renames or re-declares the kinds loses nothing, and a void
block with a src and no recognizable tag still renders as something.
In the file, <audio> and <video> are void blocks that take a real close
tag; an <audio>/<video> with <source> children promotes the first
source’s src + type on parse (the rest, and <track>, drop — a
documented lossy import), and a <picture> resolves to its inner <img>.
Inline images — the object run
A run is either a stretch of text or an atomic inline
object — exactly one of text / object is present:
Run = { text?: string, object?: InlineObject, marks: MarkUse[] }
InlineImageObject = {
type: "image",
src: string, // same value grammar as a block src (below)
alt?: string,
title?: string, // CommonMark  parity
width?: number, // intrinsic CSS px
height?: number,
// shared ElementAttrs: class, style, role, epubType, aria, lang, dir, data
}
InlineObject is the union of this image member and the
mathematical-expression member.
An object run may carry marks like any run — a linked inline image is an
object run under an href mark. Its shared
element attributes belong to the image itself, not its
paragraph or enclosing mark — which is what lets an ornamental or drop-cap
image keep the publisher class whose CSS floats and sizes it while the
paragraph text wraps alongside it.
Two projections are normative:
- As an editing position the object is one caret position and fully atomic: a splice overlapping it removes it whole, a slice never cuts it, and a mark applied across it applies to the whole object.
- As plain text the two uses differ deliberately:
inference projects the empty string (a paragraph
opening with a decorative image still matches its prefix/caps rules), while
content-derived export serializes
alt ?? ""(a plain-text reader gets the description, not a silent hole).
Inline media stops at images: inline audio or video is not representable, by
design. InlineObject.type is the extension point if that ever changes.
The media attributes
The attribute vocabulary additions, all on blocks:
| Attribute | Type | On | Description |
|---|---|---|---|
src |
string |
void (media) blocks | The media source — a reference, never bytes. |
alt |
string |
void (media) blocks | Accessible description. Images: the alt attribute; audio/video: an aria-label. |
type |
string |
void (media) blocks | Explicit media type (MIME), e.g. "image/svg+xml". Usually inferred from the src extension; state it for extensionless sources and unusual data: payloads. |
width, height |
number |
void (media) blocks | Intrinsic dimensions in CSS px (integers). A content fact, not styling — they keep layout stable before the media loads and size media on paged/e-ink surfaces. Presentation sizing stays in style. |
poster |
string |
video blocks | Poster frame (same value grammar as src). Also the video’s visual stand-in on surfaces that can’t play it. |
controls |
boolean |
audio/video blocks | Playback controls. Defaults true — a reader must be able to operate the media. |
loop, muted |
boolean |
audio/video blocks | Default false. |
narration |
{ src?, start, end } |
any block | A narration clip — this block’s slice of the work’s narration audio. |
There is deliberately no autoplay (an accessibility hazard) and no
srcset / multi-source list in this revision — one source per block.
Figures & captions
A captioned figure is not a new construct — it is the existing single-cell
grid: a figure grid block whose one
cell holds the void media block plus a figcaption-tagged text block. The
media attributes stay on the inner void block; the figure grid carries none
of them.
{ "kind": "figure", "rows": [[ { "blocks": [
{ "kind": "image", "attrs": { "src": "assets/moth.png", "alt": "A moth on linen",
"width": 800, "height": 600 } },
{ "kind": "caption", "runs": [ { "text": "Fig. 1 — the moth.", "marks": [] } ] }
] } ]] }
In the file this is <figure><img …/><figcaption>…</figcaption></figure>,
exactly as a browser expects. The same shape captions audio and video.
Narration alignment
The bridge to audiobooks. A work may carry a narration track —
meta.narration, a source reference to the master audio (with
meta.narrator naming the narrator) — plus, on any block, a clip into
it:
// the head: <meta name="narration" content="assets/narration/book.m4a" />
// <meta name="narrator" content="Ruby Dee" />
// a narrated block (spelled data-narration="#t=12.25,17.5"):
{ "kind": "paragraph", "runs": [ … ],
"attrs": { "narration": { "start": 12.25, "end": 17.5 } } }
start/endare seconds (fractional).narration.srcoverrides the document-level track per block — real audiobooks clip one master file per chapter, so most blocks state only their times.- The clip lives in attrs, per block — not in a config-side map — so
alignment survives block moves, splits, and merges through ordinary
editing, and no block needs a persisted
idjust to be narrated. - This is exactly the granularity of an EPUB 3 Media Overlay: one SMIL
<par>per narrated block. Emitting one is a package encoder’s capability (app tooling), not a model concern. A renderer with audio support MAY offer read-aloud (play the clip, highlight the block); every other renderer ignores the attribute.
Asset references
Every asset reference — block src, poster, narration.src, inline
object.src, meta.cover, meta.narration, and stylesheet url() values
for fonts — takes one of three forms:
- A relative path — the canonical, preferred form. POSIX separators,
resolved against the work file’s folder (plain browser semantics: a
.spub.htmlmust open from disk, unmodified, with its images working). By convention assets live in anassets/folder beside the file, and a cover is just an asset (meta.cover: "assets/cover.jpg"). - A
data:URI — valid everywhere a reference is; the self-contained form. An exporter MAY embed a document’s assets asdata:URIs to produce a single portable file; tooling SHOULD warn above ~1 MB per asset.data:URIs pass through untouched, never inflated or extracted. - An absolute
http(s)URL — valid but non-portable.
The format never reads bytes. The engine’s part is pure reference
collection — collectAssets(doc) walks a document (content, grid cells,
inline objects, meta.cover, meta.narration, every poster and
narration.src, CSS url()) and returns the deduplicated relative
references in document order, skipping data: and absolute URLs. Resolving
a reference to bytes — an application’s asset store, packaging, export — is
tooling outside this specification.
Media-type inference
A shared, normative extension → MIME table; an explicit type attribute
always wins:
| Extensions | Media type |
|---|---|
png |
image/png |
jpg jpeg |
image/jpeg |
gif |
image/gif |
webp |
image/webp |
avif |
image/avif |
svg |
image/svg+xml |
mp3 |
audio/mpeg |
m4a aac |
audio/mp4 |
wav |
audio/wav |
flac |
audio/flac |
ogg oga |
audio/ogg |
mp4 m4v |
video/mp4 |
webm |
video/webm |
smil |
application/smil+xml |
SVG
SVG is a first-class image format — referenced like any raster
(src: "assets/diagram.svg", type image/svg+xml), and the right choice for
line art on e-ink. Because SVG is markup that can carry script, the rules
are:
- The format never embeds SVG markup inline — reference only; there is no raw-markup block.
- Rendering contexts treat a referenced SVG as an image (an
<img src>), which browsers script-neuter. - Import tooling SHOULD sanitize SVG — strip
scriptelements, event attributes, and external references. (Informative; tooling-level, like all byte handling.)
Degradation by syntax
Media follows the standard
tiered-persistence posture. The
.spub.html file stores everything natively; foreign-format conversion
degrades:
| Syntax | Block media | Inline images | Media attrs | narration |
|---|---|---|---|---|
the file (.spub.html) |
full (<img> <audio> <video>) |
full (inline <img>) |
full | full (data-narration) |
md |
image blocks full (); audio/video/figure via one-element HTML passthrough |
full () |
width/height/type/flags drop |
drops |
txt / fountain / fdx |
drops (nothing emitted) | serialized as alt ?? "" |
drop | drops |
No syntax invents a convention it doesn’t own (there is no Fountain
[[image: …]]); the words a reader sees always survive, and everything else
survives exactly where it can be stored.