The slide DSL
A slide page is a JSON document. Editing it well means writing the exact field that holds the thing you want to change, in the exact shape the renderer reads, and leaving every neighbouring field byte-identical.
Read this part first, because it changes how you should use everything below.
The write path stores what you send. There is no markup allow-list, no
sanitizer, no colour or font normalisation, no geometry clamping, no house style
applied on the way in. patch_stage checks one thing — that the resulting
document still matches the DSL structure schema (field names, types, required
fields, closed objects) — and then persists your value byte for byte.
So there is nothing between you and the screen. A structurally legal page can still be an unreadable page: markup the renderer never styles, a colour on a field nothing paints, a formula snapshot that disagrees with its source. The guard rail catches shape, not meaning. This manual is the meaning. Where it tells you how the renderer behaves, that is the whole of your knowledge — write against it, then read the page back and check what you wrote is what is there.
Contents
- Reading before writing — the three read depths
- The document —
content→canvas - The canvas — viewport, theme, background, animations, paint order
- How an edit reaches the JSON — three ops, pointer rules
- What the structure schema refuses — the only hard boundary
- Two renderers paint your page — playback vs. preview
- Fields every element carries
- The ten element types — one section each
- The rendering truth of
contentHTML — the most important section - Quick reference — one row per element type
- Where this sits
- Hard rules
Reading before writing
read_stage has three depths — detail:"tree", detail:"source" and
detail:"text" — and they are not interchangeable.
detail |
What comes back | Use it for |
|---|---|---|
inventory (default) |
One compact line per element — id, type, plain-text text, left, top, width, height, src |
Finding an element. Never as a patch source. |
source |
The exact persisted page JSON — the root every patch path is written against | Every patch. Read it again after a write. |
text |
Every text-bearing element as { path, id, type, text } plus one page-wide combinedText |
Proving old copy left no residue. |
inventorystrips the markup. Itstextis the element's HTML with tags replaced by spaces and whitespace collapsed. A patch built from it invents style values that were never there and deletes the ones that were.sourcealways returns the whole page. There is no per-element projection, so array indices cannot drift between what you read and what you write. It returns the fullcontentobject —type,schemaVersionand the completecanvas, background, theme and animations included.detail:"text"gives you pointers. Eachpathis that element's own/content/canvas/elements/N, so a residue check hands you the pointer to fix. Note that itstextfor alatexelement is the LaTeX source, forcodethe lines joined by newlines, and fortablethe cells joined by|.
The document
A slide scene's content is:
{ type: 'slide', schemaVersion?: number, canvas: Slide }
type and schemaVersion sit outside /content/canvas/ and are therefore not
reachable by a slide-canvas patch path — such a pointer begins /content/canvas/. Everything you can
write lives under canvas.
The canvas
| Field | Required | Meaning |
|---|---|---|
id |
yes | Page identity. Changing it is rejected. |
viewportSize |
yes | Canvas width in px — 1000 in this product |
viewportRatio |
yes | Height ÷ width — 0.5625, so the canvas is 1000 × 562.5 px |
theme |
yes | backgroundColor, themeColors[], fontColor, fontName, optional outline / shadow |
elements |
yes | The element array, in paint order — index 0 is the bottom of the stack |
background |
no | Page background — see below |
animations |
no | Per-element animation records |
turningMode |
no | no / fade / slideX / slideY / random / slideX3D / slideY3D / rotate / scaleY / scaleX / scale / scaleReverse |
sectionTag |
no | { id, title? } |
type |
no | cover / contents / transition / content / end |
script |
no | Speaker notes carried in from a .pptx import |
All of them are patchable. /content/canvas/theme/fontColor, /content/canvas/background/color,
/content/canvas/animations/0/duration are ordinary pointer paths. The only two writes the
identity guard blocks are changing /content/canvas/id and changing an element's id or
type. Optional branches can be created wholesale — set on /content/canvas/background
adds the object; remove deletes it again.
Geometry — what a pixel means
viewportSize is the design-space width, not a screen width. Every element's
left / top / width / height is in those same design pixels, origin at the
top-left of the canvas, x to the right and y downwards. At render time the
whole canvas is scaled by one factor to fit its container, so design pixels are
proportions in disguise — a width: 500 element is always half the page wide.
Useful constants for the standard page:
- Canvas:
0 ≤ left ≤ 1000,0 ≤ top ≤ 562.5 - Horizontal centre:
left = (1000 - width) / 2 - Vertical centre:
top = (562.5 - height) / 2 - Nothing clips at the canvas edge in the DOM, but anything outside the box is cropped in a snapshot/export capture, so off-canvas geometry is a bug.
background
{ type, color?, image?, gradient? }. type is solid | image | gradient.
The renderer resolves it in a fixed order, and a type whose payload is missing
falls through to plain white #fff, not to another payload you left behind:
type |
Reads | Result |
|---|---|---|
solid |
color |
background-color: color. color missing → transparent, i.e. the shell shows through. |
image |
image { src, size } |
size: 'repeat' → background-repeat: repeat + background-size: contain; cover / contain → no-repeat with that size. image or image.src missing → #fff. |
gradient |
gradient { type, colors[], rotate } |
type: 'linear' → linear-gradient({rotate}deg, …); 'radial' → radial-gradient(…), and rotate is ignored for radial. gradient missing → #fff. |
| absent | — | #fff |
gradient.colors is [{ pos, color }, …] where pos is a percentage number
(0–100, written without the %) and color any CSS colour. rotate is
degrees.
Setting background.color while type is still 'gradient' changes nothing you
can see. The type field is the selector; the payload fields are inert unless
type names them.
theme
{ backgroundColor, themeColors[], fontColor, fontName, outline?, shadow? } —
all four scalars required.
What the renderer actually does with it: only fontColor and fontName are
painted. They are set on the wrapper around every element, so they are the
inherited default for any glyph whose own element and inline styles do not
override them. theme.backgroundColor is not the page background (that is
canvas.background); theme.themeColors, theme.outline and theme.shadow are
authoring defaults consumed by the editor when it creates new elements, not by
the renderer.
animations
[{ id, elId, effect, type, duration, trigger }, …], type one of in / out /
attention, trigger one of click / meantime / auto.
Nothing plays them in playback. Neither renderer reads canvas.animations;
the only consumer is the Pro-mode editor's animation panel. They are stored,
validated and preserved — treat them as data you must not destroy, never as a way
to make something move on a learner's screen. Deleting an element deletes the
animations bound to its id for you.
elements is the z-order
Depth is array position: index 0 paints first (bottom), the last element paints
last (top). There is no zIndex field, and the renderer assigns the CSS
z-index from the array index itself. This is the one change with no leaf to
address — see the restacking note below.
How an edit reaches the JSON
For slide canvases, patch_stage exposes five operations.
| Op | Arguments | What it does |
|---|---|---|
set |
path, value |
Writes one JSON Pointer path in the persisted scene |
remove |
path |
Removes one optional field or bounded array item |
str_replace |
path, oldText, newText, optional replaceAll |
Replaces an exact anchor inside one string field |
add_element |
element (complete, no id), afterId or index |
Inserts one new element; the server assigns its id |
delete_element |
elementId |
Removes that element and any animation bound to it |
setwithoutvaluefails;removewith avaluefails.add_elementrefuses anelementthat carries anid— identity is the server's. It takesafterIdorindex, never both;indexmust be an integer from0to the current element count; omit both and the element lands on top.- Creating an element is a full-element write, not a patch — every DSL-required field for that type must be present (the type sections mark them).
Addressing a field
path is a JSON Pointer rooted at the exact scene returned by read_stage detail:"source", so a slide field begins /content/canvas/. A field you read at
/content/canvas/elements/2/left is written
back through that same path.
- Address the leaf, not the branch. Give the smallest path that isolates the change. Writing a whole object back is how a neighbouring style field gets silently dropped.
- Array indices are canonical and bounded.
0,1,2—03,-1,+1and an index past the end are all rejected. - Every segment before the last must already exist. The last one need not:
seton a key the object does not have yet adds that optional field (giving a text element afill, a shape atext), andremovedeletes it again.removeon a path that is not there fails. - A path may not cross a scalar.
/content/canvas/elements/0/content/0fails becausecontentis a string, not a container. removeon an array index splices the array shorter. There is no insert-at-index for arrays other than rewriting the array whole.~1and~0escape/and~inside a key. A bare~or~2is rejected.- The value is deep-cloned on the way in, so a nested object you send is stored as its own tree.
Worked examples, one call each:
| Change | path |
value |
|---|---|---|
| A title's rich text | /content/canvas/elements/0/content |
<p><span style="color:#00a870">新标题</span></p> |
| One table cell | /content/canvas/elements/5/data/0/0/text |
"净利润" |
| One line of code | /content/canvas/elements/9/lines/1/content |
"total = price * count" |
| A shape's label | /content/canvas/elements/3/text/content |
<p>第二阶段</p> |
| A glyph colour default | /content/canvas/elements/2/defaultColor |
"#1f4e79" |
| One chart label | /content/canvas/elements/6/data/labels/2 |
"Q3" |
| An element's position | /content/canvas/elements/2/left |
120 |
| The page background | /content/canvas/background/color |
"#f7f7f5" |
| Drop an optional field | /content/canvas/elements/4/shadow |
(op: 'remove') |
| Restacking | /content/canvas/elements |
the whole array, reordered |
Z-order has no leaf to address, because paint order is array position. Write
/content/canvas/elements whole, carrying every element back unchanged in a new order —
the id set and every id→type pairing has to come back identical.
What the structure schema refuses
This is the whole of the enforcement, and a rejected write changes nothing — an error is information, not damage. Every check below runs before anything is stored, and the error message names the offending path.
Pointer-level refusals
- A slide-canvas path that does not begin
/content/canvas/. - A malformed
~escape. - A non-canonical array index (
03,-1) or one out of bounds. - A missing intermediate segment, or a path that crosses a non-container.
removeon a key or index that is not there.setwithoutvalue, orremovecarrying one.
Identity refusals (checked on the result of the write)
- Changing
/content/canvas/id. - Adding, removing, renaming or duplicating an element
id— that is whatadd_elementanddelete_elementare for. - Changing an existing id's
type. Turning a text element into a shape is a delete plus an add. add_elementwith anidin the element, or with bothafterIdandindex.
Schema refusals (the whole canvas is re-validated, at every nesting level)
- An unknown field anywhere — every object in the contract is closed
(
additionalProperties: false). A misspeltdefaultFontnamedoes not land as a stray key; it fails the call. - A wrong type — a string where a number belongs, a number where a string belongs, an object where an array belongs.
- A value outside a closed union —
style: 'double'on a line, achartTypethat is not one of the eight, analignthat is not in its set. - A required field removed —
removeon/content/canvas/elements/0/content, on a table'scolWidths, onviewportSize, on anythemescalar. - A tuple of the wrong arity —
viewBoxmust be exactly two numbers,pointsexactly two markers,clip.rangeexactly two pairs. - A field on the wrong type —
latexon a text element,contenton an image.
That list is exhaustive for the write path. Notice what is not on it: no tag
check, no CSS check, no colour-format check, no length limit, no geometry bound,
no contrast rule, no check that html agrees with latex, no check that a chart's
series matches its labels. Those are all yours to get right.
The one thing the server rewrites for you: when a patch changes an element's
latex, the server re-renders that element's html snapshot from the new source
(KaTeX, display mode, errors rendered rather than thrown) and stores it. If the
render returns nothing, html is removed instead. Nothing else in the document is
touched, ever.
Two renderers paint your page
The same JSON is drawn by two different implementations, and knowing which is which explains most "it looks right in one place and wrong in another" reports.
| Playback renderer | Preview renderer | |
|---|---|---|
| Code | the in-app element components (components/slide-renderer/components/element/*, mounted by components/slide-renderer/Editor/ScreenElement.tsx) |
@openmaic/renderer's SlideCanvas |
| Where it runs | the classroom page the learner watches — the default | page thumbnails (components/slide-renderer/SlideThumbnail.tsx), and playback too when NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED is on (off by default) |
| Stylesheet for rich text | none — only the global CSS reset, browser defaults, and your inline styles | ships its own .slide-renderer-prose rules with the canvas |
They agree on every field that matters most (geometry, fills, precedence,
html-over-latex). Where they differ, this manual says so, and the rule is
always the same: write for the intersection. The known divergences are
- list markers — restored by the preview renderer's stylesheet, absent in playback (see the HTML section);
paragraphSpaceon a text element — honoured by the preview renderer, inert in playback;vAlignon a text element and per-sideborderson a table cell — honoured by the preview renderer, ignored in playback;- a plain-text
contentwith newlines — the preview renderer setswhite-space: pre-linewhen the string contains no markup at all, so the newlines become line breaks; playback does not, so they collapse to spaces; audioelements — drawn as a click-to-play button by the preview renderer, not rendered at all in playback;- an oversized formula — the preview renderer only ever shrinks a formula to fit its box, playback also enlarges it to fill the box.
Fields every element carries
id, left, top, width, height, rotate — all required — plus optional
lock, groupId, link { type, target } and name.
left/top/width/heightare canvas px, origin top-left.rotateis degrees, clockwise, about the box centre.- A
lineelement is the exception: it has noheightand norotate, because its extent and direction come from its endpoints. Patching either onto a line is an unknown-field rejection. lock,groupIdandnameare editor bookkeeping — no renderer reads them. Preserve them; do not expect them to change a pixel.linkis likewise not painted by either renderer. An element-level hyperlink is stored and exported, not clickable in playback.- Rotation is applied to a wrapper inside the positioned box, so
left/top/width/heightalways describe the unrotated box. To reason about the visual bounds of a rotated element you have to rotate the rectangle yourself.
The ten element types
text · shape · image · line · chart · table · latex · video ·
audio · code. They are not interchangeable, and the field that holds an
element's words is different in every one of them.
text
Required: content (HTML string), defaultFontName, defaultColor, plus the
common geometry.
| Optional field | Type / values | Default when absent | What the renderer does with it |
|---|---|---|---|
fill |
colour string | none | Background colour of the box — painted on the declared height only |
outline |
{ style?, width?, color? } |
none | A rectangular SVG stroke at the box bounds |
lineHeight |
number (multiplier) | 1.5 |
line-height on the content box |
wordSpace |
number (px) | 0 |
letter-spacing on the content box |
paragraphSpace |
number (px) | 5 |
Playback ignores it (see below); the preview renderer uses it as the <p> bottom margin |
opacity |
number 0–1 |
1 |
Opacity of the whole box including fill |
shadow |
{ h, v, blur, color } |
none | text-shadow on the glyphs |
vertical |
boolean | false |
writing-mode: vertical-rl, and swaps which of width/height is auto |
vAlign |
top / middle / bottom |
top |
Preview renderer only — vertical anchor inside the box. Inert in playback |
textType |
title / subtitle / content / item / itemTitle / notes / header / footer / partNumber / itemNumber |
none | Authoring metadata. No renderer reads it |
Words live in content.
The four things that go wrong here
defaultColoranddefaultFontNameare only defaults. They are set on the container, so any inlinecolor/font-familyinsidecontentwins by ordinary inheritance. An element whose paragraphs carrycolor:#C00000does not change colour when you patchdefaultColor. Change the inline span, or change both.- The box has 10 px of padding on all four sides, so the usable text area is
(width - 20) × (height - 20). heightdoes not clip. The content box is auto-height inside a fixed-height frame with visible overflow, so text longer than the box spills out and can land on top of the element below it. Thefillcolour and theoutlinestop at the declaredheight, which is what makes the overflow obvious once you look. Text that no longer fits needs a smallerfont-sizeor a biggerheight, not a hope that it will be cropped.paragraphSpaceis inert in playback. Playback sets it as a CSS variable that no rule reads, so stacked<p>tags sit with zero gap. If you need breathing room between paragraphs in playback, putmargin-bottomin the<p>'s own inline style.
shape
Required: viewBox [w, h], path (an SVG d), fixedRatio, fill, plus
the common geometry.
| Optional field | Type / values | What the renderer does with it |
|---|---|---|
gradient |
{ type, colors[{pos,color}], rotate } |
An SVG gradient used as the path's fill |
pattern |
image URL string | An SVG image pattern used as the path's fill |
outline |
{ style?, width?, color? } |
The path's stroke, non-scaling |
opacity |
number 0–1 |
Opacity of the shape and its label together |
flipH / flipV |
boolean | Mirrors the path (the label is counter-mirrored so glyphs stay upright) |
shadow |
{ h, v, blur, color } |
drop-shadow filter |
text |
ShapeText |
The label — see below |
special |
boolean | Marks a path the exporter cannot express; no visual effect |
pathFormula |
one of 21 named formulas | Recomputes path on resize instead of scaling it |
keypoints |
number[] | Adjustment handles for a pathFormula shape |
fixedRatio |
boolean | Editor resize constraint; no visual effect |
Fill precedence: pattern beats gradient beats fill. The renderer picks the
first one present, in that order. Patching fill on a shape that carries a
gradient changes nothing you can see — remove the loser first, or patch the
winner.
Geometry. path is in viewBox coordinates; the renderer scales it by
width / viewBox[0] and height / viewBox[1]. So resizing a shape means patching
width / height and leaving viewBox and path alone — unless the shape has a
pathFormula, in which case the path is recomputed from it. Never hand-edit
path to resize a shape.
Words live in text.content — a nested ShapeText:
text field |
Required | Default | Notes |
|---|---|---|---|
content |
yes | — | HTML string, same rules as a text element's content |
defaultFontName |
yes | — | Inherited default, beaten by inline styles |
defaultColor |
yes | — | Same |
align |
yes | — | top / middle / bottom — the vertical anchor, not horizontal |
lineHeight |
no | 1.625 |
Multiplier |
wordSpace |
no | 0 |
px |
paragraphSpace |
no | 5 |
px — honoured in playback here, unlike on a text element |
type |
no | — | Same authoring metadata as textType; unread by renderers |
A shape with no text object has no words at all; giving it one means supplying
the whole object (content, defaultFontName, defaultColor, align are all
required). Horizontal alignment comes from text-align inside the HTML, never from
align. The label sits inside 10 px of padding in playback and flush to the box
edges in the preview renderer — keep a margin of your own if a tight fit matters.
image
Required: src, fixedRatio, plus the common geometry. No words.
| Optional field | Type / values | What the renderer does with it |
|---|---|---|
outline |
{ style?, width?, color? } |
An SVG stroke shaped to the clip (rect / ellipse / polygon) |
filters |
{ blur?, brightness?, contrast?, grayscale?, saturate?, 'hue-rotate'?, sepia?, invert?, opacity? } |
CSS filter — each value is a string with its unit, e.g. "4px", "120%", "90deg" |
clip |
{ range: [[x1,y1],[x2,y2]], shape } |
range is in percent of the original picture; shape is a named clip path |
radius |
number (px) | Corner radius — only takes effect when clip.shape is a rect-type shape |
flipH / flipV |
boolean | Mirrors the picture |
shadow |
{ h, v, blur, color } |
drop-shadow filter |
colorMask |
colour string | A flat colour layer painted over the picture — an opaque value hides the picture entirely |
softEdge |
number (px) | Preview renderer only — feathers the edges. Inert in playback |
imageType |
pageFigure / itemFigure / background |
Authoring metadata. No renderer reads it |
fixedRatio |
boolean | Editor resize constraint; no visual effect |
clip.shape is one of rect, rect2, rect3, roundRect, ellipse, triangle,
triangle2, triangle3, rhombus, pentagon, hexagon, heptagon, octagon,
chevron, point, arrow, parallelogram, parallelogram2, trapezoid,
trapezoid2. A name outside that set resolves to nothing and the picture disappears.
src is the pitfall. Anything matching http:, https:, data:, blob:, /
or ./ is treated as a concrete address and rendered directly. Anything else is
treated as a generation placeholder and the element renders a skeleton, an error
box or a disabled badge instead of a picture, depending on whether a generation task
exists for that reference. Writing a made-up id into src therefore does not show a
picture — it shows a placeholder forever. A real picture comes from the image
generation tool, whose returned reference you then store.
Swapping src does not touch the box, so a picture with a different aspect ratio
needs width and height re-derived together.
line
Required: start [x, y], end [x, y], style, color, points, plus
left, top and width. No height and no rotate. No words.
| Field | Type / values | Meaning |
|---|---|---|
start |
[x, y] |
Start point, relative to left / top — normally [0, 0] |
end |
[x, y] |
End point, same origin — the vector end - start is the line |
width |
number (px) | Stroke thickness, not length. Length comes from the endpoints |
style |
solid / dashed / dotted |
Dash pattern is derived from width, so a thick dashed line has long dashes |
color |
colour string | Stroke and marker colour |
points |
[start, end], each '' / 'arrow' / 'dot' |
End markers. '' means none. Marker size is max(width, 2) × 3 px |
shadow |
{ h, v, blur, color } |
drop-shadow filter |
broken |
[x, y] |
One elbow — path becomes start → point → end |
broken2 |
[x, y] |
Two elbows, orientation chosen from which span is longer |
curve |
[x, y] |
Quadratic control point |
cubic |
[[x1,y1],[x2,y2]] |
Two cubic control points |
The control points are checked in the order broken, broken2, curve, cubic
and the first one present wins — leaving a stale curve behind while adding a
broken means the curve is silently ignored. Remove the loser.
Moving a line means patching left / top; changing its direction or length means
patching start / end.
chart
Required: chartType, data, themeColors[], plus the common geometry.
| Field | Type / values | Notes |
|---|---|---|
chartType |
bar / column / line / pie / ring / area / radar / scatter |
See the shape table below |
data |
{ labels: string[], legends: string[], series: number[][] } |
The whole dataset |
themeColors |
string[] | Series palette, in order |
options |
{ lineSmooth?, stack? } |
lineSmooth for line / area; stack for bar / column / line / area |
fill |
colour string | Background of the chart box |
outline |
{ style?, width?, color? } |
A rectangular stroke at the box bounds |
textColor |
colour string | Axis labels, legend text and data labels |
lineColor |
colour string | Grid split lines |
Words live in data.labels[] and data.legends[]; numbers live in
data.series[][] — one series row per legend, one entry per label. Relabelling
is therefore usually one path for the label and one for its matching series entry
(/content/canvas/elements/N/data/labels/2), not a rewrite of data.
How each type reads that dataset:
chartType |
Orientation / shape | labels are… |
series are… |
|---|---|---|---|
bar |
Vertical columns (category on x) | the x-axis categories | one row per legend |
column |
Horizontal bars (category on y) | the y-axis categories | one row per legend |
line |
Line chart | the x-axis categories | one row per legend |
area |
Line chart with the area filled | the x-axis categories | one row per legend |
pie |
Pie, radius 70% | the slice names | only series[0] is used — one value per label |
ring |
Donut, radii 40%–70% | the slice names | only series[0] is used |
radar |
Radar | the spoke names | one polygon per legend |
scatter |
Scatter | unused | series[0] is x, series[1] is y; with no series[1], y = x |
The two that catch people out: bar is the vertical one and column is the
horizontal one — the names are the opposite way round from the shape; and
scatter reads two series as coordinate arrays rather than as two data sets.
Two more renderer facts: the legend is only drawn when there is more than one
series (pie and ring always draw one), and a chart whose series is missing or
empty renders nothing at all — an empty box, not an empty axis.
table
Required: colWidths[], cellMinHeight, outline, data, plus the common
geometry.
| Field | Type / values | Notes |
|---|---|---|
colWidths |
number[] | Ratios that sum to 1. Column px = colWidths[i] × element width |
cellMinHeight |
number (px) | Row height fallback; content taller than it still expands the row |
rowHeights |
number[] (optional) | Per-row minimum, overriding cellMinHeight; wrapped content can expand it |
outline |
{ style?, width?, color? } |
The uniform grid border. Missing width → 1 px, missing color → black |
theme |
{ color, rowHeader, rowFooter, colHeader, colFooter } (optional) |
Banding — see below |
data |
TableCell[][] |
Row-major grid |
A cell is { id, colspan, rowspan, text, style?, padding?, vAlign?, borders? }.
id, colspan, rowspan and text are all required — a normal cell is
colspan: 1, rowspan: 1. style may carry bold, em, underline,
strikethrough, color, backcolor, fontsize (a string with its unit),
fontname, align (left / center / right / justify).
Words live in data[row][col].text. One cell is one path:
/content/canvas/elements/5/data/0/0/text. Address the cell's text and never the row or
the grid, and every id, colspan, rowspan and per-cell style around it survives
untouched — a colspan you drop takes the table's shape with it.
Cell text is its own dialect. Playback runs the cell's text through a
transform before injecting it as HTML: every \n becomes <br/> and every space
becomes . That has three consequences you must design around:
\nin a cell is a line break — the one pl
…(truncated)