oapi-profile-builder 3.2.1__tar.gz → 3.3.0__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 (21) hide show
  1. {oapi_profile_builder-3.2.1/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.3.0}/PKG-INFO +46 -6
  2. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/README.md +45 -5
  3. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/pyproject.toml +1 -1
  4. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/cli.py +1 -1
  5. oapi_profile_builder-3.3.0/src/oapi_profile_builder/compile.py +126 -0
  6. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/cover.py +106 -19
  7. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/generate.py +293 -14
  8. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/models.py +123 -0
  9. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0/src/oapi_profile_builder.egg-info}/PKG-INFO +46 -6
  10. oapi_profile_builder-3.2.1/src/oapi_profile_builder/compile.py +0 -75
  11. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/LICENSE +0 -0
  12. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/setup.cfg +0 -0
  13. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/__init__.py +0 -0
  14. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/cite.py +0 -0
  15. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/cite_features.py +0 -0
  16. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/server_validation.py +0 -0
  17. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  18. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  19. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  20. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  21. {oapi_profile_builder-3.2.1 → oapi_profile_builder-3.3.0}/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.2.1
3
+ Version: 3.3.0
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
@@ -234,18 +234,20 @@ Everything about the document — cover, title-page metadata, legal page, footer
234
234
 
235
235
  | What you want to change | Field | Mechanism |
236
236
  |---|---|---|
237
- | Cover logo + tagline (replaces the OGC cover) | `document_metadata.cover` | Generated cover image via `:coverpage-image:` full replacement |
237
+ | Cover logo, tagline, fonts, watermark (replaces the OGC cover) | `document_metadata.cover` | Generated cover image via `:coverpage-image:` full replacement |
238
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
239
  | PDF colours (titles, tables, cover, backgrounds) | `document_metadata.colors` | `:presentation-metadata-color-*:` |
240
240
  | Page-ii legal text (copyright / license / notice) | `document_metadata.boilerplate` | `:boilerplate-authority:` |
241
241
  | Footer organisation name | `document_metadata.copyright_holder` | `:copyright-holder:` |
242
242
  | Remove the OGC logo from the legal page | `document_metadata.suppress_flavor_logo` | targeted `:pdf-stylesheet-override:` |
243
+ | Remove OGC design (crossing lines, circled numbers, rules, navy divider pages) | `document_metadata.suppress_design_elements` (or the individual `suppress_crossing_lines` / `plain_section_numbers` / `suppress_title_underlines`) | `:pdf-stylesheet-override:` + colour attributes |
244
+ | Every-page DRAFT-style watermark (over content) | `document_metadata.page_watermark` | `:pdf-stylesheet-override:` (footer overlay) |
243
245
  | 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 |
246
+ | Submitter table, notice paragraph, normative refs, extra terms | `document_metadata.{submitters, notice, normative_references, terms}` | Metanorma attributes / sections |
245
247
 
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.
248
+ 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. Omit all of these fields and the output keeps the standard OGC house style.
247
249
 
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.
250
+ > The `suppress_flavor_logo`, `suppress_design_elements`/`suppress_*`, and `page_watermark` overrides reach into the OGC Metanorma flavor's XSL templates (e.g. `insertLogoPreface`, `insertCrossingLines`, `insertSectionNumInCircle`, `insertFooter`) and are coupled to that flavor. Everything else uses documented Metanorma attributes. mn2pdf merges our same-named templates over the flavor's at equal precedence (via `merge_override.xsl`), which is what makes these work; a future metanorma-ogc release could require refreshing the template names. For long-term SDO adoption, a dedicated Metanorma "taste"/flavor is the most durable path.
249
251
 
250
252
  ---
251
253
 
@@ -701,6 +703,10 @@ document_metadata:
701
703
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
702
704
  background: "#FFFFFF" # optional cover background (default white)
703
705
  text_color: "#1F3864" # optional cover text colour (default black)
706
+ font_regular: "Source Sans Pro" # optional: .ttf/.otf path OR installed family name (fontconfig)
707
+ font_bold: "Source Sans Pro" # optional bold face; derived from font_regular when unset
708
+ font_italic: "Source Sans Pro" # optional italic face for the tagline
709
+ watermark: "DRAFT" # optional diagonal watermark stamped on the cover
704
710
  colors:
705
711
  text: "#000000" # body text
706
712
  cover_text: "#1F3864" # cover text / section numbers / ToC
@@ -733,7 +739,41 @@ document_metadata:
733
739
  feedback: "Comments on this document should be directed to DGIWG."
734
740
  ```
735
741
 
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).
742
+ #### Removing the OGC page design (crossing lines, circled numbers, divider pages)
743
+
744
+ The OGC flavor also draws decorative design elements: the blue "crossing lines with a dot" motif, circled section-divider numbers, a short rule under section titles, and full-navy section-divider pages. One switch removes them all:
745
+
746
+ - **`suppress_design_elements: true`** — a consolidated switch that:
747
+ - removes the blue crossing-lines + dot motif (on divider/cover pages via a template override; on the Contents/preface pages, which draw the lines inline in the flavor's own colour, by whiting that colour out);
748
+ - renders section numbers as plain text (e.g. `1 Scope`, `i Abstract`) instead of coloured circles;
749
+ - removes the short rule beneath section titles;
750
+ - rebrands the full-navy section-divider pages to a clean white page with a DGIWG-navy (from `colors.title`) plain number and title.
751
+
752
+ The individual toggles `suppress_crossing_lines`, `plain_section_numbers`, and `suppress_title_underlines` are still available if you want finer control; `suppress_design_elements` simply turns all three on plus the divider rebrand.
753
+
754
+ - **`page_watermark`** — stamps a light diagonal watermark (e.g. `DRAFT`) on **every page**, rendered *over* the content including tables. This is distinct from `cover.watermark`, which marks only the generated cover image. The cover page is left unmarked.
755
+
756
+ ```yaml
757
+ document_metadata:
758
+ suppress_design_elements: true # plain numbers, no crossing lines/rules, white divider pages
759
+ page_watermark: DRAFT # every-page diagonal watermark (over content)
760
+ ```
761
+
762
+ With `cover` + `colors` + `copyright_holder` + `boilerplate` + `suppress_flavor_logo` + `suppress_design_elements` (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. Omit these fields and you get the standard OGC house style. See [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml).
763
+
764
+ > These design-element overrides (and the `page_watermark` footer overlay) reach into the OGC Metanorma flavor's XSL template names and are coupled to that flavor; a future metanorma-ogc release could require refreshing them.
765
+
766
+ #### Extending Terms and Definitions
767
+
768
+ Add profile-specific terms (e.g. EDR Part 3 or Pub/Sub terms) appended after the base terms in the Terms and Definitions section:
769
+
770
+ ```yaml
771
+ document_metadata:
772
+ terms:
773
+ - term: service profile
774
+ definition: A named, referenceable set of constraints and additional requirements applied to an OGC API - EDR implementation.
775
+ source: "SOURCE: OGC API - EDR Part 3: Service Profiles (draft)"
776
+ ```
737
777
 
738
778
  ---
739
779
 
@@ -187,18 +187,20 @@ Everything about the document — cover, title-page metadata, legal page, footer
187
187
 
188
188
  | What you want to change | Field | Mechanism |
189
189
  |---|---|---|
190
- | Cover logo + tagline (replaces the OGC cover) | `document_metadata.cover` | Generated cover image via `:coverpage-image:` full replacement |
190
+ | Cover logo, tagline, fonts, watermark (replaces the OGC cover) | `document_metadata.cover` | Generated cover image via `:coverpage-image:` full replacement |
191
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
192
  | PDF colours (titles, tables, cover, backgrounds) | `document_metadata.colors` | `:presentation-metadata-color-*:` |
193
193
  | Page-ii legal text (copyright / license / notice) | `document_metadata.boilerplate` | `:boilerplate-authority:` |
194
194
  | Footer organisation name | `document_metadata.copyright_holder` | `:copyright-holder:` |
195
195
  | Remove the OGC logo from the legal page | `document_metadata.suppress_flavor_logo` | targeted `:pdf-stylesheet-override:` |
196
+ | Remove OGC design (crossing lines, circled numbers, rules, navy divider pages) | `document_metadata.suppress_design_elements` (or the individual `suppress_crossing_lines` / `plain_section_numbers` / `suppress_title_underlines`) | `:pdf-stylesheet-override:` + colour attributes |
197
+ | Every-page DRAFT-style watermark (over content) | `document_metadata.page_watermark` | `:pdf-stylesheet-override:` (footer overlay) |
196
198
  | 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 |
199
+ | Submitter table, notice paragraph, normative refs, extra terms | `document_metadata.{submitters, notice, normative_references, terms}` | Metanorma attributes / sections |
198
200
 
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.
201
+ 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. Omit all of these fields and the output keeps the standard OGC house style.
200
202
 
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.
203
+ > The `suppress_flavor_logo`, `suppress_design_elements`/`suppress_*`, and `page_watermark` overrides reach into the OGC Metanorma flavor's XSL templates (e.g. `insertLogoPreface`, `insertCrossingLines`, `insertSectionNumInCircle`, `insertFooter`) and are coupled to that flavor. Everything else uses documented Metanorma attributes. mn2pdf merges our same-named templates over the flavor's at equal precedence (via `merge_override.xsl`), which is what makes these work; a future metanorma-ogc release could require refreshing the template names. For long-term SDO adoption, a dedicated Metanorma "taste"/flavor is the most durable path.
202
204
 
203
205
  ---
204
206
 
@@ -654,6 +656,10 @@ document_metadata:
654
656
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
655
657
  background: "#FFFFFF" # optional cover background (default white)
656
658
  text_color: "#1F3864" # optional cover text colour (default black)
659
+ font_regular: "Source Sans Pro" # optional: .ttf/.otf path OR installed family name (fontconfig)
660
+ font_bold: "Source Sans Pro" # optional bold face; derived from font_regular when unset
661
+ font_italic: "Source Sans Pro" # optional italic face for the tagline
662
+ watermark: "DRAFT" # optional diagonal watermark stamped on the cover
657
663
  colors:
658
664
  text: "#000000" # body text
659
665
  cover_text: "#1F3864" # cover text / section numbers / ToC
@@ -686,7 +692,41 @@ document_metadata:
686
692
  feedback: "Comments on this document should be directed to DGIWG."
687
693
  ```
