OPF
DocsReferenceView source

Content payloads and tables

Slide content lives directly on a slide as a full-slide payload, in layout-agnostic blocks, or inside a promoted region key such as left, center+right, or top:left.

The slide-level title, subtitle and tag are not payloads, but like text and quote.text they accept a string or TextRun[] (colored words, bold, links, cite/footnote markers); see Rich text.

The optional payload type can make intent explicit, but OPF should usually infer the content kind from the field present:

FieldInferred typeNotes
texttextPlain string or TextRun[].
bulletstextProse bullets: plain talking points, usually string[].
itemslistStructured list: entries that share a shape or carry a description; string[] or ListItem[]. See items versus bullets.
imageimageAsset string shorthand or Asset object with src and optional metadata.
videovideoAsset string shorthand or Asset object with src and optional metadata.
chartchartChart object with type and tabular data.
tabletableTable object with optional columns and required rows.
codecodeString shorthand or Code object with source, language, filename, and highlight.
metricmetricString/number shorthand or Metric object with value, label, description, unit, delta, trend, and sentiment.
quotequoteString shorthand or Quote object with text (string or TextRun[]), attribution, role, photo, and source.
timelinetimelineArray shorthand or Timeline object with name, description, and events.

Items versus bullets

Both fields draw the same bulleted (or numbered) list: one marker, the same nesting level, rich-text entries and numbering, fitted by the same list engine. Nothing about the drawing differs. They differ in the kind they infer and in what an entry may carry:

bulletsitems
Inferred kindtextlist
Entrystring, TextRun[] or { text, level, start }string, TextRun[] or { text, description, level, start }
descriptionnot allowed (a schema error)supporting detail drawn under the entry
Beside text in one block or regionallowedan error (list and text are different kinds)

The rule. Use bullets for plain talking points: short lines that each stand alone. Use items when the entries share a shape (features, steps, options, risks) or any entry needs a description; never fold a heading and its detail into one string with a separator, that is what description is for. When in doubt, use items, because it can grow a description later without changing the payload kind. The editor helper convertListForm switches a payload between the two; going to bullets drops the descriptions.

Do not give one payload both. In a block or a promoted region, items next to bullets is the error "content payload mixes fields from incompatible content types". At slide root it validates, as the shorthand for two blocks, and composes as two lists side by side; write two blocks on purpose if you want that. numbering applies to every list in the payload.

A layout's placeholder kind (list or text) is what layout pickers match on (layoutContent), but composition does not refuse the other field and draws both the same way: the choice states what the content is, it is not a switch for the look.

Code highlight

code.highlight marks lines of a code block: a line number or an inclusive [start, end] range, 1-based, for example [3, [5, 7]]. Lines are counted by line break in code.source; a line that wraps is still one line. The preview and the PPTX export draw a theme-derived band behind the marked lines and dim the others slightly; every line keeps at least 4.5:1 contrast against what it sits on (codeHighlightColors). The PPTX export draws one native rectangle per run of marked lines behind the per-line text boxes, and import restores highlight from the code provenance tag. A line past the last line, or a range written end before start, is the validation warning code-highlight-out-of-range or code-highlight-range-reversed, and engines ignore it. Pagination keeps each page's marked lines, renumbered from 1.

Color references

Every content color field — TextRun.color, styled table cell style.fill and style.color, and table cell border color — accepts three forms:

  • A literal hex color: "#0F172A", "#B42318CC".
  • A color-scheme slot or role name, resolved through the effective color scheme after design resolution: slots accent1–accent6, dark1, dark2, light1, light2, hyperlink, followedHyperlink; roles primary, secondary, accent, background, surface, surfaceAlt, text, textSecondary. surfaceAlt is the alternate surface for banded table rows: core derives it from surface and text (surfaceAltColor), so it always differs visibly from the surface of the plain rows, on light and dark slides, and body text keeps 4.5:1 on it.
  • A variable reference var:<id> into the top-level variables map.
json
{
  "variables": { "risk": "#B42318" },
  "slides": [
    {
      "title": "What Could Go Wrong",
      "items": [
        ["Two regions at ", { "text": "85% utilization", "color": "var:risk", "bold": true }],
        ["Mitigations ship in ", { "text": "November", "color": "accent2" }]
      ]
    }
  ]
}

Prefer names and variables over literal hex: re-theming the deck updates every named reference, while a hex value stays frozen at authoring time. An unknown var: id is a validation warning, never an error; engines fall back to their default text color. The styled table cell and border color fields enforce the three forms at the schema level; run colors additionally accept any string so imported decks keep validating — unrecognized values warn, and renderers fall back to the theme color. See design-resolution.md for the resolution rules.

