├── .gitignore ├── docs.pdf ├── images ├── agrogeo.png ├── scipy.png ├── pubmatter.png ├── author-block.png ├── lapreprint.png └── normalized.png ├── typst.toml ├── Makefile ├── tests ├── test-equal-contributor.typ └── test-corresponding-author.typ ├── LICENSE ├── docs.typ ├── README.md ├── validate-frontmatter.typ └── pubmatter.typ /.gitignore: -------------------------------------------------------------------------------- 1 | pubmatter.pdf 2 | validate-frontmatter.pdf 3 | tests/*.pdf -------------------------------------------------------------------------------- /docs.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuous-foundation/pubmatter/HEAD/docs.pdf -------------------------------------------------------------------------------- /images/agrogeo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuous-foundation/pubmatter/HEAD/images/agrogeo.png -------------------------------------------------------------------------------- /images/scipy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuous-foundation/pubmatter/HEAD/images/scipy.png -------------------------------------------------------------------------------- /images/pubmatter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuous-foundation/pubmatter/HEAD/images/pubmatter.png -------------------------------------------------------------------------------- /images/author-block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuous-foundation/pubmatter/HEAD/images/author-block.png -------------------------------------------------------------------------------- /images/lapreprint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuous-foundation/pubmatter/HEAD/images/lapreprint.png -------------------------------------------------------------------------------- /images/normalized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/continuous-foundation/pubmatter/HEAD/images/normalized.png -------------------------------------------------------------------------------- /typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pubmatter" 3 | version = "0.2.2" 4 | entrypoint = "pubmatter.typ" 5 | authors = ["rowanc1"] 6 | license = "MIT" 7 | description = "Parse, normalize and show publication frontmatter, including authors and affiliations" 8 | repository = "https://github.com/continuous-foundation/pubmatter" 9 | keywords = ["frontmatter", "authors", "affiliations", "abstract", "orcid"] 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all copy-files clean 2 | 3 | # Extract the `version` value from typst.toml 4 | VERSION := $(shell sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' typst.toml) 5 | 6 | # Define a variable for the folder where files will be copied 7 | PACKAGE_FOLDER := ../typst-packages/packages/preview/pubmatter/$(VERSION) 8 | 9 | copy-files: 10 | @echo "Checking README.md for version $(VERSION)..." 11 | @if ! grep -q "@preview/pubmatter:$(VERSION)" README.md; then \ 12 | echo "Error: README.md does not contain the latest version @preview/pubmatter:$(VERSION)"; \ 13 | exit 1; \ 14 | fi 15 | @echo "✓ README.md contains correct version" 16 | @echo "Creating folder: $(PACKAGE_FOLDER)" 17 | @mkdir -p $(PACKAGE_FOLDER) 18 | @echo "Copying files into $(PACKAGE_FOLDER) ..." 19 | cp LICENSE $(PACKAGE_FOLDER) 20 | cp README.md $(PACKAGE_FOLDER) 21 | cp pubmatter.typ $(PACKAGE_FOLDER) 22 | cp typst.toml $(PACKAGE_FOLDER) 23 | cp validate-frontmatter.typ $(PACKAGE_FOLDER) 24 | @echo "Done copying files." 25 | -------------------------------------------------------------------------------- /tests/test-equal-contributor.typ: -------------------------------------------------------------------------------- 1 | #import "../pubmatter.typ" 2 | 3 | // Test with equal contributors 4 | #let fm = pubmatter.load(( 5 | title: "Test Equal Contributors", 6 | authors: ( 7 | ( 8 | name: "Alice Smith", 9 | email: "alice@example.com", 10 | equal-contributor: true, 11 | affiliations: ((name: "University A", index: 1),), 12 | ), 13 | ( 14 | name: "Bob Jones", 15 | email: "bob@example.com", 16 | equal-contributor: true, 17 | affiliations: ((name: "University B", index: 2), (name: "Research Institute C", index: 3)), 18 | ), 19 | ( 20 | name: "Carol Davis", 21 | email: "carol@example.com", 22 | affiliations: ((name: "University A", index: 1),), 23 | ), 24 | ), 25 | )) 26 | 27 | #set page(width: 6in, height: 4in, margin: 0.5in) 28 | #pubmatter.show-title(fm) 29 | #pubmatter.show-authors(fm) 30 | #pubmatter.show-affiliations(fm) 31 | 32 | #pagebreak() 33 | 34 | = Test with show-equal-contributor: false 35 | 36 | #pubmatter.show-authors(show-equal-contributor: false, fm) 37 | #pubmatter.show-affiliations(show-equal-contributor: false, fm) 38 | 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Continuous Science Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test-corresponding-author.typ: -------------------------------------------------------------------------------- 1 | #import "../pubmatter.typ" 2 | 3 | #let fm-block = (fm) => { 4 | box(inset: 10pt, width: 100%, fill: luma(95%), stroke: 1pt + luma(200), [ 5 | #set text(size: 7pt) 6 | #fm.authors 7 | ]) 8 | } 9 | 10 | #set page(width: 6in, height: auto, margin: 0.5in) 11 | 12 | = Test 1: Author with corresponding: true 13 | 14 | #let fm1 = pubmatter.load(( 15 | authors: ( 16 | (name: "Alice Smith", email: "alice@example.com"), 17 | (name: "Bob Jones", email: "bob@example.com", corresponding: true), 18 | (name: "Carol Davis", email: "carol@example.com"), 19 | ), 20 | )) 21 | #fm-block(fm1) 22 | 23 | #let corresp = pubmatter.get-corresponding-author(fm1) 24 | #pubmatter.show-authors(fm1) 25 | #pubmatter.show-affiliations(fm1) 26 | *Corresponding Author:* #corresp.name (#corresp.email) 27 | 28 | #v(10pt) 29 | 30 | = Test 2: No explicit corresponding, first author with email 31 | 32 | #let fm2 = pubmatter.load(( 33 | authors: ( 34 | (name: "Alice Smith", email: "alice@example.com"), 35 | (name: "Bob Jones", email: "bob@example.com"), 36 | (name: "Carol Davis"), 37 | ), 38 | )) 39 | 40 | #fm-block(fm2) 41 | 42 | #let corresp2 = pubmatter.get-corresponding-author(fm2) 43 | #pubmatter.show-authors(fm2) 44 | #pubmatter.show-affiliations(fm2) 45 | *Corresponding Author:* #corresp2.name (#corresp2.email) 46 | 47 | #v(10pt) 48 | 49 | = Test 3: First author has corresponding: false, second has email 50 | 51 | #let fm3 = pubmatter.load(( 52 | authors: ( 53 | (name: "Alice Smith", email: "alice@example.com", corresponding: false), 54 | (name: "Bob Jones", email: "bob@example.com"), 55 | (name: "Carol Davis"), 56 | ), 57 | )) 58 | #fm-block(fm3) 59 | 60 | #let corresp3 = pubmatter.get-corresponding-author(fm3) 61 | 62 | #pubmatter.show-authors(fm3) 63 | #pubmatter.show-affiliations(fm3) 64 | *Corresponding Author:* #corresp3.name (#corresp3.email) 65 | 66 | 67 | #v(10pt) 68 | 69 | = Test 4: No authors with email 70 | 71 | #let fm4 = pubmatter.load(( 72 | authors: ( 73 | (name: "Alice Smith"), 74 | (name: "Bob Jones"), 75 | ), 76 | )) 77 | #fm-block(fm4) 78 | 79 | #let corresp4 = pubmatter.get-corresponding-author(fm4) 80 | #pubmatter.show-authors(fm4) 81 | #pubmatter.show-affiliations(fm4) 82 | *Corresponding Author:* #if corresp4 == none { [None found] } else { corresp4.name } 83 | 84 | 85 | #v(10pt) 86 | 87 | = Test 5: Empty authors list 88 | 89 | #let fm5 = pubmatter.load(( 90 | authors: (), 91 | )) 92 | #fm-block(fm5) 93 | #let corresp5 = pubmatter.get-corresponding-author(fm5) 94 | #pubmatter.show-authors(fm5) 95 | #pubmatter.show-affiliations(fm5) 96 | *Corresponding Author:* #if corresp5 == none { [None found] } else { corresp5.name } 97 | 98 | -------------------------------------------------------------------------------- /docs.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/tidy:0.2.0" 2 | #import "./pubmatter.typ" 3 | 4 | #let example = (it) => { 5 | box(fill: luma(95%), inset: (x: 10pt), width: 100%, it) 6 | } 7 | 8 | #let fm = pubmatter.load(( 9 | title: "pubmatter", 10 | subtitle: "A typst library for parsing, normalizing and showing publication frontmatter", 11 | authors: ( 12 | ( 13 | name: "Rowan Cockett", 14 | email: "rowan@curvenote.com", 15 | orcid: "0000-0002-7859-8394", 16 | github: "rowanc1", 17 | affiliations: ((name: "Curvenote Inc.", ror: "02mz0e468"), "Continuous Science Foundation", "Project Jupyter"), 18 | ), 19 | ), 20 | open-access: true, 21 | license: "CC-BY-4.0", 22 | venue: "Typst Package", 23 | date: "2024/01/26", 24 | abstract: [ 25 | Utilities for loading and working with authors, affiliations, abstracts, keywords and other frontmatter information common in scientific publications. 26 | 27 | Our goal is to introduce standardized ways of working with this content to expose metadata to scientific publishers who are interested in using typst in a standardized way. The specification for this pubmatter is based on MyST Markdown and Quarto, and can load their YAML files directly. 28 | ], 29 | keywords: ("typst package", "open-science", "standards") 30 | )) 31 | 32 | 33 | #show raw.where(lang: "example"): (it) => { 34 | set text(font: "Noto Sans", size: 9pt) 35 | box(inset: (left: 10pt, y: 5pt), stroke: (left: blue + 2pt))[ 36 | #raw(lang: "typst", it.text.replace(regex("pubmatter."), "")) 37 | #box(fill: luma(95%), inset: (x: 10pt, y: 5pt), width: 100%, eval("[" + it.text + "]", scope: ( 38 | pubmatter: pubmatter, 39 | fm: fm, 40 | authors: fm.authors, 41 | affiliations: fm.affiliations, 42 | ) 43 | )) 44 | ] 45 | } 46 | 47 | 48 | #let theme = (color: red.darken(20%), font: "Noto Sans") 49 | #state("THEME").update(theme) 50 | #set page(header: pubmatter.show-page-header(fm), footer: pubmatter.show-page-footer(fm)) 51 | #show link: it => [#text(fill: blue)[#it]] 52 | 53 | #pubmatter.show-title-block(fm) 54 | #state("THEME").update((color: purple.darken(20%), font: "Noto Sans")) 55 | #pubmatter.show-abstract-block(fm) 56 | 57 | #set text(font: "Noto Serif", size: 9pt) 58 | 59 | == Loading Frontmatter 60 | 61 | The frontmatter can contain all information for an article, including title, authors, affiliations, abstracts and keywords. These are then normalized into a standardized format that can be used with a number of `show` functions like `show-authors`. For example, we might have a YAML file that looks like this: 62 | 63 | ```yaml 64 | author: Rowan Cockett 65 | date: 2024/01/26 66 | ``` 67 | 68 | You can load that file with `yaml`, and pass it to the `load` function: 69 | 70 | ```typst 71 | #let fm = pubmatter.load(yaml("pubmatter.yml")) 72 | ``` 73 | 74 | This will give you a normalized data-structure that can be used with the `show` functions for showing various parts of a document. 75 | 76 | You can also use a `dictionary` directly: 77 | 78 | 79 | ```example 80 | #let fm = pubmatter.load(( 81 | author: ( 82 | ( 83 | name: "Rowan Cockett", 84 | email: "rowan@curvenote.com", 85 | orcid: "0000-0002-7859-8394", 86 | affiliations: "Curvenote Inc.", 87 | ), 88 | ), 89 | date: datetime(year: 2024, month: 01, day: 26), 90 | doi: "10.1190/tle35080703.1", 91 | )) 92 | #pubmatter.show-author-block(fm) 93 | ``` 94 | 95 | #pagebreak() 96 | 97 | = Theming 98 | 99 | The theme including color and font choice can be set using the `THEME` state. 100 | For example, this document has the following theme set: 101 | 102 | ```typst 103 | #let theme = (color: red.darken(20%), font: "Noto Sans") 104 | #state("THEME").update(theme) 105 | #set page(header: pubmatter.show-page-header(fm), footer: pubmatter.show-page-footer(fm)) 106 | ``` 107 | 108 | Note that for the `header` the theme must be passed in directly. This will hopefully become easier in the future, however, there is a current bug that removes the page header/footer if you set this above the `set page`. See #link("https://github.com/typst/typst/issues/2987")[\#2987]. 109 | 110 | The `font` option only corresponds to the frontmatter content (abstracts, title, header/footer etc.) allowing the body of your document to have a different font choice. 111 | 112 | #pagebreak() 113 | 114 | = Normalized Frontmatter Object 115 | 116 | The frontmatter object has the following normalized structure: 117 | 118 | ```yaml 119 | title: content 120 | subtitle: content 121 | short-title: string # alias: running-title, running-head 122 | # Authors Array 123 | # simple string provided for author is turned into ((name: string),) 124 | authors: # alias: author 125 | - name: string 126 | url: string # alias: website, homepage 127 | email: string 128 | phone: string 129 | fax: string 130 | orcid: string # alias: ORCID 131 | note: string 132 | corresponding: boolean # default: `true` when email set 133 | equal-contributor: boolean # alias: equalContributor, equal_contributor 134 | deceased: boolean 135 | roles: string[] # must be a contributor role 136 | affiliations: # alias: affiliation 137 | - id: string 138 | index: number 139 | # Affiliations Array 140 | affiliations: # alias: affiliation 141 | - string # simple string is turned into (name: string) 142 | - id: string 143 | index: number 144 | name: string 145 | institution: string # use either name or institution 146 | # Other publication metadata 147 | open-access: boolean 148 | license: # Can be set with a SPDX ID for creative commons 149 | id: string 150 | url: string 151 | name: string 152 | doi: string # must be only the ID, not the full URL 153 | date: datetime # validates from 'YYYY-MM-DD' if a string 154 | citation: content 155 | # Abstracts Array 156 | # content is turned into ((title: "Abstract", content: string),) 157 | abstracts: # alias: abstract 158 | - title: content 159 | content: content 160 | ``` 161 | 162 | #pagebreak() 163 | Note that you will usually write the affiliations directly in line, in the following example, we can see that the output is a normalized affiliation object that is linked by the `id` of the affiliation (just the name if it is a string!). 164 | 165 | ```example 166 | #let fm = pubmatter.load(( 167 | authors: ( 168 | ( 169 | name: "Rowan Cockett", 170 | affiliations: "Curvenote Inc.", 171 | ), 172 | ( 173 | name: "Steve Purves", 174 | affiliations: ("Project Jupyter", "Curvenote Inc."), 175 | ), 176 | ), 177 | )) 178 | #raw(lang:"yaml", yaml.encode(fm)) 179 | ``` 180 | 181 | #pagebreak() 182 | = API Documentation 183 | 184 | #let docs = tidy.parse-module( 185 | read("./pubmatter.typ"), 186 | name: "pubmatter", 187 | ) 188 | 189 | #let validate-docs = tidy.parse-module( 190 | read("./validate-frontmatter.typ"), 191 | name: "validate-frontmatter", 192 | ) 193 | 194 | #tidy.show-module(docs) 195 | #tidy.show-module(validate-docs) 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pubmatter 2 | 3 | _Beautiful scientific documents with structured metadata for publishers_ 4 | 5 | [![Documentation](https://img.shields.io/badge/typst-docs-orange.svg)](https://github.com/continuous-foundation/pubmatter/blob/main/docs.pdf) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/continuous-foundation/pubmatter/blob/main/LICENSE) 7 | 8 | Pubmatter is a typst library for parsing, normalizing and showing scientific publication frontmatter. 9 | 10 | Utilities for loading, normalizing and working with authors, affiliations, abstracts, keywords and other frontmatter information common in scientific publications. Our goal is to introduce standardized ways of working with this content to expose metadata to scientific publishers who are interested in using typst in a standardized way. The specification for this `pubmatter` is based on [MyST Markdown](https://mystmd.org) and [Quarto](https://quarto.org), and can load their YAML files directly. 11 | 12 | ## Examples 13 | 14 | Pubmatter was used to create these documents, for loading the authors in a standardized way and creating the common elements (authors, affiliations, ORCIDs, DOIs, Open Access Links, copyright statements, etc.) 15 | 16 | ![](https://github.com/continuous-foundation/pubmatter/blob/main/images/lapreprint.png?raw=true) 17 | 18 | ![](https://github.com/continuous-foundation/pubmatter/blob/main/images/scipy.png?raw=true) 19 | 20 | ![](https://github.com/continuous-foundation/pubmatter/blob/main/images/agrogeo.png?raw=true) 21 | 22 | ## Documentation 23 | 24 | The full documentation can be found in [docs.pdf](https://github.com/continuous-foundation/pubmatter/blob/main/docs.pdf). To use `pubmatter` import it: 25 | 26 | ```typst 27 | #import "@preview/pubmatter:0.2.2" 28 | ``` 29 | 30 | The docs also use `pubmatter`, in a simplified way, you can see the [docs.typ](https://github.com/continuous-foundation/pubmatter/blob/main/docs.typ) to see a simple example of using various components to create a new document. Here is a preview of the docs: 31 | 32 | [![](https://github.com/continuous-foundation/pubmatter/blob/main/images/pubmatter.png?raw=true)](https://github.com/continuous-foundation/pubmatter/blob/main/docs.pdf) 33 | 34 | ### Loading Frontmatter 35 | 36 | The frontmatter can contain all information for an article, including title, authors, affiliations, abstracts and keywords. These are then normalized into a standardized format that can be used with a number of `show` functions like `show-authors`. For example, we might have a YAML file that looks like this: 37 | 38 | ```yaml 39 | author: Rowan Cockett 40 | date: 2024/01/26 41 | ``` 42 | 43 | You can load that file with `yaml`, and pass it to the `load` function: 44 | 45 | ```typst 46 | #let fm = pubmatter.load(yaml("pubmatter.yml")) 47 | ``` 48 | 49 | This will give you a normalized data-structure that can be used with the `show` functions for showing various parts of a document. 50 | 51 | You can also use a `dictionary` directly: 52 | 53 | ```typst 54 | #let fm = pubmatter.load(( 55 | author: ( 56 | ( 57 | name: "Rowan Cockett", 58 | email: "rowan@curvenote.com", 59 | orcid: "0000-0002-7859-8394", 60 | affiliations: "Curvenote Inc.", 61 | ), 62 | ), 63 | date: datetime(year: 2024, month: 01, day: 26), 64 | doi: "10.1190/tle35080703.1", 65 | )) 66 | #pubmatter.show-author-block(fm) 67 | ``` 68 | 69 | ![](https://github.com/continuous-foundation/pubmatter/blob/main/images/author-block.png?raw=true) 70 | 71 | ### Theming 72 | 73 | The theme including color and font choice can be set using the `THEME` state. 74 | For example, this document has the following theme set: 75 | 76 | ```typst 77 | #let theme = (color: red.darken(20%), font: "Noto Sans") 78 | #state("THEME").update(theme) 79 | #set page(header: pubmatter.show-page-header(fm), footer: pubmatter.show-page-footer(fm)) 80 | ``` 81 | 82 | Note that for the `header` the theme must be passed in directly. This will hopefully become easier in the future, however, there is a current bug that removes the page header/footer if you set this above the `set page`. See [https://github.com/typst/typst/issues/2987](#2987). 83 | 84 | The `font` option only corresponds to the frontmatter content (abstracts, title, header/footer etc.) allowing the body of your document to have a different font choice. 85 | 86 | ### Normalized Frontmatter Object 87 | 88 | The frontmatter object has the following normalized structure: 89 | 90 | ```yaml 91 | title: content 92 | subtitle: content 93 | short-title: string # alias: running-title, running-head 94 | # Authors Array 95 | # simple string provided for author is turned into ((name: string),) 96 | authors: # alias: author 97 | - name: string 98 | url: string # alias: website, homepage 99 | email: string 100 | phone: string 101 | fax: string 102 | orcid: string # alias: ORCID 103 | note: string 104 | corresponding: boolean # default: `true` when email set 105 | equal-contributor: boolean # alias: equalContributor, equal_contributor 106 | deceased: boolean 107 | roles: string[] # must be a contributor role 108 | affiliations: # alias: affiliation 109 | - id: string 110 | index: number 111 | # Affiliations Array 112 | affiliations: # alias: affiliation 113 | - string # simple string is turned into (name: string) 114 | - id: string 115 | index: number 116 | name: string 117 | institution: string # use either name or institution 118 | # Other publication metadata 119 | open-access: boolean 120 | license: # Can be set with a SPDX ID for creative commons 121 | id: string 122 | url: string 123 | name: string 124 | doi: string # must be only the ID, not the full URL 125 | date: datetime # validates from 'YYYY-MM-DD' if a string 126 | citation: content 127 | # Abstracts Array 128 | # content is turned into ((title: "Abstract", content: string),) 129 | abstracts: # alias: abstract 130 | - title: content 131 | content: content 132 | ``` 133 | 134 | Note that you will usually write the affiliations directly in line, in the following example, we can see that the output is a normalized affiliation object that is linked by the `id` of the affiliation (just the name if it is a string!). 135 | 136 | ```typst 137 | #let fm = pubmatter.load(( 138 | authors: ( 139 | ( 140 | name: "Rowan Cockett", 141 | affiliations: "Curvenote Inc.", 142 | ), 143 | ( 144 | name: "Steve Purves", 145 | affiliations: ("Project Jupyter", "Curvenote Inc."), 146 | ), 147 | ), 148 | )) 149 | #raw(lang:"yaml", yaml.encode(fm)) 150 | ``` 151 | 152 | ![](https://github.com/continuous-foundation/pubmatter/blob/main/images/normalized.png?raw=true) 153 | 154 | ### Full List of Functions 155 | 156 | - `load()` - Load a raw frontmatter object 157 | - `doi-link()` - Create a DOI link 158 | - `email-link()` - Create a mailto link with an email icon 159 | - `github-link()` - Create a link to a GitHub profile with the GitHub icon 160 | - `open-access-link()` - Create a link to Wikipedia with an OpenAccess icon 161 | - `orcid-link()` - Create a ORCID link with an ORCID logo 162 | - `show-abstract-block()` - Show abstract-block including all abstracts and keywords 163 | - `show-abstracts()` - Show all abstracts (e.g. abstract, plain language summary) 164 | - `show-affiliations()` - Show affiliations 165 | - `show-author-block()` - Show author block, including author, icon links (e.g. ORCID, email, etc.) and affiliations 166 | - `show-authors()` - Show authors 167 | - `show-citation()` - Create a short citation in APA format, e.g. Cockett _et al._, 2023 168 | - `show-copyright()` - Show copyright statement based on license 169 | - `show-keywords()` - Show keywords as a list 170 | - `show-license-badge()` - Show the license badges 171 | - `show-page-footer()` - Show the venue, date and page numbers 172 | - `show-page-header()` - Show an open-access badge and the DOI and then the running-title and citation 173 | - `show-spaced-content()` 174 | - `show-title()` - Show title and subtitle 175 | - `show-title-block()` - Show title, authors and affiliations 176 | 177 | ## Contributing 178 | 179 | To help with standardization of metadata or improve the show-functions please contribute to this package: \ 180 | https://github.com/continuous-foundation/pubmatter 181 | -------------------------------------------------------------------------------- /validate-frontmatter.typ: -------------------------------------------------------------------------------- 1 | 2 | #let validateContent(raw, name, alias: none) = { 3 | if (name in raw) { 4 | assert(type(raw.at(name)) == str or type(raw.at(name)) == content, message: name + " must be a string or content") 5 | return raw.at(name) 6 | } 7 | if (type(alias) != array) { return } 8 | for a in alias { if (a in raw) { validateContent(raw, a) } } 9 | } 10 | 11 | #let validateString(raw, name, alias: none) = { 12 | if (name in raw) { 13 | assert(type(raw.at(name)) == str, message: name + " must be a string") 14 | return raw.at(name) 15 | } 16 | if (type(alias) != array) { return } 17 | for a in alias { if (a in raw) { validateString(raw, a) } } 18 | } 19 | 20 | #let validateBoolean(raw, name, alias: none) = { 21 | if (name in raw) { 22 | assert(type(raw.at(name)) == bool, message: name + " must be a boolean") 23 | return raw.at(name) 24 | } 25 | if (type(alias) != array) { return } 26 | for a in alias { if (a in raw) { validateBoolean(raw, a) } } 27 | } 28 | 29 | #let validateArray(raw, name, alias: none) = { 30 | if (name in raw) { 31 | assert(type(raw.at(name)) == array, message: name + " must be an array") 32 | return raw.at(name) 33 | } 34 | if (type(alias) != array) { return } 35 | for a in alias { if (a in raw) { validateArray(raw, a) } } 36 | } 37 | 38 | #let validateDate(raw, name, alias: none) = { 39 | if (name in raw) { 40 | let rawDate = raw.at(name) 41 | if (type(rawDate) == datetime) { return rawDate } 42 | if (type(rawDate) == int) { 43 | // assume this is the year 44 | assert(rawDate > 1000 and rawDate < 3000, message: "The date is assumed to be a year between 1000 and 3000") 45 | return datetime(year: rawDate, month: 1, day: 1) 46 | } 47 | if (type(rawDate) == str) { 48 | let yearMatch = rawDate.find(regex(`^([1|2])([0-9]{3})$`.text)) 49 | if (yearMatch != none) { 50 | // This isn't awesome, but probably fine 51 | return datetime(year: int(rawDate), month: 1, day: 1) 52 | } 53 | let dateMatch = rawDate.find(regex(`^([1|2])([0-9]{3})([-\/])([0-9]{1,2})([-\/])([0-9]{1,2})$`.text)) 54 | if (dateMatch != none) { 55 | let parts = rawDate.split(regex("[-\/]")) 56 | return datetime( 57 | year: int(parts.at(0)), 58 | month: int(parts.at(1)), 59 | day: int(parts.at(2)), 60 | ) 61 | } 62 | panic("Unknown datetime object from string, try: `2020/03/15` as YYYY/MM/DD, also accepts `2020-03-15`") 63 | } 64 | if (type(rawDate) == dictionary) { 65 | if ("year" in rawDate and "month" in rawDate and "day" in rawDate) { 66 | return return datetime( 67 | year: rawDate.at("year"), 68 | month: rawDate.at("month"), 69 | day: rawDate.at("day"), 70 | ) 71 | } 72 | if ("year" in rawDate and "month" in rawDate) { 73 | return return datetime( 74 | year: rawDate.at("year"), 75 | month: rawDate.at("month"), 76 | day: 1, 77 | ) 78 | } 79 | if ("year" in rawDate) { 80 | return return datetime( 81 | year: rawDate.at("year"), 82 | month: 1, 83 | day: 1, 84 | ) 85 | } 86 | panic("Unknown datetime object from dictionary, try: `(year: 2022, month: 2, day: 3)`") 87 | } 88 | panic("Unknown date of type '" + type(rawDate)+ "' accepts: datetime, str, int, and object") 89 | } 90 | if (type(alias) != array) { return } 91 | for a in alias { if (a in raw) { return validateDate(raw, a) } } 92 | } 93 | 94 | #let validateAffiliation(raw) = { 95 | let out = (:) 96 | if (type(raw) == str) { 97 | out.name = raw; 98 | return out; 99 | } 100 | let id = validateString(raw, "id") 101 | if (id != none) { out.id = id } 102 | let name = validateString(raw, "name") 103 | if (name != none) { out.name = name } 104 | let institution = validateString(raw, "institution") 105 | if (institution != none) { out.institution = institution } 106 | let department = validateString(raw, "department") 107 | if (department != none) { out.department = department } 108 | let doi = validateString(raw, "doi") 109 | if (doi != none) { out.doi = doi } 110 | let ror = validateString(raw, "ror") 111 | if (ror != none) { out.ror = ror } 112 | let address = validateString(raw, "address") 113 | if (address != none) { out.address = address } 114 | let city = validateString(raw, "city") 115 | if (city != none) { out.city = city } 116 | let region = validateString(raw, "region", alias: ("state", "province")) 117 | if (region != none) { out.region = region } 118 | let postal-code = validateString(raw, "postal-code", alias: ("postal_code", "postalCode", "zip_code", "zip-code", "zipcode", "zipCode")) 119 | if (postal-code != none) { out.postal-code = postal-code } 120 | let country = validateString(raw, "country") 121 | if (country != none) { out.country = country } 122 | let phone = validateString(raw, "phone") 123 | if (phone != none) { out.phone = phone } 124 | let fax = validateString(raw, "fax") 125 | if (fax != none) { out.fax = fax } 126 | let email = validateString(raw, "email") 127 | if (email != none) { out.email = email } 128 | let url = validateString(raw, "url") 129 | if (url != none) { out.url = url } 130 | let collaboration = validateBoolean(raw, "collaboration") 131 | if (collaboration != none) { out.collaboration = collaboration } 132 | return out; 133 | } 134 | 135 | #let pickAffiliationsObject(raw) = { 136 | if ("affiliation" in raw and "affiliations" in raw) { 137 | panic("You can only use `affiliation` or `affiliations`, not both") 138 | } 139 | if ("affiliation" in raw) { 140 | raw.affiliations = raw.affiliation 141 | } 142 | if ("affiliations" not in raw) { return; } 143 | if (type(raw.affiliations) == str or type(raw.affiliations) == dictionary) { 144 | // convert to a list 145 | return (validateAffiliation(raw.affiliations),) 146 | } else if (type(raw.affiliations) == array) { 147 | // validate each entry 148 | return raw.affiliations.map(validateAffiliation) 149 | } else { 150 | panic("The `affiliation` or `affiliations` must be a array, dictionary or string, got:", type(raw.affiliations)) 151 | } 152 | } 153 | 154 | #let validateAuthor(raw) = { 155 | let out = (:) 156 | if (type(raw) == str) { 157 | out.name = raw; 158 | out.affiliations = () 159 | return out; 160 | } 161 | let name = validateString(raw, "name") 162 | if (name != none) { out.name = name } 163 | let orcid = validateString(raw, "orcid", alias: ("ORCID",)) 164 | if (orcid != none) { out.orcid = orcid } 165 | let email = validateString(raw, "email") 166 | if (email != none) { out.email = email } 167 | let corresponding = validateBoolean(raw, "corresponding") 168 | if (corresponding != none) { out.corresponding = corresponding } 169 | let phone = validateString(raw, "phone") 170 | if (phone != none) { out.phone = phone } 171 | let fax = validateString(raw, "fax") 172 | if (fax != none) { out.fax = fax } 173 | let url = validateString(raw, "url", alias: ("website", "homepage")) 174 | if (url != none) { out.url = url } 175 | let github = validateString(raw, "github") 176 | if (github != none) { out.github = github } 177 | 178 | let deceased = validateBoolean(raw, "deceased") 179 | if (deceased != none and deceased) { out.deceased = deceased } 180 | let equal-contributor = validateBoolean(raw, "equal_contributor", alias: ("equal-contributor", "equalContributor")) 181 | if (equal-contributor != none and equal-contributor) { out.equal-contributor = equal-contributor } 182 | 183 | let note = validateString(raw, "note") 184 | if (note != none) { out.note = note } 185 | 186 | let affiliations = pickAffiliationsObject(raw); 187 | if (affiliations != none) { out.affiliations = affiliations } else { out.affiliations = () } 188 | 189 | return out; 190 | } 191 | 192 | #let consolidateAffiliations(authors, affiliations) = { 193 | let cnt = 0 194 | for affiliation in affiliations { 195 | if ("id" not in affiliation) { 196 | affiliation.insert("id", "aff-" + str(cnt + 1)) 197 | } 198 | affiliations.at(cnt) = affiliation 199 | cnt += 1 200 | } 201 | 202 | let authorCnt = 0 203 | for author in authors { 204 | let affCnt = 0 205 | for affiliation in author.affiliations { 206 | let pos = affiliations.position(item => { ("id" in item and item.id == affiliation.name) or ("name" in item and item.name == affiliation.name) }) 207 | if (pos != none) { 208 | affiliation.remove("name") 209 | affiliation.id = affiliations.at(pos).id 210 | affiliations.at(pos) = affiliations.at(pos) + affiliation 211 | } else { 212 | affiliation.id = if ("id" in affiliation) { affiliation.id } else { affiliation.name } 213 | affiliations.push(affiliation) 214 | } 215 | author.affiliations.at(affCnt) = (id: affiliation.id) 216 | affCnt += 1 217 | } 218 | authors.at(authorCnt) = author 219 | authorCnt += 1 220 | } 221 | 222 | // Now that they are normalized, loop again and update the numbers 223 | let fullAffCnt = 0 224 | let authorCnt = 0 225 | for author in authors { 226 | let affCnt = 0 227 | for affiliation in author.affiliations { 228 | let pos = affiliations.position(item => { item.id == affiliation.id }) 229 | let aff = affiliations.at(pos) 230 | if ("index" not in aff) { 231 | fullAffCnt += 1 232 | aff.index = fullAffCnt 233 | affiliations.at(pos) = affiliations.at(pos) + (index: fullAffCnt) 234 | } 235 | author.affiliations.at(affCnt) = (id: affiliation.id, index: aff.index) 236 | affCnt += 1 237 | } 238 | authors.at(authorCnt) = author 239 | authorCnt += 1 240 | } 241 | return (authors: authors, affiliations: affiliations) 242 | } 243 | 244 | /// Create a short citation in APA format, e.g. Cockett _et al._, 2023 245 | /// - show-year (boolean): Include the year in the citation 246 | /// - fm (fm): The frontmatter object 247 | /// -> content 248 | #let show-citation(show-year: true, fm) = { 249 | if ("authors" not in fm) {return none} 250 | let authors = fm.authors 251 | let date = fm.date 252 | let year = if (show-year and date != none) { ", " + date.display("[year]") } else { none } 253 | if (authors.len() == 1) { 254 | return authors.at(0).name.split(" ").last() + year 255 | } else if (authors.len() == 2) { 256 | return authors.at(0).name.split(" ").last() + " & " + authors.at(1).name.split(" ").last() + year 257 | } else if (authors.len() > 2) { 258 | return authors.at(0).name.split(" ").last() + " " + emph("et al.") + year 259 | } 260 | return none 261 | } 262 | 263 | 264 | #let validateLicense(raw) = { 265 | if ("license" not in raw) { return none } 266 | let rawLicense = raw.at("license") 267 | if (type(rawLicense) == str) { 268 | if (rawLicense == "CC0" or rawLicense == "CC0-1.0") { 269 | return ( 270 | id: "CC0-1.0", 271 | url: "https://creativecommons.org/licenses/zero/1.0/", 272 | name: "Creative Commons Zero v1.0 Universal", 273 | ) 274 | } else if (rawLicense == "CC-BY" or rawLicense == "CC-BY-4.0") { 275 | return ( 276 | id: "CC-BY-4.0", 277 | url: "https://creativecommons.org/licenses/by/4.0/", 278 | name: "Creative Commons Attribution 4.0 International", 279 | ) 280 | } else if (rawLicense == "CC-BY-NC" or rawLicense == "CC-BY-NC-4.0") { 281 | return ( 282 | id: "CC-BY-NC-4.0", 283 | url: "https://creativecommons.org/licenses/by-nc/4.0/", 284 | name: "Creative Commons Attribution Non Commercial 4.0 International", 285 | ) 286 | } else if (rawLicense == "CC-BY-NC-SA" or rawLicense == "CC-BY-NC-SA-4.0") { 287 | return ( 288 | id: "CC-BY-NC-SA-4.0", 289 | url: "https://creativecommons.org/licenses/by-nc-sa/4.0/", 290 | name: "Creative Commons Attribution Non Commercial Share Alike 4.0 International", 291 | ) 292 | } else if (rawLicense == "CC-BY-ND" or rawLicense == "CC-BY-ND-4.0") { 293 | return ( 294 | id: "CC-BY-ND-4.0", 295 | url: "https://creativecommons.org/licenses/by-nd/4.0/", 296 | name: "Creative Commons Attribution No Derivatives 4.0 International", 297 | ) 298 | } else if (rawLicense == "CC-BY-NC-ND" or rawLicense == "CC-BY-NC-ND-4.0") { 299 | return ( 300 | id: "CC-BY-NC-ND-4.0", 301 | url: "https://creativecommons.org/licenses/by-nc-nd/4.0/", 302 | name: "Creative Commons Attribution Non Commercial No Derivatives 4.0 International", 303 | ) 304 | } 305 | panic("Unknown license string: '" + rawLicense + "'") 306 | } 307 | if (type(rawLicense) == dictionary) { 308 | assert("id" in rawLicense and "url" in rawLicense and "name" in rawLicense, message: "License must contain fields of 'id' (the SPDX ID), 'url': the URL to the license, and 'name' the human-readable license name") 309 | let id = validateString(rawLicense, "id") 310 | let url = validateString(rawLicense, "url") 311 | let name = validateString(rawLicense, "name") 312 | return (id: id, url: url, name: name) 313 | } 314 | panic("Unknown format for license: '" + type(rawLicense) + "'") 315 | } 316 | 317 | #let load(raw) = { 318 | let out = (:) 319 | let title = validateContent(raw, "title") 320 | if (title != none) { out.title = title } 321 | let subtitle = validateContent(raw, "subtitle") 322 | if (subtitle != none) { out.subtitle = subtitle } 323 | let short-title = validateString(raw, "short-title", alias: ("short_title", "shortTitle", "running-head", "running_head", "runningHead", "runningTitle", "running_title", "running-title")) 324 | if (short-title != none) { out.short-title = short-title } 325 | 326 | // author information 327 | if ("author" in raw and "authors" in raw) { 328 | panic("You can only use `author` or `authors`, not both") 329 | } 330 | if ("author" in raw) { 331 | raw.authors = raw.author 332 | } 333 | if ("authors" in raw) { 334 | if (type(raw.authors) == str or type(raw.authors) == dictionary) { 335 | // convert to a list 336 | out.authors = (validateAuthor(raw.authors),) 337 | } else if (type(raw.authors) == array) { 338 | // validate each entry 339 | out.authors = raw.authors.map(validateAuthor) 340 | } else { 341 | panic("The `author` or `authors` must be a array, dictionary or string, got:", type(raw.authors)) 342 | } 343 | } else { 344 | out.authors = () 345 | } 346 | 347 | let affiliations = pickAffiliationsObject(raw); 348 | if (affiliations != none) { out.affiliations = affiliations } else { out.affiliations = () } 349 | 350 | let open-access = validateBoolean(raw, "open-access", alias: ("open_access", "openAccess",)) 351 | if (open-access != none) { out.open-access = open-access } 352 | let venue = validateString(raw, "venue") 353 | if (venue != none) { out.venue = venue } 354 | let subject = validateString(raw, "subject") 355 | if (subject != none) { out.subject = subject } 356 | let license = validateLicense(raw) 357 | if (license != none) { out.license = license } 358 | let doi = validateString(raw, "doi") 359 | if (doi != none) { 360 | assert(not doi.starts-with("http"), message: "DOIs should not include the link, use only the part after `https://doi.org/[]`") 361 | out.doi = doi 362 | } 363 | 364 | if ("date" in raw) { 365 | out.date = validateDate(raw, "date"); 366 | } else { 367 | out.date = datetime.today() 368 | } 369 | let citation = validateString(raw, "citation") 370 | 371 | if (citation != none) { 372 | out.citation = citation; 373 | } else { 374 | out.citation = show-citation(out) 375 | } 376 | 377 | if ("abstract" in raw and "abstracts" in raw) { 378 | panic("You can only use `abstract` or `abstracts`, not both") 379 | } 380 | if ("abstract" in raw) { 381 | raw.abstracts = raw.abstract 382 | } 383 | if ("abstracts" in raw) { 384 | if (type(raw.abstracts) == str or type(raw.abstracts) == content) { 385 | raw.abstracts = (content: raw.abstracts) 386 | } 387 | if (type(raw.abstracts) == dictionary) { 388 | if ("title" not in raw.abstracts) { 389 | raw.abstracts.title = "Abstract" 390 | } 391 | raw.abstracts = (raw.abstracts,) 392 | } 393 | if (type(raw.abstracts) == array) { 394 | // validate each entry 395 | out.abstracts = raw.abstracts.map((abs) => { 396 | if (type(abs) != dictionary or "title" not in abs or "content" not in abs) { 397 | return 398 | } 399 | return (title: abs.at("title"), content: abs.at("content")) 400 | }) 401 | } else { 402 | panic("The `abstract` or `abstracts` must be content, or an array, got:", type(raw.abstracts)) 403 | } 404 | } 405 | 406 | let keywords = validateArray(raw, "keywords") 407 | if (keywords != none) { 408 | out.keywords = keywords.map((k) => validateString((keyword: k), "keyword")) 409 | } 410 | 411 | let consolidated = consolidateAffiliations(out.authors, out.affiliations) 412 | out.authors = consolidated.authors 413 | out.affiliations = consolidated.affiliations 414 | 415 | return out 416 | } 417 | 418 | -------------------------------------------------------------------------------- /pubmatter.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/scienceicons:0.1.0": orcid-icon, email-icon, open-access-icon, github-icon, cc-icon, cc-zero-icon, cc-by-icon, cc-nc-icon, cc-nd-icon, cc-sa-icon, ror-icon 2 | #import "./validate-frontmatter.typ": load, show-citation 3 | 4 | #let THEME = state("THEME", (color: blue.darken(20%), font: "")) 5 | 6 | #let with-theme(func) = context { 7 | let theme = THEME.at(here()) 8 | func(theme) 9 | } 10 | 11 | /// Create a ORCID link with an ORCID logo 12 | /// 13 | /// ```example 14 | /// #pubmatter.orcid-link(orcid: "0000-0002-7859-8394") 15 | /// ``` 16 | /// 17 | /// - orcid (str): Use an ORCID identifier with no URL, e.g. `0000-0000-0000-0000` 18 | /// -> content 19 | #let orcid-link( 20 | orcid: none, 21 | ) = { 22 | let orcid-green = rgb("#AECD54") 23 | if (orcid == none) { return orcid-icon(color: orcid-green) } 24 | if (orcid.starts-with("https://")) { return link(orcid, orcid-icon(color: orcid-green)) } 25 | return link("https://orcid.org/" + orcid, orcid-icon(color: orcid-green)) 26 | } 27 | 28 | /// Create a DOI link 29 | /// 30 | /// ```example 31 | /// #pubmatter.doi-link(doi: "10.1190/tle35080703.1") 32 | /// ``` 33 | /// 34 | /// - doi (str): Only include the DOI identifier, not the URL 35 | /// -> content 36 | #let doi-link(doi: none) = { 37 | if (doi == none) { return none } 38 | // Proper practices are to show the whole DOI link in text 39 | if (doi.starts-with("https://")) { return link(doi, doi) }; 40 | return link("https://doi.org/" + doi, "https://doi.org/" + doi) 41 | } 42 | 43 | /// Create a ROR link 44 | /// 45 | /// ```example 46 | /// #pubmatter.ror-link(ror: "02mz0e468") 47 | /// ``` 48 | /// 49 | /// - ror (str): Only include the ROR identifier, not the URL 50 | /// -> content 51 | #let ror-link(ror: none) = { 52 | let ror-black = rgb("#2c2c2c") 53 | if (ror == none) { return none } 54 | if (ror.starts-with("https://")) { return link(ror, ror-icon(color: ror-black)) }; 55 | return link("https://ror.org/" + ror, ror-icon(color: ror-black)) 56 | } 57 | 58 | /// Create a mailto link with an email icon 59 | /// 60 | /// ```example 61 | /// #pubmatter.email-link(email: "rowan@curvenote.com") 62 | /// ``` 63 | /// 64 | /// - email (str): Email as a string 65 | /// -> content 66 | #let email-link(email: none) = { 67 | if (email == none) { return none } 68 | return link("mailto:" + email, email-icon(color: gray)) 69 | } 70 | 71 | /// Create a link to Wikipedia with an OpenAccess icon. 72 | /// 73 | /// ```example 74 | /// #pubmatter.open-access-link() 75 | /// ``` 76 | /// 77 | /// -> content 78 | #let open-access-link() = { 79 | let orange = rgb("#E78935") 80 | return link("https://en.wikipedia.org/wiki/Open_access", open-access-icon(color: orange)) 81 | } 82 | 83 | 84 | /// Create a link to a GitHub profile with the GitHub icon. 85 | /// 86 | /// ```example 87 | /// #pubmatter.github-link(github: "rowanc1") 88 | /// ``` 89 | /// 90 | /// - github (str): GitHub username (no `@`) 91 | /// -> content 92 | #let github-link(github: none) = { 93 | if (github.starts-with("https://")) { return link(github, github-icon()) } 94 | return link("https://github.com/" + github, github-icon()) 95 | } 96 | 97 | 98 | /// Create a spaced content array separated with a `spacer`. 99 | /// 100 | /// The default spacer is ` | `, and undefined elements are removed. 101 | /// 102 | /// ```example 103 | /// #pubmatter.show-spaced-content(("Hello", "There")) 104 | /// ``` 105 | /// 106 | /// - spacer (content): How to join the content 107 | /// - content (array): The various things to going together 108 | /// -> content 109 | #let show-spaced-content(spacer: text(fill: gray)[#h(8pt) | #h(8pt)], content) = { 110 | content.filter(h => h != none and h != "").join(spacer) 111 | } 112 | 113 | 114 | /// Show license badge 115 | /// 116 | /// Works for creative common license and other license. 117 | /// 118 | /// ```example 119 | /// #pubmatter.show-license-badge(pubmatter.load((license: "CC0"))) 120 | /// ``` 121 | /// 122 | /// ```example 123 | /// #pubmatter.show-license-badge(pubmatter.load((license: "CC-BY-4.0"))) 124 | /// ``` 125 | /// 126 | /// ```example 127 | /// #pubmatter.show-license-badge(pubmatter.load((license: "CC-BY-NC-4.0"))) 128 | /// ``` 129 | /// 130 | /// ```example 131 | /// #pubmatter.show-license-badge(pubmatter.load((license: "CC-BY-NC-ND-4.0"))) 132 | /// ``` 133 | /// 134 | /// - fm (fm): The frontmatter object 135 | /// -> content 136 | #let show-license-badge(color: black, fm) = { 137 | let license = if ("license" in fm) { fm.license } 138 | if (license == none) { return none } 139 | if (license.id == "CC0-1.0") { 140 | return link(license.url, [#cc-icon(color: color)#cc-zero-icon(color: color)]) 141 | } 142 | if (license.id == "CC-BY-4.0") { 143 | return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)]) 144 | } 145 | if (license.id == "CC-BY-NC-4.0") { 146 | return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)#cc-nc-icon(color: color)]) 147 | } 148 | if (license.id == "CC-BY-NC-SA-4.0") { 149 | return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)#cc-nc-icon(color: color)]) 150 | } 151 | if (license.id == "CC-BY-ND-4.0") { 152 | return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)#cc-nd-icon(color: color)]) 153 | } 154 | if (license.id == "CC-BY-NC-ND-4.0") { 155 | return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)#cc-nc-icon(color: color)#cc-nd-icon(color: color)]) 156 | } 157 | } 158 | 159 | /// Show copyright 160 | /// 161 | /// Function chose a short citation with the copyright year followed by the license text. 162 | /// If the license is a Creative Commons License, additional explainer text is shown. 163 | /// 164 | /// ```example 165 | /// #pubmatter.show-copyright(fm) 166 | /// ``` 167 | /// 168 | /// - fm (fm): The frontmatter object 169 | /// -> content 170 | #let show-copyright(fm) = { 171 | let year = if (fm.date != none) { fm.date.display("[year]") } 172 | let citation = show-citation(show-year: false, fm) 173 | let license = if ("license" in fm) { fm.license } 174 | if (license == none) { 175 | return [Copyright © #{ year } 176 | #citation#{if (fm.at("open-access", default: none) == true){[. This article is open-access.]}}] 177 | } 178 | return [Copyright © #{ year } 179 | #citation. 180 | This #{if (fm.at("open-access", default: none) == true){[is an open-access article]} else {[article is]}} distributed under the terms of the 181 | #link(license.url, license.name) license#{ 182 | if (license.id == "CC-BY-4.0") { 183 | [, which enables reusers to distribute, remix, adapt, and build upon the material in any medium or format, so long as attribution is given to the creator] 184 | } else if (license.id == "CC-BY-NC-4.0") { 185 | [, which enables reusers to distribute, remix, adapt, and build upon the material in any medium or format for _noncommercial purposes only_, and only so long as attribution is given to the creator] 186 | } else if (license.id == "CC-BY-NC-SA-4.0") { 187 | [, which enables reusers to distribute, remix, adapt, and build upon the material in any medium or format for noncommercial purposes only, and only so long as attribution is given to the creator. If you remix, adapt, or build upon the material, you must license the modified material under identical terms] 188 | } else if (license.id == "CC-BY-ND-4.0") { 189 | [, which enables reusers to copy and distribute the material in any medium or format in _unadapted form only_, and only so long as attribution is given to the creator] 190 | } else if (license.id == "CC-BY-NC-ND-4.0") { 191 | [, which enables reusers to copy and distribute the material in any medium or format in _unadapted form only_, for _noncommercial purposes only_, and only so long as attribution is given to the creator] 192 | } 193 | }.] 194 | } 195 | 196 | /// Get corresponding author 197 | /// 198 | /// Returns the first author marked as corresponding author, or the first author with an email. 199 | /// 200 | /// ```example 201 | /// #let author = pubmatter.get-corresponding-author(authors) 202 | /// ``` 203 | /// 204 | /// - authors (fm, array): The frontmatter object or authors directly 205 | /// -> dictionary 206 | #let get-corresponding-author(authors) = { 207 | // Allow to pass frontmatter as well 208 | let authors = if (type(authors) == dictionary and "authors" in authors) {authors.authors} else { authors } 209 | if authors.len() == 0 { return none } 210 | 211 | // First, look for an author with corresponding: true 212 | for author in authors { 213 | if ("corresponding" in author and author.corresponding == true) { 214 | return author 215 | } 216 | } 217 | 218 | // If none found, look for first author with email that doesn't have corresponding: false 219 | for author in authors { 220 | if ("email" in author and ("corresponding" not in author or author.corresponding != false)) { 221 | return author 222 | } 223 | } 224 | 225 | return none 226 | } 227 | 228 | /// Show authors 229 | /// 230 | /// ```example 231 | /// #pubmatter.show-authors(authors) 232 | /// ``` 233 | /// 234 | /// - size (length): Size of the author text 235 | /// - weight (weight): Weight of the author text 236 | /// - show-affiliations (boolean): Show affiliations text 237 | /// - show-orcid (boolean): Show orcid logo 238 | /// - show-email (boolean): Show email logo 239 | /// - show-github (boolean): Show github logo 240 | /// - show-equal-contributor (boolean): Show equal contributor asterisk 241 | /// - authors (fm, array): The frontmatter object or authors directly 242 | /// -> content 243 | #let show-authors( 244 | size: 10pt, 245 | weight: "semibold", 246 | show-affiliations: true, 247 | show-orcid: true, 248 | show-email: true, 249 | show-github: true, 250 | show-equal-contributor: true, 251 | authors, 252 | ) = { 253 | // Allow to pass frontmatter as well 254 | let authors = if (type(authors) == dictionary and "authors" in authors) {authors.authors} else { authors } 255 | if authors.len() == 0 { return none } 256 | 257 | return box(inset: (top: 10pt, bottom: 5pt), width: 100%, { 258 | with-theme((theme) => { 259 | set text(size, font: theme.font) 260 | authors.map(author => { 261 | text(size, font: theme.font, weight: weight, author.name) 262 | if (show-affiliations and "affiliations" in author) { 263 | text(size: 2.5pt, [~]) // Ensure this is not a linebreak 264 | if (type(author.affiliations) == str) { 265 | super(author.affiliations) 266 | } else if (type(author.affiliations) == array) { 267 | super(author.affiliations.map((affiliation) => str(affiliation.index)).join(",")) 268 | } 269 | if (show-equal-contributor and "equal-contributor" in author and author.equal-contributor) { 270 | super("†") 271 | } 272 | } 273 | if (show-orcid and "orcid" in author) { 274 | orcid-link(orcid: author.orcid) 275 | } 276 | if (show-github and "github" in author) { 277 | github-link(github: author.github) 278 | } 279 | if (show-email and "email" in author) { 280 | email-link(email: author.email) 281 | } 282 | }).join(", ", last: ", and ") 283 | }) 284 | }) 285 | } 286 | 287 | 288 | /// Show affiliations 289 | /// 290 | /// ```example 291 | /// #pubmatter.show-affiliations(affiliations) 292 | /// ``` 293 | /// 294 | /// - size (length): Size of the affiliations text 295 | /// - fill (color): Color of of the affiliations text 296 | /// - show-ror (boolean): Show ror logo 297 | /// - show-equal-contributor (boolean): Show equal contributor note 298 | /// - separator (str): Separator between affiliations 299 | /// - affiliations (fm, array): The frontmatter object or affiliations directly 300 | /// -> content 301 | #let show-affiliations( 302 | size: 8pt, 303 | fill: gray.darken(50%), 304 | show-ror: true, 305 | show-equal-contributor: true, 306 | separator: ", ", 307 | affiliations 308 | ) = { 309 | // Allow to pass frontmatter as well 310 | let fm = affiliations 311 | let affiliations = if (type(affiliations) == dictionary and "affiliations" in affiliations) {affiliations.affiliations} else { affiliations } 312 | if affiliations.len() == 0 { return none } 313 | 314 | // Check if any author has equal-contributor 315 | let has-equal-contributor = false 316 | if (show-equal-contributor and type(fm) == dictionary and "authors" in fm) { 317 | has-equal-contributor = fm.authors.any(author => "equal-contributor" in author and author.equal-contributor) 318 | } 319 | 320 | return box(inset: (bottom: 9pt), width: 100%, { 321 | with-theme((theme) => { 322 | set text(size, font: theme.font, fill: fill) 323 | affiliations.map(affiliation => { 324 | super(str(affiliation.index)) 325 | text(size: 2.5pt, [~]) // Ensure this is not a linebreak 326 | if ("name" in affiliation) { 327 | affiliation.name 328 | } else if ("institution" in affiliation) { 329 | affiliation.institution 330 | } 331 | if ("ror" in affiliation) { 332 | text(size: 8pt, [~]) // Ensure this is not a linebreak 333 | ror-link(ror: affiliation.ror) 334 | } 335 | }).join(separator) 336 | 337 | if (has-equal-contributor) { 338 | "; " 339 | super("†") 340 | text(size: 2.5pt, [~]) // Ensure this is not a linebreak 341 | [Contributed Equally] 342 | } 343 | }) 344 | }) 345 | } 346 | 347 | 348 | /// Show author block, including author, icon links (e.g. ORCID, email, etc.) and affiliations 349 | /// 350 | /// ```example 351 | /// #pubmatter.show-author-block(fm) 352 | /// ``` 353 | /// 354 | /// - fm (fm): The frontmatter object 355 | /// -> content 356 | #let show-author-block(fm) = { 357 | show-authors(fm) 358 | show-affiliations(fm) 359 | } 360 | 361 | /// Show title and subtitle 362 | /// 363 | /// ```example 364 | /// #pubmatter.show-title(fm) 365 | /// ``` 366 | /// 367 | /// - fm (fm): The frontmatter object 368 | /// -> content 369 | #let show-title(fm) = { 370 | with-theme(theme => { 371 | set text(font: theme.font) 372 | let title = if (type(fm) == dictionary and "title" in fm) {fm.title} else if (type(fm) == str or type(fm) == content) { fm } else { none } 373 | let subtitle = if (type(fm) == dictionary and "subtitle" in fm) {fm.subtitle} else { none } 374 | if (title != none) { 375 | box(inset: (bottom: 2pt), width: 100%, text(17pt, weight: "bold", fill: theme.color, title)) 376 | } 377 | if (subtitle != none) { 378 | parbreak() 379 | box(width: 100%, text(12pt, fill: gray.darken(30%), subtitle)) 380 | } 381 | }) 382 | } 383 | 384 | /// Show title block - title, authors and affiliations 385 | /// 386 | /// ```example 387 | /// #pubmatter.show-title-block(fm) 388 | /// ``` 389 | /// 390 | /// - fm (fm): The frontmatter object 391 | /// -> content 392 | #let show-title-block(fm) = { 393 | with-theme(theme => { 394 | show-title(fm) 395 | show-author-block(fm) 396 | }) 397 | } 398 | 399 | /// Show page footer 400 | /// 401 | /// Default is the venue, date and page numbers 402 | /// 403 | /// ```example 404 | /// #pubmatter.show-page-footer(fm) 405 | /// ``` 406 | /// 407 | /// - fm (fm): The frontmatter object 408 | /// -> content 409 | #let show-page-footer(fm) = { 410 | return block( 411 | width: 100%, 412 | stroke: (top: 1pt + gray), 413 | inset: (top: 8pt, right: 2pt), 414 | with-theme((theme) => [ 415 | #set text(font: theme.font) 416 | #grid(columns: (75%, 25%), 417 | align(left, text(size: 9pt, fill: gray.darken(50%), 418 | show-spaced-content(( 419 | if("venue" in fm) {emph(fm.venue)}, 420 | if("date" in fm and fm.date != none) {fm.date.display("[month repr:long] [day], [year]")} 421 | )) 422 | )), 423 | align(right)[ 424 | #text( 425 | size: 9pt, fill: gray.darken(50%) 426 | )[ 427 | #counter(page).display() of #{context {counter(page).final().first()}} 428 | ] 429 | ] 430 | ) 431 | ]) 432 | ) 433 | } 434 | 435 | /// Show page header 436 | /// 437 | /// Default an open-access badge and the DOI and then the running-title and citation 438 | /// 439 | /// ```example 440 | /// #pubmatter.show-page-header(fm) 441 | /// ``` 442 | /// 443 | /// - fm (fm): The frontmatter object 444 | /// -> content 445 | #let show-page-header(fm) = context { 446 | let loc = here() 447 | if(loc.page() == 1) { 448 | let headers = ( 449 | if ("open-access" in fm) {[#smallcaps[Open Access] #open-access-link()]}, 450 | if ("doi" in fm) { link("https://doi.org/" + fm.doi, "https://doi.org/" + fm.doi)} 451 | ) 452 | // TODO: There is a bug in the first page state update 453 | // https://github.com/typst/typst/issues/2987 454 | return with-theme((theme) => { 455 | align(left, text(size: 8pt, font: theme.font, fill: gray, show-spaced-content(headers))) 456 | }) 457 | } else { 458 | return with-theme((theme) => {align(right + top, box(inset: (top: 1cm), text(size: 8pt, font: theme.font, fill: gray.darken(50%), 459 | show-spaced-content(( 460 | if ("short-title" in fm) { fm.short-title } else if ("title" in fm) { fm.title }, 461 | if ("citation" in fm) { fm.citation }, 462 | ))) 463 | )) 464 | }) 465 | } 466 | } 467 | 468 | /// Show all abstracts (e.g. abstract, plain language summary) 469 | /// 470 | /// ```example 471 | /// #pubmatter.show-abstracts(fm) 472 | /// ``` 473 | /// 474 | /// - fm (fm): The frontmatter object 475 | /// -> content 476 | #let show-abstracts(fm) = { 477 | let abstracts 478 | if (type(fm) == content) { 479 | abstracts = ((title: "Abstract", content: fm),) 480 | } else if (type(fm) == dictionary and "abstracts" in fm) { 481 | abstracts = fm.abstracts 482 | } else { 483 | return 484 | } 485 | 486 | with-theme((theme) => { 487 | abstracts.map(abs => { 488 | set text(font: theme.font) 489 | text(fill: theme.color, weight: "semibold", size: 9pt, abs.title) 490 | parbreak() 491 | set par(justify: true) 492 | text(size: 9pt, abs.content) 493 | }).join(parbreak()) 494 | }) 495 | } 496 | 497 | /// Show keywords 498 | /// 499 | /// ```example 500 | /// #pubmatter.show-keywords(fm) 501 | /// ``` 502 | /// 503 | /// - fm (fm): The frontmatter object 504 | /// -> content 505 | #let show-keywords(fm) = { 506 | let keywords 507 | if (type(fm) == dictionary and "keywords" in fm) { 508 | keywords = fm.keywords 509 | } else { 510 | return 511 | } 512 | if (keywords.len() > 0) { 513 | with-theme((theme) => { 514 | text(size: 9pt, font: theme.font, { 515 | text(fill: theme.color, weight: "semibold", "Keywords") 516 | h(8pt) 517 | keywords.join(", ") 518 | }) 519 | }) 520 | } 521 | } 522 | 523 | /// Show abstract-block including all abstracts and keywords 524 | /// 525 | /// ```example 526 | /// #pubmatter.show-abstract-block(fm) 527 | /// ``` 528 | /// 529 | /// - fm (fm): The frontmatter object 530 | /// -> content 531 | #let show-abstract-block(fm) = { 532 | box(inset: (top: 16pt, bottom: 16pt), stroke: (top: 0.5pt + gray.lighten(30%), bottom: 0.5pt + gray.lighten(30%)), show-abstracts(fm)) 533 | show-keywords(fm) 534 | v(10pt) 535 | } 536 | --------------------------------------------------------------------------------