Skip to content

Commit 6aaaede

Browse files
committed
Add existing XMP namespace policy for portable XMP
Introduce XmpExistingNamespacePolicy (KnownPortableOnly, PreserveCustom) to control whether generated portable XMP drops or preserves safe custom namespaces. Wire the policy through public APIs, CLI, Python bindings and internal options (headers, sidecar/portable options, prepare/dump paths). Implement collection/emission of deterministic omnsN prefixes for preserved custom namespaces and namespace-safety checks, and update dump logic to include custom declarations when PreserveCustom is requested. Add tests exercising default-drop and preserve behaviors and update README/docs to document the new option.
1 parent c0dcbb2 commit 6aaaede

File tree

11 files changed

+444
-39
lines changed

11 files changed

+444
-39
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ In practice:
149149
- Generated portable XMP also has an explicit conflict policy for existing
150150
decoded XMP versus generated EXIF/IPTC mappings:
151151
current behavior, `existing_wins`, or `generated_wins`.
152+
- Generated portable XMP now also has an explicit existing-namespace policy:
153+
keep only OpenMeta's known portable namespaces, or preserve safe custom
154+
existing namespaces with deterministic generated prefixes.
152155
- Transfer preparation can also fold an existing sibling `.xmp` sidecar from
153156
the destination path into generated portable XMP when that bounded mode is
154157
requested, with explicit `sidecar_wins` or `source_wins` precedence against
@@ -180,7 +183,8 @@ In practice:
180183
- Prepared bundles record resolved policy decisions for MakerNote, JUMBF,
181184
C2PA, EXIF-to-XMP projection, and IPTC-to-XMP projection.
182185
- This is still not a full MWG-style sync engine. OpenMeta does not yet try to
183-
solve all EXIF/IPTC/XMP conflict resolution or canonical writeback policy.
186+
solve all EXIF/IPTC/XMP conflict resolution or full canonical writeback
187+
policy.
184188

185189
For transfer details, see
186190
[docs/metadata_transfer_plan.md](docs/metadata_transfer_plan.md).

docs/metadata_transfer_plan.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,9 @@ OpenMeta now has a bounded public sync-policy layer for generated XMP.
310310
Current controls:
311311
- `xmp_project_exif`
312312
- `xmp_project_iptc`
313+
- `xmp_existing_namespace_policy`
314+
- `KnownPortableOnly`
315+
- `PreserveCustom`
313316
- `xmp_conflict_policy`
314317
- `xmp_existing_sidecar_mode` on the file-read/prepare path:
315318
- `Ignore`
@@ -432,7 +435,8 @@ Missing pieces include:
432435
- conflict resolution rules
433436
- broader sidecar vs embedded policy beyond the current bounded writeback mode
434437
- canonical writeback policy
435-
- broader namespace reconciliation behavior
438+
- broader namespace reconciliation behavior beyond the current bounded
439+
custom-namespace preservation control
436440

437441
### 3. MakerNote-safe rewrite expectations
438442

