oapi-profile-builder 3.1.0__tar.gz → 3.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {oapi_profile_builder-3.1.0/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.2.1}/PKG-INFO +138 -3
  2. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/README.md +135 -2
  3. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/pyproject.toml +2 -1
  4. oapi_profile_builder-3.2.1/src/oapi_profile_builder/cover.py +176 -0
  5. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder/generate.py +120 -0
  6. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder/models.py +165 -0
  7. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder/server_validation.py +91 -0
  8. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1/src/oapi_profile_builder.egg-info}/PKG-INFO +138 -3
  9. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder.egg-info/SOURCES.txt +1 -0
  10. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder.egg-info/requires.txt +3 -0
  11. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/LICENSE +0 -0
  12. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/setup.cfg +0 -0
  13. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder/__init__.py +0 -0
  14. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder/cite.py +0 -0
  15. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder/cite_features.py +0 -0
  16. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder/cli.py +0 -0
  17. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder/compile.py +0 -0
  18. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  19. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  20. {oapi_profile_builder-3.1.0 → oapi_profile_builder-3.2.1}/src/oapi_profile_builder.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.1.0
3
+ Version: 3.2.1
4
4
  Summary: Authoritative tooling for creating OGC API Service Profiles (EDR, Features)
5
5
  Author-email: Shane Mill <shane.mill@noaa.gov>
6
6
  License: Apache License
@@ -41,11 +41,13 @@ Requires-Dist: pyyaml>=6.0
41
41
  Requires-Dist: requests>=2.31
42
42
  Provides-Extra: validate
43
43
  Requires-Dist: schemathesis>=3.0; extra == "validate"
44
+ Provides-Extra: pdf
45
+ Requires-Dist: pillow>=10.0; extra == "pdf"
44
46
  Dynamic: license-file
45
47
 
46
48
  # OGC API Service Profile Builder
47
49
 
48
- Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.1.0, AsyncAPI, AsciiDoc requirements, and conformance tests.
50
+ Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.1.0, AsyncAPI, AsciiDoc requirements and conformance tests, and a publication-ready Metanorma PDF with optional custom (e.g. DGIWG) branding.
49
51
 
