Operational Context
A rights engineer owns the step that turns each object’s stored license into the value a viewer will read, and the records fight back. The collections database holds Creative Commons facts as whatever a decade of catalogers typed: "CC BY 4.0", "by-nc-nd", "cc0", "Public Domain Mark", a bare "http://creativecommons.org/licenses/by-sa/3.0/us/", or the ambiguous "CC-BY" with no version at all. The IIIF Presentation 3.0 rights property accepts none of that variety directly — it takes exactly one URI, and only from an approved vocabulary. This page resolves the narrow task of producing, from one varied internal CC string, the single spec-allowed rights URI plus the attribution a viewer must display — or of correctly producing nothing when the value maps to no recognized license. It is the Creative Commons counterpart to the router in routing Creative Commons licenses, a sibling to automating CC BY-NC-ND tagging in Python, and the mapping that adding rights and requiredStatement blocks to IIIF manifests assumes already exists before it validates the manifest.
Root Cause Analysis
Three properties of the Creative Commons vocabulary defeat naive mapping code, and each one is a specification fact rather than a corner case.
First, IIIF constrains the vocabulary, not just the syntax. The rights property must be a string, that string must be a URI, and the URI must come from either creativecommons.org or rightsstatements.org. A perfectly valid, resolvable URI on any other host still fails conformance, and an aggregator will drop the record rather than guess. Free text — "© Trustees", "CC BY 4.0" as prose, a local license/17 path — is not drawn from the approved set and is never acceptable in this field. The machine-readable identifier and the human-readable credit are two different properties; the identifier belongs in rights, the prose belongs in requiredStatement.
Second, version and scheme drift change identity, not just spelling. A CC URI is an opaque identifier whose canonical form for the current suite is https://creativecommons.org/licenses/<code>/4.0/ — https, lowercase, trailing slash included. But by-sa/3.0/ is not a stale rendering of by-sa/4.0/; it is a different license with different terms, and a ported by-sa/3.0/us/ is different again. Repairing cosmetic drift (case, http versus https, a missing trailing slash) is safe because it never changes which license is named. Inferring a version, or silently upgrading 3.0 to 4.0, invents terms the source never asserted.
Third, CC0 and the Public Domain Mark are not licenses. They live under a different path — /publicdomain/zero/1.0/ and /publicdomain/mark/1.0/, never /licenses/ — because neither grants rights the way BY-family licenses do. CC0 is a rightsholder’s dedication waiving copyright; the Public Domain Mark (PDM) is a label for works already free of copyright, applied by someone who is usually not the rightsholder. Code that treats them as license codes emits a URL that does not exist. And because neither carries an attribution obligation, forcing a requiredStatement on them is wrong too. The fix on all three fronts is one discipline: normalize the internal value to a canonical creativecommons.org URI, validate it against the allowed set, and only then decide whether attribution is owed.
Canonical Solution
The routine below takes one internal CC value and produces at most two things: a canonical rights URI drawn from the IIIF-approved set, and — only for the BY family, and only when a creator is known — a requiredStatement attribution. A value that names no recognized license or tool yields an empty block, so the caller omits rights rather than fabricating one. A strict Pydantic v2 model makes the source record the single boundary where a malformed value is caught.
from __future__ import annotations
import re
from pydantic import BaseModel, ConfigDict, field_validator
# Six BY-family licenses; public-domain TOOLS (zero, mark) are handled apart.
_LICENSE_CODES = {"by", "by-sa", "by-nc", "by-nc-sa", "by-nd", "by-nc-nd"}
_DEFAULT_LICENSE_VERSION = "4.0" # the current suite; never inferred for 3.0
_PD_VERSION = "1.0" # CC0 and PDM only ever ship as 1.0
_VERSION_RE = re.compile(r"(\d\.\d)")
_PLACEHOLDERS = {"", "unknown", "n/a", "na", "none", "tbd", "all rights reserved"}
# The exact set IIIF Presentation 3.0 permits from creativecommons.org. A URI
# that does not match this — wrong host, wrong path, missing slash — is invalid
# for `rights` and must be omitted, not coerced.
_ALLOWED = re.compile(
r"^https://creativecommons\.org/"
r"(licenses/(by|by-sa|by-nc|by-nc-sa|by-nd|by-nc-nd)/\d\.\d"
r"|publicdomain/(zero|mark)/1\.0)/$"
)
_LICENSE_LABELS = {
"by": "CC BY",
"by-sa": "CC BY-SA",
"by-nc": "CC BY-NC",
"by-nc-sa": "CC BY-NC-SA",
"by-nd": "CC BY-ND",
"by-nc-nd": "CC BY-NC-ND",
"zero": "CC0 1.0 (public domain dedication)",
"mark": "Public Domain Mark 1.0",
}
def _license_code_from_tokens(tokens: set[str]) -> str | None:
# Rebuild the canonical component order (by, then nc, then nd XOR sa) from
# whatever order the source used. `nd` and `sa` are mutually exclusive, so
# a value naming both is not a real license and returns None.
if "by" not in tokens:
return None
if "nd" in tokens and "sa" in tokens:
return None
parts = ["by"]
if "nc" in tokens:
parts.append("nc")
if "nd" in tokens:
parts.append("nd")
elif "sa" in tokens:
parts.append("sa")
code = "-".join(parts)
return code if code in _LICENSE_CODES else None
def normalize_cc_uri(value: str | None) -> str | None:
"""Map a varied internal CC value to a canonical https creativecommons.org
URI, or None when it names no recognized license or public-domain tool."""
if value is None:
return None
s = value.strip().lower()
if s in _PLACEHOLDERS:
return None
tokens = set(re.split(r"[^a-z0-9]+", s))
# 1. Public-domain tools first: fixed 1.0, /publicdomain/ path, never
# /licenses/. Checked before the BY family so they can never be misread
# as a license code.
if "cc0" in tokens or "zero" in tokens:
return f"https://creativecommons.org/publicdomain/zero/{_PD_VERSION}/"
if "pdm" in tokens or "mark" in tokens:
return f"https://creativecommons.org/publicdomain/mark/{_PD_VERSION}/"
# 2. BY-family license: requires a `by` component to exist at all.
code = _license_code_from_tokens(tokens)
if code is None:
return None
# 3. Preserve the stated version; default to the current 4.0 suite ONLY
# when none is present. A 3.0 value stays 3.0 — a different license.
match = _VERSION_RE.search(s)
version = match.group(1) if match else _DEFAULT_LICENSE_VERSION
return f"https://creativecommons.org/licenses/{code}/{version}/"
def is_allowed_iiif_rights(uri: str) -> bool:
return bool(_ALLOWED.match(uri))
def _label_for(uri: str) -> str | None:
if (m := re.search(r"/licenses/([a-z-]+)/", uri)):
return _LICENSE_LABELS.get(m.group(1))
if (m := re.search(r"/publicdomain/(zero|mark)/", uri)):
return _LICENSE_LABELS.get(m.group(1))
return None
def build_required_statement(
creator: str | None, title: str | None, uri: str
) -> dict | None:
# requiredStatement is a DISPLAY obligation, not access control. It is owed
# by the BY family (which requires credit) and only when a creator is known;
# CC0 and PDM impose no attribution, so they return None here.
if "/licenses/" not in uri or not creator:
return None
label = _label_for(uri)
work = f'"{title}"' if title else "This work"
value = f"{work} by {creator} is licensed under {label}."
return {"label": {"en": ["Attribution"]}, "value": {"en": [value]}}
class CCRightsSource(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
object_id: str
cc_value: str | None = None # the varied internal license string
creator: str | None = None
title: str | None = None
@field_validator("cc_value", mode="before")
@classmethod
def blank_to_none(cls, v: str | None) -> str | None:
# Collapse empty and placeholder strings to None at the boundary so
# "missing rights" is a single downstream state, not many spellings.
return v or None
def to_manifest_rights(self) -> dict:
block: dict = {}
uri = normalize_cc_uri(self.cc_value)
if uri is None or not is_allowed_iiif_rights(uri):
return block # omit rights — never fabricate
block["rights"] = uri
statement = build_required_statement(self.creator, self.title, uri)
if statement is not None:
block["requiredStatement"] = statement
return blockApplied to a record whose internal value drifted on scheme and version separators, the mapping produces exactly the fragment a viewer consumes:
rec = CCRightsSource(
object_id="OBJ.1901",
cc_value="CC BY-SA 4.0",
creator="A. Cataloger",
title="Study of Hands",
)
print(rec.to_manifest_rights()){
"rights": "https://creativecommons.org/licenses/by-sa/4.0/",
"requiredStatement": {
"label": {"en": ["Attribution"]},
"value": {"en": ["\"Study of Hands\" by A. Cataloger is licensed under CC BY-SA."]}
}
}A cc_value of "cc0" yields {"rights": "https://creativecommons.org/publicdomain/zero/1.0/"} with no requiredStatement; a cc_value of "© all rights reserved" yields {}, and the manifest simply carries no rights property. The branch that trips up most engineers — normalize, test against the allowed set, then set both properties or set nothing — is worth seeing laid out.
Edge Cases and Variants
- CSV vs. XML vs. API input.
CCRightsSourceis agnostic to source: feed it acsv.DictReaderrow, a field pulled from a LIDO iterparse loop, or an item from a paged API cursor. Only the reader changes; the normalize-then-validate contract is the invariant. - BY-NC-ND and other restrictive variants. Every BY-family code — including the most restrictive
by-nc-nd— normalizes to a validrightsURI and, being a BY license, still requires attribution. Restrictiveness governs reuse routing, not manifest eligibility; the bulk-tagging discipline for that specific code lives in automating CC BY-NC-ND tagging in Python. - Version 3.0 vs. 4.0. A
3.0value is preserved as3.0, never upgraded — the URI stays a valid, allowed rights value for that distinct license. Standardizing an institution onto 4.0 is a rights decision made in the catalog, not a normalization the mapper is entitled to make. - CC0 vs. PDM. Both resolve to
/publicdomain/.../1.0/and both skiprequiredStatement, but they are semantically different: CC0 is a deliberate waiver by the rightsholder, PDM a label on an already-free work. Keep the internal value distinct so the credit line the reading room shows can differ even though therightsURI class is the same. - Ported and jurisdiction URIs. A
by-sa/3.0/us/ported URI failsis_allowed_iiif_rightsbecause its path carries an extra segment. Do not strip the jurisdiction to force a match — that changes the license. Route such records to an explicit review list, the same conservative posture used when handling orphan works. - Missing attribution on a BY license. When the source names a BY license but carries no creator, the URI is still emitted while
requiredStatementis withheld rather than invented. A statement crediting an unknown author is worse than none; flag the record so a cataloger can supply the credit. - Strict vs. lenient tokenizing. The tokenizer needs separators —
"byncnd"with no delimiters yields nobytoken and maps toNoneby design. If a legacy export genuinely runs the components together, add a targeted pre-split for that source only; never loosen the shared normalizer.
Validation
Prove the two behaviors that matter — recognized values map to the exact allowed URI (with attribution only where owed), and junk maps to nothing rather than to a fabricated URI:
# test_cc_to_iiif.py -> run with: pytest -q test_cc_to_iiif.py
import pytest
from pydantic import ValidationError
def test_by_family_maps_and_carries_attribution():
rec = CCRightsSource(object_id="A", cc_value="cc by-sa 4.0", creator="X")
block = rec.to_manifest_rights()
assert block["rights"] == "https://creativecommons.org/licenses/by-sa/4.0/"
assert is_allowed_iiif_rights(block["rights"])
assert block["requiredStatement"]["value"]["en"][0].endswith("CC BY-SA.")
def test_scheme_and_version_are_normalized_not_inferred():
assert normalize_cc_uri("http://creativecommons.org/licenses/BY/4.0") == (
"https://creativecommons.org/licenses/by/4.0/"
)
# A 3.0 value stays 3.0 — a different, still-valid license.
assert normalize_cc_uri("by-nc 3.0") == (
"https://creativecommons.org/licenses/by-nc/3.0/"
)
# No version present defaults to the current suite.
assert normalize_cc_uri("CC-BY").endswith("/by/4.0/")
def test_public_domain_tools_use_publicdomain_path_no_attribution():
for value, seg in [("cc0", "zero"), ("Public Domain Mark", "mark")]:
block = CCRightsSource(
object_id="A", cc_value=value, creator="X"
).to_manifest_rights()
assert block["rights"] == (
f"https://creativecommons.org/publicdomain/{seg}/1.0/"
)
assert "requiredStatement" not in block
def test_unmappable_values_omit_rights_entirely():
for junk in ["© all rights reserved", "unknown", "", "by-sa-nd"]:
assert CCRightsSource(object_id="A", cc_value=junk).to_manifest_rights() == {}
def test_ported_uri_is_rejected_by_the_allowed_gate():
assert not is_allowed_iiif_rights(
"https://creativecommons.org/licenses/by-sa/3.0/us/"
)
def test_missing_object_id_raises_at_the_boundary():
with pytest.raises(ValidationError):
CCRightsSource(cc_value="cc0")A green run confirms that scheme and version drift are repaired without changing which license is named, that CC0 and PDM route to /publicdomain/ and skip attribution, that a ported or contradictory value maps to no URI at all, and that a structurally invalid record raises rather than leaking an empty rights field into a manifest.
Standards Alignment
The output satisfies two specifications at once. The IIIF Presentation 3.0 rights property requires a single URI from the Creative Commons or RightsStatements.org vocabularies; this mapping guarantees the produced value is a canonical Creative Commons URI in that set, or absent. The canonical path shapes — /licenses/<code>/<version>/ for the six BY licenses and /publicdomain/{zero,mark}/1.0/ for the two public-domain tools — follow Creative Commons’ own URI conventions, which is why version and jurisdiction segments are preserved rather than rewritten. Attribution is modeled as an IIIF requiredStatement language map, a display obligation the viewer must honor and never an access gate; enforcement stays upstream in the rights metadata and licensing automation stage. The manifest that consumes this block is assembled in building IIIF Presentation manifests, and the same value round-trips into LIDO lido:rightsType for catalog exports.
FAQ
Why don’t CC0 and the Public Domain Mark live under /licenses/?
Because neither is a license. CC0 is a rightsholder’s dedication that waives copyright entirely, and the Public Domain Mark is a label applied to works already free of copyright — often by someone who is not the rightsholder. Creative Commons gives both their own path, /publicdomain/zero/1.0/ and /publicdomain/mark/1.0/, and neither carries an attribution obligation. Emitting them under /licenses/ produces a URL that does not exist, which is why the mapper checks for them before the BY family and skips requiredStatement.
Should I upgrade a 3.0 URI to 4.0 during normalization?
No. Version 3.0 and 4.0 are different licenses with different terms and different jurisdiction handling, so by-sa/3.0/ is not a stale form of by-sa/4.0/. The mapper preserves whatever version the source states and only defaults to the current 4.0 suite when no version is present at all. Both remain valid, allowed rights values. Migrating a collection onto 4.0 is a deliberate rights decision recorded in the catalog, not something the normalizer is entitled to infer.
What belongs in rights versus requiredStatement?
The machine-readable license identifier goes in rights as one approved URI; the human-readable credit line goes in requiredStatement as an IIIF language map. They are separate properties for separate audiences — a client anywhere can map the rights URI to known terms without reading prose, while the requiredStatement is text a conforming viewer must display. Never pack a copyright notice or credit into rights, and never treat requiredStatement as a gate: it is a display obligation, not access control.
When should the rights property be omitted entirely?
Whenever the internal value maps to no recognized Creative Commons license or public-domain tool — an all-rights-reserved notice, a blank field, a local URI, a contradictory by-sa-nd, or a ported URI with an extra jurisdiction segment. IIIF makes rights optional precisely so you can leave it out rather than emit something invalid, and a missing rights property is spec-compliant while a fabricated one is not. Route the record to review so a cataloger can supply the correct value.
Related
- Routing Creative Commons Licenses — parent routing engine
- Automating CC BY-NC-ND Tagging in Python — sibling bulk-tagging path
- Adding Rights & requiredStatement Blocks to IIIF Manifests — where this URI is consumed
- Building IIIF Presentation Manifests — assembling the manifest
- Rights Metadata Mapping & Licensing Automation — the full rights pipeline