oapi-profile-builder 3.2.0__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.0/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.3.0}/PKG-INFO +46 -6
  2. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/README.md +45 -5
  3. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/pyproject.toml +1 -1
  4. {oapi_profile_builder-3.2.0 → 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.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/cover.py +106 -19
  7. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/generate.py +293 -14
  8. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/models.py +123 -0
  9. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/server_validation.py +91 -0
  10. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0/src/oapi_profile_builder.egg-info}/PKG-INFO +46 -6
  11. oapi_profile_builder-3.2.0/src/oapi_profile_builder/compile.py +0 -75
  12. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/LICENSE +0 -0
  13. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/setup.cfg +0 -0
  14. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/__init__.py +0 -0
  15. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/cite.py +0 -0
  16. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder/cite_features.py +0 -0
  17. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  18. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  19. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  20. {oapi_profile_builder-3.2.0 → oapi_profile_builder-3.3.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  21. {oapi_profile_builder-3.2.0 → 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.0
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.0"
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()