Image Metadata Schema Framework
Use conditional-logic schema generation to keep Image Metadata markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralImage Metadata documentationUse on pages that show images whose creator, credit, copyright or license details you want Google Images to display, including the Licensable badge.
Key Implementation Documentation Highlights
- Required: contentUrl, plus at least one of creator, creditText, copyrightNotice, license.
- Recommended properties: acquireLicensePage, creator, creditText, copyrightNotice, license.
- license is needed for the Licensable badge; add acquireLicensePage alongside it so users can find out how to license the image.
- contentUrl, license and acquireLicensePage are absolute URLs. contentUrl tells Google which image the metadata applies to, so each image gets its own ImageObject.
- creator is a Person or Organization with a name (usually the photographer, but it can be a company).
- Structured data or IPTC photo metadata is enough on its own. If they conflict, Google uses the structured data. IPTC mappings: Web Statement of Rights = license, Licensor URL = acquireLicensePage, Credit Line = creditText, Creator = creator, Copyright Notice = copyrightNotice.
- Validate with the Rich Results Test before deploying.
Template Approach
const pageData = {
imageUrl: "https://www.example.com/photos/full/harbor-fog-at-dawn.jpg",
licenseUrl: "https://www.example.com/license/standard",
licensePage: "https://www.example.com/photos/harbor-fog-at-dawn/license",
credit: "Rachel Kim / Example Photo Agency",
copyright: "© 2026 Example Photo Agency",
photographer: "Rachel Kim"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "ImageObject",
"contentUrl": pageData.imageUrl,
"license": pageData.licenseUrl,
"acquireLicensePage": pageData.licensePage,
"creditText": pageData.credit,
"copyrightNotice": pageData.copyright,
"creator": { "@type": "Person", "name": pageData.photographer }
};
{
"@context": "https://schema.org",
"@type": "ImageObject",
"contentUrl": "https://www.example.com/photos/full/harbor-fog-at-dawn.jpg",
"license": "https://www.example.com/license/standard",
"acquireLicensePage": "https://www.example.com/photos/harbor-fog-at-dawn/license",
"creditText": "Rachel Kim / Example Photo Agency",
"copyrightNotice": "© 2026 Example Photo Agency",
"creator": {
"@type": "Person",
"name": "Rachel Kim"
}
}
Conditional-logic Framework
// Shared helpers
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_DATETIME = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$/;
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
} catch {
return false;
}
}
function toIsoDate(value) {
const match = typeof value === "string" ? ISO_DATE.exec(value) : null;
if (!match) return null;
const [y, m, d] = match.slice(1).map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
return date.getUTCMonth() === m - 1 && date.getUTCDate() === d ? value : null;
}
// Keeps the original timezone offset; rejects naive or unparseable values.
function toIsoDateTime(value) {
const match = typeof value === "string" ? ISO_DATETIME.exec(value) : null;
return match && toIsoDate(match[1]) ? value : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((item) => item !== undefined);
return items.length ? items : undefined;
}
if (value !== null && typeof value === "object") {
const out = {};
for (const [key, item] of Object.entries(value)) {
const kept = compact(item);
if (kept !== undefined) out[key] = kept;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
// Page gates: indexable, self-canonical, marked-up content visible.
function passesPageGates(page) {
if (!page || page.noindex || page.robotsBlocked) return false;
if (!isAbsoluteUrl(page.url)) return false;
if (page.canonicalUrl && page.canonicalUrl !== page.url) return false;
return page.contentVisible === true;
}
// Image metadata helpers
function buildCreator(creator) {
if (!creator || !creator.name) return null; // creator object needs name
return {
"@type": creator.type === "Organization" ? "Organization" : "Person",
name: creator.name,
url: isAbsoluteUrl(creator.url) ? creator.url : null
};
}
// One ImageObject per image; contentUrl tells Google which image the metadata applies to.
function buildImageObjectSchema(source) {
const page = source.page;
const image = source.image || {};
if (!passesPageGates(page) || !image.visibleOnPage) return null;
// Required: contentUrl (absolute).
if (!isAbsoluteUrl(image.contentUrl)) return null;
const license = isAbsoluteUrl(image.licenseUrl) ? image.licenseUrl : null;
const creators = (image.creators || []).map(buildCreator).filter(Boolean);
const schema = {
"@context": "https://schema.org",
"@type": "ImageObject",
contentUrl: image.contentUrl,
license, // needed for the Licensable badge
acquireLicensePage: isAbsoluteUrl(image.acquireLicenseUrl) ? image.acquireLicenseUrl : null,
creditText: image.creditText,
copyrightNotice: image.copyrightNotice,
creator: creators.length > 1 ? creators : creators[0] ?? null
};
// Required either-or: at least one of creator, creditText, copyrightNotice, license.
if (!schema.creator && !schema.creditText && !schema.copyrightNotice && !schema.license) return null;
return compact(schema) ?? null;
}
// Usage:
// const source = {
// "page": {
// "url": "https://www.example.com/photos/harbor-fog-at-dawn",
// "canonicalUrl": "https://www.example.com/photos/harbor-fog-at-dawn",
// "noindex": false,
// "contentVisible": true
// },
// "image": {
// "visibleOnPage": true,
// "contentUrl": "https://www.example.com/photos/full/harbor-fog-at-dawn.jpg",
// "licenseUrl": "https://www.example.com/license/standard",
// "acquireLicenseUrl": "https://www.example.com/photos/harbor-fog-at-dawn/license",
// "creditText": "Rachel Kim / Example Photo Agency",
// "copyrightNotice": "© 2026 Example Photo Agency",
// "creators": [
// {
// "type": "Person",
// "name": "Rachel Kim",
// "url": "https://www.example.com/photographers/rachel-kim"
// }
// ]
// }
// };
// const schema = buildImageObjectSchema(source);
# Shared helpers
import re
from datetime import date
from urllib.parse import urlparse
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
ISO_DATETIME = re.compile(r"^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-](0\d|1[0-4]):[0-5]\d)$")
def is_absolute_url(value):
if not isinstance(value, str):
return False
try:
parts = urlparse(value)
except ValueError:
return False
return parts.scheme in ("http", "https") and bool(parts.hostname)
def to_iso_date(value):
if not isinstance(value, str) or not ISO_DATE.match(value):
return None
try:
date.fromisoformat(value)
except ValueError:
return None
return value
# Keeps the original timezone offset; rejects naive or unparseable values.
def to_iso_datetime(value):
match = ISO_DATETIME.match(value) if isinstance(value, str) else None
return value if match and to_iso_date(match.group(1)) else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [kept for kept in (compact(item) for item in value) if kept is not None]
return items or None
if isinstance(value, dict):
out = {key: kept for key, kept in ((k, compact(v)) for k, v in value.items()) if kept is not None}
return out or None
return None if value is None or value == "" else value
# Page gates: indexable, self-canonical, marked-up content visible.
def passes_page_gates(page):
if not page or page.get("noindex") or page.get("robotsBlocked"):
return False
if not is_absolute_url(page.get("url")):
return False
if page.get("canonicalUrl") and page["canonicalUrl"] != page["url"]:
return False
return page.get("contentVisible") is True
# Image metadata helpers
def build_creator(creator):
if not creator or not creator.get("name"):
return None # creator object needs name
return {
"@type": "Organization" if creator.get("type") == "Organization" else "Person",
"name": creator["name"],
"url": creator.get("url") if is_absolute_url(creator.get("url")) else None,
}
# One ImageObject per image; contentUrl tells Google which image the metadata applies to.
def build_image_object_schema(source):
page = source.get("page")
image = source.get("image") or {}
if not passes_page_gates(page) or not image.get("visibleOnPage"):
return None
# Required: contentUrl (absolute).
if not is_absolute_url(image.get("contentUrl")):
return None
license_url = image.get("licenseUrl") if is_absolute_url(image.get("licenseUrl")) else None
creators = [c for c in (build_creator(c) for c in (image.get("creators") or [])) if c]
schema = {
"@context": "https://schema.org",
"@type": "ImageObject",
"contentUrl": image["contentUrl"],
"license": license_url, # needed for the Licensable badge
"acquireLicensePage": image.get("acquireLicenseUrl")
if is_absolute_url(image.get("acquireLicenseUrl")) else None,
"creditText": image.get("creditText"),
"copyrightNotice": image.get("copyrightNotice"),
"creator": creators if len(creators) > 1 else (creators[0] if creators else None),
}
# Required either-or: at least one of creator, creditText, copyrightNotice, license.
if not any(schema[k] for k in ("creator", "creditText", "copyrightNotice", "license")):
return None
return compact(schema)
{
"@context": "https://schema.org",
"@type": "ImageObject",
"contentUrl": "https://www.example.com/photos/full/harbor-fog-at-dawn.jpg",
"license": "https://www.example.com/license/standard",
"acquireLicensePage": "https://www.example.com/photos/harbor-fog-at-dawn/license",
"creditText": "Rachel Kim / Example Photo Agency",
"copyrightNotice": "© 2026 Example Photo Agency",
"creator": {
"@type": "Person",
"name": "Rachel Kim",
"url": "https://www.example.com/photographers/rachel-kim"
}
}
Why Conditional Logic Is Better Than Static Templates
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Image metadata (license) documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Required, one of: Include at least one of creator, creditText, copyrightNotice or license.
| Property | Expects | Notes | |
|---|---|---|---|
| contentUrl | Required | URL | The URL of the actual image file. |
| creator | One of | Person or Organization, can repeat | The photographer, company or organization that created the image. |
| creator.name | Recommended | Text | The name of the creator. |
| creditText | One of | Text | Who is credited when the image is published. |
| copyrightNotice | One of | Text | The copyright notice for claiming the intellectual property for this photograph. IPTC equivalent: Copyright Notice. |
| license | One of | URL | A URL describing the license governing the image's use. |
| acquireLicensePage | Recommended | URL | A page explaining how to license the image. |