Numbered lists

numbering on an items or bullets payload draws numbers instead of bullets: a style name (arabic, roman-upper, roman-lower, alpha-upper, alpha-lower), a { style, start, suffix } object, or an array with one entry per list level. PowerPoint export writes native auto-numbers and the preview draws the same numbers. See numbered lists.

json
{ "items": ["Define", "Build", "Ship"], "numbering": { "style": "roman-lower", "suffix": "paren" } }

Blocks

Use slide-level blocks when a slide contains multiple content payloads, but exact placement should be inferred by the renderer. Blocks may contain a concrete content payload or a nested group with its own blocks and optional composition. Groups cannot mix child blocks with leaf payload fields. See dynamic composition for nesting and inheritance rules.

json
{
  "title": "Customer Feedback Summary",
  "blocks": [
    {
      "table": {
        "columns": ["Theme", "Mentions"],
        "rows": [
          ["Speed", 42],
          ["Ease of use", 31]
        ]
      }
    },
    {
      "quote": {
        "text": "The new workflow cut review time in half.",
        "attribution": "Operations Lead",
        "source": "Customer interview"
      }
    }
  ]
}

At slide root only, multiple content payload kinds are accepted as shorthand for the equivalent blocks form when there is no explicit type, no blocks, and no promoted region keys:

json
{
  "title": "Habitat & Territory",
  "text": "Jaguars are strongly associated with presence of water and dense cover.",
  "items": [
    "Primary habitats include dense rainforests, swamps, and seasonally flooded wetlands.",
    "Solitary animals that establish and defend large territories."
  ]
}

The same shorthand works for other content kinds:

json
{
  "title": "Evidence Snapshot",
  "chart": {
    "type": "line",
    "data": {
      "columns": ["Quarter", "Sightings"],
      "rows": [
        ["Q1", 12],
        ["Q2", 18]
      ]
    }
  },
  "quote": {
    "text": "Jaguar conservation depends on connected habitat.",
    "attribution": "Field researcher"
  }
}

Chart

Chart-specific fields are grouped under chart. Do not put loose chart data directly on a slide or region.

json
{
  "title": "Revenue Trend",
  "chart": {
    "type": "line",
    "data": {
      "columns": ["Quarter", "Revenue", "Costs"],
      "rows": [
        ["Q1", 12, 8],
        ["Q2", 18, 11],
        ["Q3", 24, 15]
      ]
    }
  }
}

Inline chart data is tabular by default. Renderers convert columns and rows into series, axes, legends, and workbook data internally.

Give every chart an alt: one or two sentences that say what the data shows (the point and the key numbers), not "a chart" or "bar chart". The preview names the chart with it (role="img", aria-label) and the PowerPoint export writes it as the chart frame's alternative text (descr), which fromPptx reads back. "alt": "" marks a chart decorative, as an empty image alt does; validate (opf/chart-text-alternative) still reports it as info so the choice is reviewed. Without alt, text beside the chart (a subtitle, text or table) satisfies the rule. See chart options.

Value cells are numbers. A string is read only in strict decimal syntax ("12", " -3.5 ", "1e6"); anything else ("12%", "$5", "(5)", "1,234", "Q1") is a gap in the preview and the export and a chart-value-not-numeric warning, never a guessed value. null and "" are gaps without a warning. Core chartNumber is the one rule every engine uses (RR-54; before it the PPTX exporter stripped non-numeric characters, so "12%" exported as 12).

A column is a string or a DataColumn with a number format, which the data labels, the value axis and the exported workbook use:

json
{
  "chart": {
    "type": "column",
    "data": {
      "columns": ["Quarter", { "name": "Revenue", "format": "$#,##0.0" }, { "name": "Margin", "format": "0%" }],
      "rows": [["Q1", 12.4, 0.31], ["Q2", 18.1, 0.34]],
      "source": { "src": "./data/revenue.csv", "retrieved": "2026-10-05" }
    },
    "mapping": { "category": "Quarter", "series": ["Revenue"] }
  }
}

