TickMark

Image metadata API

Read the route inside the photo.

Every image TickMark exports carries its climb as structured data: grade, styles, rules, every outlined hold and the route markers. Read it from any app, in any language, without an account or a network call.

route data v3compatible ≥ v2XMPPNG · JPEG

Overview

TickMark writes standard XMP into each export, in its own namespace. Photos apps ignore unknown namespaces, so captions, keywords and dates stay untouched. Inside, one property, tickmark:Data, holds a JSON document: that JSON is the contract this page documents. A few flat, human-readable properties are written alongside it for quick inspection.

Namespacehttps://tickmark.baguette-studios.dev/ns/1.0/
Prefix tickmark
Payloadtickmark:Data — UTF-8 JSON, format: "tickmark-route"

Where it lives

FileLocation
PNGAn uncompressed iTXt chunk with the standard keyword XML:com.adobe.xmp, right after IHDR. This is what TickMark exports and saves.
JPEGA standard XMP APP1 segment (http://ns.adobe.com/xap/1.0/). iOS stores this version when TickMark updates a photo already in your library.
Element or attributeTickMark writes tickmark:Data as an element. Some tools, including Apple's ImageIO, rewrite simple properties as attributes (tickmark:Data="…", with quotes escaped as "). Accept both.

Quick start

Command line

shellexiftool
# Register nothing; exiftool prints unknown XMP namespaces as-is.
exiftool -XMP-tickmark:Data -b climb.png | jq .

Swift (iOS, macOS)

swiftImageIO
import ImageIO

func tickmarkData(at url: URL) -> [String: Any]? {
  guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
        let metadata = CGImageSourceCopyMetadataAtIndex(source, 0, nil),
        let mutable = CGImageMetadataCreateMutableCopy(metadata) else return nil
  CGImageMetadataRegisterNamespaceForPrefix(mutable, "https://tickmark.baguette-studios.dev/ns/1.0/" as CFString, "tickmark" as CFString, nil)
  guard let json = CGImageMetadataCopyStringValueWithPath(mutable, nil, "tickmark:Data" as CFString) as String? else return nil
  return try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]
}

JavaScript (browser or Node)

javascriptno dependencies
// Works for PNG and JPEG: TickMark's XMP packet is stored as plain UTF-8 text.
export function readTickmark(bytes) {
  const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
  const raw = text.match(/<tickmark:Data>([\s\S]*?)<\/tickmark:Data>/)?.[1]
           ?? text.match(/tickmark:Data="([^"]*)"/)?.[1];
  if (!raw) return null;
  const json = raw.replace(/&quot;/g, '"').replace(/&apos;/g, "'")
    .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
  const data = JSON.parse(json);
  // Refuse data written for a newer reader (see Versioning).
  return (data.compatible ?? data.version) <= 3 ? data : null;
}

Kotlin (Android)

kotlinno dependencies
fun readTickmark(bytes: ByteArray): JSONObject? {
  val text = String(bytes, Charsets.UTF_8)
  val raw = Regex("<tickmark:Data>([\\s\\S]*?)</tickmark:Data>").find(text)?.groupValues?.get(1)
    ?: Regex("tickmark:Data=\"([^\"]*)\"").find(text)?.groupValues?.get(1) ?: return null
  val json = raw.replace("&quot;", "\"").replace("&apos;", "'")
    .replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
  return JSONObject(json).takeIf { it.optInt("compatible", it.optInt("version")) <= 3 }
}

Python

pythonstandard library
import html, json, re

def read_tickmark(path):
    text = open(path, "rb").read().decode("utf-8", "ignore")
    m = re.search(r"<tickmark:Data>(.*?)</tickmark:Data>", text, re.S) or re.search(r'tickmark:Data="([^"]*)"', text)
    if not m: return None
    data = json.loads(html.unescape(m.group(1)))
    return data if data.get("compatible", data["version"]) <= 3 else None

Route data

The top-level JSON object in tickmark:Data.

FieldDescription
format
"tickmark-route"
Always "tickmark-route". Check it before trusting anything else.
version
integer
Version of the format that wrote this data. 3 on this page.
compatible
integer
Oldest reader version that can read it correctly. 2 for v3 data. See Versioning.
nameoptional
string
The climb's name in TickMark.
grade
Grade | null
Difficulty, a single grade or a range. null when ungraded.
styles
string[]
Style tags such as "Powerful", "Slabby" or the climber's own custom tags, in the order chosen.
rules
GlobalRule[]
Rules for the whole problem, or pinned to every hold of one outline colour.
areas
Area[]
Every outlined hold, volume or area, in drawing order.
markersoptional
Marker[]
Start, top and zone tags with their tapes, exactly as drawn on the image.
jsonexample
{
  "format": "tickmark-route", "version": 3, "compatible": 2,
  "name": "Moonlight traverse",
  "grade": { "system": "font", "low": "7A", "high": "7B" },
  "styles": ["Powerful", "Compression"],
  "rules": [{ "text": "No matching" }, { "text": "Feet only", "color": "#FF646B" }],
  "areas": [{
    "id": "outline-mvf0c565-2", "color": "#FFD84D", "allowed": true,
    "roles": ["start"], "rules": ["Right hand only"],
    "bounds": [0.3512, 0.1806, 0.2911, 0.1524],
    "outline": [[0.3644, 0.2611, 0.4102, 0.1902, 0.6023, 0.1871, /* … */]]
  }],
  "markers": [{ "kind": "start", "label": "START", "center": [0.845, 0.3647], "radius": 0.052,
    "tapes": [[0.775, 0.3, 0.845, 0.3647]] }]
}