50
52
  [![PyPI](https://img.shields.io/pypi/v/oapi-profile-builder)](https://pypi.org/project/oapi-profile-builder/)
51
53
  [![License](https://img.shields.io/badge/license-Apache-blue)](LICENSE)
@@ -80,13 +82,16 @@ output/
80
82
 
81
83
  ## Example Profiles
82
84
 
83
- Three working examples are included:
85
+ Six working examples are included:
84
86
 
85
87
  | File | What it shows |
86
88
  |---|---|
87
89
  | [`examples/minimal_profile.yaml`](examples/minimal_profile.yaml) | Smallest valid profile — one collection, one requirement |
88
90
  | [`examples/insitu_observations_profile.yaml`](examples/insitu_observations_profile.yaml) | Full meteorological profile — 8 parameters with QUDT units, CF standard names, metocean extensions, CRS listing, temporal extent, custom dimensions, `parameter_schema` |
91
+ | [`examples/nws_connect_profile.yaml`](examples/nws_connect_profile.yaml) | PubSub profile — generates `asyncapi.yaml` alongside the OpenAPI |
89
92
  | [`examples/nwsviz_profile.yaml`](examples/nwsviz_profile.yaml) | Production profile — 13 collections, 3 OGC API Processes, PDF metadata |
93
+ | [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml) | **DGIWG-branded** profile — custom cover logo, DGIWG colours/footer/legal page, `default_output_format`, per-query CRS, vertical `positive`, DGIWG namespace |
94
+ | [`examples/nwp_earth_observations_lightning_profile.yaml`](examples/nwp_earth_observations_lightning_profile.yaml) | DGIWG lightning profile — same branding, CF units |
90
95
 
91
96
  ---
92
97
 
@@ -115,6 +120,8 @@ Add `--pdf` to also compile an OGC-compliant PDF via the `metanorma/metanorma` D
115
120
  oapi-profile-builder generate --config my_profile.yaml --output ./output --pdf
116
121
  ```
117
122
 
123
+ The PDF can be rebranded for another SDO (e.g. DGIWG) — custom cover logo, colours, legal page, footer, and identifier namespace — all from the profile config. See [Document generation and branding](#document-generation-and-branding).
124
+
118
125
  ### `validate`
119
126
 
120
127
  Validates the config without writing any files. Useful in CI before generating.
@@ -219,6 +226,29 @@ jobs:
219
226
 
220
227
  ---
221
228
 
229
+ ## Document generation and branding
230
+
231
+ With `--pdf`, the builder compiles a Metanorma document (`document.adoc` + `sections/`, `requirements/`, `abstract_tests/`) into `document.pdf` using the official `metanorma/metanorma` Docker image. By default this is an OGC `draft-standard` document.
232
+
233
+ Everything about the document — cover, title-page metadata, legal page, footer, colours, and even the requirement/conformance identifier namespace — is driven from the profile config, so the same tooling can produce a document branded for another SDO such as **DGIWG** with no OGC branding remaining.
234
+
235
+ | What you want to change | Field | Mechanism |
236
+ |---|---|---|
237
+ | Cover logo + tagline (replaces the OGC cover) | `document_metadata.cover` | Generated cover image via `:coverpage-image:` full replacement |
238
+ | Title, doc number, edition, dates, sub-title/status | `title`, `version`, `document_metadata.{doc_number, doc_type, doc_subtype, status, *_date}` | Metanorma document attributes |
239
+ | PDF colours (titles, tables, cover, backgrounds) | `document_metadata.colors` | `:presentation-metadata-color-*:` |
240
+ | Page-ii legal text (copyright / license / notice) | `document_metadata.boilerplate` | `:boilerplate-authority:` |
241
+ | Footer organisation name | `document_metadata.copyright_holder` | `:copyright-holder:` |
242
+ | Remove the OGC logo from the legal page | `document_metadata.suppress_flavor_logo` | targeted `:pdf-stylesheet-override:` |
243
+ | Requirement / conformance identifier namespace | `spec_uri_base` | derives `req_uri` / `conf_uri` |
244
+ | Submitter table, notice paragraph, normative refs | `document_metadata.{submitters, notice, normative_references}` | Metanorma attributes / sections |
245
+
246
+ Custom branding requires Pillow for the cover image: `pip install 'oapi-profile-builder[pdf]'`. Field-by-field detail is in the [`document_metadata`](#document_metadata) reference below; [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml) is a complete DGIWG-branded example.
247
+
248
+ > The `suppress_flavor_logo` override reaches into the OGC Metanorma flavor's XSL (template `insertLogoPreface`) and is coupled to that flavor. Everything else uses documented Metanorma attributes. For long-term SDO adoption, a dedicated Metanorma "taste"/flavor is the most durable path.
249
+
250
+ ---
251
+
222
252
  ## Profile Config Reference
223
253
 
224
254
  A profile config is a YAML file. The full JSON Schema is at [`profile.schema.json`](profile.schema.json).
@@ -445,6 +475,61 @@ extent_requirements:
445
475
 
446
476
  At least one of `extent_crs` or `supported_crs` is required. The same applies to TRS and VRS — all are optional individually but at least one CRS constraint must be present.
447
477
 
478
+ #### Vertical direction (`positive`)
479
+
480
+ OGC API - EDR's vertical extent has no field for which way values increase. This is ambiguous for VRS like pressure levels, which different providers order top-to-bottom or bottom-to-top inconsistently (pressure increases as altitude *decreases*). Set `extent.vertical.positive` to `up` or `down` on a collection (following the CF Conventions `positive` attribute) to make the ordering explicit:
481
+
482
+ ```yaml
483
+ collections:
484
+ - id: my_collection
485
+ extent:
486
+ spatial: { ... }
487
+ vertical:
488
+ interval:
489
+ - ["1000", "100"] # hPa, surface to top of atmosphere
490
+ values: ["1000", "850", "700", "500", "300", "100"]
491
+ vrs: "http://www.opengis.net/def/crs/EPSG/0/5798" # or similar pressure VRS
492
+ positive: down # pressure increases downward, toward the surface
493
+ ```
494
+
495
+ Set `extent_requirements.require_vertical_direction: true` to make `positive` mandatory on every collection with a vertical extent, so a profile can enforce one consistent convention rather than leaving it up to each collection author:
496
+
497
+ ```yaml
498
+ extent_requirements:
499
+ minimum_bbox: [-180, -90, 180, 90]
500
+ extent_crs:
501
+ allowed: ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"]
502
+ require_vertical_direction: true
503
+ ```
504
+
505
+ ---
506
+
507
+ ### `output_formats`
508
+
509
+ Maps format names (referenced by `collections[].output_formats` and `data_queries.*.variables.output_formats`) to a media type and, optionally, a schema reference. The `schema_ref` flows into the generated OpenAPI response `content` for that format — use it to point a format at a specific schema (e.g. a DGIWG-published schema for GeoTIFF or CoverageJSON) instead of the tool's built-in default.
510
+
511
+ ```yaml
512
+ output_formats:
513
+ - name: CoverageJSON
514
+ media_type: application/prs.coverage+json
515
+ schema_ref: https://schemas.opengis.net/covjson/1.0/coveragejson.json
516
+ - name: GeoJSON
517
+ media_type: application/geo+json
518
+ schema_ref: https://geojson.org/schema/FeatureCollection.json
519
+ - name: GeoTIFF
520
+ media_type: image/tiff; application=geotiff
521
+ # Omit schema_ref until a published schema exists for this format —
522
+ # the requirement/abstract test can still state the standard to conform to.
523
+ ```
524
+
525
+ | Field | Required | Description |
526
+ |---|---|---|
527
+ | `name` | yes | Format name, referenced elsewhere as a string (e.g. `CoverageJSON`) |
528
+ | `media_type` | yes | MIME type used as the OpenAPI response `content` key and the `f` parameter value |
529
+ | `schema_ref` | no | URL to a JSON Schema (or other schema) for this format. When set, the generated response uses `{"$ref": schema_ref}`; when omitted, a generic/default schema is used |
530
+
531
+ `schema_ref` is optional — if no schema exists yet for a format (as is currently the case for some binary formats like GeoTIFF), leave it unset and capture the required standard as a normative `requirements[]` entry instead. Add `schema_ref` later once a schema URL exists.
532
+
448
533
  ---
449
534
 
450
535
  ### `parameter_schema`
@@ -601,6 +686,55 @@ document_metadata:
601
686
 
602
687
  The cover-page sub-title (e.g. "Draft Standard — Implementation — Approved") is produced by Metanorma from the `doc_type`, `doc_subtype`, and `status` combination.
603
688
 
689
+ #### Custom branding (logo + colors)
690
+
691
+ Two `document_metadata` blocks let you rebrand the PDF (e.g. for DGIWG) without a custom Metanorma flavor:
692
+
693
+ - **`colors`** — recolours the PDF via Metanorma's `:presentation-metadata-color-*:` attributes. Values are hex colours.
694
+ - **`cover`** — replaces the built-in OGC cover page with a generated one: your logo plus the document's title, number, edition and date. Requires Pillow (`pip install 'oapi-profile-builder[pdf]'`). The builder renders `cover.png` into the output directory and points Metanorma at it via `:coverpage-image:` + `:presentation-metadata-full-coverpage-replacement:`.
695
+
696
+ ```yaml
697
+ document_metadata:
698
+ doc_number: "DGIWG 134"
699
+ cover:
700
+ logo: assets/dgiwg_logo.png # path relative to the working directory (or absolute)
701
+ tagline: Delivering Military Advantage through multi-national geospatial interoperability
702
+ background: "#FFFFFF" # optional cover background (default white)
703
+ text_color: "#1F3864" # optional cover text colour (default black)
704
+ colors:
705
+ text: "#000000" # body text
706
+ cover_text: "#1F3864" # cover text / section numbers / ToC
707
+ cover_lines: "#1F3864" # preface 'crossing lines' element
708
+ title: "#1F3864" # clause/table/figure titles
709
+ page_background: "#FFFFFF" # cover + section page background
710
+ table_header: "#1F3864" # table header background
711
+ table_row_even: "#EEF1F7" # even table rows
712
+ table_row_odd: "#FFFFFF" # odd table rows
713
+ ```
714
+
715
+ Note: colours work against the normal OGC cover too. The `cover` block does a *full* cover replacement, so the dynamic title/number/edition/date are drawn onto the generated image (which is why the builder composes it rather than relying on Metanorma attributes — logo attributes like `:coverpage-image:` alone are not rendered by the OGC flavor).
716
+
717
+ #### De-branding the rest of the document (legal page + footer + logo)
718
+
719
+ Cover + colors handle the front page, but the OGC flavor also stamps its identity on the page-ii legal boilerplate, the page footer, and an OGC logo on the legal page. Three more `document_metadata` fields remove those:
720
+
721
+ - **`copyright_holder`** — sets `:copyright-holder:`, which also drives the **PDF footer** organisation name (replacing "OPEN GEOSPATIAL CONSORTIUM").
722
+ - **`boilerplate`** — replaces the page-ii legal text (copyright / license / legal notice / feedback) via `:boilerplate-authority:`. Any field left unset is synthesised from `copyright_holder`, so no OGC text leaks through.
723
+ - **`suppress_flavor_logo`** — removes the OGC logo from the preface/legal page via a targeted PDF-stylesheet override. (Coupled to the OGC Metanorma flavor's XSL.)
724
+
725
+ ```yaml
726
+ document_metadata:
727
+ copyright_holder: DGIWG
728
+ suppress_flavor_logo: true
729
+ boilerplate:
730
+ copyright: "Copyright © 2026 Defence Geospatial Information Working Group (DGIWG). All rights reserved."
731
+ license: "Use of this document is subject to the DGIWG terms and conditions."
732
+ legal: "Attention is drawn to the possibility that some elements may be subject to patent rights; DGIWG shall not be held responsible for identifying any such rights."
733
+ feedback: "Comments on this document should be directed to DGIWG."
734
+ ```
735
+
736
+ With `cover` + `colors` + `copyright_holder` + `boilerplate` + `suppress_flavor_logo` (and `spec_uri_base` for the requirement/conformance identifiers), the generated PDF carries no OGC branding — only legitimate citations to the OGC standards the profile conforms to. See [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml).
737
+
604
738
  ---
605
739
 
606
740
  ### `provider`, `classification`, and provenance metadata
@@ -672,6 +806,7 @@ The tool enforces these rules at build time. Violations produce clear error mess
672
806
  | Collection TRS validated | Each `extent.temporal.trs` checked against `extent_trs` |
673
807
  | `crs_details` / `crs` validated | Each `data_queries.*.variables.crs_details[].crs` and `crs[]` checked against `supported_crs` |
674
808
  | `default_output_format` validated | Each `data_queries.*.variables.default_output_format` must be one of that query's `output_formats` |
809
+ | `require_vertical_direction` enforced | If set, every collection with a vertical extent must declare `extent.vertical.positive` as `up` or `down` |
675
810
  | Document dates format | `submission_date` / `approval_date` / `publication_date` must be `YYYY-MM-DD` |
676
811
  | Parameters need `unit` + `observedProperty` | Required by OGC API - EDR Part 3 |
677
812
  | `parameter_name_pattern` enforced | All `parameter_names` keys must match if set |
@@ -1,6 +1,6 @@
1
1
  # OGC API Service Profile Builder
2
2
 
3
- Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.1.0, AsyncAPI, AsciiDoc requirements, and conformance tests.
3
+ Generate OGC API - EDR Part 3 Service Profile artifacts from a YAML config — OpenAPI 3.1.0, AsyncAPI, AsciiDoc requirements and conformance tests, and a publication-ready Metanorma PDF with optional custom (e.g. DGIWG) branding.
4
4
 
5
5
  [![PyPI](https://img.shields.io/pypi/v/oapi-profile-builder)](https://pypi.org/project/oapi-profile-builder/)
6
6
  [![License](https://img.shields.io/badge/license-Apache-blue)](LICENSE)
@@ -35,13 +35,16 @@ output/
35
35
 
36
36
  ## Example Profiles
37
37
 
38
- Three working examples are included:
38
+ Six working examples are included:
39
39
 
40
40
  | File | What it shows |
41
41
  |---|---|
42
42
  | [`examples/minimal_profile.yaml`](examples/minimal_profile.yaml) | Smallest valid profile — one collection, one requirement |
43
43
  | [`examples/insitu_observations_profile.yaml`](examples/insitu_observations_profile.yaml) | Full meteorological profile — 8 parameters with QUDT units, CF standard names, metocean extensions, CRS listing, temporal extent, custom dimensions, `parameter_schema` |
44
+ | [`examples/nws_connect_profile.yaml`](examples/nws_connect_profile.yaml) | PubSub profile — generates `asyncapi.yaml` alongside the OpenAPI |
44
45
  | [`examples/nwsviz_profile.yaml`](examples/nwsviz_profile.yaml) | Production profile — 13 collections, 3 OGC API Processes, PDF metadata |
46
+ | [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml) | **DGIWG-branded** profile — custom cover logo, DGIWG colours/footer/legal page, `default_output_format`, per-query CRS, vertical `positive`, DGIWG namespace |
47
+ | [`examples/nwp_earth_observations_lightning_profile.yaml`](examples/nwp_earth_observations_lightning_profile.yaml) | DGIWG lightning profile — same branding, CF units |
45
48
 
46
49
  ---
47
50
 
@@ -70,6 +73,8 @@ Add `--pdf` to also compile an OGC-compliant PDF via the `metanorma/metanorma` D
70
73
  oapi-profile-builder generate --config my_profile.yaml --output ./output --pdf
71
74
  ```
72
75
 
76
+ The PDF can be rebranded for another SDO (e.g. DGIWG) — custom cover logo, colours, legal page, footer, and identifier namespace — all from the profile config. See [Document generation and branding](#document-generation-and-branding).
77
+
73
78
  ### `validate`
74
79
 
75
80
  Validates the config without writing any files. Useful in CI before generating.
@@ -174,6 +179,29 @@ jobs:
174
179
 
175
180
  ---
176
181
 
182
+ ## Document generation and branding
183
+
184
+ With `--pdf`, the builder compiles a Metanorma document (`document.adoc` + `sections/`, `requirements/`, `abstract_tests/`) into `document.pdf` using the official `metanorma/metanorma` Docker image. By default this is an OGC `draft-standard` document.
185
+
186
+ Everything about the document — cover, title-page metadata, legal page, footer, colours, and even the requirement/conformance identifier namespace — is driven from the profile config, so the same tooling can produce a document branded for another SDO such as **DGIWG** with no OGC branding remaining.
187
+
188
+ | What you want to change | Field | Mechanism |
189
+ |---|---|---|
190
+ | Cover logo + tagline (replaces the OGC cover) | `document_metadata.cover` | Generated cover image via `:coverpage-image:` full replacement |
191
+ | Title, doc number, edition, dates, sub-title/status | `title`, `version`, `document_metadata.{doc_number, doc_type, doc_subtype, status, *_date}` | Metanorma document attributes |
192
+ | PDF colours (titles, tables, cover, backgrounds) | `document_metadata.colors` | `:presentation-metadata-color-*:` |
193
+ | Page-ii legal text (copyright / license / notice) | `document_metadata.boilerplate` | `:boilerplate-authority:` |
194
+ | Footer organisation name | `document_metadata.copyright_holder` | `:copyright-holder:` |
195
+ | Remove the OGC logo from the legal page | `document_metadata.suppress_flavor_logo` | targeted `:pdf-stylesheet-override:` |
196
+ | Requirement / conformance identifier namespace | `spec_uri_base` | derives `req_uri` / `conf_uri` |
197
+ | Submitter table, notice paragraph, normative refs | `document_metadata.{submitters, notice, normative_references}` | Metanorma attributes / sections |
198
+
199
+ Custom branding requires Pillow for the cover image: `pip install 'oapi-profile-builder[pdf]'`. Field-by-field detail is in the [`document_metadata`](#document_metadata) reference below; [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml) is a complete DGIWG-branded example.
200
+
201
+ > The `suppress_flavor_logo` override reaches into the OGC Metanorma flavor's XSL (template `insertLogoPreface`) and is coupled to that flavor. Everything else uses documented Metanorma attributes. For long-term SDO adoption, a dedicated Metanorma "taste"/flavor is the most durable path.
202
+
203
+ ---
204
+
177
205
  ## Profile Config Reference
178
206
 
179
207
  A profile config is a YAML file. The full JSON Schema is at [`profile.schema.json`](profile.schema.json).
@@ -400,6 +428,61 @@ extent_requirements:
400
428
 
401
429
  At least one of `extent_crs` or `supported_crs` is required. The same applies to TRS and VRS — all are optional individually but at least one CRS constraint must be present.
402
430
 
431
+ #### Vertical direction (`positive`)
432
+
433
+ OGC API - EDR's vertical extent has no field for which way values increase. This is ambiguous for VRS like pressure levels, which different providers order top-to-bottom or bottom-to-top inconsistently (pressure increases as altitude *decreases*). Set `extent.vertical.positive` to `up` or `down` on a collection (following the CF Conventions `positive` attribute) to make the ordering explicit:
434
+
435
+ ```yaml
436
+ collections:
437
+ - id: my_collection
438
+ extent:
439
+ spatial: { ... }
440
+ vertical:
441
+ interval:
442
+ - ["1000", "100"] # hPa, surface to top of atmosphere
443
+ values: ["1000", "850", "700", "500", "300", "100"]
444
+ vrs: "http://www.opengis.net/def/crs/EPSG/0/5798" # or similar pressure VRS
445
+ positive: down # pressure increases downward, toward the surface
446
+ ```
447
+
448
+ Set `extent_requirements.require_vertical_direction: true` to make `positive` mandatory on every collection with a vertical extent, so a profile can enforce one consistent convention rather than leaving it up to each collection author:
449
+
450
+ ```yaml
451
+ extent_requirements:
452
+ minimum_bbox: [-180, -90, 180, 90]
453
+ extent_crs:
454
+ allowed: ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"]
455
+ require_vertical_direction: true
456
+ ```
457
+
458
+ ---
459
+
460
+ ### `output_formats`
461
+
462
+ Maps format names (referenced by `collections[].output_formats` and `data_queries.*.variables.output_formats`) to a media type and, optionally, a schema reference. The `schema_ref` flows into the generated OpenAPI response `content` for that format — use it to point a format at a specific schema (e.g. a DGIWG-published schema for GeoTIFF or CoverageJSON) instead of the tool's built-in default.
463
+
464
+ ```yaml
465
+ output_formats:
466
+ - name: CoverageJSON
467
+ media_type: application/prs.coverage+json
468
+ schema_ref: https://schemas.opengis.net/covjson/1.0/coveragejson.json
469
+ - name: GeoJSON
470
+ media_type: application/geo+json
471
+ schema_ref: https://geojson.org/schema/FeatureCollection.json
472
+ - name: GeoTIFF
473
+ media_type: image/tiff; application=geotiff
474
+ # Omit schema_ref until a published schema exists for this format —
475
+ # the requirement/abstract test can still state the standard to conform to.
476
+ ```
477
+
478
+ | Field | Required | Description |
479
+ |---|---|---|
480
+ | `name` | yes | Format name, referenced elsewhere as a string (e.g. `CoverageJSON`) |
481
+ | `media_type` | yes | MIME type used as the OpenAPI response `content` key and the `f` parameter value |
482
+ | `schema_ref` | no | URL to a JSON Schema (or other schema) for this format. When set, the generated response uses `{"$ref": schema_ref}`; when omitted, a generic/default schema is used |
483
+
484
+ `schema_ref` is optional — if no schema exists yet for a format (as is currently the case for some binary formats like GeoTIFF), leave it unset and capture the required standard as a normative `requirements[]` entry instead. Add `schema_ref` later once a schema URL exists.
485
+
403
486
  ---
404
487
 
405
488
  ### `parameter_schema`
@@ -556,6 +639,55 @@ document_metadata:
556
639
 
557
640
  The cover-page sub-title (e.g. "Draft Standard — Implementation — Approved") is produced by Metanorma from the `doc_type`, `doc_subtype`, and `status` combination.
558
641
 
642
+ #### Custom branding (logo + colors)
643
+
644
+ Two `document_metadata` blocks let you rebrand the PDF (e.g. for DGIWG) without a custom Metanorma flavor:
645
+
646
+ - **`colors`** — recolours the PDF via Metanorma's `:presentation-metadata-color-*:` attributes. Values are hex colours.
647
+ - **`cover`** — replaces the built-in OGC cover page with a generated one: your logo plus the document's title, number, edition and date. Requires Pillow (`pip install 'oapi-profile-builder[pdf]'`). The builder renders `cover.png` into the output directory and points Metanorma at it via `:coverpage-image:` + `:presentation-metadata-full-coverpage-replacement:`.
648
+
649
+ ```yaml
650
+ document_metadata:
651
+ doc_number: "DGIWG 134"
652
+ cover:
653
+ logo: assets/dgiwg_logo.png # path relative to the working directory (or absolute)
654
+ tagline: Delivering Military Advantage through multi-national geospatial interoperability
655
+ background: "#FFFFFF" # optional cover background (default white)
656
+ text_color: "#1F3864" # optional cover text colour (default black)
657
+ colors:
658
+ text: "#000000" # body text
659
+ cover_text: "#1F3864" # cover text / section numbers / ToC
660
+ cover_lines: "#1F3864" # preface 'crossing lines' element
661
+ title: "#1F3864" # clause/table/figure titles
662
+ page_background: "#FFFFFF" # cover + section page background
663
+ table_header: "#1F3864" # table header background
664
+ table_row_even: "#EEF1F7" # even table rows
665
+ table_row_odd: "#FFFFFF" # odd table rows
666
+ ```
667
+
668
+ Note: colours work against the normal OGC cover too. The `cover` block does a *full* cover replacement, so the dynamic title/number/edition/date are drawn onto the generated image (which is why the builder composes it rather than relying on Metanorma attributes — logo attributes like `:coverpage-image:` alone are not rendered by the OGC flavor).
669
+
670
+ #### De-branding the rest of the document (legal page + footer + logo)
671
+
672
+ Cover + colors handle the front page, but the OGC flavor also stamps its identity on the page-ii legal boilerplate, the page footer, and an OGC logo on the legal page. Three more `document_metadata` fields remove those:
673
+
674
+ - **`copyright_holder`** — sets `:copyright-holder:`, which also drives the **PDF footer** organisation name (replacing "OPEN GEOSPATIAL CONSORTIUM").
675
+ - **`boilerplate`** — replaces the page-ii legal text (copyright / license / legal notice / feedback) via `:boilerplate-authority:`. Any field left unset is synthesised from `copyright_holder`, so no OGC text leaks through.
676
+ - **`suppress_flavor_logo`** — removes the OGC logo from the preface/legal page via a targeted PDF-stylesheet override. (Coupled to the OGC Metanorma flavor's XSL.)
677
+
678
+ ```yaml
679
+ document_metadata:
680
+ copyright_holder: DGIWG
681
+ suppress_flavor_logo: true
682
+ boilerplate:
683
+ copyright: "Copyright © 2026 Defence Geospatial Information Working Group (DGIWG). All rights reserved."
684
+ license: "Use of this document is subject to the DGIWG terms and conditions."
685
+ legal: "Attention is drawn to the possibility that some elements may be subject to patent rights; DGIWG shall not be held responsible for identifying any such rights."
686
+ feedback: "Comments on this document should be directed to DGIWG."
687
+ ```
688
+
689
+ With `cover` + `colors` + `copyright_holder` + `boilerplate` + `suppress_flavor_logo` (and `spec_uri_base` for the requirement/conformance identifiers), the generated PDF carries no OGC branding — only legitimate citations to the OGC standards the profile conforms to. See [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml).
690
+
559
691
  ---
560
692
 
561
693
  ### `provider`, `classification`, and provenance metadata
@@ -627,6 +759,7 @@ The tool enforces these rules at build time. Violations produce clear error mess
627
759
  | Collection TRS validated | Each `extent.temporal.trs` checked against `extent_trs` |
628
760
  | `crs_details` / `crs` validated | Each `data_queries.*.variables.crs_details[].crs` and `crs[]` checked against `supported_crs` |
629
761
  | `default_output_format` validated | Each `data_queries.*.variables.default_output_format` must be one of that query's `output_formats` |
762
+ | `require_vertical_direction` enforced | If set, every collection with a vertical extent must declare `extent.vertical.positive` as `up` or `down` |
630
763
  | Document dates format | `submission_date` / `approval_date` / `publication_date` must be `YYYY-MM-DD` |
631
764
  | Parameters need `unit` + `observedProperty` | Required by OGC API - EDR Part 3 |
632
765
  | `parameter_name_pattern` enforced | All `parameter_names` keys must match if set |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "3.1.0"
7
+ version = "3.2.1"
8
8
  description = "Authoritative tooling for creating OGC API Service Profiles (EDR, Features)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -30,6 +30,7 @@ dependencies = [
30
30
 
31
31
  [project.optional-dependencies]
32
32
  validate = ["schemathesis>=3.0"]
33
+ pdf = ["pillow>=10.0"]
33
34
 
34
35
  [project.urls]
35
36
  Homepage = "https://github.com/ShaneMill1/OGC-API-Service-Profile-Builder"
@@ -0,0 +1,176 @@
1
+ # =================================================================
2
+ #
3
+ # Authors: Shane Mill <shane.mill@noaa.gov>
4
+ #
5
+ # Copyright (c) 2026 Shane Mill
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person
8
+ # obtaining a copy of this software and associated documentation
9
+ # files (the "Software"), to deal in the Software without
10
+ # restriction, including without limitation the rights to use,
11
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ # copies of the Software, and to permit persons to whom the
13
+ # Software is furnished to do so, subject to the following
14
+ # conditions:
15
+ #
16
+ # The above copyright notice and this permission notice shall be
17
+ # included in all copies or substantial portions of the Software.
18
+ #
19
+ # =================================================================
20
+ """
21
+ Custom PDF cover-page generation.
22
+
23
+ When a profile sets ``document_metadata.cover.logo``, the builder renders a
24
+ replacement cover-page image (organisation logo + the document's title, number,
25
+ edition and date) which Metanorma then uses in place of the built-in OGC cover
26
+ via ``:coverpage-image:`` + ``:presentation-metadata-full-coverpage-replacement:``.
27
+
28
+ Rendering uses Pillow. DejaVu fonts are bundled with Pillow and resolved by name,
29
+ so no system font paths are required.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from datetime import datetime
35
+ from pathlib import Path
36
+
37
+ # A4 portrait at ~150 DPI.
38
+ _PAGE_W = 1240
39
+ _PAGE_H = 1754
40
+
41
+ _COVER_FILENAME = "cover.png"
42
+
43
+
44
+ def _load_font(bold: bool, size: int):
45
+ from PIL import ImageFont
46
+
47
+ name = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
48
+ try:
49
+ return ImageFont.truetype(name, size)
50
+ except Exception:
51
+ # Last-resort fallback; not scalable but keeps rendering from failing.
52
+ return ImageFont.load_default()
53
+
54
+
55
+ def _italic_font(size: int):
56
+ from PIL import ImageFont
57
+
58
+ try:
59
+ return ImageFont.truetype("DejaVuSans-Oblique.ttf", size)
60
+ except Exception:
61
+ return _load_font(False, size)
62
+
63
+
64
+ def _format_date(iso_date: str | None) -> str | None:
65
+ """Format 'YYYY-MM-DD' as 'DD Month YYYY' (e.g. '15 May 2026')."""
66
+ if not iso_date:
67
+ return None
68
+ try:
69
+ dt = datetime.strptime(iso_date, "%Y-%m-%d")
70
+ except ValueError:
71
+ return iso_date
72
+ # %-d is not portable; strip a leading zero manually.
73
+ return f"{dt.day} {dt.strftime('%B %Y')}"
74
+
75
+
76
+ def build_cover_image(profile, output_dir: Path) -> str | None:
77
+ """Render the cover PNG into output_dir. Returns its filename, or None.
78
+
79
+ Returns None when no cover logo is configured. Raises RuntimeError with a
80
+ clear message if Pillow is unavailable or the logo file cannot be found.
81
+ """
82
+ m = getattr(profile, "document_metadata", None)
83
+ cover = getattr(m, "cover", None) if m else None
84
+ if not cover or not cover.logo:
85
+ return None
86
+
87
+ try:
88
+ from PIL import Image, ImageDraw
89
+ except ImportError as exc: # pragma: no cover - depends on env
90
+ raise RuntimeError(
91
+ "document_metadata.cover.logo is set but Pillow is not installed. "
92
+ "Install it with: pip install 'oapi-profile-builder[pdf]'"
93
+ ) from exc
94
+
95
+ logo_path = Path(cover.logo).expanduser()
96
+ if not logo_path.is_absolute():
97
+ logo_path = (Path.cwd() / logo_path).resolve()
98
+ if not logo_path.exists():
99
+ raise RuntimeError(f"Cover logo not found: {logo_path}")
100
+
101
+ bg = cover.background or "#FFFFFF"
102
+ fg = cover.text_color or "#000000"
103
+
104
+ page = Image.new("RGB", (_PAGE_W, _PAGE_H), bg)
105
+ draw = ImageDraw.Draw(page)
106
+
107
+ def centered(text: str, font, y: int, fill: str) -> int:
108
+ """Draw text horizontally centered; return the y below the text."""
109
+ bbox = draw.textbbox((0, 0), text, font=font)
110
+ tw = bbox[2] - bbox[0]
111
+ th = bbox[3] - bbox[1]
112
+ draw.text(((_PAGE_W - tw) / 2, y), text, font=font, fill=fill)
113
+ return y + th
114
+
115
+ y = 180
116
+
117
+ # --- Logo (centered, scaled to a target width) ---
118
+ logo = Image.open(logo_path).convert("RGBA")
119
+ target_logo_w = 420 # desired cover logo width
120
+ max_logo_w = 520 # hard cap
121
+ max_upscale = 3.0 # don't upscale tiny logos beyond this (avoids heavy blur)
122
+ desired = min(target_logo_w, int(logo.width * max_upscale), max_logo_w)
123
+ if logo.width != desired:
124
+ scale = desired / logo.width
125
+ logo = logo.resize((desired, max(1, int(logo.height * scale))))
126
+ logo_x = int((_PAGE_W - logo.width) / 2)
127
+ page.paste(logo, (logo_x, y), logo)
128
+ y += logo.height + 60
129
+
130
+ # --- Tagline (italic) ---
131
+ if cover.tagline:
132
+ y = centered(cover.tagline, _italic_font(30), y, fg) + 60
133
+
134
+ # --- Document number ---
135
+ if m and m.doc_number:
136
+ y = centered(m.doc_number, _load_font(True, 46), y, fg) + 40
137
+
138
+ # --- Edition ---
139
+ edition = getattr(profile, "version", None)
140
+ if edition:
141
+ y = centered(f"Edition {edition}", _load_font(False, 28), y, fg) + 24
142
+
143
+ # --- Date ---
144
+ date_str = _format_date(m.publication_date if m else None)
145
+ if date_str:
146
+ y = centered(f"Dated {date_str}", _load_font(False, 28), y, fg) + 80
147
+
148
+ # --- Title (large, bold, wrapped) ---
149
+ title = getattr(profile, "title", "") or ""
150
+ title_font = _load_font(True, 54)
151
+ for line in _wrap(draw, title, title_font, _PAGE_W - 240):
152
+ y = centered(line, title_font, y, fg) + 16
153
+
154
+ target = (output_dir / _COVER_FILENAME).resolve()
155
+ if not str(target).startswith(str(output_dir.resolve())):
156
+ raise RuntimeError(f"Refusing to write cover outside output directory: {target}")
157
+ page.save(target)
158
+ return _COVER_FILENAME
159
+
160
+
161
+ def _wrap(draw, text: str, font, max_width: int) -> list[str]:
162
+ """Greedy word-wrap to fit max_width pixels."""
163
+ words = text.split()
164
+ lines: list[str] = []
165
+ cur = ""
166
+ for w in words:
167
+ trial = f"{cur} {w}".strip()
168
+ bbox = draw.textbbox((0, 0), trial, font=font)
169
+ if bbox[2] - bbox[0] <= max_width or not cur:
170
+ cur = trial
171
+ else:
172
+ lines.append(cur)
173
+ cur = w
174
+ if cur:
175
+ lines.append(cur)
176
+ return lines