Formats use the NumberVariable.format syntax (#,##0, 0.0%, $#,##0.00, #,##0 units; a % multiplies by 100). source records provenance only; engines never read or refresh it. mapping picks the category, the X column of a scatter chart and the plotted series by column name; without it the first column is the category and every other column a series. Shared data lives in the top-level datasets map and a chart plots it with "data": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] }. The full contract, with validation codes and the engine behaviour, is Chart and table data.

Chart data is inline columns and rows or a dataset. A data source by file or asset ("data": { "src": "asset:revenue-csv", "columns": [...] }) is not part of the format: no engine loaded it, and the validator rejects it. Import the data inline (with a source) or into a dataset instead.

Table

Table-specific fields are grouped under table. Do not put loose columns or rows directly on a slide or region.

json
{
  "title": "Pipeline",
  "table": {
    "columns": ["Stage", "Count", "Value"],
    "rows": [
      ["Qualified", 42, "$1.2M"],
      ["Proposal", 18, "$840K"]
    ]
  }
}

A table may instead show a dataset: "table": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] } (no rows or columns); it takes its headers, rows and column formats from the dataset, and per-cell styles need an inline table. A column header may be a DataColumn ({ "name": "Revenue", "format": "$#,##0.0" }) and a styled header or body cell takes a format; a number cell displays formatted (the body cell's own format wins over the column's), and layoutTable measures the formatted text. See Chart and table data.

Table body cells accept strings, numbers, booleans, or null. Since core 0.5.0, a cell or column header also accepts the same TextRun[] used by rich text:

json
{
  "table": {
    "columns": [["Quarter ", {"text": "growth", "bold": true}], "Value"],
    "rows": [
      [["Up ", {"text": "12%", "color": "#008800"}], 12]
    ]
  }
}

Use the published Node 24 set (node scripts/registry-plan.mjs) and the compatibility matrix for exact pins and evidence. Core measures run styles when checking overflow and keeps each row intact when paginating. The renderer traces rich cells for the editor's existing formatting, typing and undo controls; the exporter emits editable native text runs. PPTX 0.4.0 introduced import of supported native character styles, paragraph defaults, theme fonts/colors, external links and significant whitespace as rich runs. Unstyled body cells remain strings, and cached display text cannot recover original scalar types or live fields. Conditional table styles, merged geometry and cell fills/borders/alignment remain limited; native PowerPoint visual parity is not yet verified.

Table text size and bold. Table text draws at the body size (about 19 pt, 25 reference pixels) and the whole table shrinks together toward composition.minFontSize when its rows do not fit, so a table next to bullets no longer reads smaller than they do. A run's own fontSize is drawn as written. TableCellStyle takes bold to make the whole cell bold; a numeric cell can be bold and still use its number format ({"value": 18.1, "style": {"bold": true}}), and a cell written as runs in a numeric column keeps the column's alignment.

A table may carry an alt (FA-27), the same text alternative as Chart.alt: one or two sentences that say what the table shows (the point and the key numbers), not "a table". The preview names the table with it (a role="group" with aria-label; the cells stay readable) and the PowerPoint export writes it as the table frame's alternative text (descr), which fromPptx reads back. "alt": "" hides a purely decorative table, as an empty image alt does. Unlike a chart, a table needs no alt: a screen reader reads a native table cell by cell, so validate reports nothing when it is missing, and a table beside a chart still counts as that chart's text alternative. The alt is a summary read before the cells, which stay readable text (in PowerPoint too, where descr on a table frame does not hide its cells), so it is most useful for a large or dense table.

Core 0.6.0 adds layoutTable from @openpresentation/opf/composition. It measures scalar and rich cells, keeps short rows compact, and gives wrapped or multiline rows the height they need. When space is constrained it reduces spare row height before shrinking text, and reports overflow when the minimum fitting size cannot fit. Pass the same scale, font family, measurement provider and effective minFontSize to each consumer. The returned row boxes, cell text boxes and fits are shared by the coordinated SVG and PPTX implementations; rich table cells use uniform line advances to match native cell paragraph spacing. Native viewer fidelity remains a separate verification boundary.

Captions

An image, chart, table or video payload takes a caption: a string, TextRun[], or { "text", "position": "below" | "above", "align": "left" | "center" | "right" } (defaults below, left). It sits beside the payload field on a block or promoted-region payload, or on the slide root when the root holds exactly one of those payloads; anywhere else it is a caption-unsupported-payload error.

json
{
  "title": "Pipeline",
  "blocks": [
    { "image": "asset:funnel", "caption": "Figure 1. Pipeline by stage, Q3" },
    { "table": { "columns": ["Stage", "Count"], "rows": [["Qualified", 42]] }, "caption": { "text": "Table 1. Counts", "position": "above", "align": "center" } }
  ]
}

