{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://krazystitcher.com/docs/ks-open-format-v1.schema.json",
  "title": "Krazy Stitcher pattern file (ks-open-format 1)",
  "description": "One bead pattern, as written by Krazy Stitcher in .ks files, inside .ksp packages, in a pattern folder and by File > Export > Export Open Format (.json). Human-readable spec: https://krazystitcher.com/KrazyStitcher-OpenFormat.html. This schema describes what the app accepts (src-shared/sanitize.js is the authority). Some rules cannot be written in JSON Schema and are checked by the app: grid, shapeMask and every layer grid must have exactly width x height entries; width x height must be at most 4,000,000; symbolOverrides may not give two colors the same symbol; callout rows and columns must lie inside the pattern; a colorway bead must exist in that brand's bead list. A file that breaks a rule marked 'refused' is not imported at all; anything else that is wrong is cleaned (dropped, emptied, shortened or clamped) and the user is told how many cells, colors, layers, notes or names were changed.",
  "type": "object",
  "required": ["width", "height", "grid"],
  "properties": {
    "ksFormat": {
      "description": "Version of this file layout. Written first by every writer since release F; files without it are read as version 1. A whole number above the reader's own version is read (unknown fields are kept) with a note to the user, and saving that pattern again writes the higher number. Any other value is refused in a file being opened; in a pattern already stored in the browser, a backup or the pattern folder, it is removed and the pattern is kept.",
      "type": "integer", "minimum": 1, "maximum": 1000
    },
    "id": {
      "description": "Pattern id. Opening a file always gives the pattern a new id; backups and the pattern folder keep a valid id.",
      "type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"
    },
    "name": { "description": "Pattern name. Longer names are shortened; control characters are removed; empty becomes 'Imported Pattern'.", "type": "string", "maxLength": 200 },
    "type": {
      "description": "Stitch type id (language-neutral; shown translated in the app). Missing, null or '' means 'loom'. Any other value is refused. ladder and albion are Pro and Studio stitches. craw, chenille, triangle and stpetersburg are accepted but no edition can draw them yet.",
      "enum": ["loom", "peyote", "brick", "brick2", "brick3", "peyote2", "peyote3", "peyote4", "peyote5", "peyote6", "peyote7", "peyote8", "peyote9", "gourd", "herringbone", "raw", "square", "spiral", "dutch_spiral", "netting", "shaped_peyote", "ndebele", "ladder", "albion", "crochet-rope", "craw", "chenille", "triangle", "stpetersburg", null, ""]
    },
    "width": { "description": "Beads across. A whole number; anything else is refused.", "type": "integer", "minimum": 1, "maximum": 2000 },
    "height": { "description": "Beads down (rows). A whole number; anything else is refused.", "type": "integer", "minimum": 1, "maximum": 2000 },
    "grid": {
      "description": "One entry per bead, row by row from the top-left: index = row * width + column. null = no bead. A wrong length is refused; a value that is not #rrggbb becomes null and is counted.",
      "type": "array", "items": { "$ref": "#/$defs/cell" }
    },
    "colorMap": {
      "description": "What bead each grid color is. Keys are the exact #rrggbb spelling used in grid. At most 20,000 colors; up to 40 fields per color (field names up to 40 characters); only strings (up to 200 characters), numbers and true/false are kept.",
      "type": "object",
      "propertyNames": { "pattern": "^#[0-9A-Fa-f]{6}$" },
      "additionalProperties": { "$ref": "#/$defs/colorEntry" },
      "maxProperties": 20000
    },
    "created": { "description": "Milliseconds since 1970-01-01 UTC. Missing or invalid: the time of opening.", "type": "number", "minimum": 0, "maximum": 8640000000000000 },
    "modified": { "description": "Milliseconds since 1970-01-01 UTC. Missing or invalid: the time of opening.", "type": "number", "minimum": 0, "maximum": 8640000000000000 },
    "notes": { "description": "Pattern notes. Longer text is shortened.", "type": "string", "maxLength": 200000 },
    "shape": { "description": "Outline the pattern was started from. Any other value becomes 'rect'.", "enum": ["rect", "triangle", "diamond", "hexagon"] },
    "shapeMask": { "description": "width x height true/false values: false = no bead may go here. Any other length is dropped.", "type": "array", "items": { "type": "boolean" } },
    "fringe": {
      "description": "Fringe strands, one entry per column (at most width): null, or up to 500 beads (#rrggbb or null) hanging from that column.",
      "type": "array", "items": { "oneOf": [{ "type": "null" }, { "type": "array", "maxItems": 500, "items": { "$ref": "#/$defs/cell" } }] }
    },
    "workRow": { "description": "Work Tracker: the current row (0 = top). Clamped to 0..height.", "type": "integer", "minimum": 0 },
    "completedRows": { "description": "Work Tracker: rows marked done (0 = top), at most height of them.", "type": "array", "items": { "type": "integer", "minimum": 0 } },
    "originRow": { "description": "Row numbering offset for printed charts.", "type": "integer", "minimum": 0, "maximum": 8000 },
    "originCol": { "description": "Column numbering offset for printed charts.", "type": "integer", "minimum": 0, "maximum": 8000 },
    "splitId": { "description": "Split Pattern: id shared by the tiles of one split.", "type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$" },
    "splitOf": { "description": "Split Pattern: id of the pattern this tile came from.", "type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$" },
    "splitParentName": { "type": "string", "maxLength": 200 },
    "splitGrid": {
      "description": "Split Pattern: where this tile sits. Dropped unless 1 <= rows, cols <= 2000, 0 <= r < rows and 0 <= c < cols.",
      "type": "object", "required": ["rows", "cols", "r", "c"],
      "properties": { "rows": { "type": "integer", "minimum": 1, "maximum": 2000 }, "cols": { "type": "integer", "minimum": 1, "maximum": 2000 }, "r": { "type": "integer", "minimum": 0 }, "c": { "type": "integer", "minimum": 0 } }
    },
    "layers": {
      "description": "Layers (Studio; Essentials has its own simpler layers). At most 64; a layer whose grid has the wrong length is dropped and counted. Studio files: grid is all visible layers combined, bottom (first) to top. Essentials files: grid holds the layer named by activeLayer while it is being edited. Pro keeps layers but shows and edits the combined grid.",
      "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/layer" }
    },
    "activeLayerId": { "description": "Studio: id of the layer being edited (must be one of layers[].id; otherwise the first layer).", "type": "string" },
    "activeLayer": { "description": "Essentials: id of the layer whose beads are in grid and not yet copied back to that layer (must be one of layers[].id; otherwise dropped).", "type": "string" },
    "swap": { "description": "Brand Swap: record of the last swap that made this pattern (Pro and Studio make it; every edition keeps it). Rebuilt from the known fields; dropped when from or to is not a known brand.", "$ref": "#/$defs/swap" },
    "symbolOverrides": {
      "description": "Chart symbols chosen by hand: #rrggbb -> one symbol from the app's symbol list. Unknown symbols, and a second color with the same symbol, are dropped.",
      "type": "object", "propertyNames": { "pattern": "^#[0-9A-Fa-f]{6}$" }, "additionalProperties": { "type": "string", "maxLength": 2 }
    },
    "symbolSet": { "description": "'easy' = Easy to read symbols (numbers and letters first). Any other value is dropped (Classic shapes).", "const": "easy" },
    "beadShape": {
      "description": "Bead Shape: the cell shape drawn for this pattern. Missing or invalid = square cells.",
      "oneOf": [
        { "type": "object", "required": ["mode", "bead"], "properties": {
          "mode": { "const": "bead" },
          "bead": { "enum": ["11delica", "11round", "11rocaille", "11matsuno", "11millhill", "11aiko", "11treasure", "8round", "15round", "10delica"] },
          "holes": { "description": "Any other value becomes 'auto'.", "enum": ["auto", "across", "updown"] },
          "v": { "const": 2 },
          "override": { "description": "Kept only with holes other than 'auto'.", "const": true } } },
        { "type": "object", "required": ["mode", "ratio"], "properties": {
          "mode": { "const": "custom" },
          "ratio": { "description": "Cell height / width, clamped to 0.5..2 and rounded to 0.01; exactly 1 means square and is dropped.", "type": "number", "exclusiveMinimum": 0 } } }
      ]
    },
    "colorways": {
      "description": "Colorways (Pro and Studio; Essentials keeps them but does not show them). At most 3 are kept.",
      "type": "array", "items": { "$ref": "#/$defs/colorway" }
    },
    "callouts": {
      "description": "Callouts on the chart (Studio; Essentials and Pro keep them but do not show them). At most 200; a callout off the grid is dropped.",
      "type": "array", "maxItems": 200, "items": { "$ref": "#/$defs/callout" }
    },
    "stitchLog": {
      "description": "Stitching Timer sessions (all editions). The last 1,000 are kept.",
      "type": "array", "items": { "type": "object", "required": ["s", "ms"], "properties": {
        "s": { "description": "Start, milliseconds since 1970 (whole number after reading).", "type": "number", "minimum": 0, "maximum": 8640000000000000 },
        "ms": { "description": "Length in milliseconds, more than 0; capped at 24 hours.", "type": "number", "exclusiveMinimum": 0 } } }
    }
  },
  "additionalProperties": {
    "description": "Fields this version does not know are kept when the name is at most 60 characters and does not start with '_' (names starting with '_' are the app's own temporary data and are dropped). Values are reduced to JSON: strings up to 2,000 characters, arrays up to 1,000 items, objects up to 200 keys, three levels deep. Keys named __proto__, constructor or prototype are removed everywhere, and a file nested more than 64 levels deep is refused."
  },
  "$defs": {
    "cell": { "oneOf": [{ "type": "null" }, { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" }] },
    "colorEntry": {
      "type": "object",
      "properties": {
        "id": { "description": "Bead code, e.g. 'DB0010' (Miyuki Delica).", "type": "string", "maxLength": 200 },
        "name": { "description": "Bead color name.", "type": "string", "maxLength": 200 },
        "finish": { "description": "Finish chosen when painting (e.g. 'matte'); absent = standard.", "type": "string", "maxLength": 200 },
        "brand": { "description": "Set by Brand Swap: the brand of this bead. Any other value is dropped.", "enum": ["delica", "toho", "preciosa", "rocailles", "matsuno", "millhill"] }
      },
      "additionalProperties": { "type": ["string", "number", "boolean"] }
    },
    "layer": {
      "type": "object", "required": ["grid"],
      "properties": {
        "id": { "description": "Replaced when missing, unsafe or repeated.", "type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$" },
        "name": { "type": "string", "maxLength": 80 },
        "grid": { "type": "array", "items": { "$ref": "#/$defs/cell" } },
        "visible": { "description": "Only false hides the layer.", "type": "boolean" },
        "opacity": { "description": "Essentials writes whole percents 0-100; Pro and Studio write 0-1. Readers convert: a value above 1 can only be a percent, a fraction between 0 and 1 can only be 0-1; when every value is 0 or 1, a list where every layer has a true/false 'locked' is Pro/Studio's 0-1.", "type": "number", "minimum": 0, "maximum": 100 },
        "locked": { "type": "boolean" }
      }
    },
    "swap": {
      "type": "object", "required": ["from", "to"],
      "properties": {
        "from": { "$ref": "#/$defs/swapBrand" }, "to": { "$ref": "#/$defs/swapBrand" },
        "datasetId": { "type": "string", "maxLength": 80 }, "datasetDate": { "type": "string", "maxLength": 40 },
        "wordingVersion": { "type": "string", "maxLength": 40 }, "at": { "type": ["number", "string"] },
        "rows": { "type": "array", "maxItems": 2000, "items": { "type": "object", "required": ["src"], "properties": {
          "src": { "type": "string", "maxLength": 9 }, "dst": { "type": ["string", "null"], "maxLength": 9 },
          "srcId": { "type": "string", "maxLength": 24 }, "dstId": { "type": "string", "maxLength": 24 },
          "rating": { "enum": ["Excellent", "Good", "Fair", "Poor", ""] }, "conf": { "type": "string", "maxLength": 16 }, "kept": { "const": true } } } },
        "resample": { "type": "object", "properties": { "mode": { "enum": ["keep", "width", "size"] },
          "srcDims": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "type": "integer", "minimum": 1, "maximum": 1000 } },
          "dstDims": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "type": "integer", "minimum": 1, "maximum": 1000 } },
          "srcBead": { "type": ["string", "null"], "maxLength": 24 }, "dstBead": { "type": ["string", "null"], "maxLength": 24 },
          "factors": { "type": "object", "required": ["cols", "rows"], "properties": { "cols": { "type": "number" }, "rows": { "type": "number" } } } } },
        "history": { "type": "array", "maxItems": 5, "items": { "type": "object", "properties": { "from": { "$ref": "#/$defs/swapBrand" }, "to": { "$ref": "#/$defs/swapBrand" }, "datasetId": { "type": "string", "maxLength": 80 }, "at": { "type": ["number", "string"] } } } }
      }
    },
    "swapBrand": { "enum": ["delica", "toho", "preciosa", "rocailles", "matsuno", "millhill"] },
    "colorway": {
      "type": "object",
      "properties": {
        "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,24}$" },
        "name": { "type": "string", "maxLength": 40 },
        "brand": { "description": "Colorway brand ids (note: not the same spelling as Brand Swap's). Any other value becomes 'miyuki'.", "enum": ["miyuki", "toho", "preciosa", "miyukiRocailles", "matsuno", "millHill"] },
        "map": {
          "description": "Grid color -> bead of this brand. On reading, name and hex are taken from the app's own bead list for that id; ids not in the list, and colors not in the grid, are dropped.",
          "type": "object", "propertyNames": { "pattern": "^#[0-9A-Fa-f]{6}$" },
          "additionalProperties": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "hex": { "type": "string" } } }
        }
      }
    },
    "callout": {
      "type": "object", "required": ["kind", "r", "c"],
      "properties": {
        "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,24}$" },
        "kind": { "enum": ["note", "start", "step", "arrow"] },
        "r": { "description": "Row, 0 = top.", "type": "integer", "minimum": 0 },
        "c": { "description": "Column, 0 = left.", "type": "integer", "minimum": 0 },
        "r2": { "description": "Arrow end row (required for arrows).", "type": "integer", "minimum": 0 },
        "c2": { "description": "Arrow end column (required for arrows).", "type": "integer", "minimum": 0 },
        "text": { "description": "Notes (required, not empty) and arrows. Control characters become spaces.", "type": "string", "maxLength": 80 },
        "n": { "description": "Step number (steps). Missing or invalid becomes 1.", "type": "integer", "minimum": 1, "maximum": 999 }
      }
    }
  }
}