Grade

FieldDescription
system
"font" | "v"
Fontainebleau or V scale.
low
string
The grade, or the easier end of a range.
highoptional
string
The harder end of a range. Absent for a single grade. Always on the same scale and at least as hard as low.
Font3 · 4 · 4+ · 5 · 5+ · 6A · 6A+ · 6B · 6B+ · 6C · 6C+ · 7A · 7A+ · 7B · 7B+ · 7C · 7C+ · 8A · 8A+ · 8B · 8B+ · 8C · 8C+ · 9A
V scaleVB · V0 · V1 · V2 · V3 · V4 · V5 · V6 · V7 · V8 · V9 · V10 · V11 · V12 · V13 · V14 · V15 · V16 · V17

Rules

Rules exist at two levels. Global rules live in the top-level rules array; hold rules live on each area.

GlobalRuleDescription
text
string
The rule as shown to the climber, e.g. "No matching", "Sit start", or a custom rule.
coloroptional
"#RRGGBB"
When present, the rule applies to every area whose color matches (case-insensitive). When absent, it applies to the whole problem.

Built-in texts you may want to recognise (custom rules can be anything): whole problem — No matching, Feet follow hands, Marked feet only, Feet anywhere, No feet (campus), Sit start, No dynos; holds — No matching, Right hand only, Left hand only, Hands only, Feet only, Use once.

Areas

FieldDescription
id
string
Stable within one image. Not meaningful across images.
color
"#RRGGBB"
The outline colour chosen for this area. Colour-pinned rules match on it.
allowed
boolean
false for an area marked “not allowed” (hatched in TickMark). Treat it as off limits.
roles
("start" | "finish" | "zone")[]
The route roles this area carries. A start hold may be used by several contacts; "finish" is shown as TOP.
rules
string[]
Rules for this area only. Colour-pinned global rules are not repeated here; see Rules for a hold.
bounds
[x, y, w, h] | null
Bounding box in normalized coordinates. Ideal for tooltips and hit areas.
outline
number[][]
Closed loops of flat x, y pairs. Fill with the even-odd rule: loops inside loops are holes. May be simplified, or empty, on very busy photos (see the callout below).
Size budgetThe whole XMP packet stays under about 56 KB so it survives JPEG re-encoding. On busy photos outlines are simplified step by step, then holes, then marker tapes, then outlines are dropped. id, color, allowed, roles, rules and bounds are always kept.

Markers

FieldDescription
kind
"start" | "finish" | "zone"
Which tag this is.
label
string
The text on the tag: START, TOP, ZONE, or ZONE 1 / ZONE 2.
center
[x, y]
Tag centre, normalized.
radius
number
Tag radius as a fraction of the image width.
tapes
number[][]
One polyline (flat x, y pairs) per contact, from the hold to the tag. Parts hidden under outlines on the image are still included.

Coordinates

All positions are fractions of the image: x / width and y / height, origin at the top-left, rounded to four decimals. They are resolution independent: multiply by the decoded image's pixel size. Because x and y are scaled separately, convert to pixels before measuring distances.

XMP properties

Flat properties for quick inspection in tools like exiftool. tickmark:Data is the source of truth.

PropertyExample
tickmark:Version3
tickmark:NameMoonlight traverse
tickmark:GradeSystemfont
tickmark:Grade7A–7B (en dash for ranges)
tickmark:GradeLow · tickmark:GradeHigh7A · 7B
tickmark:Stylesrdf:Bag of style tags
tickmark:Rulesrdf:Bag of global rules; pinned ones read Feet only (#FF646B)
tickmark:DataThe JSON documented above

Rules for a hold

A hold's effective rules are its own rules, plus global rules pinned to its colour, plus unpinned global rules.

javascriptrulesFor(data, area)
function rulesFor(data, area) {
  const same = (a, b) => a?.toUpperCase() === b?.toUpperCase();
  return {
    hold: area.rules,
    colour: data.rules.filter(r => r.color && same(r.color, area.color)).map(r => r.text),
    problem: data.rules.filter(r => !r.color).map(r => r.text),
  };
}

Versioning

Every TickMark data block states three things: its format, the version that wrote it, and the oldest reader it stays compatible with.

TickMarkRoute data
1.0 and laterversion 3, compatible 2
The project package is privateTickMark exports also contain a private re-editing package (a tkPj PNG chunk, or APP11 "TickMarkPkg" JPEG segments) so a shared image reopens fully editable in TickMark. It is not part of this API and may change without notice; other apps can safely ignore it.

What survives sharing

KeptAirDrop, Files, email attachments, saving to and exporting from Photos, Google Photos backup at original quality.
StrippedMost messaging and social apps re-encode images: WhatsApp, Instagram, and Messages when it sends a “most compatible” copy.

Changelog

VersionChanges
v3 · TickMark 1.0First public release. format, compatible and name; grade, styles, global and hold rules, area outlines with roles and bounds, route markers.

Questions or building something with TickMark data? support@baguette-studios.dev