Core composition reserves the caption band inside the block's region and shrinks the media by its height (item.caption carries the band, item.box is the media box); the preview and the PPTX export draw that band in the muted text colour at the caption size (0.6 of the body size, never under the readable floor). Captioned blocks are the only ones whose geometry changes. See footnotes, citations and captions.

Code

Code-specific fields are grouped under code. A string value is shorthand for code.source; use object form when syntax highlighting or a file label matters. In object form, source is required. language colours the code in the preview and the PowerPoint export (comments, strings, numbers, keywords, names and types, in colours from the deck theme); an unknown language stays plain and the text is never changed. See dynamic composition for the supported languages.

json
{
  "title": "Decision Rule",
  "code": {
    "source": "if risk > threshold:\n    escalate(owner)\nelse:\n    approve(change)",
    "language": "python",
    "filename": "decision.py"
  }
}

Metric

Metric-specific fields are grouped under metric. A string or number value is shorthand for metric.value; numeric values stay numeric and are formatted by renderers at display time. Use object form when labels, descriptions, units, deltas, trends, or sentiment matter. A trend (up, down, flat) draws an arrow beside its word, coloured with the delta text, in the preview and the PowerPoint export; the arrow always points the way the trend does, and its colour follows sentiment (positive green, negative red, neutral the neutral text colour). When sentiment is absent up is positive, down is negative and flat is neutral, so set it when the direction is not the verdict: a falling churn, cost or latency is "trend": "down", "sentiment": "positive". Only the trend arrow and the trend and delta text take the colour, so sentiment has no visible effect on a metric without a trend; the word stays editable text and the arrow carries "Trend: up" as its alternative text (see dynamic composition).

The metrics v2 layout accepts metric blocks in its body region and flows them as a grid. For several metrics, use separate { "metric": ... } entries in blocks; the content kind and payload key are metric. Choosing a layout does not reinterpret existing text as numeric data.

json
{
  "title": "Operating Metric",
  "metric": {
    "value": "42%",
    "label": "Review cycle reduction",
    "description": "Median reduction across customer review workflows.",
    "delta": "+11 pts",
    "trend": "up"
  }
}

A falling value that is good news keeps its downward arrow and takes the green:

json
{
  "title": "Churn",
  "metric": {
    "value": "3.1%",
    "label": "Monthly churn",
    "delta": "-0.6 pts",
    "trend": "down",
    "sentiment": "positive"
  }
}

Quote

Quote-specific fields are grouped under quote. A string value is shorthand for quote.text; use object form when attribution or citation matters. text is a string or TextRun[] with inline formatting; attribution, role and source are plain strings.

json
{
  "title": "Customer Proof",
  "quote": {
    "text": "The new workflow made exceptions visible before they became escalations.",
    "attribution": "Priya Raman",
    "source": "Customer interview"
  }
}

A testimonial attributes the quote to a person with a title and a face. role is the person's title and organization, and photo is their headshot, an Asset (a source string or an object with src and alt):

json
{
  "title": "Customer Proof",
  "quote": {
    "text": "The new workflow made exceptions visible before they became escalations.",
    "attribution": "Priya Raman",
    "role": "VP Operations, Acme",
    "photo": { "src": "asset:priya-raman", "alt": "Priya Raman" },
    "source": "Customer interview, March 2026"
  }
}

The footer under the quote is the attribution, then the role on its own line, then the source after - on the last line. Without a role it is the single line it always was (attribution - source). With a photo, the headshot is a circle at the start edge of the footer row (the left in a left-to-right deck, the right in a right-to-left one), three times the footer font size across, and the footer lines sit beside it, centered on it. The photo is cropped to fill the circle, and its size follows the footer's font size when the readability floor shrinks the footer. Without a photo nothing else changes. The preview draws it with the circular clip that an image block uses for shape circle, and PowerPoint export writes a native picture with the ellipse geometry and the alt text as its description; importing that file restores role and photo. A photo needs alt text (the opf/missing-alt-text rule of validate checks quote.photo); an SVG photo exports as its PNG raster. A photo with no attribution, role or source still draws, alone in the footer row. The Markdown dialect has no native form for role and photo: a quote that carries them is written as an opf block, which round trips exactly, and a plain > — Name, Title line stays an attribution.

Timeline

Timeline-specific fields are grouped under timeline. An array value is shorthand for timeline.events; use object form when the timeline needs a name or description. Timeline events use when, what, description and status. status is done, current or planned and marks progress: done is a filled marker with normal text, current ("we are here") is a ringed marker with a bold label, and planned is a hollow outlined marker with muted text. An event without a status draws as a plain filled marker. Schedule health (at risk, blocked) is not a status; say it in the event text. See Dynamic composition for the exact drawing.