src/include/openmeta/metadata_transfer.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,8 @@ struct PrepareTransferRequest final {
427427
bool xmp_project_exif = true;
428428
bool xmp_project_iptc = true;
429429
bool xmp_include_existing = true;
430+
XmpExistingNamespacePolicy xmp_existing_namespace_policy
431+
= XmpExistingNamespacePolicy::KnownPortableOnly;
430432
XmpConflictPolicy xmp_conflict_policy
431433
= XmpConflictPolicy::CurrentBehavior;
432434
bool xmp_exiftool_gpsdatetime_alias = false;

src/include/openmeta/xmp_dump.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ enum class XmpConflictPolicy : uint8_t {
3030
GeneratedWins,
3131
};
3232

33+
/// Existing XMP namespace policy for portable XMP generation.
34+
enum class XmpExistingNamespacePolicy : uint8_t {
35+
/// Keep only the standard portable namespaces known to OpenMeta.
36+
KnownPortableOnly,
37+
/// Also preserve safe simple/indexed properties from custom namespaces.
38+
PreserveCustom,
39+
};
40+
3341
/// XMP dump result status.
3442
enum class XmpDumpStatus : uint8_t {
3543
Ok,
@@ -68,6 +76,9 @@ struct XmpPortableOptions final {
6876
///
6977
/// \note Currently only simple `property_path` values are emitted (no `/` nesting).
7078
bool include_existing_xmp = false;
79+
/// Existing XMP namespace writeback policy for portable output.
80+
XmpExistingNamespacePolicy existing_namespace_policy
81+
= XmpExistingNamespacePolicy::KnownPortableOnly;
7182
/// Conflict policy between existing decoded XMP and generated portable
7283
/// EXIF/IPTC mappings.
7384
XmpConflictPolicy conflict_policy = XmpConflictPolicy::CurrentBehavior;
@@ -105,6 +116,8 @@ struct XmpSidecarRequest final {
105116
bool include_exif = true;
106117
bool include_iptc = true;
107118
bool include_existing_xmp = false;
119+
XmpExistingNamespacePolicy portable_existing_namespace_policy
120+
= XmpExistingNamespacePolicy::KnownPortableOnly;
108121
XmpConflictPolicy portable_conflict_policy
109122
= XmpConflictPolicy::CurrentBehavior;
110123
bool portable_exiftool_gpsdatetime_alias = false;

src/openmeta/metadata_transfer.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9998,6 +9998,8 @@ prepare_metadata_for_target_impl(const MetaStore& store,
99989998
xmp_req.include_exif = request.xmp_project_exif;
99999999
xmp_req.include_iptc = request.xmp_project_iptc;
1000010000
xmp_req.include_existing_xmp = request.xmp_include_existing;
10001+
xmp_req.portable_existing_namespace_policy
10002+
= request.xmp_existing_namespace_policy;
1000110003
xmp_req.portable_conflict_policy = request.xmp_conflict_policy;
1000210004
xmp_req.portable_exiftool_gpsdatetime_alias
1000310005
= request.xmp_exiftool_gpsdatetime_alias;
@@ -10369,6 +10371,8 @@ prepare_metadata_for_target_impl(const MetaStore& store,
1036910371
xmp_req.include_exif = false;
1037010372
xmp_req.include_iptc = true;
1037110373
xmp_req.include_existing_xmp = false;
10374+
xmp_req.portable_existing_namespace_policy
10375+
= request.xmp_existing_namespace_policy;
1037210376
xmp_req.portable_conflict_policy
1037310377
= request.xmp_conflict_policy;
1037410378

src/openmeta/xmp_dump.cc

Lines changed: 179 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ namespace {
127127
std::string_view uri;
128128
};
129129

130+
struct PortableCustomNsDecl final {
131+
std::string prefix;
132+
std::string uri;
133+
};
134+
130135
static void emit_xmp_packet_begin(SpanWriter* w,
131136
std::span<const XmpNsDecl> decls) noexcept
132137
{
@@ -1316,6 +1321,48 @@ namespace {
13161321
return false;
13171322
}
13181323

1324+
static bool xmp_namespace_uri_is_xml_attr_safe(std::string_view uri) noexcept
1325+
{
1326+
if (uri.empty()) {
1327+
return false;
1328+
}
1329+
for (size_t i = 0; i < uri.size(); ++i) {
1330+
const unsigned char c = static_cast<unsigned char>(uri[i]);
1331+
if (c < 0x20U || c > 0x7EU || c == '"' || c == '&' || c == '<'
1332+
|| c == '>') {
1333+
return false;
1334+
}
1335+
}
1336+
return true;
1337+
}
1338+
1339+
static bool portable_custom_ns_prefix_for_uri(
1340+
std::string_view ns, std::span<const PortableCustomNsDecl> decls,
1341+
std::string_view* out_prefix) noexcept
1342+
{
1343+
if (!out_prefix) {
1344+
return false;
1345+
}
1346+
*out_prefix = {};
1347+
for (size_t i = 0; i < decls.size(); ++i) {
1348+
if (decls[i].uri == ns) {
1349+
*out_prefix = decls[i].prefix;
1350+
return true;
1351+
}
1352+
}
1353+
return false;
1354+
}
1355+
1356+
static bool portable_ns_to_prefix(
1357+
std::string_view ns, std::span<const PortableCustomNsDecl> decls,
1358+
std::string_view* out_prefix) noexcept
1359+
{
1360+
if (xmp_ns_to_portable_prefix(ns, out_prefix)) {
1361+
return true;
1362+
}
1363+
return portable_custom_ns_prefix_for_uri(ns, decls, out_prefix);
1364+
}
1365+
13191366
static std::string_view
13201367
portable_property_name_for_existing_xmp(std::string_view prefix,
13211368
std::string_view name) noexcept
@@ -3174,7 +3221,8 @@ namespace {
31743221
}
31753222

31763223
static bool process_portable_existing_xmp_entry(
3177-
const ByteArena& arena, const Entry& e, uint32_t order, SpanWriter* w,
3224+
const ByteArena& arena, std::span<const PortableCustomNsDecl> decls,
3225+
const Entry& e, uint32_t order, SpanWriter* w,
31783226
PortablePropertyOwnerMap* claims,
31793227
std::vector<PortableIndexedProperty>* indexed) noexcept
31803228
{
@@ -3188,7 +3236,7 @@ namespace {
31883236
const std::string_view name
31893237
= arena_string(arena, e.key.data.xmp_property.property_path);
31903238
std::string_view prefix;
3191-
if (!xmp_ns_to_portable_prefix(ns, &prefix)) {
3239+
if (!portable_ns_to_prefix(ns, decls, &prefix)) {
31923240
return false;
31933241
}
31943242

@@ -3240,6 +3288,70 @@ namespace {
32403288
return false;
32413289
}
32423290

3291+
static void collect_portable_custom_ns_decls(
3292+
const ByteArena& arena, std::span<const Entry> entries,
3293+
const XmpPortableOptions& options,
3294+
std::vector<PortableCustomNsDecl>* out) noexcept
3295+
{
3296+
if (!out) {
3297+
return;
3298+
}
3299+
out->clear();
3300+
if (!options.include_existing_xmp
3301+
|| options.existing_namespace_policy
3302+
!= XmpExistingNamespacePolicy::PreserveCustom) {
3303+
return;
3304+
}
3305+
3306+
uint32_t next_index = 1U;
3307+
for (size_t i = 0; i < entries.size(); ++i) {
3308+
const Entry& e = entries[i];
3309+
if (e.key.kind != MetaKeyKind::XmpProperty) {
3310+
continue;
3311+
}
3312+
3313+
const std::string_view ns
3314+
= arena_string(arena, e.key.data.xmp_property.schema_ns);
3315+
std::string_view prefix;
3316+
if (xmp_ns_to_portable_prefix(ns, &prefix)
3317+
|| !xmp_namespace_uri_is_xml_attr_safe(ns)
3318+
|| portable_custom_ns_prefix_for_uri(
3319+
ns, std::span<const PortableCustomNsDecl>(out->data(),
3320+
out->size()),
3321+
&prefix)) {
3322+
continue;
3323+
}
3324+
3325+
const std::string_view name
3326+
= arena_string(arena, e.key.data.xmp_property.property_path);
3327+
std::string_view portable_name;
3328+
if (is_simple_xmp_property_name(name)) {
3329+
portable_name = portable_property_name_for_existing_xmp(
3330+
"omns", name);
3331+
} else {
3332+
std::string_view base_name;
3333+
uint32_t index = 0U;
3334+
if (!parse_indexed_xmp_property_name(name, &base_name,
3335+
&index)) {
3336+
continue;
3337+
}
3338+
portable_name = portable_property_name_for_existing_xmp(
3339+
"omns", base_name);
3340+
}
3341+
if (portable_name.empty()
3342+
|| xmp_property_is_nonportable_blob("omns", portable_name)
3343+
|| !portable_scalar_like_value_supported(arena, e.value)) {
3344+
continue;
3345+
}
3346+
3347+
PortableCustomNsDecl decl;
3348+
decl.prefix = "omns" + std::to_string(next_index);
3349+
decl.uri.assign(ns.data(), ns.size());
3350+
out->push_back(std::move(decl));
3351+
next_index += 1U;
3352+
}
3353+
}
3354+
32433355
static bool
32443356
process_portable_exif_entry(const ByteArena& arena,
32453357
std::span<const Entry> entries, const Entry& e,
@@ -3600,6 +3712,7 @@ namespace {
36003712

36013713
static void emit_portable_pass(
36023714
PortablePassKind pass, const ByteArena& arena,
3715+
std::span<const PortableCustomNsDecl> custom_decls,
36033716
std::span<const Entry> entries, const XmpPortableOptions& options,
36043717
SpanWriter* w, PortablePropertyOwnerMap* claims,
36053718
std::vector<PortableIndexedProperty>* indexed, uint32_t* emitted,
@@ -3640,7 +3753,8 @@ namespace {
36403753
continue;
36413754
}
36423755
if (process_portable_existing_xmp_entry(
3643-
arena, e, static_cast<uint32_t>(i), w, claims,
3756+
arena, custom_decls, e, static_cast<uint32_t>(i), w,
3757+
claims,
36443758
indexed)) {
36453759
*emitted += 1U;
36463760
}
@@ -3677,12 +3791,24 @@ dump_xmp_portable(const MetaStore& store, std::span<std::byte> out,
36773791
XmpNsDecl { "photoshop", kXmpNsPhotoshop },
36783792
XmpNsDecl { "Iptc4xmpCore", kXmpNsIptc4xmpCore },
36793793
};
3680-
emit_xmp_packet_begin(&w, std::span<const XmpNsDecl>(kDecls.data(),
3681-
kDecls.size()));
3682-
36833794
const ByteArena& arena = store.arena();
36843795
const std::span<const Entry> es = store.entries();
36853796

3797+
std::vector<PortableCustomNsDecl> custom_decls;
3798+
collect_portable_custom_ns_decls(arena, es, options, &custom_decls);
3799+
3800+
std::vector<XmpNsDecl> decls;
3801+
decls.reserve(kDecls.size() + custom_decls.size());
3802+
for (size_t i = 0; i < kDecls.size(); ++i) {
3803+
decls.push_back(kDecls[i]);
3804+
}
3805+
for (size_t i = 0; i < custom_decls.size(); ++i) {
3806+
decls.push_back(XmpNsDecl { custom_decls[i].prefix,
3807+
custom_decls[i].uri });
3808+
}
3809+
emit_xmp_packet_begin(&w, std::span<const XmpNsDecl>(decls.data(),
3810+
decls.size()));
3811+
36863812
std::vector<PortableIndexedProperty> indexed;
36873813
indexed.reserve(128);
36883814
PortablePropertyOwnerMap claims;
@@ -3692,26 +3818,53 @@ dump_xmp_portable(const MetaStore& store, std::span<std::byte> out,
36923818
uint32_t iptc_order = 0U;
36933819

36943820
if (options.conflict_policy == XmpConflictPolicy::ExistingWins) {
3695-
emit_portable_pass(PortablePassKind::ExistingXmp, arena, es, options,
3696-
&w, &claims, &indexed, &emitted, &iptc_order);
3697-
emit_portable_pass(PortablePassKind::Exif, arena, es, options, &w,
3698-
&claims, &indexed, &emitted, &iptc_order);
3699-
emit_portable_pass(PortablePassKind::Iptc, arena, es, options, &w,
3700-
&claims, &indexed, &emitted, &iptc_order);
3821+
emit_portable_pass(PortablePassKind::ExistingXmp, arena,
3822+
std::span<const PortableCustomNsDecl>(
3823+
custom_decls.data(), custom_decls.size()),
3824+
es, options, &w, &claims, &indexed, &emitted,
3825+
&iptc_order);
3826+
emit_portable_pass(PortablePassKind::Exif, arena,
3827+
std::span<const PortableCustomNsDecl>(
3828+
custom_decls.data(), custom_decls.size()),
3829+
es, options, &w, &claims, &indexed, &emitted,
3830+
&iptc_order);
3831+
emit_portable_pass(PortablePassKind::Iptc, arena,
3832+
std::span<const PortableCustomNsDecl>(
3833+
custom_decls.data(), custom_decls.size()),
3834+
es, options, &w, &claims, &indexed, &emitted,
3835+
&iptc_order);
37013836
} else if (options.conflict_policy == XmpConflictPolicy::GeneratedWins) {
3702-
emit_portable_pass(PortablePassKind::Exif, arena, es, options, &w,
3703-
&claims, &indexed, &emitted, &iptc_order);
3704-
emit_portable_pass(PortablePassKind::Iptc, arena, es, options, &w,
3705-
&claims, &indexed, &emitted, &iptc_order);
3706-
emit_portable_pass(PortablePassKind::ExistingXmp, arena, es, options,
3707-
&w, &claims, &indexed, &emitted, &iptc_order);
3837+
emit_portable_pass(PortablePassKind::Exif, arena,
3838+
std::span<const PortableCustomNsDecl>(
3839+
custom_decls.data(), custom_decls.size()),
3840+
es, options, &w, &claims, &indexed, &emitted,
3841+
&iptc_order);
3842+
emit_portable_pass(PortablePassKind::Iptc, arena,
3843+
std::span<const PortableCustomNsDecl>(
3844+
custom_decls.data(), custom_decls.size()),
3845+
es, options, &w, &claims, &indexed, &emitted,
3846+
&iptc_order);
3847+
emit_portable_pass(PortablePassKind::ExistingXmp, arena,
3848+
std::span<const PortableCustomNsDecl>(
3849+
custom_decls.data(), custom_decls.size()),
3850+
es, options, &w, &claims, &indexed, &emitted,
3851+
&iptc_order);
37083852
} else {
3709-
emit_portable_pass(PortablePassKind::Exif, arena, es, options, &w,
3710-
&claims, &indexed, &emitted, &iptc_order);
3711-
emit_portable_pass(PortablePassKind::ExistingXmp, arena, es, options,
3712-
&w, &claims, &indexed, &emitted, &iptc_order);
3713-
emit_portable_pass(PortablePassKind::Iptc, arena, es, options, &w,
3714-
&claims, &indexed, &emitted, &iptc_order);
3853+
emit_portable_pass(PortablePassKind::Exif, arena,
3854+
std::span<const PortableCustomNsDecl>(
3855+
custom_decls.data(), custom_decls.size()),
3856+
es, options, &w, &claims, &indexed, &emitted,
3857+
&iptc_order);
3858+
emit_portable_pass(PortablePassKind::ExistingXmp, arena,
3859+
std::span<const PortableCustomNsDecl>(
3860+
custom_decls.data(), custom_decls.size()),
3861+
es, options, &w, &claims, &indexed, &emitted,
3862+
&iptc_order);
3863+
emit_portable_pass(PortablePassKind::Iptc, arena,
3864+
std::span<const PortableCustomNsDecl>(
3865+
custom_decls.data(), custom_decls.size()),
3866+
es, options, &w, &claims, &indexed, &emitted,
3867+
&iptc_order);
37153868
}
37163869

37173870
emit_portable_indexed_groups(&w, arena, &indexed,
@@ -3805,6 +3958,8 @@ make_xmp_sidecar_options(const XmpSidecarRequest& request) noexcept
38053958
options.portable.include_exif = request.include_exif;
38063959
options.portable.include_iptc = request.include_iptc;
38073960
options.portable.include_existing_xmp = request.include_existing_xmp;
3961+
options.portable.existing_namespace_policy
3962+
= request.portable_existing_namespace_policy;
38083963
options.portable.conflict_policy = request.portable_conflict_policy;
38093964
options.portable.exiftool_gpsdatetime_alias
38103965
= request.portable_exiftool_gpsdatetime_alias;

0 commit comments

Comments
 (0)