688
694
 
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).
695
+ #### Removing the OGC page design (crossing lines, circled numbers, divider pages)
696
+
697
+ The OGC flavor also draws decorative design elements: the blue "crossing lines with a dot" motif, circled section-divider numbers, a short rule under section titles, and full-navy section-divider pages. One switch removes them all:
698
+
699
+ - **`suppress_design_elements: true`** — a consolidated switch that:
700
+ - removes the blue crossing-lines + dot motif (on divider/cover pages via a template override; on the Contents/preface pages, which draw the lines inline in the flavor's own colour, by whiting that colour out);
701
+ - renders section numbers as plain text (e.g. `1 Scope`, `i Abstract`) instead of coloured circles;
702
+ - removes the short rule beneath section titles;
703
+ - rebrands the full-navy section-divider pages to a clean white page with a DGIWG-navy (from `colors.title`) plain number and title.
704
+
705
+ The individual toggles `suppress_crossing_lines`, `plain_section_numbers`, and `suppress_title_underlines` are still available if you want finer control; `suppress_design_elements` simply turns all three on plus the divider rebrand.
706
+
707
+ - **`page_watermark`** — stamps a light diagonal watermark (e.g. `DRAFT`) on **every page**, rendered *over* the content including tables. This is distinct from `cover.watermark`, which marks only the generated cover image. The cover page is left unmarked.
708
+
709
+ ```yaml
710
+ document_metadata:
711
+ suppress_design_elements: true # plain numbers, no crossing lines/rules, white divider pages
712
+ page_watermark: DRAFT # every-page diagonal watermark (over content)
713
+ ```
714
+
715
+ With `cover` + `colors` + `copyright_holder` + `boilerplate` + `suppress_flavor_logo` + `suppress_design_elements` (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. Omit these fields and you get the standard OGC house style. See [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml).
716
+
717
+ > These design-element overrides (and the `page_watermark` footer overlay) reach into the OGC Metanorma flavor's XSL template names and are coupled to that flavor; a future metanorma-ogc release could require refreshing them.
718
+
719
+ #### Extending Terms and Definitions
720
+
721
+ Add profile-specific terms (e.g. EDR Part 3 or Pub/Sub terms) appended after the base terms in the Terms and Definitions section:
722
+
723
+ ```yaml
724
+ document_metadata:
725
+ terms:
726
+ - term: service profile
727
+ definition: A named, referenceable set of constraints and additional requirements applied to an OGC API - EDR implementation.
728
+ source: "SOURCE: OGC API - EDR Part 3: Service Profiles (draft)"
729
+ ```
690
730
 
691
731
  ---
692
732
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "3.2.1"
7
+ version = "3.3.0"
8
8
  description = "Authoritative tooling for creating OGC API Service Profiles (EDR, Features)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -164,7 +164,7 @@ def main() -> None:
164
164
  generate(profile, args.output)
165
165
 
166
166
  if args.pdf:
167
- compile_pdf(args.output.resolve())
167
+ compile_pdf(args.output.resolve(), profile=profile)
168
168
 
169
169
 
170
170
  def _run_validate_server(args) -> None:
@@ -0,0 +1,126 @@
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
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
21
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
23
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
24
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
26
+ # OTHER DEALINGS IN THE SOFTWARE.
27
+ #
28
+ # =================================================================
29
+ """
30
+ PDF compilation: shell out to the Metanorma Docker image.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import shutil
36
+ import subprocess
37
+ import sys
38
+ from pathlib import Path
39
+
40
+
41
+ def compile_pdf(output_dir: Path, profile=None) -> bool:
42
+ """
43
+ Run `docker run metanorma/metanorma compile` against document.adoc
44
+ in output_dir. Returns True on success.
45
+
46
+ If *profile* is provided, custom fonts declared in document_metadata will be
47
+ pre-installed into the fontist cache so Metanorma can embed them.
48
+ """
49
+ if shutil.which("docker") is None:
50
+ print("docker not found. Install Docker to use --pdf.", file=sys.stderr)
51
+ sys.exit(1)
52
+
53
+ doc = output_dir / "document.adoc"
54
+ if not doc.exists():
55
+ print(f"document.adoc not found in {output_dir}", file=sys.stderr)
56
+ sys.exit(1)
57
+
58
+ fonts_dir = Path.home() / ".fontist" / "fonts"
59
+ fonts_dir.mkdir(parents=True, exist_ok=True)
60
+
61
+ # Pre-install any custom fonts requested by the profile so fontist inside
62
+ # the container recognizes them during PDF generation.
63
+ extra_fonts: list[str] = []
64
+ if profile is not None:
65
+ m = getattr(profile, "document_metadata", None)
66
+ if m:
67
+ for attr in ("body_font", "header_font", "monospace_font"):
68
+ val = getattr(m, attr, None)
69
+ if val:
70
+ extra_fonts.append(val)
71
+
72
+ # De-duplicate while preserving order.
73
+ if extra_fonts:
74
+ seen: set[str] = set()
75
+ extra_fonts = [f for f in extra_fonts if not (f in seen or seen.add(f))] # type: ignore[func-returns-value]
76
+
77
+ # Build the Docker command. When custom fonts are needed, we run fontist
78
+ # install + metanorma compile in the *same* container so the fontist
79
+ # database is available at compile time. We also monkey-patch the OGC
80
+ # processor's fonts_manifest so mn2pdf receives the correct font paths.
81
+ if extra_fonts:
82
+ install_cmds = " && ".join(f"fontist install '{f}'" for f in extra_fonts)
83
+ # Ruby snippet to inject extra fonts into the OGC processor's manifest
84
+ # so mn2pdf's font-manifest YAML includes them.
85
+ font_hash_entries = ", ".join(f'\\"{f}\\" => nil' for f in extra_fonts)
86
+ # Write the Ruby patch via heredoc to avoid shell quoting issues.
87
+ patch_rb_escaped = (
88
+ 'require \\"metanorma-ogc\\"; '
89
+ "module Metanorma; module Ogc; class Processor; "
90
+ "alias_method :_orig_fonts_manifest, :fonts_manifest; "
91
+ f"def fonts_manifest; _orig_fonts_manifest.merge({{{font_hash_entries}}}); end; "
92
+ "end; end; end"
93
+ )
94
+ shell_script = (
95
+ f"{install_cmds} && "
96
+ f'printf "%s" "{patch_rb_escaped}" > /tmp/_font_patch.rb && '
97
+ "RUBYOPT='-r/tmp/_font_patch.rb' "
98
+ "metanorma compile --agree-to-terms -t ogc -x pdf document.adoc"
99
+ )
100
+ cmd = [
101
+ "docker", "run", "--rm",
102
+ "-v", f"{output_dir.resolve()}:/metanorma",
103
+ "-v", f"{fonts_dir}:/config/fonts",
104
+ "-w", "/metanorma",
105
+ "metanorma/metanorma",
106
+ "bash", "-c", shell_script,
107
+ ]
108
+ print(f"Installing custom fonts ({', '.join(extra_fonts)}) and compiling PDF...")
109
+ else:
110
+ cmd = [
111
+ "docker", "run", "--rm",
112
+ "-v", f"{output_dir.resolve()}:/metanorma",
113
+ "-v", f"{fonts_dir}:/config/fonts",
114
+ "metanorma/metanorma",
115
+ "metanorma", "compile",
116
+ "--agree-to-terms",
117
+ "-t", "ogc",
118
+ "-x", "pdf",
119
+ "document.adoc",
120
+ ]
121
+ print("Compiling PDF via Metanorma Docker...")
122
+ result = subprocess.run(cmd, check=False)
123
+ if result.returncode == 0:
124
+ pdf = output_dir / "document.pdf"
125
+ print(f"PDF written to {pdf}")
126
+ return result.returncode == 0
@@ -31,6 +31,8 @@ so no system font paths are required.
31
31
 
32
32
  from __future__ import annotations
33
33
 
34
+ import shutil
35
+ import subprocess
34
36
  from datetime import datetime
35
37
  from pathlib import Path
36
38
 
@@ -40,25 +42,83 @@ _PAGE_H = 1754
40
42
 
41
43
  _COVER_FILENAME = "cover.png"
42
44
 
45
+ # Default font family (bundled with Pillow, resolved by name).
46
+ _DEFAULT_REGULAR = "DejaVuSans.ttf"
47
+ _DEFAULT_BOLD = "DejaVuSans-Bold.ttf"
48
+ _DEFAULT_ITALIC = "DejaVuSans-Oblique.ttf"
43
49
 
44
- def _load_font(bold: bool, size: int):
45
- from PIL import ImageFont
46
50
 
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()
51
+ def _resolve_font_file(spec: str | None, *, bold: bool = False, italic: bool = False) -> str | None:
52
+ """Resolve a user font spec to a usable font file path.
53
53
 
54
+ ``spec`` may be either a path to a ``.ttf``/``.otf`` file or a font family
55
+ name (e.g. ``"Source Sans Pro"``). Family names are resolved via
56
+ ``fc-match`` (fontconfig) when available. Returns ``None`` when the spec is
57
+ empty or cannot be resolved, so the caller can fall back to a default.
58
+ """
59
+ if not spec:
60
+ return None
61
+ p = Path(spec).expanduser()
62
+ if p.is_file():
63
+ return str(p)
64
+ if shutil.which("fc-match"):
65
+ styles = []
66
+ if bold:
67
+ styles.append("Bold")
68
+ if italic:
69
+ styles.append("Italic")
70
+ query = spec if not styles else f"{spec}:style={' '.join(styles)}"
71
+ try:
72
+ out = subprocess.run(
73
+ ["fc-match", "-f", "%{file}", query],
74
+ capture_output=True, text=True, timeout=5, check=False,
75
+ )
76
+ path = out.stdout.strip()
77
+ if path and Path(path).is_file():
78
+ return path
79
+ except Exception:
80
+ pass
81
+ return None
82
+
83
+
84
+ class _FontSet:
85
+ """Resolves cover fonts from optional profile config, with sane defaults.
86
+
87
+ Each of ``regular``/``bold``/``italic`` may be a font-file path or a family
88
+ name; unset styles are derived from ``regular`` (with the matching style) and
89
+ ultimately fall back to the bundled DejaVu family.
90
+ """
54
91
 
55
- def _italic_font(size: int):
56
- from PIL import ImageFont
92
+ def __init__(self, cover=None):
93
+ self.regular = getattr(cover, "font_regular", None) if cover else None
94
+ self.bold = getattr(cover, "font_bold", None) if cover else None
95
+ self.italic = getattr(cover, "font_italic", None) if cover else None
96
+
97
+ def _font(self, size: int, *, bold: bool = False, italic: bool = False):
98
+ from PIL import ImageFont
99
+
100
+ # Prefer an explicit per-style spec, else derive from the regular family.
101
+ explicit = self.bold if bold else (self.italic if italic else self.regular)
102
+ candidates = [
103
+ _resolve_font_file(explicit, bold=bold, italic=italic),
104
+ _resolve_font_file(self.regular, bold=bold, italic=italic),
105
+ ]
106
+ default = _DEFAULT_BOLD if bold else (_DEFAULT_ITALIC if italic else _DEFAULT_REGULAR)
107
+ candidates.append(default)
108
+ for cand in candidates:
109
+ if not cand:
110
+ continue
111
+ try:
112
+ return ImageFont.truetype(cand, size)
113
+ except Exception:
114
+ continue
115
+ return ImageFont.load_default()
57
116
 
58
- try:
59
- return ImageFont.truetype("DejaVuSans-Oblique.ttf", size)
60
- except Exception:
61
- return _load_font(False, size)
117
+ def regular_font(self, size: int, bold: bool = False):
118
+ return self._font(size, bold=bold)
119
+
120
+ def italic_font(self, size: int):
121
+ return self._font(size, italic=True)
62
122
 
63
123
 
64
124
  def _format_date(iso_date: str | None) -> str | None:
@@ -100,6 +160,7 @@ def build_cover_image(profile, output_dir: Path) -> str | None:
100
160
 
101
161
  bg = cover.background or "#FFFFFF"
102
162
  fg = cover.text_color or "#000000"
163
+ fonts = _FontSet(cover)
103
164
 
104
165
  page = Image.new("RGB", (_PAGE_W, _PAGE_H), bg)
105
166
  draw = ImageDraw.Draw(page)
@@ -129,28 +190,32 @@ def build_cover_image(profile, output_dir: Path) -> str | None:
129
190
 
130
191
  # --- Tagline (italic) ---
131
192
  if cover.tagline:
132
- y = centered(cover.tagline, _italic_font(30), y, fg) + 60
193
+ y = centered(cover.tagline, fonts.italic_font(30), y, fg) + 60
133
194
 
134
195
  # --- Document number ---
135
196
  if m and m.doc_number:
136
- y = centered(m.doc_number, _load_font(True, 46), y, fg) + 40
197
+ y = centered(m.doc_number, fonts.regular_font(46, bold=True), y, fg) + 40
137
198
 
138
199
  # --- Edition ---
139
200
  edition = getattr(profile, "version", None)
140
201
  if edition:
141
- y = centered(f"Edition {edition}", _load_font(False, 28), y, fg) + 24
202
+ y = centered(f"Edition {edition}", fonts.regular_font(28), y, fg) + 24
142
203
 
143
204
  # --- Date ---
144
205
  date_str = _format_date(m.publication_date if m else None)
145
206
  if date_str:
146
- y = centered(f"Dated {date_str}", _load_font(False, 28), y, fg) + 80
207
+ y = centered(f"Dated {date_str}", fonts.regular_font(28), y, fg) + 80
147
208
 
148
209
  # --- Title (large, bold, wrapped) ---
149
210
  title = getattr(profile, "title", "") or ""
150
- title_font = _load_font(True, 54)
211
+ title_font = fonts.regular_font(54, bold=True)
151
212
  for line in _wrap(draw, title, title_font, _PAGE_W - 240):
152
213
  y = centered(line, title_font, y, fg) + 16
153
214
 
215
+ # --- Draft watermark (diagonal, semi-transparent) ---
216
+ if getattr(cover, "watermark", None):
217
+ _stamp_watermark(page, cover.watermark, fonts)
218
+
154
219
  target = (output_dir / _COVER_FILENAME).resolve()
155
220
  if not str(target).startswith(str(output_dir.resolve())):
156
221
  raise RuntimeError(f"Refusing to write cover outside output directory: {target}")
@@ -158,6 +223,28 @@ def build_cover_image(profile, output_dir: Path) -> str | None:
158
223
  return _COVER_FILENAME
159
224
 
160
225
 
226
+ def _stamp_watermark(page, text: str, fonts: "_FontSet") -> None:
227
+ """Overlay a large, diagonal, semi-transparent watermark on the cover.
228
+
229
+ Rendered on its own RGBA layer and rotated 45°, then alpha-composited onto
230
+ the (RGB) cover page. Gives a clear 'DRAFT' style indication on the cover;
231
+ an every-page watermark additionally requires the Metanorma layer.
232
+ """
233
+ from PIL import Image, ImageDraw
234
+
235
+ layer = Image.new("RGBA", page.size, (0, 0, 0, 0))
236
+ ldraw = ImageDraw.Draw(layer)
237
+ font = fonts.regular_font(150, bold=True)
238
+ bbox = ldraw.textbbox((0, 0), text, font=font)
239
+ tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
240
+ ldraw.text(
241
+ ((page.width - tw) / 2, (page.height - th) / 2),
242
+ text, font=font, fill=(200, 0, 0, 70),
243
+ )
244
+ layer = layer.rotate(45, resample=Image.BICUBIC, center=(page.width / 2, page.height / 2))
245
+ page.paste(Image.alpha_composite(page.convert("RGBA"), layer).convert("RGB"), (0, 0))
246
+
247
+
161
248
  def _wrap(draw, text: str, font, max_width: int) -> list[str]:
162
249
  """Greedy word-wrap to fit max_width pixels."""
163
250
  words = text.split()
@@ -1374,18 +1374,249 @@ def _individual_test_adoc(profile: ServiceProfile, test_id: str) -> str:
1374
1374
  # Metanorma root document
1375
1375
  # ---------------------------------------------------------------------------
1376
1376
 
1377
- _LOGO_OVERRIDE_XSL = """<?xml version="1.0" encoding="UTF-8"?>
1378
- <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
1379
- xmlns:fo="http://www.w3.org/1999/XSL/Format"
1380
- version="1.0">
1381
- <!-- Rebranding override: suppress the OGC flavor logo on the preface/legal
1382
- page (and the cover, which is replaced separately). Coupled to the OGC
1383
- Metanorma flavor's XSL template names. -->
1377
+ _PDF_OVERRIDE_FILENAME = "pdf_override.xsl"
1378
+
1379
+ # Individual template overrides for the OGC Metanorma flavor's PDF XSL. Each is
1380
+ # keyed to a real template name in metanorma-ogc's ogc.standard.xsl and merged
1381
+ # in via :pdf-stylesheet-override:. These are coupled to the OGC flavor.
1382
+ _OVERRIDE_SUPPRESS_LOGO = """
1383
+ <!-- Suppress the OGC flavor logo on cover/preface (rebranding). -->
1384
1384
  <xsl:template name="insertLogoPreface"/>
1385
1385
  <xsl:template name="insertLogo"/>
1386
- </xsl:stylesheet>
1387
1386
  """
1388
1387
 
1388
+ _OVERRIDE_SUPPRESS_CROSSING_LINES = """
1389
+ <!-- Remove the 'crossing lines' design element by making it invisible. -->
1390
+ <xsl:template name="insertCrossingLines">
1391
+ <fo:block-container absolute-position="fixed" width="0mm" height="0mm" font-size="0">
1392
+ <fo:block/>
1393
+ </fo:block-container>
1394
+ </xsl:template>
1395
+ """
1396
+
1397
+ _OVERRIDE_SUPPRESS_TITLE_UNDERLINES = """
1398
+ <!-- Remove the horizontal rule drawn beneath section titles. -->
1399
+ <xsl:template name="insertShortHorizontalLine">
1400
+ <fo:block/>
1401
+ </xsl:template>
1402
+ <xsl:template name="insertBigHorizontalLine">
1403
+ <fo:block/>
1404
+ </xsl:template>
1405
+ """
1406
+
1407
+ # Render the section-divider number as plain text instead of a coloured circle.
1408
+ # Mirrors the flavor's own number computation so annexes still number correctly.
1409
+ # The colour is injected so the number stays readable against whatever divider
1410
+ # background is in effect (white text on the flavor's navy, or a dark DGIWG
1411
+ # colour on a rebranded light page).
1412
+ def _plain_section_numbers_override(color: str) -> str:
1413
+ return f"""
1414
+ <!-- Section-divider number as plain text (no OGC circle), coloured {color}. -->
1415
+ <xsl:template name="insertSectionNumInCircle">
1416
+ <xsl:variable name="sectionNum_"><xsl:call-template name="getSection"/></xsl:variable>
1417
+ <xsl:variable name="sectionNum">
1418
+ <xsl:choose>
1419
+ <xsl:when test="normalize-space($sectionNum_) = '' and self::mn:annex">
1420
+ <xsl:number format="A" count="mn:annex[not(@continue = 'true')]" level="any" lang="en"/>
1421
+ </xsl:when>
1422
+ <xsl:otherwise><xsl:value-of select="$sectionNum_"/></xsl:otherwise>
1423
+ </xsl:choose>
1424
+ </xsl:variable>
1425
+ <fo:block font-size="24pt" font-weight="bold" color="{color}"><xsl:value-of select="$sectionNum"/></fo:block>
1426
+ </xsl:template>
1427
+ """
1428
+
1429
+
1430
+ # Force the big section-divider title colour. The flavor's divider page-sequence
1431
+ # sets the text colour to white (readable on its navy background); when the
1432
+ # divider page is rebranded to a light background that white title becomes
1433
+ # invisible, so we set an explicit (dark) colour here.
1434
+ def _section_title_color_override(color: str) -> str:
1435
+ return f"""
1436
+ <!-- Section-divider title colour (readable on a rebranded light page). -->
1437
+ <xsl:template name="insertSectionTitleBig">
1438
+ <xsl:param name="title"/>
1439
+ <fo:block font-size="33pt" margin-bottom="6pt" color="{color}">
1440
+ <xsl:apply-templates select="xalan:nodeset($title)" mode="titlebig"/>
1441
+ </fo:block>
1442
+ <xsl:call-template name="insertBigHorizontalLine"/>
1443
+ </xsl:template>
1444
+ """
1445
+
1446
+
1447
+ # Every-page watermark (e.g. "DRAFT"). The OGC flavor's region-body flow is
1448
+ # painted on top of the header region, so a watermark placed in the header sits
1449
+ # behind opaque tables. The footer region is painted after the body, so we
1450
+ # reproduce the flavor's footer and prepend a page-fixed, rotated, semi-
1451
+ # transparent SVG text mark inside the footer static-content — this overlays
1452
+ # every page, including over tables.
1453
+ def _page_watermark_override(text: str, color: str = "rgb(200,200,200)") -> str:
1454
+ esc = _xml_escape(text)
1455
+ watermark = f"""
1456
+ <fo:block-container absolute-position="fixed" left="0mm" top="0mm" font-size="0">
1457
+ <fo:block>
1458
+ <fo:instream-foreign-object content-height="{{$pageHeight}}mm" content-width="{{$pageWidth}}mm" fox:alt-text="Watermark">
1459
+ <svg xmlns="http://www.w3.org/2000/svg" width="{{$pageWidth}}mm" height="{{$pageHeight}}mm" viewBox="0 0 210 297">
1460
+ <text x="105" y="150" text-anchor="middle" transform="rotate(-45 105 150)" font-size="38" font-family="Lato" fill="{color}" fill-opacity="0.55">{esc}</text>
1461
+ </svg>
1462
+ </fo:instream-foreign-object>
1463
+ </fo:block>
1464
+ </fo:block-container>"""
1465
+ return f"""
1466
+ <!-- Every-page diagonal watermark, prepended to the footer region (painted
1467
+ over the body, so it also overlays tables). Reproduces the OGC footer. -->
1468
+ <xsl:template name="insertFooter">
1469
+ <xsl:param name="num"/>
1470
+ <xsl:param name="color"/>
1471
+ <fo:static-content flow-name="footer" role="artifact">
1472
+ {watermark}
1473
+ <fo:block-container font-size="8pt" color="{{$color}}" padding-top="6mm">
1474
+ <xsl:if test="normalize-space($color) = ''">
1475
+ <xsl:variable name="color_text_title">
1476
+ <xsl:call-template name="getVariable"><xsl:with-param name="variable">color_text_title</xsl:with-param></xsl:call-template>
1477
+ </xsl:variable>
1478
+ <xsl:attribute name="color"><xsl:value-of select="$color_text_title"/></xsl:attribute>
1479
+ </xsl:if>
1480
+ <fo:table table-layout="fixed" width="100%">
1481
+ <fo:table-column column-width="90%"/>
1482
+ <fo:table-column column-width="10%"/>
1483
+ <fo:table-body>
1484
+ <fo:table-row>
1485
+ <fo:table-cell>
1486
+ <fo:block>
1487
+ <fo:inline font-weight="bold">
1488
+ <xsl:call-template name="addLetterSpacing">
1489
+ <xsl:with-param name="text" select="concat($variables/mnx:doc[@num = $num]/copyright-owner, ' ')"/>
1490
+ <xsl:with-param name="letter-spacing" select="0.2"/>
1491
+ </xsl:call-template>
1492
+ </fo:inline>
1493
+ <xsl:call-template name="addLetterSpacing">
1494
+ <xsl:with-param name="text" select="$variables/mnx:doc[@num = $num]/docnumber"/>
1495
+ <xsl:with-param name="letter-spacing" select="0.2"/>
1496
+ </xsl:call-template>
1497
+ </fo:block>
1498
+ </fo:table-cell>
1499
+ <fo:table-cell text-align="right">
1500
+ <fo:block font-weight="bold">
1501
+ <fo:page-number/>
1502
+ </fo:block>
1503
+ </fo:table-cell>
1504
+ </fo:table-row>
1505
+ </fo:table-body>
1506
+ </fo:table>
1507
+ </fo:block-container>
1508
+ </fo:static-content>
1509
+ </xsl:template>
1510
+ """
1511
+
1512
+
1513
+ def _effective_pdf_config(m) -> dict:
1514
+ """Resolve effective PDF-styling flags and divider colours from metadata.
1515
+
1516
+ ``suppress_design_elements`` is the consolidated "debrand" switch: it turns
1517
+ on crossing-line suppression, plain section numbers and title-underline
1518
+ suppression together, and rebrands the section-divider pages to a light
1519
+ (default white) background with dark, readable numbers/titles so they no
1520
+ longer carry the OGC navy house style.
1521
+ """
1522
+ debrand = bool(getattr(m, "suppress_design_elements", False))
1523
+ crossing = debrand or bool(getattr(m, "suppress_crossing_lines", False))
1524
+ underlines = debrand or bool(getattr(m, "suppress_title_underlines", False))
1525
+ plain_nums = debrand or bool(getattr(m, "plain_section_numbers", False))
1526
+
1527
+ colors = getattr(m, "colors", None)
1528
+ page_bg = getattr(colors, "page_background", None) if colors else None
1529
+ title_c = getattr(colors, "title", None) if colors else None
1530
+ cover_c = getattr(colors, "cover_text", None) if colors else None
1531
+
1532
+ # Divider background + foreground colours.
1533
+ if debrand:
1534
+ # Rebrand: light divider pages (white unless overridden) + dark text.
1535
+ divider_bg = page_bg or "#FFFFFF"
1536
+ divider_fg = title_c or cover_c or "#1F3864"
1537
+ elif page_bg:
1538
+ # Explicit custom background: assume it is light, use a dark foreground.
1539
+ divider_bg = page_bg
1540
+ divider_fg = title_c or cover_c or "#1F3864"
1541
+ else:
1542
+ # Flavor default: navy divider pages, white number/title.
1543
+ divider_bg = None
1544
+ divider_fg = "white"
1545
+
1546
+ return {
1547
+ "logo": bool(getattr(m, "suppress_flavor_logo", False)),
1548
+ "crossing": crossing,
1549
+ "underlines": underlines,
1550
+ "plain_nums": plain_nums,
1551
+ "body_font": getattr(m, "body_font", None),
1552
+ "watermark": getattr(m, "page_watermark", None),
1553
+ "divider_bg": divider_bg,
1554
+ "divider_fg": divider_fg,
1555
+ }
1556
+
1557
+
1558
+ def _pdf_override_flags(m) -> list[str]:
1559
+ """Return the override snippets enabled by document_metadata, in order."""
1560
+ if not m:
1561
+ return []
1562
+ cfg = _effective_pdf_config(m)
1563
+ parts: list[str] = []
1564
+ if cfg["logo"]:
1565
+ parts.append(_OVERRIDE_SUPPRESS_LOGO)
1566
+ if cfg["crossing"]:
1567
+ parts.append(_OVERRIDE_SUPPRESS_CROSSING_LINES)
1568
+ if cfg["underlines"]:
1569
+ parts.append(_OVERRIDE_SUPPRESS_TITLE_UNDERLINES)
1570
+ if cfg["plain_nums"]:
1571
+ parts.append(_plain_section_numbers_override(cfg["divider_fg"]))
1572
+ # When the divider background is rebranded (light), force the big title
1573
+ # colour too — the flavor inherits white text, invisible on a light page.
1574
+ if cfg["divider_bg"] is not None:
1575
+ parts.append(_section_title_color_override(cfg["divider_fg"]))
1576
+ if cfg["watermark"]:
1577
+ parts.append(_page_watermark_override(cfg["watermark"]))
1578
+ # Body font override — the OGC flavor hardcodes Lato in the root-style
1579
+ # attribute-set, so :body-font: alone doesn't work; we need an XSL override.
1580
+ body = cfg["body_font"]
1581
+ if body:
1582
+ noto = '<xsl:value-of select="$font_noto_sans"/>'
1583
+ parts.append(
1584
+ f'\n <!-- Override body font family (OGC default is Lato). -->\n'
1585
+ f' <xsl:attribute-set name="root-style">\n'
1586
+ f' <xsl:attribute name="font-family">{body}, STIX Two Math, {noto}</xsl:attribute>\n'
1587
+ f' <xsl:attribute name="font-family-generic">Sans</xsl:attribute>\n'
1588
+ f' <xsl:attribute name="font-size">11pt</xsl:attribute>\n'
1589
+ f' </xsl:attribute-set>\n'
1590
+ )
1591
+ return parts
1592
+
1593
+
1594
+ def _build_pdf_override_xsl(m) -> str | None:
1595
+ """Assemble the consolidated PDF stylesheet override, or None when unused.
1596
+
1597
+ :pdf-stylesheet-override: accepts a single file, so all requested template
1598
+ overrides (logo suppression, crossing lines, section-number circles, title
1599
+ underlines, divider rebranding and the every-page watermark) are merged into
1600
+ one stylesheet. mn2pdf splices these named-template bodies over the flavor's
1601
+ at equal import precedence, so they take effect.
1602
+ """
1603
+ parts = _pdf_override_flags(m)
1604
+ if not parts:
1605
+ return None
1606
+ return (
1607
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
1608
+ '<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"\n'
1609
+ ' xmlns:fo="http://www.w3.org/1999/XSL/Format"\n'
1610
+ ' xmlns:mn="https://www.metanorma.org/ns/standoc"\n'
1611
+ ' xmlns:fox="http://xmlgraphics.apache.org/fop/extensions"\n'
1612
+ ' xmlns:xalan="http://xml.apache.org/xalan"\n'
1613
+ ' xmlns:java="http://xml.apache.org/xalan/java"\n'
1614
+ ' xmlns:mnx="https://www.metanorma.org/ns/xslt"\n'
1615
+ ' version="1.0">\n'
1616
+ + "".join(parts)
1617
+ + "</xsl:stylesheet>\n"
1618
+ )
1619
+
1389
1620
 
1390
1621
  def _xml_escape(text: str) -> str:
1391
1622
  return (
@@ -1489,6 +1720,28 @@ def _build_document_adoc(profile: ServiceProfile) -> str:
1489
1720
  if m and m.colors:
1490
1721
  for attr, value in m.colors.to_metanorma_attributes().items():
1491
1722
  lines.append(f":{attr}: {value}")
1723
+ # When debranding (suppress_design_elements) rebrands the section-divider
1724
+ # pages to a light background, emit the background-page colour unless the
1725
+ # profile already set one explicitly via colors.page_background.
1726
+ if m:
1727
+ cfg = _effective_pdf_config(m)
1728
+ explicit_bg = bool(m.colors and m.colors.page_background)
1729
+ if cfg["divider_bg"] is not None and not explicit_bg:
1730
+ lines.append(
1731
+ f":presentation-metadata-color-background-page: {cfg['divider_bg']}"
1732
+ )
1733
+ # The preface/contents pages draw the OGC 'crossing lines with dot'
1734
+ # motif inline (using the secondary-shade-2 / color_design_light colour),
1735
+ # not via a named template, so an XSL override can't remove them. When
1736
+ # crossing lines are being suppressed we white them out via the colour
1737
+ # instead, unless the profile already set colors.cover_lines explicitly.
1738
+ explicit_lines = bool(m.colors and m.colors.cover_lines)
1739
+ if cfg["crossing"] and not explicit_lines:
1740
+ lines.append(":presentation-metadata-color-secondary-shade-2: #FFFFFF")
1741
+ # PDF font overrides (body/header/monospace).
1742
+ if m and m.fonts:
1743
+ for attr, value in m.fonts.to_metanorma_attributes().items():
1744
+ lines.append(f":{attr}: {value}")
1492
1745
  # Custom cover page: replace the built-in OGC cover with our generated image.
1493
1746
  # The image file (cover.png) is written by generate(); this just references it.
1494
1747
  if m and m.cover and m.cover.logo:
@@ -1497,12 +1750,20 @@ def _build_document_adoc(profile: ServiceProfile) -> str:
1497
1750
  # Copyright holder → also sets the PDF footer org name (replaces OGC default).
1498
1751
  if m and m.copyright_holder:
1499
1752
  lines.append(f":copyright-holder: {m.copyright_holder}")
1753
+ # PDF font family overrides (e.g. DGIWG house font 'Source Sans Pro').
1754
+ if m and m.body_font:
1755
+ lines.append(f":body-font: {m.body_font}")
1756
+ if m and m.header_font:
1757
+ lines.append(f":header-font: {m.header_font}")
1758
+ if m and m.monospace_font:
1759
+ lines.append(f":monospace-font: {m.monospace_font}")
1500
1760
  # Custom legal boilerplate (page ii). The XML file is written by generate().
1501
1761
  if m and m.boilerplate:
1502
1762
  lines.append(":boilerplate-authority: boilerplate.xml")
1503
- # Suppress the flavor's built-in (OGC) logo on the preface page when rebranding.
1504
- if m and m.suppress_flavor_logo:
1505
- lines.append(":pdf-stylesheet-override: logo_override.xsl")
1763
+ # Consolidated PDF stylesheet override: logo suppression, crossing lines,
1764
+ # section-number circles, title underlines. The file is written by generate().
1765
+ if _pdf_override_flags(m):
1766
+ lines.append(f":pdf-stylesheet-override: {_PDF_OVERRIDE_FILENAME}")
1506
1767
  lines += [
1507
1768
  ":mn-document-class: ogc",
1508
1769
  ":mn-output-extensions: xml,html,pdf",
@@ -1527,6 +1788,21 @@ def _build_document_adoc(profile: ServiceProfile) -> str:
1527
1788
  return "\n".join(lines) + "\n"
1528
1789
 
1529
1790
 
1791
+ def _terms_adoc(profile: ServiceProfile) -> str:
1792
+ """Render profile-supplied terms as Metanorma term clauses, or empty string."""
1793
+ m = profile.document_metadata
1794
+ terms = getattr(m, "terms", None) if m else None
1795
+ if not terms:
1796
+ return ""
1797
+ out = ["\n"]
1798
+ for t in terms:
1799
+ out.append(f"=== {t.term}\n\n{t.definition}\n")
1800
+ if t.source:
1801
+ out.append(f"\n_{t.source}_\n")
1802
+ out.append("\n")
1803
+ return "".join(out)
1804
+
1805
+
1530
1806
  def _build_sections(profile: ServiceProfile) -> dict[str, str]:
1531
1807
  """Return the minimal boilerplate sections required by Metanorma OGC."""
1532
1808
  m = profile.document_metadata
@@ -1655,6 +1931,7 @@ def _build_sections(profile: ServiceProfile) -> dict[str, str]:
1655
1931
  "which is based on the ISO/IEC Directives, Part 2, Rules for the structure and drafting of International Standards. "
1656
1932
  "In particular, the word \"shall\" (not \"must\") is the verb form used to indicate a requirement to be strictly followed to conform to this standard.\n\n"
1657
1933
  "This document also uses terms defined in OGC API - EDR Part 1: Core.\n"
1934
+ + _terms_adoc(profile)
1658
1935
  ),
1659
1936
  "sections/06-requirements.adoc": (
1660
1937
  "== Requirements\n\n"
@@ -1738,8 +2015,10 @@ def generate(profile: ServiceProfile, output_dir: Path) -> None:
1738
2015
  if boilerplate_xml:
1739
2016
  safe_write("boilerplate.xml", boilerplate_xml)
1740
2017
 
1741
- # PDF stylesheet override to suppress the flavor's preface-page logo.
1742
- if m and m.suppress_flavor_logo:
1743
- safe_write("logo_override.xsl", _LOGO_OVERRIDE_XSL)
2018
+ # Consolidated PDF stylesheet override (logo, crossing lines, section-number
2019
+ # circles, title underlines) — written when any override flag is set.
2020
+ pdf_override_xsl = _build_pdf_override_xsl(m)
2021
+ if pdf_override_xsl:
2022
+ safe_write(_PDF_OVERRIDE_FILENAME, pdf_override_xsl)
1744
2023
 
1745
2024
  print(f"Profile '{profile.name}' written to {output_dir}")
@@ -353,6 +353,20 @@ class NormativeReference(BaseModel):
353
353
  title: str = Field(description="Full reference title")
354
354
 
355
355
 
356
+ class Term(BaseModel):
357
+ """A profile-supplied entry for the Terms and Definitions section.
358
+
359
+ Appended after the base terms so a profile can extend the vocabulary (e.g.
360
+ with EDR Part 3 or Pub/Sub terms). Rendered as a Metanorma term clause.
361
+ """
362
+ term: str = Field(description="The term being defined, e.g. 'publish/subscribe'")
363
+ definition: str = Field(description="The definition text")
364
+ source: str | None = Field(
365
+ default=None,
366
+ description="Optional source/authority for the definition, e.g. 'SOURCE: OGC 19-086r6'",
367
+ )
368
+
369
+
356
370
  # Metanorma OGC document stages (`:status:` / `:docstage:`). See
357
371
  # https://www.metanorma.org/author/ogc/ref/document-attributes/
358
372
  DocStatus = Literal[
@@ -435,6 +449,29 @@ class CoverPage(BaseModel):
435
449
  tagline: str | None = Field(default=None, description="Optional line rendered under the logo, e.g. an organisation motto")
436
450
  background: str | None = Field(default=None, description="Cover background hex colour (default white)")
437
451
  text_color: str | None = Field(default=None, description="Cover text hex colour (default black)")
452
+ font_regular: str | None = Field(
453
+ default=None,
454
+ description=(
455
+ "Cover font for normal text: a font-file path (.ttf/.otf) or an installed "
456
+ "family name (e.g. 'Source Sans Pro'). Family names are resolved via fontconfig. "
457
+ "Defaults to DejaVu Sans."
458
+ ),
459
+ )
460
+ font_bold: str | None = Field(
461
+ default=None,
462
+ description="Cover font for bold text (doc number/title). Path or family name; derived from font_regular when unset.",
463
+ )
464
+ font_italic: str | None = Field(
465
+ default=None,
466
+ description="Cover font for the italic tagline. Path or family name; derived from font_regular when unset.",
467
+ )
468
+ watermark: str | None = Field(
469
+ default=None,
470
+ description=(
471
+ "Optional watermark text stamped diagonally across the cover (e.g. 'DRAFT'). "
472
+ "Note: this marks the cover only; an every-page watermark requires the Metanorma layer."
473
+ ),
474
+ )
438
475
 
439
476
  @field_validator("background", "text_color")
440
477
  @classmethod
@@ -444,6 +481,25 @@ class CoverPage(BaseModel):
444
481
  return v
445
482
 
446
483
 
484
+ class PdfFonts(BaseModel):
485
+ """PDF body/heading/monospace font overrides.
486
+
487
+ Maps to Metanorma's ``:body-font:``, ``:header-font:`` and
488
+ ``:monospace-font:`` document attributes, so a profile can set the PDF
489
+ typeface (e.g. DGIWG's Source Sans Pro) without a custom flavor. The named
490
+ fonts must be resolvable by the Metanorma container (i.e. present in the
491
+ mounted ``~/.fontist/fonts``). See
492
+ https://www.metanorma.org/author/ref/document-attributes/
493
+ """
494
+ body: str | None = Field(default=None, description="Body text font family (:body-font:), e.g. 'Source Sans Pro'")
495
+ header: str | None = Field(default=None, description="Heading font family (:header-font:)")
496
+ monospace: str | None = Field(default=None, description="Monospace/code font family (:monospace-font:)")
497
+
498
+ def to_metanorma_attributes(self) -> dict[str, str]:
499
+ mapping = {"body": "body-font", "header": "header-font", "monospace": "monospace-font"}
500
+ return {attr: getattr(self, f) for f, attr in mapping.items() if getattr(self, f, None)}
501
+
502
+
447
503
  class Boilerplate(BaseModel):
448
504
  """Front-matter legal boilerplate (page ii) — copyright, license, legal notice.
449
505
 
@@ -508,11 +564,22 @@ class DocumentMetadata(BaseModel):
508
564
  default_factory=list,
509
565
  description="Additional normative references appended to the References section.",
510
566
  )
567
+ terms: list[Term] = Field(
568
+ default_factory=list,
569
+ description=(
570
+ "Profile-supplied terms appended to the Terms and Definitions section "
571
+ "(e.g. EDR Part 3 / Pub/Sub terms), each with a definition and optional source."
572
+ ),
573
+ )
511
574
  external_id: str | None = None
512
575
  colors: PdfColors | None = Field(
513
576
  default=None,
514
577
  description="PDF colour-scheme overrides (mapped to Metanorma :presentation-metadata-color-*: attributes).",
515
578
  )
579
+ fonts: PdfFonts | None = Field(
580
+ default=None,
581
+ description="PDF font overrides (body/header/monospace) mapped to Metanorma :body-font:/:header-font:/:monospace-font:.",
582
+ )
516
583
  cover: CoverPage | None = Field(
517
584
  default=None,
518
585
  description="Custom cover-page branding (logo + generated cover) replacing the built-in OGC cover.",
@@ -542,6 +609,62 @@ class DocumentMetadata(BaseModel):
542
609
  "Metanorma flavor."
543
610
  ),
544
611
  )
612
+ body_font: str | None = Field(
613
+ default=None,
614
+ description=(
615
+ "PDF body-text font family (Metanorma :body-font:), e.g. 'Source Sans Pro'. "
616
+ "The font must be resolvable by fontist/the Metanorma container."
617
+ ),
618
+ )
619
+ header_font: str | None = Field(
620
+ default=None,
621
+ description="PDF heading font family (Metanorma :header-font:).",
622
+ )
623
+ monospace_font: str | None = Field(
624
+ default=None,
625
+ description="PDF monospace font family (Metanorma :monospace-font:).",
626
+ )
627
+ suppress_crossing_lines: bool = Field(
628
+ default=False,
629
+ description=(
630
+ "When true, remove the OGC flavor's 'crossing lines' design element (the "
631
+ "blue crossed lines with dots on the cover and section-divider pages) via "
632
+ "the PDF stylesheet override. Coupled to the OGC Metanorma flavor."
633
+ ),
634
+ )
635
+ plain_section_numbers: bool = Field(
636
+ default=False,
637
+ description=(
638
+ "When true, render section-divider numbers as plain text instead of the OGC "
639
+ "flavor's coloured circles (e.g. '1 Scope' rather than a circled '1'). "
640
+ "Coupled to the OGC Metanorma flavor."
641
+ ),
642
+ )
643
+ suppress_title_underlines: bool = Field(
644
+ default=False,
645
+ description=(
646
+ "When true, remove the short/long horizontal rule the OGC flavor draws "
647
+ "beneath section titles. Coupled to the OGC Metanorma flavor."
648
+ ),
649
+ )
650
+ suppress_design_elements: bool = Field(
651
+ default=False,
652
+ description=(
653
+ "When true, strip the OGC flavor's decorative PDF design elements — the "
654
+ "blue 'crossing lines' motif, the circular badges around clause numbers, and "
655
+ "the short rule under clause titles — via a targeted PDF stylesheet override. "
656
+ "Produces a plainer 'i. Abstract' / '1 Scope' heading style for SDOs (e.g. "
657
+ "DGIWG) that don't use the OGC house style. Coupled to the OGC Metanorma flavor."
658
+ ),
659
+ )
660
+ page_watermark: str | None = Field(
661
+ default=None,
662
+ description=(
663
+ "Optional text (e.g. 'DRAFT') rendered as a light diagonal watermark on every "
664
+ "PDF page via a PDF stylesheet override. Distinct from cover.watermark, which "
665
+ "only marks the generated cover image. Coupled to the OGC Metanorma flavor."
666
+ ),
667
+ )
545
668
 
546
669
  @field_validator("submission_date", "approval_date", "publication_date")
547
670
  @classmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.2.1
3
+ Version: 3.3.0
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
@@ -234,18 +234,20 @@ Everything about the document — cover, title-page metadata, legal page, footer
234
234
 
235
235
  | What you want to change | Field | Mechanism |
236
236
  |---|---|---|
237
- | Cover logo + tagline (replaces the OGC cover) | `document_metadata.cover` | Generated cover image via `:coverpage-image:` full replacement |
237
+ | Cover logo, tagline, fonts, watermark (replaces the OGC cover) | `document_metadata.cover` | Generated cover image via `:coverpage-image:` full replacement |
238
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
239
  | PDF colours (titles, tables, cover, backgrounds) | `document_metadata.colors` | `:presentation-metadata-color-*:` |
240
240
  | Page-ii legal text (copyright / license / notice) | `document_metadata.boilerplate` | `:boilerplate-authority:` |
241
241
  | Footer organisation name | `document_metadata.copyright_holder` | `:copyright-holder:` |
242
242
  | Remove the OGC logo from the legal page | `document_metadata.suppress_flavor_logo` | targeted `:pdf-stylesheet-override:` |
243
+ | Remove OGC design (crossing lines, circled numbers, rules, navy divider pages) | `document_metadata.suppress_design_elements` (or the individual `suppress_crossing_lines` / `plain_section_numbers` / `suppress_title_underlines`) | `:pdf-stylesheet-override:` + colour attributes |
244
+ | Every-page DRAFT-style watermark (over content) | `document_metadata.page_watermark` | `:pdf-stylesheet-override:` (footer overlay) |
243
245
  | 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 |
246
+ | Submitter table, notice paragraph, normative refs, extra terms | `document_metadata.{submitters, notice, normative_references, terms}` | Metanorma attributes / sections |
245
247
 
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.
248
+ 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. Omit all of these fields and the output keeps the standard OGC house style.
247
249
 
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.
250
+ > The `suppress_flavor_logo`, `suppress_design_elements`/`suppress_*`, and `page_watermark` overrides reach into the OGC Metanorma flavor's XSL templates (e.g. `insertLogoPreface`, `insertCrossingLines`, `insertSectionNumInCircle`, `insertFooter`) and are coupled to that flavor. Everything else uses documented Metanorma attributes. mn2pdf merges our same-named templates over the flavor's at equal precedence (via `merge_override.xsl`), which is what makes these work; a future metanorma-ogc release could require refreshing the template names. For long-term SDO adoption, a dedicated Metanorma "taste"/flavor is the most durable path.
249
251
 
250
252
  ---
251
253
 
@@ -701,6 +703,10 @@ document_metadata:
701
703
  tagline: Delivering Military Advantage through multi-national geospatial interoperability
702
704
  background: "#FFFFFF" # optional cover background (default white)
703
705
  text_color: "#1F3864" # optional cover text colour (default black)
706
+ font_regular: "Source Sans Pro" # optional: .ttf/.otf path OR installed family name (fontconfig)
707
+ font_bold: "Source Sans Pro" # optional bold face; derived from font_regular when unset
708
+ font_italic: "Source Sans Pro" # optional italic face for the tagline
709
+ watermark: "DRAFT" # optional diagonal watermark stamped on the cover
704
710
  colors:
705
711
  text: "#000000" # body text
706
712
  cover_text: "#1F3864" # cover text / section numbers / ToC
@@ -733,7 +739,41 @@ document_metadata:
733
739
  feedback: "Comments on this document should be directed to DGIWG."
734
740
  ```
735
741
 
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).
742
+ #### Removing the OGC page design (crossing lines, circled numbers, divider pages)
743
+
744
+ The OGC flavor also draws decorative design elements: the blue "crossing lines with a dot" motif, circled section-divider numbers, a short rule under section titles, and full-navy section-divider pages. One switch removes them all:
745
+
746
+ - **`suppress_design_elements: true`** — a consolidated switch that:
747
+ - removes the blue crossing-lines + dot motif (on divider/cover pages via a template override; on the Contents/preface pages, which draw the lines inline in the flavor's own colour, by whiting that colour out);
748
+ - renders section numbers as plain text (e.g. `1 Scope`, `i Abstract`) instead of coloured circles;
749
+ - removes the short rule beneath section titles;
750
+ - rebrands the full-navy section-divider pages to a clean white page with a DGIWG-navy (from `colors.title`) plain number and title.
751
+
752
+ The individual toggles `suppress_crossing_lines`, `plain_section_numbers`, and `suppress_title_underlines` are still available if you want finer control; `suppress_design_elements` simply turns all three on plus the divider rebrand.
753
+
754
+ - **`page_watermark`** — stamps a light diagonal watermark (e.g. `DRAFT`) on **every page**, rendered *over* the content including tables. This is distinct from `cover.watermark`, which marks only the generated cover image. The cover page is left unmarked.
755
+
756
+ ```yaml
757
+ document_metadata:
758
+ suppress_design_elements: true # plain numbers, no crossing lines/rules, white divider pages
759
+ page_watermark: DRAFT # every-page diagonal watermark (over content)
760
+ ```
761
+
762
+ With `cover` + `colors` + `copyright_holder` + `boilerplate` + `suppress_flavor_logo` + `suppress_design_elements` (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. Omit these fields and you get the standard OGC house style. See [`examples/nwp_radar.yaml`](examples/nwp_radar.yaml).
763
+
764
+ > These design-element overrides (and the `page_watermark` footer overlay) reach into the OGC Metanorma flavor's XSL template names and are coupled to that flavor; a future metanorma-ogc release could require refreshing them.
765
+
766
+ #### Extending Terms and Definitions
767
+
768
+ Add profile-specific terms (e.g. EDR Part 3 or Pub/Sub terms) appended after the base terms in the Terms and Definitions section:
769
+
770
+ ```yaml
771
+ document_metadata:
772
+ terms:
773
+ - term: service profile
774
+ definition: A named, referenceable set of constraints and additional requirements applied to an OGC API - EDR implementation.
775
+ source: "SOURCE: OGC API - EDR Part 3: Service Profiles (draft)"
776
+ ```
737
777
 
738
778
  ---
739
779
 
@@ -1,75 +0,0 @@
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
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20
- # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
21
- # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
- # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
23
- # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
24
- # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25
- # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
26
- # OTHER DEALINGS IN THE SOFTWARE.
27
- #
28
- # =================================================================
29
- """
30
- PDF compilation: shell out to the Metanorma Docker image.
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import shutil
36
- import subprocess
37
- import sys
38
- from pathlib import Path
39
-
40
-
41
- def compile_pdf(output_dir: Path) -> bool:
42
- """
43
- Run `docker run metanorma/metanorma compile` against document.adoc
44
- in output_dir. Returns True on success.
45
- """
46
- if shutil.which("docker") is None:
47
- print("docker not found. Install Docker to use --pdf.", file=sys.stderr)
48
- sys.exit(1)
49
-
50
- doc = output_dir / "document.adoc"
51
- if not doc.exists():
52
- print(f"document.adoc not found in {output_dir}", file=sys.stderr)
53
- sys.exit(1)
54
-
55
- fonts_dir = Path.home() / ".fontist" / "fonts"
56
- fonts_dir.mkdir(parents=True, exist_ok=True)
57
-
58
- cmd = [
59
- "docker", "run", "--rm",
60
- "-v", f"{output_dir.resolve()}:/metanorma",
61
- "-v", f"{fonts_dir}:/config/fonts",
62
- "metanorma/metanorma",
63
- "metanorma", "compile",
64
- "--agree-to-terms",
65
- "-t", "ogc",
66
- "-x", "pdf",
67
- "document.adoc",
68
- ]
69
-
70
- print(f"Compiling PDF via Metanorma Docker...")
71
- result = subprocess.run(cmd, check=False)
72
- if result.returncode == 0:
73
- pdf = output_dir / "document.pdf"
74
- print(f"PDF written to {pdf}")
75
- return result.returncode == 0