json
{
  "title": "Rollout Plan",
  "timeline": {
    "name": "Regional Rollout",
    "description": "Major milestones for the rollout.",
    "events": [
      {
        "when": "Q1",
        "what": "Pilot",
        "description": "Launch with one operations team."
      },
      {
        "when": "Q2",
        "what": "Rollout",
        "description": "Expand to all regions."
      }
    ]
  }
}

A roadmap with progress states:

json
{
  "title": "Product roadmap",
  "timeline": [
    { "when": "Q1", "what": "Discovery", "status": "done" },
    { "when": "Q2", "what": "Pilot", "status": "current" },
    { "when": "Q3", "what": "Rollout", "status": "planned" }
  ]
}

Regions

Region keys address a 3×3 grid of rows (top, middle, bottom) and columns (left, center, right):

text
left                 center                 right
         +--------------------+--------------------+--------------------+
   top   |  top:left          |  top:center        |  top:right         |
         +--------------------+--------------------+--------------------+
  middle |  middle:left       |  middle:center     |  middle:right      |
         +--------------------+--------------------+--------------------+
  bottom |  bottom:left       |  bottom:center     |  bottom:right      |
         +--------------------+--------------------+--------------------+
  • A bare column key (left) spans all three rows; a bare row key (top) spans all three columns.
  • + spans adjacent rows or columns: center+right, top+middle.
  • row:column combines the two: top:left, middle+bottom:center+right.
  • Keys on one slide must not overlap, and regions cannot be mixed with root payload fields.

Spans compose into common slide shapes:

text
"left" + "center+right"             "top" + "middle+bottom"
  (sidebar + main)                    (headline band + body)
  +----------+------------------+     +-------------------------------+
  |          |                  |     |              top              |
  |          |                  |     +-------------------------------+
  |   left   |  center+right    |     |                               |
  |          |                  |     |         middle+bottom         |
  |          |                  |     |                               |
  +----------+------------------+     +-------------------------------+

  "top" + "middle+bottom:left" + "middle+bottom:center+right"
  (headline band, then sidebar + main)
  +---------------------------------------------+
  |                     top                     |
  +---------------+-----------------------------+
  |               |                             |
  | middle+bottom | middle+bottom:center+right  |
  | :left         |                             |
  |               |                             |
  +---------------+-----------------------------+

The same payload objects work inside regions — here, the sidebar-plus-main shape:

json
{
  "title": "Operating Snapshot",
  "left": {
    "table": {
      "columns": ["Metric", "Value"],
      "rows": [
        ["Revenue", "$4.2M"],
        ["Gross margin", "68%"]
      ]
    }
  },
  "center+right": {
    "chart": {
      "type": "line",
      "data": {
        "columns": ["Month", "Revenue"],
        "rows": [
          ["Jan", 3.4],
          ["Feb", 3.8],
          ["Mar", 4.2]
        ]
      }
    }
  }
}

Accessibility of the preview

The SVG preview and the PowerPoint export agree on what a reader can reach. The slide root is a labelled container (role="group", aria-roledescription="slide", aria-label set to the slide title), never an image: an image makes everything inside it presentational, so a screen reader would hear only the title. Text stays reachable, in the order it is drawn: tag, title and subtitle, the content in layout order, footnotes, then header and footer. That covers body text, list entries, quotes, metric values and labels, table cells, timeline entries, captions and furniture text.

A picture with alt is an image with that name. Purely decorative drawing is hidden (aria-hidden): the background and its overlay, card frames, table cell fills and borders, dividers, timeline markers, watermarks, list bullets, a picture or chart whose alt is "", and a header or footer picture without alt (a logo repeated on every slide). A chart with alt is an image with that name, and its drawn marks are not announced separately. The deck element, the player and renderDeckHtml in @openpresentation/opf-render put the one slide container (region or figure) around the drawing, so a slide is announced once.

Testing status (opf-render#170): this contract is the DOM and ARIA structure. It is checked with axe-core, the browser's accessibility tree and focus-order assertions, and it is not tested with assistive technology: no screen reader (NVDA, JAWS, VoiceOver, TalkBack) has been run against the preview, the deck element or the player (owner decision 2026-10-10). Forced colors are verified with Chromium's emulation only, not the Windows contrast theme (evidence).