{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://docs.printless.app/schemas/printless-v2.schema.json",
  "title": ".printless Document v2.0",
  "description": "Canonical schema for .printless v2.0 (guide: https://docs.printless.app, section \".printless v2.0\"). v2 is v1.2 plus images carried as text: a document-level `assets` map (raster AVIF/WebP, vector SVG, 1-bit mono PNG, signature strokes), each with media type, size, sha256 and ThumbHash, referenced by id from image, brand_header and signature blocks. It also adopts the shared presentation definitions drafted for v1.3 (textSize with sm, direction-relative align, tone, ext) and barcode captions. No block type is added or removed. The envelope is marked by `printless: \"2.x\"` and MUST NOT carry the v1 marker `printlessDoc`. Unknown extra properties on blocks are still ignored by renderers; unknown block types are still rejected. What the schema cannot check — that bytes match their sha256, true pixel sizes, per-document totals, that every reference resolves — is checked by the Printless server when a document is issued.",
  "type": "object",
  "required": [
    "printless",
    "blocks"
  ],
  "properties": {
    "printless": {
      "description": "Format version, \"major.minor\". Renderers gate on the major: a 2.x renderer reads every 2.y document (unknown minor additions degrade via fallbackText and `uses`).",
      "type": "string",
      "pattern": "^2\\.(0|[1-9][0-9]{0,2})$"
    },
    "type": {
      "description": "What kind of document this is — receipt, token, ticket, parking, invoice, booking, work_order, pass, invitation, coupon, print, or any vendor-defined name. Renderers dispatch on this to pick a type-specific layout, exactly as they dispatch on Print.printType today; a type they do not recognise renders generically from blocks, which is what makes vendor-defined formats work with no client release. Optional for backward compatibility (documents issued before this field default to \"print\"); the server stamps it on every document going forward.",
      "type": "string",
      "minLength": 1,
      "maxLength": 64,
      "pattern": "^[a-z][a-z0-9_]*$"
    },
    "blocks": {
      "$ref": "#/$defs/blocks"
    },
    "lang": {
      "type": "string",
      "pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$",
      "maxLength": 35,
      "description": "BCP 47 language tag of the document's text (\"en\", \"ar\", \"ja\"). Renderers pick fonts and hyphenation from it. Absent = the app's language."
    },
    "dir": {
      "enum": [
        "ltr",
        "rtl"
      ],
      "description": "Writing direction. Absent = derived from lang (ar, he, fa, ur → rtl) — set it explicitly for a bilingual document."
    },
    "locale": {
      "type": "string",
      "pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$",
      "maxLength": 35,
      "description": "Locale for number, currency and date formatting (\"en-IN\", \"ar-SA\", \"ja-JP\") when it differs from lang. Not used by the server PDF, which formats numbers, money and dates from its own language and the currency rules so its bytes never depend on a field a document can vary."
    },
    "uses": {
      "type": "array",
      "maxItems": 30,
      "uniqueItems": true,
      "items": {
        "type": "string",
        "maxLength": 40
      },
      "description": "Block types in this document that postdate v1.1 (\"meter\"). Optional hint so a renderer that lacks one can offer an update instead of silently showing fallbackText."
    },
    "assets": {
      "$ref": "#/$defs/assets"
    }
  },
  "$defs": {
    "blocks": {
      "type": "array",
      "minItems": 1,
      "maxItems": 200,
      "items": {
        "$ref": "#/$defs/block"
      }
    },
    "block": {
      "type": "object",
      "required": [
        "type"
      ],
      "properties": {
        "type": {
          "enum": [
            "header",
            "paragraph",
            "label_value",
            "metric_grid",
            "divider",
            "spacer",
            "line_items",
            "summary",
            "stat_row",
            "qr",
            "barcode",
            "brand_header",
            "page_break",
            "image",
            "table",
            "list",
            "callout",
            "quote",
            "code",
            "chart",
            "signature",
            "attachment",
            "columns",
            "meter"
          ]
        },
        "fallbackText": {
          "description": "Plain-text rendering of this block, shown by any renderer that does not recognise the block type. Optional on input — the server derives one for every block at issue time, so stored documents always carry it. A renderer MUST NOT silently drop a block it cannot render: it shows fallbackText, or a visible 'unsupported content' placeholder when absent. Never omit a block from the display.",
          "type": "string",
          "maxLength": 2000
        },
        "key": {
          "description": "Stable semantic identifier for this block within its document type, e.g. \"queuePosition\", \"vehicleNumber\", \"total\". This is what lets a type-specific screen render a bespoke layout while the document stays a single list of blocks: the token screen looks up key \"queuePosition\" instead of relying on block order, and a generic renderer ignores keys and draws the blocks in sequence. Keys are unique within a document. Without this, a bespoke layout would need the structured payload stored alongside the blocks, and the two would drift.",
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$"
        },
        "ext": {
          "$ref": "#/$defs/ext"
        }
      },
      "allOf": [
        {
          "if": {
            "properties": {
              "type": {
                "const": "header"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/headerBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "paragraph"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/paragraphBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "label_value"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/labelValueBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "metric_grid"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/metricGridBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "divider"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/dividerBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "spacer"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/spacerBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "line_items"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/lineItemsBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "summary"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/summaryBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "stat_row"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/statRowBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "qr"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/qrBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "barcode"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/barcodeBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "brand_header"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/brandHeaderBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "image"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/imageBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "table"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/tableBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "list"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/listBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "callout"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/calloutBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "quote"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/quoteBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "code"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/codeBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "chart"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/chartBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "signature"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/signatureBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "attachment"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/attachmentBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "columns"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/columnsBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "meter"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/meterBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "page_break"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/pageBreakBlock"
          }
        }
      ]
    },
    "plainOrRichText": {
      "description": "Either a plain string or a rich-text span array (Notion-compatible subset).",
      "oneOf": [
        {
          "type": "string",
          "maxLength": 20000
        },
        {
          "$ref": "#/$defs/richText"
        }
      ]
    },
    "richText": {
      "type": "array",
      "minItems": 1,
      "maxItems": 100,
      "items": {
        "$ref": "#/$defs/richTextSpan"
      }
    },
    "richTextSpan": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "type": {
          "const": "text"
        },
        "text": {
          "type": "object",
          "required": [
            "content"
          ],
          "properties": {
            "content": {
              "type": "string",
              "maxLength": 4000
            },
            "link": {
              "oneOf": [
                {
                  "type": "null"
                },
                {
                  "type": "object",
                  "required": [
                    "url"
                  ],
                  "properties": {
                    "url": {
                      "type": "string",
                      "format": "uri",
                      "maxLength": 2000
                    }
                  }
                }
              ]
            }
          }
        },
        "annotations": {
          "type": "object",
          "properties": {
            "bold": {
              "type": "boolean"
            },
            "italic": {
              "type": "boolean"
            },
            "strikethrough": {
              "type": "boolean"
            },
            "underline": {
              "type": "boolean"
            },
            "code": {
              "type": "boolean"
            }
          }
        }
      }
    },
    "scalarText": {
      "description": "String value; numbers are accepted and rendered with default formatting.",
      "type": [
        "string",
        "number"
      ]
    },
    "headerBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "type": "string",
          "maxLength": 500
        },
        "size": {
          "$ref": "#/$defs/textSize"
        },
        "align": {
          "$ref": "#/$defs/align"
        }
      }
    },
    "paragraphBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "$ref": "#/$defs/plainOrRichText"
        },
        "size": {
          "$ref": "#/$defs/textSize"
        },
        "align": {
          "$ref": "#/$defs/align"
        }
      },
      "description": "A run of text. `size` and `align` are new in v2: a caption is a paragraph at \"sm\". Absent size renders exactly as v1."
    },
    "labelValueBlock": {
      "type": "object",
      "required": [
        "value"
      ],
      "properties": {
        "label": {
          "type": "string",
          "maxLength": 200
        },
        "value": {
          "oneOf": [
            {
              "$ref": "#/$defs/scalarText"
            },
            {
              "$ref": "#/$defs/richText"
            }
          ]
        },
        "bold": {
          "type": "boolean"
        },
        "currency": {
          "description": "ISO 4217 code, present only when the numeric value is meant for client-side money computation (e.g. parking hourlyRate × elapsed). Blocks are self-contained — the currency lives on the block that needs it, never on the envelope.",
          "type": "string",
          "minLength": 3,
          "maxLength": 8
        },
        "format": {
          "$ref": "#/$defs/valueFormat"
        }
      },
      "description": "A label and its value. On a keyed block (`key`) the label is optional: every renderer derives it from the key, the document type and `lang` (the Printless key vocabulary), and the server down-converts with it filled. A key the registry has no label for in the document's language needs its own `label` (otherwise refused with LABEL_REQUIRED).",
      "anyOf": [
        {
          "required": [
            "label"
          ]
        },
        {
          "required": [
            "key"
          ]
        }
      ],
      "allOf": [
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "money"
              }
            }
          },
          "then": {
            "required": [
              "currency"
            ],
            "properties": {
              "value": {
                "anyOf": [
                  {
                    "type": "number"
                  },
                  {
                    "type": "string",
                    "pattern": "^-?[0-9]+(\\.[0-9]+)?$"
                  }
                ]
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "number"
              }
            }
          },
          "then": {
            "properties": {
              "value": {
                "anyOf": [
                  {
                    "type": "number"
                  },
                  {
                    "type": "string",
                    "pattern": "^-?[0-9]+(\\.[0-9]+)?$"
                  }
                ]
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "date"
              }
            }
          },
          "then": {
            "properties": {
              "value": {
                "type": "string",
                "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "datetime"
              }
            }
          },
          "then": {
            "properties": {
              "value": {
                "type": "string",
                "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "enum"
              }
            }
          },
          "then": {
            "properties": {
              "value": {
                "type": "string",
                "pattern": "^[A-Za-z][A-Za-z0-9_]*$"
              }
            }
          }
        }
      ]
    },
    "metricGridBlock": {
      "type": "object",
      "required": [
        "items"
      ],
      "properties": {
        "columns": {
          "type": "integer",
          "minimum": 1,
          "maximum": 4
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 12,
          "items": {
            "$ref": "#/$defs/labelValuePair"
          }
        }
      }
    },
    "labelValuePair": {
      "type": "object",
      "required": [
        "label",
        "value"
      ],
      "properties": {
        "label": {
          "type": "string",
          "maxLength": 200
        },
        "value": {
          "$ref": "#/$defs/scalarText"
        },
        "ext": {
          "$ref": "#/$defs/ext"
        },
        "format": {
          "$ref": "#/$defs/valueFormat"
        },
        "currency": {
          "description": "New in v2: ISO 4217 code, with format \"money\".",
          "type": "string",
          "minLength": 3,
          "maxLength": 8
        }
      },
      "allOf": [
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "money"
              }
            }
          },
          "then": {
            "required": [
              "currency"
            ],
            "properties": {
              "value": {
                "anyOf": [
                  {
                    "type": "number"
                  },
                  {
                    "type": "string",
                    "pattern": "^-?[0-9]+(\\.[0-9]+)?$"
                  }
                ]
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "number"
              }
            }
          },
          "then": {
            "properties": {
              "value": {
                "anyOf": [
                  {
                    "type": "number"
                  },
                  {
                    "type": "string",
                    "pattern": "^-?[0-9]+(\\.[0-9]+)?$"
                  }
                ]
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "date"
              }
            }
          },
          "then": {
            "properties": {
              "value": {
                "type": "string",
                "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "datetime"
              }
            }
          },
          "then": {
            "properties": {
              "value": {
                "type": "string",
                "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "format"
            ],
            "properties": {
              "format": {
                "const": "enum"
              }
            }
          },
          "then": {
            "properties": {
              "value": {
                "type": "string",
                "pattern": "^[A-Za-z][A-Za-z0-9_]*$"
              }
            }
          }
        }
      ]
    },
    "dividerBlock": {
      "type": "object",
      "properties": {
        "style": {
          "enum": [
            "solid",
            "dashed",
            "dotted"
          ]
        }
      }
    },
    "spacerBlock": {
      "type": "object",
      "properties": {
        "size": {
          "enum": [
            "sm",
            "md",
            "lg"
          ]
        }
      }
    },
    "lineItemsBlock": {
      "type": "object",
      "required": [
        "items"
      ],
      "properties": {
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 200,
          "items": {
            "type": "object",
            "required": [
              "label"
            ],
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 500
              },
              "amount": {
                "type": [
                  "number",
                  "string"
                ]
              },
              "currency": {
                "type": "string",
                "maxLength": 8
              },
              "ext": {
                "$ref": "#/$defs/ext"
              }
            }
          }
        }
      }
    },
    "summaryBlock": {
      "type": "object",
      "required": [
        "rows"
      ],
      "properties": {
        "rows": {
          "type": "array",
          "minItems": 1,
          "maxItems": 50,
          "items": {
            "type": "object",
            "required": [
              "label"
            ],
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 200
              },
              "value": {
                "$ref": "#/$defs/scalarText"
              },
              "secondary": {
                "type": "boolean"
              },
              "bold": {
                "type": "boolean"
              },
              "large": {
                "type": "boolean"
              },
              "ext": {
                "$ref": "#/$defs/ext"
              },
              "amount": {
                "description": "New in v2: the row's figure as a number in the major unit (57.64, not 5764).",
                "type": "number"
              },
              "currency": {
                "description": "New in v2: ISO 4217 code of `amount`. Absent = a plain number.",
                "type": "string",
                "minLength": 3,
                "maxLength": 8
              }
            },
            "description": "One row. `amount` (new in v2) is the figure as a number in the major unit, with `currency`; renderers format it when `value` is absent, and when both are present `value` is what is printed and MUST show the same figure. v1 kept the number in ext.amount; the down-conversion writes it there again.",
            "anyOf": [
              {
                "required": [
                  "value"
                ]
              },
              {
                "required": [
                  "amount"
                ]
              }
            ]
          }
        }
      }
    },
    "statRowBlock": {
      "type": "object",
      "required": [
        "items"
      ],
      "properties": {
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 6,
          "items": {
            "$ref": "#/$defs/labelValuePair"
          }
        }
      }
    },
    "qrBlock": {
      "type": "object",
      "required": [
        "value"
      ],
      "properties": {
        "value": {
          "type": "string",
          "minLength": 1,
          "maxLength": 2000
        },
        "caption": {
          "type": "string",
          "maxLength": 200
        }
      }
    },
    "barcodeBlock": {
      "type": "object",
      "required": [
        "value"
      ],
      "properties": {
        "format": {
          "enum": [
            "code128",
            "qr",
            "pdf417"
          ],
          "description": "v2.0 keeps code128 | qr | pdf417; EAN-13, Data Matrix and Aztec are planned for v2.1."
        },
        "value": {
          "type": "string",
          "minLength": 1,
          "maxLength": 500
        },
        "caption": {
          "description": "New in v2. Text under the code, as on `qr`.",
          "type": "string",
          "maxLength": 200
        }
      }
    },
    "brandHeaderBlock": {
      "type": "object",
      "properties": {
        "logo": {
          "description": "v1: a URL or data URI. Still read by v2 renderers for v1-era documents; v2 issuers send `asset` and the server converts a data-URI logo to an asset at issue. A remote URL is never fetched by the server PDF.",
          "type": "string",
          "format": "uri",
          "maxLength": 2000
        },
        "name": {
          "type": "string",
          "maxLength": 200
        },
        "tagline": {
          "type": "string",
          "maxLength": 300
        },
        "asset": {
          "description": "New in v2: the logo as an asset (raster, vector or mono). Wins over `logo`.",
          "$ref": "#/$defs/assetId"
        },
        "address": {
          "description": "New in v2: the issuer's postal address as printed (one string, lines separated by \"\\n\"). Filled by the server from the issuing business at issue; a sent value is replaced.",
          "type": "string",
          "maxLength": 500
        },
        "phone": {
          "description": "New in v2: the issuer's phone number as printed. Server-filled, as `address`.",
          "type": "string",
          "maxLength": 40
        },
        "email": {
          "description": "New in v2: the issuer's email as printed. Server-filled, as `address`.",
          "type": "string",
          "maxLength": 254
        },
        "taxNumber": {
          "description": "New in v2: the issuer's tax registration number (VAT, GSTIN, TRN…) exactly as registered. Renderers label it from the issuer's tax country and the document `lang` (\"VAT number\", \"الرقم الضريبي\"). Server-filled, as `address`.",
          "type": "string",
          "maxLength": 64
        }
      }
    },
    "imageBlock": {
      "description": "A picture: an asset reference (v2) or, for v1-era documents, inline SVG source. Exactly one of `asset` and `svg`.",
      "type": "object",
      "properties": {
        "asset": {
          "description": "New in v2: raster, vector or mono asset id.",
          "$ref": "#/$defs/assetId"
        },
        "svg": {
          "description": "v1 inline SVG. Valid in v2; the server moves it into a vector asset at issue, so stored v2 documents use `asset`.",
          "type": "string",
          "minLength": 10,
          "maxLength": 120000
        },
        "alt": {
          "description": "What the picture says, for screen readers and for the fallback text.",
          "type": "string",
          "maxLength": 300
        },
        "caption": {
          "$ref": "#/$defs/plainOrRichText"
        },
        "align": {
          "$ref": "#/$defs/align"
        },
        "width": {
          "description": "Fraction of the content column - never pixels. \"quarter\" is new in v2 (ID-card portraits, thumbnails).",
          "enum": [
            "full",
            "half",
            "third",
            "quarter"
          ]
        },
        "aspect": {
          "description": "width ÷ height of the box. Optional: absent = the asset's own aspect. With fit \"cover\" it crops to this box.",
          "type": "number",
          "exclusiveMinimum": 0,
          "maximum": 20
        },
        "fit": {
          "description": "New in v2. contain (default) shows the whole picture; cover fills the box (needs `aspect`) and crops the centre.",
          "enum": [
            "contain",
            "cover"
          ]
        },
        "frame": {
          "description": "New in v2. none (default), rounded (a portrait card) or circle (an avatar; implies a square box).",
          "enum": [
            "none",
            "rounded",
            "circle"
          ]
        }
      },
      "oneOf": [
        {
          "required": [
            "asset"
          ],
          "not": {
            "required": [
              "svg"
            ]
          }
        },
        {
          "required": [
            "svg"
          ],
          "not": {
            "required": [
              "asset"
            ]
          }
        }
      ]
    },
    "tableCell": {
      "description": "Scalar text or a rich-text span array.",
      "oneOf": [
        {
          "$ref": "#/$defs/scalarText"
        },
        {
          "$ref": "#/$defs/richText"
        }
      ]
    },
    "tableBlock": {
      "type": "object",
      "required": [
        "rows"
      ],
      "properties": {
        "columns": {
          "description": "Header + per-column alignment. Omit `label` for a headerless table.",
          "type": "array",
          "minItems": 1,
          "maxItems": 12,
          "items": {
            "type": "object",
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 120
              },
              "align": {
                "$ref": "#/$defs/align"
              },
              "width": {
                "description": "Relative weight, not pixels.",
                "type": "number",
                "minimum": 0.1,
                "maximum": 10
              }
            }
          }
        },
        "rows": {
          "type": "array",
          "minItems": 1,
          "maxItems": 200,
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 12,
            "items": {
              "$ref": "#/$defs/tableCell"
            }
          }
        },
        "zebra": {
          "description": "Alternate row shading.",
          "type": "boolean"
        }
      }
    },
    "listBlock": {
      "type": "object",
      "required": [
        "items"
      ],
      "properties": {
        "style": {
          "enum": [
            "bulleted",
            "numbered",
            "checklist"
          ]
        },
        "start": {
          "description": "First number for a numbered list.",
          "type": "integer",
          "minimum": 1
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 200,
          "items": {
            "oneOf": [
              {
                "$ref": "#/$defs/scalarText"
              },
              {
                "type": "object",
                "required": [
                  "text"
                ],
                "properties": {
                  "text": {
                    "$ref": "#/$defs/plainOrRichText"
                  },
                  "level": {
                    "description": "Indent depth. Padding, not nested blocks.",
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 2
                  },
                  "checked": {
                    "type": "boolean"
                  }
                }
              }
            ]
          }
        }
      }
    },
    "calloutBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "$ref": "#/$defs/plainOrRichText"
        },
        "title": {
          "type": "string",
          "maxLength": 200
        },
        "icon": {
          "description": "A single emoji.",
          "type": "string",
          "maxLength": 8
        },
        "tone": {
          "$ref": "#/$defs/tone"
        }
      }
    },
    "quoteBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "$ref": "#/$defs/plainOrRichText"
        },
        "attribution": {
          "type": "string",
          "maxLength": 200
        }
      }
    },
    "codeBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "type": "string",
          "maxLength": 20000
        },
        "language": {
          "description": "A hint a renderer may ignore; highlighting is not required.",
          "type": "string",
          "maxLength": 40
        },
        "caption": {
          "$ref": "#/$defs/plainOrRichText"
        }
      }
    },
    "chartBlock": {
      "type": "object",
      "required": [
        "kind",
        "series"
      ],
      "properties": {
        "kind": {
          "description": "The preferred shape. The server PDF draws every kind as bars, because on paper the numbers are the point, and each reference as a dashed limit line.",
          "enum": [
            "bar",
            "line",
            "pie",
            "donut"
          ]
        },
        "title": {
          "type": "string",
          "maxLength": 200
        },
        "labels": {
          "type": "array",
          "maxItems": 50,
          "items": {
            "type": "string",
            "maxLength": 60
          }
        },
        "series": {
          "type": "array",
          "minItems": 1,
          "maxItems": 6,
          "items": {
            "type": "object",
            "required": [
              "data"
            ],
            "properties": {
              "name": {
                "type": "string",
                "maxLength": 80
              },
              "data": {
                "type": "array",
                "minItems": 1,
                "maxItems": 50,
                "items": {
                  "type": [
                    "number",
                    "null"
                  ]
                }
              }
            }
          }
        },
        "reference": {
          "description": "Dashed limit line per label — the thing clinical and financial charts need.",
          "type": "array",
          "maxItems": 50,
          "items": {
            "type": [
              "number",
              "null"
            ]
          }
        },
        "unit": {
          "type": "string",
          "maxLength": 20
        },
        "showValues": {
          "type": "boolean"
        }
      }
    },
    "signatureBlock": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "maxLength": 200
        },
        "role": {
          "type": "string",
          "maxLength": 200
        },
        "signatureSvg": {
          "description": "The signature itself as inline SVG (a path traced from the signing pad). A ruled line is drawn when absent.",
          "type": "string",
          "minLength": 10,
          "maxLength": 60000
        },
        "signedAt": {
          "type": "string",
          "format": "date-time"
        },
        "statement": {
          "type": "string",
          "maxLength": 300
        },
        "asset": {
          "description": "New in v2: the drawn signature as an asset — strokes (preferred), vector or mono. Contract signatures are NOT carried here at issue; the server projects them at read time.",
          "$ref": "#/$defs/assetId"
        },
        "stamp": {
          "description": "New in v2: a seal drawn with the signature (mono, raster or vector asset). The server fills it with the business stamp (asset \"brand-stamp\") where the layout asks for one — v1 marked that with ext.stamp: true, which v2 still honours.",
          "$ref": "#/$defs/assetId"
        }
      },
      "not": {
        "required": [
          "asset",
          "signatureSvg"
        ],
        "description": "One drawing per block."
      }
    },
    "attachmentBlock": {
      "type": "object",
      "required": [
        "url"
      ],
      "properties": {
        "url": {
          "type": "string",
          "maxLength": 2000
        },
        "label": {
          "type": "string",
          "maxLength": 200
        },
        "mime": {
          "type": "string",
          "maxLength": 100
        },
        "sizeBytes": {
          "type": "integer",
          "minimum": 0
        },
        "ext": {
          "allOf": [
            {
              "$ref": "#/$defs/ext"
            }
          ],
          "properties": {
            "attachment": {
              "description": "Written ONLY by the read-time projection (stripped from issuer input). Identifies the file in the print's attachments descriptor.",
              "type": "object",
              "required": [
                "index"
              ],
              "properties": {
                "index": {
                  "description": "Descriptor index; the file is served at /v1/print/{printId}/file/{index}.",
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 9
                },
                "sha256": {
                  "description": "Lowercase hex SHA-256 of the file bytes. On a Contract it is part of what each signature covers.",
                  "type": "string",
                  "pattern": "^[0-9a-f]{64}$"
                },
                "fieldKey": {
                  "description": "The template `files` field or added Files field the file was attached to; absent for a Legacy upload or a Contract supporting document.",
                  "type": "string",
                  "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,39}$"
                },
                "party": {
                  "description": "Contracts only: who attached it.",
                  "enum": [
                    "recipient",
                    "issuer"
                  ]
                },
                "removed": {
                  "description": "The file was deleted on request under the retention rule; the card stays, disabled.",
                  "const": true
                }
              }
            },
            "attachmentSlot": {
              "description": "A stored placeholder for a files field (issuer-written). Never reaches a reader.",
              "type": "object",
              "required": [
                "fieldKey"
              ],
              "properties": {
                "fieldKey": {
                  "type": "string",
                  "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,39}$"
                }
              }
            }
          }
        }
      },
      "description": "A file card (PDF or image) opened in the device viewer over the authorised `url`; never drawn inline, never embedded in the PDF. v2: the one attachments component on every print type — Legacy files, Contract supporting documents, template `files` fields and added Files fields. Readers receive cards the server projected at read time, with projection-only `ext.attachment` { index, sha256, fieldKey?, party?, removed? }. A stored document may instead hold a slot for a files field: url \"printless:attachments/{fieldKey}\" with `ext.attachmentSlot` { fieldKey }, which the projection replaces with that field's cards or removes; renderers draw nothing for a slot. Using attachments needs the Professional or Business plan (402 ATTACHMENTS_REQUIRE_PLAN)."
    },
    "columnsBlock": {
      "description": "The only nesting block, one level deep — enforced by nestedBlock. A narrow screen may stack the columns and ignore `ratio` — that is a correct rendering, not a fallback.",
      "type": "object",
      "required": [
        "columns"
      ],
      "properties": {
        "ratio": {
          "type": "array",
          "minItems": 2,
          "maxItems": 3,
          "items": {
            "type": "number",
            "minimum": 0.1,
            "maximum": 10
          }
        },
        "columns": {
          "type": "array",
          "minItems": 2,
          "maxItems": 3,
          "items": {
            "type": "object",
            "required": [
              "blocks"
            ],
            "properties": {
              "blocks": {
                "type": "array",
                "minItems": 1,
                "maxItems": 30,
                "items": {
                  "$ref": "#/$defs/nestedBlock"
                }
              }
            }
          }
        }
      }
    },
    "nestedBlock": {
      "description": "A block inside a column: anything a top-level block may be, except another `columns`. The prose has always said one level deep, but v1.1 expressed the children as a plain $ref back to `block`, which made arbitrary depth schema-legal and let a document smuggle thousands of blocks past the 200-block cap.",
      "allOf": [
        {
          "$ref": "#/$defs/block"
        },
        {
          "properties": {
            "type": {
              "not": {
                "const": "columns"
              }
            }
          }
        }
      ]
    },
    "meterBlock": {
      "description": "A value against its range — the HTML <meter>: data used of a quota, a fuel or charge level, stamps on a punch card, sessions left on a package. The one v1.2 block: metric_grid and stat_row can state a value but not what it is out of. Required: current, max. Optional: min (default 0), unit, tone, minLabel/maxLabel for meters where the number is not the point (\"E\" → \"F\").",
      "type": "object",
      "required": [
        "current",
        "max"
      ],
      "properties": {
        "label": {
          "type": "string",
          "maxLength": 200
        },
        "current": {
          "type": "number"
        },
        "min": {
          "description": "Defaults to 0.",
          "type": "number"
        },
        "max": {
          "type": "number"
        },
        "unit": {
          "description": "\"GB\", \"kWh\", \"%\", \"stamps\". Omit for a bare count.",
          "type": "string",
          "maxLength": 20
        },
        "minLabel": {
          "description": "Endpoint label where the number is not the point — \"E\" on a fuel gauge.",
          "type": "string",
          "maxLength": 40
        },
        "maxLabel": {
          "type": "string",
          "maxLength": 40
        },
        "tone": {
          "$ref": "#/$defs/tone"
        }
      }
    },
    "ext": {
      "description": "Reserved extension namespace. Anything the vocabulary does not name — a tax code, a PLU, a seat, an integrator's own ids — goes here, never as a new top-level property, so it can never collide with a future core property. Renderers ignore it; it survives round-trips. Exception: the contract-signing keys (signed, signedWith, signedOn, signedAt, signatureVoided) are written ONLY by the server's read-time projection and are stripped from anything an issuer sends.",
      "type": "object"
    },
    "textSize": {
      "description": "Display step, largest to smallest. On `header`, absent = md. On `paragraph`, absent and \"md\" are body text, \"xl\"/\"lg\" are headings and \"sm\" is the caption step.",
      "enum": [
        "xl",
        "lg",
        "md",
        "sm"
      ]
    },
    "align": {
      "description": "Alignment relative to the WRITING DIRECTION, not the physical page. \"start\" and \"end\" are canonical; \"left\" and \"right\" are accepted as direction-relative aliases (left = start, right = end) because v1 spelled it that way on `header`. A right-to-left document mirrors either spelling.",
      "enum": [
        "start",
        "center",
        "end",
        "left",
        "right"
      ]
    },
    "tone": {
      "description": "Semantic, never a colour — each app maps it to its own palette so a tone stays correct in dark mode and in print.",
      "enum": [
        "info",
        "success",
        "warning",
        "danger",
        "neutral"
      ]
    },
    "assetId": {
      "description": "Key of an entry in the document `assets` map.",
      "type": "string",
      "pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,31}$"
    },
    "assets": {
      "description": "Asset ids starting with \"brand-\" are reserved for the business brand assets the server embeds from Brand settings (brand-logo, brand-stamp), and ids starting with \"sig-\" for the signature drawings the server projects at read time; an issuer may not send either (ASSET_RESERVED). Every picture the document shows, stored once and referenced by id (like PDF XObjects). Limits beyond shape — at most 8 images (raster+mono+vector; the server-embedded brand-logo and brand-stamp are not counted), 8 signatures, 256 KB decoded in total, every entry referenced at least once, no two entries with the same sha256 — are enforced by the server when a document is issued.",
      "type": "object",
      "maxProperties": 16,
      "propertyNames": {
        "pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,31}$"
      },
      "additionalProperties": {
        "$ref": "#/$defs/asset"
      }
    },
    "asset": {
      "type": "object",
      "required": [
        "kind",
        "type",
        "width",
        "height",
        "sha256",
        "data"
      ],
      "additionalProperties": false,
      "properties": {
        "kind": {
          "description": "raster = photo (AVIF preferred, WebP fallback); vector = sanitised SVG; mono = 1-bit greyscale PNG (stamps, scans), drawn in an ink colour; strokes = signature pen points, static in v2.0 (no pressure or timing).",
          "enum": [
            "raster",
            "vector",
            "mono",
            "strokes"
          ]
        },
        "type": {
          "description": "Media type of `data`.",
          "type": "string",
          "maxLength": 64
        },
        "width": {
          "description": "Pixels (raster, mono), viewBox width rounded (vector) or grid width (strokes). Must match the data.",
          "type": "integer",
          "minimum": 1,
          "maximum": 4096
        },
        "height": {
          "description": "As width.",
          "type": "integer",
          "minimum": 1,
          "maximum": 4096
        },
        "sha256": {
          "description": "Lowercase hex SHA-256 of the decoded bytes (vector: of the UTF-8 SVG text).",
          "type": "string",
          "pattern": "^[0-9a-f]{64}$"
        },
        "thumbhash": {
          "description": "ThumbHash (https://evanw.github.io/thumbhash/), base64. Painted instantly while the asset decodes, and in its place if it cannot. Required on every kind except strokes.",
          "type": "string",
          "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",
          "minLength": 8,
          "maxLength": 36
        },
        "data": {
          "description": "Base64 (strict, padded) of the file bytes; for vector, the SVG source text itself.",
          "type": "string",
          "minLength": 4
        },
        "alt": {
          "description": "What the picture says, for screen readers, fallbackText and the v1 down-conversion. Blocks may override it.",
          "type": "string",
          "maxLength": 300
        },
        "ext": {
          "$ref": "#/$defs/ext"
        }
      },
      "allOf": [
        {
          "if": {
            "properties": {
              "kind": {
                "const": "raster"
              }
            }
          },
          "then": {
            "required": [
              "thumbhash"
            ],
            "properties": {
              "type": {
                "enum": [
                  "image/avif",
                  "image/webp"
                ]
              },
              "data": {
                "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",
                "maxLength": 87384
              },
              "width": {
                "maximum": 1024
              },
              "height": {
                "maximum": 1024
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "kind": {
                "const": "mono"
              }
            }
          },
          "then": {
            "required": [
              "thumbhash"
            ],
            "properties": {
              "type": {
                "const": "image/png"
              },
              "data": {
                "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",
                "maxLength": 87384
              },
              "width": {
                "maximum": 1024
              },
              "height": {
                "maximum": 1024
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "kind": {
                "const": "vector"
              }
            }
          },
          "then": {
            "required": [
              "thumbhash"
            ],
            "properties": {
              "type": {
                "const": "image/svg+xml"
              },
              "data": {
                "maxLength": 120000,
                "pattern": "^\\s*<svg[\\s>]"
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "kind": {
                "const": "strokes"
              }
            }
          },
          "then": {
            "properties": {
              "type": {
                "const": "application/vnd.printless.strokes"
              },
              "data": {
                "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",
                "maxLength": 5464
              }
            }
          }
        }
      ]
    },
    "pageBreakBlock": {
      "description": "Forces the next block onto a new page. Screens draw it as a thin rule or nothing.",
      "type": "object"
    },
    "valueFormat": {
      "description": "What `value` holds, so renderers format it for the reader and converters write it one way. text (default): shown as is. number: a JSON number (or a numeric string), formatted for the reader's locale, always with the digits 0-9. money: a number in the major unit with `currency`. date: \"YYYY-MM-DD\". datetime: ISO 8601 with \"Z\" or an offset, shown in the print's zone. enum: a code the Printless key vocabulary names for the block's key, shown by its label. code: an identifier (ticket, PNR, invoice number) shown verbatim, left to right, never translated.",
      "enum": [
        "text",
        "number",
        "money",
        "date",
        "datetime",
        "enum",
        "code"
      ]
    }
  },
  "not": {
    "required": [
      "printlessDoc"
    ],
    "description": "v2 never carries the v1 marker, so a document is exactly one version."
  }
}
