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.
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.
https://tickmark.baguette-studios.dev/ns/1.0/Prefix
tickmarktickmark:Data — UTF-8 JSON, format: "tickmark-route"Where it lives
| File | Location |
|---|---|
PNG | An uncompressed iTXt chunk with the standard keyword XML:com.adobe.xmp, right after IHDR. This is what TickMark exports and saves. |
JPEG | A standard XMP APP1 segment (http://ns.adobe.com/xap/1.0/). iOS stores this version when TickMark updates a photo already in your library. |
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
# Register nothing; exiftool prints unknown XMP namespaces as-is.
exiftool -XMP-tickmark:Data -b climb.png | jq .
Swift (iOS, macOS)
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)
// 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(/"/g, '"').replace(/'/g, "'")
.replace(/</g, '<').replace(/>/g, '>').replace(/&/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)
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(""", "\"").replace("'", "'")
.replace("<", "<").replace(">", ">").replace("&", "&")
return JSONObject(json).takeIf { it.optInt("compatible", it.optInt("version")) <= 3 }
}
Python
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.
| Field | Description |
|---|---|
format"tickmark-route" | Always "tickmark-route". Check it before trusting anything else. |
versioninteger | Version of the format that wrote this data. 3 on this page. |
compatibleinteger | Oldest reader version that can read it correctly. 2 for v3 data. See Versioning. |
nameoptionalstring | The climb's name in TickMark. |
gradeGrade | null | Difficulty, a single grade or a range. null when ungraded. |
stylesstring[] | Style tags such as "Powerful", "Slabby" or the climber's own custom tags, in the order chosen. |
rulesGlobalRule[] | Rules for the whole problem, or pinned to every hold of one outline colour. |
areasArea[] | Every outlined hold, volume or area, in drawing order. |
markersoptionalMarker[] | Start, top and zone tags with their tapes, exactly as drawn on the image. |
{
"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
| Field | Description |
|---|---|
system"font" | "v" | Fontainebleau or V scale. |
lowstring | The grade, or the easier end of a range. |
highoptionalstring | The harder end of a range. Absent for a single grade. Always on the same scale and at least as hard as low. |
Rules
Rules exist at two levels. Global rules live in the top-level rules array; hold rules live on each area.
| GlobalRule | Description |
|---|---|
textstring | 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
| Field | Description |
|---|---|
idstring | Stable within one image. Not meaningful across images. |
color"#RRGGBB" | The outline colour chosen for this area. Colour-pinned rules match on it. |
allowedboolean | 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. |
rulesstring[] | 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. |
outlinenumber[][] | 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). |
id, color, allowed, roles, rules and bounds are always kept.Markers
| Field | Description |
|---|---|
kind"start" | "finish" | "zone" | Which tag this is. |
labelstring | The text on the tag: START, TOP, ZONE, or ZONE 1 / ZONE 2. |
center[x, y] | Tag centre, normalized. |
radiusnumber | Tag radius as a fraction of the image width. |
tapesnumber[][] | 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.
| Property | Example |
|---|---|
tickmark:Version | 3 |
tickmark:Name | Moonlight traverse |
tickmark:GradeSystem | font |
tickmark:Grade | 7A–7B (en dash for ranges) |
tickmark:GradeLow · tickmark:GradeHigh | 7A · 7B |
tickmark:Styles | rdf:Bag of style tags |
tickmark:Rules | rdf:Bag of global rules; pinned ones read Feet only (#FF646B) |
tickmark:Data | The 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.
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.
- Additive changes (new optional fields) raise
versiononly. Older readers keep working; ignore fields you don't know. - Breaking changes raise both. If
compatibleis greater than the version your code supports, stop: the data needs a newer reader.
| TickMark | Route data |
|---|---|
1.0 and later | version 3, compatible 2 |
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
Changelog
| Version | Changes |
|---|---|
v3 · TickMark 1.0 | First 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