pubmarq 0.1.0__py3-none-any.whl

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 (61) hide show
  1. pubmarq/__init__.py +45 -0
  2. pubmarq/__main__.py +6 -0
  3. pubmarq/article.py +600 -0
  4. pubmarq/build.py +212 -0
  5. pubmarq/check.py +221 -0
  6. pubmarq/cli.py +164 -0
  7. pubmarq/emblems/umg-pl.svg +53 -0
  8. pubmarq/faces/CascadiaCode/CascadiaCode-Bold.ttf +0 -0
  9. pubmarq/faces/CascadiaCode/CascadiaCode-BoldItalic.ttf +0 -0
  10. pubmarq/faces/CascadiaCode/CascadiaCode-Italic.ttf +0 -0
  11. pubmarq/faces/CascadiaCode/CascadiaCode-Regular.ttf +0 -0
  12. pubmarq/faces/IBMPlexMono/IBMPlexMono-Bold.ttf +0 -0
  13. pubmarq/faces/IBMPlexMono/IBMPlexMono-BoldItalic.ttf +0 -0
  14. pubmarq/faces/IBMPlexMono/IBMPlexMono-Italic.ttf +0 -0
  15. pubmarq/faces/IBMPlexMono/IBMPlexMono-Regular.ttf +0 -0
  16. pubmarq/faces/OFL-CascadiaCode.txt +88 -0
  17. pubmarq/faces/OFL-IBMPlex.txt +88 -0
  18. pubmarq/faces/OFL-OpenSans.txt +88 -0
  19. pubmarq/faces/OFL-Sora.txt +88 -0
  20. pubmarq/faces/OFL-SourceSerif4.txt +88 -0
  21. pubmarq/faces/OFL-Tinos.txt +88 -0
  22. pubmarq/faces/OpenSans/OpenSans-Bold.ttf +0 -0
  23. pubmarq/faces/OpenSans/OpenSans-BoldItalic.ttf +0 -0
  24. pubmarq/faces/OpenSans/OpenSans-Italic.ttf +0 -0
  25. pubmarq/faces/OpenSans/OpenSans-Regular.ttf +0 -0
  26. pubmarq/faces/OpenSansCondensed/OpenSansCondensed-Bold.ttf +0 -0
  27. pubmarq/faces/OpenSansCondensed/OpenSansCondensed-BoldItalic.ttf +0 -0
  28. pubmarq/faces/OpenSansCondensed/OpenSansCondensed-Italic.ttf +0 -0
  29. pubmarq/faces/OpenSansCondensed/OpenSansCondensed-Regular.ttf +0 -0
  30. pubmarq/faces/OpenSansSemiBold/OpenSansSemiBold-Bold.ttf +0 -0
  31. pubmarq/faces/OpenSansSemiBold/OpenSansSemiBold-BoldItalic.ttf +0 -0
  32. pubmarq/faces/OpenSansSemiBold/OpenSansSemiBold-Italic.ttf +0 -0
  33. pubmarq/faces/OpenSansSemiBold/OpenSansSemiBold-Regular.ttf +0 -0
  34. pubmarq/faces/Sora/Sora-Bold.ttf +0 -0
  35. pubmarq/faces/Sora/Sora-Regular.ttf +0 -0
  36. pubmarq/faces/SourceSerif4/SourceSerif4-Bold.ttf +0 -0
  37. pubmarq/faces/SourceSerif4/SourceSerif4-BoldItalic.ttf +0 -0
  38. pubmarq/faces/SourceSerif4/SourceSerif4-Italic.ttf +0 -0
  39. pubmarq/faces/SourceSerif4/SourceSerif4-Regular.ttf +0 -0
  40. pubmarq/faces/Tinos/Tinos-Bold.ttf +0 -0
  41. pubmarq/faces/Tinos/Tinos-BoldItalic.ttf +0 -0
  42. pubmarq/faces/Tinos/Tinos-Italic.ttf +0 -0
  43. pubmarq/faces/Tinos/Tinos-Regular.ttf +0 -0
  44. pubmarq/fonts.py +79 -0
  45. pubmarq/journal.py +348 -0
  46. pubmarq/journals.py +285 -0
  47. pubmarq/langs.py +82 -0
  48. pubmarq/layout.py +423 -0
  49. pubmarq/pages.py +30 -0
  50. pubmarq/refs.py +149 -0
  51. pubmarq/thesis_docx.py +414 -0
  52. pubmarq/thesis_pdf.py +422 -0
  53. pubmarq/to_docx.py +427 -0
  54. pubmarq/to_pdf.py +858 -0
  55. pubmarq/typography.py +69 -0
  56. pubmarq-0.1.0.dist-info/METADATA +153 -0
  57. pubmarq-0.1.0.dist-info/RECORD +61 -0
  58. pubmarq-0.1.0.dist-info/WHEEL +5 -0
  59. pubmarq-0.1.0.dist-info/entry_points.txt +2 -0
  60. pubmarq-0.1.0.dist-info/licenses/license +21 -0
  61. pubmarq-0.1.0.dist-info/top_level.txt +1 -0
pubmarq/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ # pubmarq/__init__.py
2
+
3
+ """
4
+ Scientific articles and theses from markdown, set for a journal or a school:
5
+ PDF through pdfmarq, DOCX through docmarq.
6
+
7
+ The article says what it is, a journal profile how it looks,
8
+ so one source goes to any journal pubmarq has a profile for.
9
+
10
+ Example:
11
+ >>> from pubmarq import build
12
+ >>> build("paper.md", "ijet", out_dir="out").files
13
+ """
14
+
15
+ #--------------------------------------------------------------------------- Metadata for auto-toml
16
+
17
+ __version__ = "0.1.0"
18
+ __repo__ = "Xaeian/PubMarQ"
19
+ __python__ = ">=3.12" # as xaeian
20
+ __description__ = "Scientific articles and theses from markdown, set for a journal as PDF and DOCX"
21
+ __author__ = "Xaeian"
22
+ __keywords__ = ["journal", "article", "thesis", "markdown", "pdf", "docx", "ieee"]
23
+ __dependencies__ = [
24
+ "docmarq[md]>=0.5.1", "pdfmarq[md]>=0.7.1", "markdown-it-py", "Pillow", "xaeian>=1.2.2",
25
+ "rlPyCairo", # docmarq's, named so CI installs the system cairo pycairo builds against
26
+ ]
27
+ __scripts__ = {"pubmarq": "pubmarq.cli:main"}
28
+
29
+ #--------------------------------------------------------------------------------------- Public API
30
+
31
+ from .langs import Lang, LANGS
32
+ from .journal import Foot, Head, Journal, RunIn, School, Type
33
+ from .journals import JOURNALS, get_journal
34
+ from .article import Article, Parser, read
35
+ from .check import Issue, check
36
+ from .to_docx import write_docx
37
+ from .to_pdf import write_pdf
38
+ from .build import Result, build, preflight, read_md, read_file
39
+
40
+ __all__ = [
41
+ "Lang", "LANGS", "Foot", "Head", "RunIn", "School", "Type", "Journal", "JOURNALS", "get_journal",
42
+ "Article", "Parser", "read", "Issue", "check",
43
+ "write_docx", "write_pdf",
44
+ "Result", "build", "preflight", "read_md", "read_file",
45
+ ]
pubmarq/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # pubmarq/__main__.py
2
+
3
+ import sys
4
+ from .cli import main
5
+
6
+ sys.exit(main())
pubmarq/article.py ADDED
@@ -0,0 +1,600 @@
1
+ # pubmarq/article.py
2
+
3
+ """
4
+ An article read from markdown: its parts, in the order and with the numbers a journal sets.
5
+
6
+ `read` takes the text and an engine's `Parser`, and resolves for one journal:
7
+ abstracts and keywords, heading numbers, figures and tables with their captions,
8
+ citations as reference numbers, mentions of figures, tables and labelled formulas, typography.
9
+ Writers draw the blocks, and nothing here knows Word, PDF or an engine.
10
+
11
+ Example:
12
+ >>> article = read(open("paper.md").read(), get_journal("ijet"), parser)
13
+ >>> [b.kind for b in article.blocks][:3]
14
+ ['heading', 'para', 'para']
15
+ """
16
+ import re
17
+ from dataclasses import dataclass, field, replace
18
+ from typing import Callable
19
+ from markdown_it.token import Token
20
+ from .journal import Journal
21
+ from .langs import LANGS
22
+ from .refs import entries, format_ref, has_doi, inline, text
23
+ from .typography import EN_DASH, NBSP, bind, counter, roman, smarten
24
+
25
+ # The names a source is read by, in every language: a heading or an opening, to its language
26
+ _ABSTRACT = {lang.abstract.lower(): key for key, lang in LANGS.items()}
27
+ _KEYWORD_NAMES = {name: key for key, lang in LANGS.items()
28
+ for name in (lang.keywords.lower(), *lang.keyword_names)}
29
+ _KEYWORDS = re.compile(rf"\s*({'|'.join(map(re.escape, _KEYWORD_NAMES))})\s*:?\s*(.*)",
30
+ re.I | re.S)
31
+ _TABLES = "|".join(lang.table for lang in LANGS.values())
32
+ _FIGURES = "|".join(lang.figure for lang in LANGS.values())
33
+ _NUMBER = r"\d+(?:\.\d+)*" # of a figure or table as a source writes it: `3`, or `2.1` by chapter
34
+ _CAPTION = re.compile(
35
+ rf"(?:(?P<table>{_TABLES})|(?P<figure>{_FIGURES}))\s*(?P<number>{_NUMBER})\.?", re.I)
36
+ # `Table 2`, `Tables 1 and 3`, `Figs. 2-4`, `Rys. 1 i 2`: the word, and the numbers after it
37
+ _TABLE_NAMES = "|".join(lang.table_mention for lang in LANGS.values())
38
+ _FIGURE_NAMES = "|".join(lang.figure_mention for lang in LANGS.values())
39
+ _JOINS = "|".join(word for lang in LANGS.values() for word in lang.joins)
40
+ _LIST = (rf"{_NUMBER}(?:(?:\s*,\s*(?:(?:{_JOINS})\s+)?|\s+(?:{_JOINS}|&)\s+|\s*[-–]\s*)"
41
+ rf"{_NUMBER})*")
42
+ _MENTION = re.compile(
43
+ rf"\b(?P<word>(?P<table>{_TABLE_NAMES})|(?P<figure>{_FIGURE_NAMES}))\s+(?P<list>{_LIST})\b")
44
+ # A formula named as LaTeX names it, `\label{ohm}` in its `$$`, and mentioned so in the text,
45
+ # in `$...$` or out of it: `\eqref{ohm}` its number in brackets, `\ref{ohm}` the number alone
46
+ _LABEL = re.compile(r"\\label\{([^{}]+)\}")
47
+ _EQREF = re.compile(r"\\(eq)?ref\{([^{}]+)\}")
48
+ # Chapters of a thesis named as lists of figures or tables, in any language, which list them
49
+ _LISTS = {"figure": re.compile("|".join(lang.figure_list for lang in LANGS.values()), re.I),
50
+ "table": re.compile("|".join(lang.table_list for lang in LANGS.values()), re.I)}
51
+ _RANGE = re.compile(r"\s*[-–]\s*")
52
+ _DROPPED = re.compile("(" + "|".join(name for lang in LANGS.values() for name in lang.refs) + ")",
53
+ re.I)
54
+ _ACK = re.compile("(" + "|".join(lang.ack for lang in LANGS.values()) + ")", re.I)
55
+ # `published: IJET 2027, 73(1), 245-250`: journal, year, volume(issue), pages, as written
56
+ _PUBLISHED = re.compile(r"(?P<journal>.+?)\s+(?P<year>\S+),\s*(?P<volume>[^\s(,]+)"
57
+ r"\((?P<issue>[^)]*)\),\s*(?P<pages>\S+)")
58
+ _ANONYMOUS = re.compile(r"Anonymous(?:(?:,? and |, )Anonymous)+") # authors of one reference
59
+ _BREAKS = ("softbreak", "hardbreak") # where a line of the source ends within a paragraph
60
+
61
+ @dataclass(frozen=True)
62
+ class Parser:
63
+ """What reading takes from an engine: its markdown, as the engine reads it."""
64
+ parse: Callable[[str], list[Token]] # `MarkdownRenderer.parse`
65
+ blocks: Callable[[list[Token]], list[tuple[int, int]]] # `top_blocks`
66
+ frontmatter: Callable[[str], tuple[dict, str]] # `split_frontmatter`
67
+ people: Callable[[object], list[dict]] # `people`
68
+ group_options: Callable[[str], list[str]] # `group_options`
69
+
70
+ @dataclass
71
+ class Author:
72
+ name: str
73
+ aff: tuple = () # keys of `Article.affiliations`
74
+ email: str = ""
75
+ orcid: str = ""
76
+ corresponding: bool = False # the one to write to, `corresponding: true` in the frontmatter
77
+
78
+ @dataclass
79
+ class Block:
80
+ """One part a writer draws: the tokens an engine draws, and what pubmarq sets around them."""
81
+ # One of heading, para, figure, table, math, raw, wide, narrow, pages of a PDF set in,
82
+ # and abstract, keywords, authors, refs, summary where the layout's `flow` puts them
83
+ kind: str
84
+ tokens: list = field(default_factory=list)
85
+ text: Token|None = None # inline text of a heading or a caption
86
+ label: str = "" # `II.`, `Fig. 3.`, `TABLE II`
87
+ level: int = 0 # of a heading, from 1
88
+ first: bool = False # the paragraph a drop cap opens
89
+ ack: bool = False # part of the acknowledgment, which a blind copy leaves out
90
+ number: str = "" # the author's own number of a figure or table, as written: `3`, `2.1`
91
+ n: int = 0 # its number in reading order
92
+ lang: str = "" # of an abstract a thesis sets on a page of its own at the back
93
+
94
+ @dataclass
95
+ class Ref:
96
+ number: int
97
+ text: Token # inline, set in the journal's style
98
+ doi: bool
99
+
100
+ @dataclass
101
+ class Article:
102
+ title: str
103
+ short_title: str
104
+ authors: list
105
+ affiliations: dict
106
+ funding: str
107
+ abstracts: dict # language → inline tokens, one per paragraph
108
+ keywords: dict # language → words
109
+ blocks: list
110
+ refs: list
111
+ published: dict # `journal`, `year`, `volume`, `issue`, `pages` and `doi` known so far
112
+ meta: dict # the frontmatter as written
113
+ facts: dict # what reading noticed, for the preflight
114
+ lang: str = "en" # language it is written in, as `lang:` says, else its journal's
115
+
116
+ def title_in(self, lang:str) -> str:
117
+ """The title in `lang`: `title` in its own language, else `title_<lang>` of the frontmatter."""
118
+ return self.title if lang == self.lang else str(self.meta.get(f"title_{lang}") or "")
119
+
120
+ def blind(self) -> "Article":
121
+ """
122
+ The article for blind review: no authors, affiliations, funding or acknowledgment,
123
+ and in the references `Anonymous` for the names of its authors.
124
+ """
125
+ names = re.compile("|".join(_name(a.name) for a in self.authors)) if self.authors else None
126
+ return replace(self, authors=[], affiliations={}, funding="",
127
+ blocks=[b for b in self.blocks if not b.ack],
128
+ refs=[replace(r, text=_unnamed(r.text, names)) for r in self.refs] if names else self.refs)
129
+
130
+ def plain(inline:Token, math:bool=False) -> str:
131
+ """
132
+ Words of an inline token, code among them, markup dropped, formulas as written by `math`,
133
+ a space where a line of the source ends.
134
+ """
135
+ kinds = ("text", "code_inline", "math_inline") if math else ("text", "code_inline")
136
+ return "".join(" " if c.type in _BREAKS else c.content for c in inline.children or []
137
+ if c.type in kinds + _BREAKS)
138
+
139
+ #--------------------------------------------------------------------------------------------- Read
140
+
141
+ def read(md_text:str, journal:Journal, parser:Parser) -> Article:
142
+ """
143
+ The article `md_text` holds, resolved for `journal`, its tokens from an engine's `parser`,
144
+ in the language its frontmatter's `lang:` names, else the journal's.
145
+ """
146
+ fm, _ = parser.frontmatter(md_text)
147
+ journal = journal.speaking(fm.get("lang"))
148
+ tokens = parser.parse(md_text)
149
+ title = str(fm.get("title") or "")
150
+ facts = {"uncited": [], "missing": [], "renumbered": [], "mentioned": set(), "images": [],
151
+ "plain": 0, "bold": 0, "labels": {}, "referred": set(), "unlabelled": [], "doubled": [],
152
+ "apart": set()}
153
+ blocks, abstracts, keywords = _structure(_items(tokens, parser), title, fm, journal)
154
+ refs = _cite(blocks, abstracts, _notes(tokens), journal, facts)
155
+ _number(blocks, journal, facts)
156
+ _formulas(blocks, abstracts, facts)
157
+ cells = {id(t) for b in blocks if b.kind == "table" for t in b.tokens if t.type == "inline"}
158
+ for piece in _inlines(blocks, abstracts):
159
+ _polish(piece, journal, facts, bold=journal.bold != "drop" or id(piece) in cells,
160
+ text=id(piece) not in cells)
161
+ if not journal.caption_stop:
162
+ for b in blocks:
163
+ if b.kind in ("figure", "table") and b.text is not None: _unstopped(b.text)
164
+ _run_in(blocks, journal)
165
+ authors, affiliations = _authors(fm, parser.people)
166
+ return Article(title=title, short_title=str(fm.get("short_title") or title),
167
+ authors=authors, affiliations=affiliations, funding=str(fm.get("funding") or "").strip(),
168
+ abstracts=abstracts, keywords=keywords, blocks=blocks, refs=refs,
169
+ published=_published(fm, facts), meta=fm, facts=facts, lang=journal.lang)
170
+
171
+ def _items(tokens:list[Token], parser:Parser) -> list[tuple[str, list[Token]]]:
172
+ """
173
+ Top-level blocks as `(kind, tokens)`, captions paired with what they name.
174
+ A group is opened, and a `wide` one set between `wide` and `narrow` marks.
175
+ """
176
+ items = []
177
+ for start, end in parser.blocks(tokens):
178
+ part, t = tokens[start:end], tokens[start]
179
+ if t.type == "html_block":
180
+ if len(part) == 1: continue # a comment, not a group `blocks` cut out
181
+ inner = _items(part[1:-1], parser)
182
+ wide = "wide" in parser.group_options(t.content)
183
+ items += ([("wide", [])] + inner + [("narrow", [])]) if wide else inner
184
+ elif t.type == "paragraph_open":
185
+ images = all(c.type in ("image", "softbreak") for c in part[1].children or [])
186
+ items.append(("image" if images else "para", part))
187
+ elif t.type == "blockquote_open": items.append(("caption" if _caption(part) else "raw", part))
188
+ elif t.type in ("heading_open", "table_open", "math_block"):
189
+ items.append((t.type.split("_")[0], part))
190
+ elif t.type != "footnote_block_open": items.append(("raw", part))
191
+ return items
192
+
193
+ def _caption(part:list[Token]) -> tuple[str, str, Token]|None:
194
+ """
195
+ `(kind, number, text)` of a quote reading `> **Table 2.** ...` or `> **Rysunek 2.1.** ...`,
196
+ `None` for any other quote.
197
+ """
198
+ inlines = [t for t in part if t.type == "inline"]
199
+ if len(inlines) != 1: return None
200
+ kids = [c for c in inlines[0].children or [] if c.type != "text" or c.content]
201
+ if len(kids) < 3 or kids[0].type != "strong_open" or kids[2].type != "strong_close": return None
202
+ m = _CAPTION.fullmatch(kids[1].content.strip())
203
+ if not m: return None
204
+ rest = kids[3:]
205
+ if rest and rest[0].type == "text": rest = [text(rest[0].content.lstrip())] + rest[1:]
206
+ kind = "table" if m["table"] else "figure"
207
+ return kind, m["number"], Token("inline", "", 0, children=rest)
208
+
209
+ def _structure(
210
+ items:list, title:str, fm:dict, journal:Journal,
211
+ ) -> tuple[list[Block], dict, dict]:
212
+ """Blocks of the text, and the abstracts and keywords taken out of it."""
213
+ levels = [int(p[0].tag[1]) for kind, p in items if kind == "heading"
214
+ and plain(p[1]).strip().lower() not in _ABSTRACT and plain(p[1]).strip() != title]
215
+ base = min(levels, default=2)
216
+ blocks, abstracts, keywords = [], {}, {}
217
+ mode, ack, dropped = None, False, False
218
+ i = 0
219
+ while i < len(items):
220
+ kind, part = items[i]
221
+ after = items[i + 1] if i + 1 < len(items) else ("", [])
222
+ i += 1
223
+ if kind == "heading":
224
+ name = plain(part[1]).strip()
225
+ mode = _ABSTRACT.get(name.lower())
226
+ dropped = bool(_DROPPED.fullmatch(name))
227
+ level = int(part[0].tag[1]) - base + 1
228
+ # What follows the references' name goes, as they come from the notes, but a thesis keeps
229
+ # a chapter of that name: they stand in its place
230
+ kept = dropped and journal.school is not None and level == 1
231
+ if mode or (dropped and not kept) or name == title: continue
232
+ ack = bool(_ACK.fullmatch(name))
233
+ blocks.append(Block("heading", part, part[1], level=level, ack=ack))
234
+ continue
235
+ if dropped: continue
236
+ if kind == "para":
237
+ m = _KEYWORDS.fullmatch(plain(part[1]))
238
+ if m:
239
+ lang = _KEYWORD_NAMES[m[1].lower()]
240
+ keywords[lang] = [w for w in re.split(r"\s*[;,]\s*", m[2].strip().rstrip(".")) if w]
241
+ mode = None
242
+ elif mode: abstracts.setdefault(mode, []).append(part[1])
243
+ else: blocks.append(Block("para", part, ack=ack))
244
+ continue
245
+ caption = _caption(after[1]) if after[0] == "caption" else None
246
+ if kind == "image" and caption and caption[0] == "figure":
247
+ blocks.append(Block("figure", part, caption[2], number=caption[1], ack=ack))
248
+ i += 1
249
+ elif kind == "image" and journal.school is not None and pages_file(part):
250
+ blocks.append(Block("pages", part, ack=ack)) # a thesis sets in a PDF that has no caption
251
+ elif kind == "caption" and after[0] == "table" and _caption(part)[0] == "table":
252
+ _, number, words = _caption(part)
253
+ blocks.append(Block("table", after[1], words, number=number, ack=ack))
254
+ i += 1
255
+ elif kind in ("wide", "narrow", "math"): blocks.append(Block(kind, part, ack=ack))
256
+ else: blocks.append(Block("raw", part, ack=ack))
257
+ if not keywords and fm.get("keywords"):
258
+ words = fm["keywords"]
259
+ keywords[journal.lang] = words if isinstance(words, list) else str(words).split(", ")
260
+ return blocks, abstracts, keywords
261
+
262
+ def pages_file(tokens:list[Token]) -> str:
263
+ """
264
+ The PDF a paragraph of one image names, `![](paper.pdf)`, whose pages a document sets in
265
+ as its own, as a dissertation sets in its published articles: `""` for any other paragraph.
266
+ """
267
+ images = [c for t in tokens if t.type == "inline" for c in t.children or []
268
+ if c.type == "image"]
269
+ src = images[0].attrGet("src") if len(images) == 1 else ""
270
+ return src if str(src).lower().endswith(".pdf") else ""
271
+
272
+ def _authors(fm:dict, people) -> tuple[list[Author], dict]:
273
+ """
274
+ Authors of `author:`, as the engine's `people` reads it, and the places of `affiliations:`.
275
+ One with no `aff` is with the issuer of the document, `entity` at `address`.
276
+ """
277
+ places = {str(k): str(v) for k, v in (fm.get("affiliations") or {}).items()}
278
+ issuer = ", ".join(str(fm[k]) for k in ("entity", "address") if fm.get(k))
279
+ authors = []
280
+ for p in people(fm.get("author")):
281
+ aff = tuple(_listed(p.get("aff"))) or (("entity",) if issuer else ())
282
+ authors.append(Author(p["name"], aff, str(p.get("email") or ""), str(p.get("orcid") or ""),
283
+ bool(p.get("corresponding"))))
284
+ if any("entity" in a.aff for a in authors): places.setdefault("entity", issuer)
285
+ return authors, places
286
+
287
+ def _listed(value) -> list[str]:
288
+ if isinstance(value, list): return [str(v).strip() for v in value]
289
+ return [v.strip() for v in str(value).split(",")] if value else []
290
+
291
+ def _published(fm:dict, facts:dict) -> dict:
292
+ """`published:` read into its fields as written, and `doi:`, for the journal's lines."""
293
+ out = {"doi": str(fm["doi"])} if fm.get("doi") else {}
294
+ if not fm.get("published"): return out
295
+ m = _PUBLISHED.fullmatch(str(fm["published"]).strip())
296
+ if m: return m.groupdict() | out
297
+ facts["published"] = str(fm["published"])
298
+ return out
299
+
300
+ def _name(name:str) -> str:
301
+ """Pattern of `name`, its given names in full or as initials: `Anna Nowak`, `A. Nowak`."""
302
+ *given, surname = name.split() or [""]
303
+ if not given: return re.escape(surname)
304
+ space = r"\s+" # a no-break space too
305
+ full = space.join(map(re.escape, given))
306
+ initials = r"\s*".join(rf"{re.escape(g[0])}\." for g in given)
307
+ return rf"(?:{full}|{initials}){space}{re.escape(surname)}"
308
+
309
+ def _unnamed(piece:Token, names:re.Pattern) -> Token:
310
+ """`piece` with `names` as `Anonymous`, the authors of one reference as one."""
311
+ return inline([text(_ANONYMOUS.sub("Anonymous", names.sub("Anonymous", c.content)))
312
+ if c.type == "text" else c for c in piece.children or []])
313
+
314
+ #---------------------------------------------------------------------------------------- Citations
315
+
316
+ def _notes(tokens:list[Token]) -> dict[str, Token]:
317
+ """Footnote definitions by label: the inline of the first paragraph of each."""
318
+ notes, label = {}, None
319
+ for t in tokens:
320
+ if t.type == "footnote_open": label = (t.meta or {}).get("label")
321
+ elif t.type == "inline" and label is not None and label not in notes: notes[label] = t
322
+ elif t.type == "footnote_close": label = None
323
+ return notes
324
+
325
+ def _inlines(blocks:list[Block], abstracts:dict) -> list[Token]:
326
+ """Every inline token in reading order: abstracts, then the blocks, captions where they stand."""
327
+ out = [t for parts in abstracts.values() for t in parts]
328
+ for b in blocks:
329
+ if b.kind == "figure": out += [t for t in b.tokens if t.type == "inline"] + [b.text]
330
+ elif b.kind == "table": out += [b.text] + [t for t in b.tokens if t.type == "inline"]
331
+ elif b.kind == "heading": out.append(b.text)
332
+ else: out += [t for t in b.tokens if t.type == "inline"]
333
+ return out
334
+
335
+ def _cite(
336
+ blocks:list[Block], abstracts:dict, notes:dict, journal:Journal, facts:dict,
337
+ ) -> list[Ref]:
338
+ """
339
+ Footnote marks turned into reference numbers in the order of first citation.
340
+ A footnote naming several references gives each its own number.
341
+ """
342
+ refs, numbers = [], {}
343
+ def number(label:str) -> list[int]:
344
+ if label not in numbers:
345
+ numbers[label] = []
346
+ for entry in entries(notes[label]):
347
+ refs.append(Ref(len(refs) + 1, format_ref(entry, journal.cite, journal.lang, journal.dash),
348
+ has_doi(entry)))
349
+ numbers[label].append(len(refs))
350
+ return numbers[label]
351
+ for piece in _inlines(blocks, abstracts):
352
+ out, group = [], []
353
+ for c in (piece.children or []) + [None]:
354
+ if c is not None and c.type == "footnote_ref":
355
+ group.append((c.meta or {}).get("label"))
356
+ continue
357
+ if group:
358
+ cited = sorted({n for g in group for n in number(g)})
359
+ _bind_mark(out, marks(cited, journal.cite_sep, journal.cite_joined))
360
+ group = []
361
+ if c is not None: out.append(c)
362
+ piece.children = out
363
+ facts["uncited"] = [label for label in notes if label not in numbers]
364
+ return refs
365
+
366
+ def marks(numbers:list[int], sep:str, joined:bool=False) -> str:
367
+ """
368
+ `[1],[2]`, and a run of three or more as `[3]` to `[5]` joined by an en dash,
369
+ or `joined` in one bracket: `[1, 2]`, `[3–5]`, `[6, 10–12, 17]`.
370
+ """
371
+ runs, start = [], 0 # `(first, last)` of each run of numbers, a lone one its own run
372
+ for i in range(1, len(numbers) + 1):
373
+ if i == len(numbers) or numbers[i] != numbers[i - 1] + 1:
374
+ run = numbers[start:i]
375
+ runs += [(run[0], run[-1])] if len(run) > 2 else [(n, n) for n in run]
376
+ start = i
377
+ if joined: return "[" + sep.join(str(a) if a == b else f"{a}{EN_DASH}{b}" for a, b in runs) + "]"
378
+ return sep.join(f"[{a}]" if a == b else f"[{a}]{EN_DASH}[{b}]" for a, b in runs)
379
+
380
+ def _bind_mark(out:list[Token], mark:str):
381
+ """Append citation `mark`, bound to the word before it by a no-break space."""
382
+ if out and out[-1].type == "text":
383
+ before = out[-1].content
384
+ space = "" if before.endswith(("(", "[")) or not before else NBSP
385
+ out[-1] = text(before.rstrip(" ") + space)
386
+ elif out: out.append(text(NBSP))
387
+ out.append(text(mark))
388
+
389
+ #---------------------------------------------------------------------------------------- Numbering
390
+
391
+ def _number(blocks:list[Block], journal:Journal, facts:dict):
392
+ """
393
+ Labels of headings, figures, tables and formulas,
394
+ mentions of figures and tables renumbered to match, and of formulas named by their label.
395
+ A chapter the journal sets without a number, as `Wstęp` of a thesis, and its sections take none.
396
+ Numbered within chapters, a figure, table or formula of a numbered one bears its number: `2.1`,
397
+ and one outside them counts apart: `1`, `2`.
398
+ A source may number them either way, through the text or by chapter.
399
+ """
400
+ depth = max(len(journal.heads), 1)
401
+ counts, maps, totals = [0] * depth, {"figure": {}, "table": {}}, {"figure": 0, "table": 0}
402
+ shown = {"figure": {}, "table": {}} # each number in reading order as set: `II`, `2.1`
403
+ within = {"figure": 0, "table": 0, "math": 0} # of the chapter
404
+ apart = dict(within) # outside the numbered chapters
405
+ equation, section, opened, loose = 0, False, not journal.dropcap, False
406
+ def counted(kind:str, n:int) -> str:
407
+ """How the `n`th of `kind` in reading order is numbered."""
408
+ if kind == "table" and journal.table_roman: return roman(n)
409
+ if not journal.by_chapter: return str(n)
410
+ if loose or not counts[0]:
411
+ apart[kind] += 1
412
+ return str(apart[kind])
413
+ within[kind] += 1
414
+ return f"{counts[0]}.{within[kind]}"
415
+ for b in blocks:
416
+ if b.kind == "heading" and not b.ack:
417
+ if b.level == 1:
418
+ loose = unnumbered(b, journal)
419
+ within = dict.fromkeys(within, 0)
420
+ if loose: continue
421
+ level = min(b.level, depth)
422
+ counts[level - 1] += 1
423
+ counts[level:] = [0] * (depth - level)
424
+ b.label = _head_label(counts, level, journal)
425
+ section = section or level == 1
426
+ elif b.kind in maps:
427
+ totals[b.kind] += 1
428
+ n = b.n = totals[b.kind]
429
+ maps[b.kind].setdefault(b.number, n)
430
+ label = shown[b.kind][n] = counted(b.kind, n)
431
+ if b.number not in (str(n), label): # neither as read nor as set
432
+ facts["renumbered"].append(f"{b.kind} {b.number} → {label}")
433
+ if b.kind == "figure":
434
+ b.label = f"{journal.figure} {label}."
435
+ facts["images"] += [a for t in b.tokens for c in t.children or [] if c.type == "image"
436
+ for a in [c.attrGet("src")]]
437
+ else:
438
+ b.label = f"{journal.table} {label}"
439
+ if not journal.table_split: b.label += "."
440
+ elif b.kind == "math":
441
+ source = b.tokens[0].content # its label read and left out, which no engine draws
442
+ names, source = _LABEL.findall(source), _LABEL.sub("", source)
443
+ if journal.equations:
444
+ equation += 1
445
+ number = counted("math", equation)
446
+ source = f"{source.rstrip()} \\tag{{{number}}}"
447
+ facts["doubled"] += [name for name in names if name in facts["labels"]]
448
+ facts["labels"].update(dict.fromkeys(names, number))
449
+ b.tokens[0].content = source
450
+ elif b.kind == "para" and section and not opened:
451
+ b.first = opened = True
452
+ mention = lambda m: _mention(m, maps, shown, facts)
453
+ for piece in _inlines(blocks, {}):
454
+ for c in piece.children or []:
455
+ if c.type == "text": c.content = _MENTION.sub(mention, c.content)
456
+
457
+ def _formulas(blocks:list[Block], abstracts:dict, facts:dict):
458
+ """
459
+ Mentions of labelled formulas as their numbers, wherever they are written:
460
+ in the words, in a formula of the text and in the abstracts,
461
+ a `$...$` holding one mention alone set as words, any other kept a formula.
462
+ A formula in a list or quote takes no number, so its label is left out and noted.
463
+ """
464
+ refer = lambda m: _refer(m, facts)
465
+ nested = [t for b in blocks if b.kind != "math" for t in b.tokens if t.type == "math_block"]
466
+ for t in nested:
467
+ facts["apart"].update(_LABEL.findall(t.content))
468
+ t.content = _LABEL.sub("", t.content)
469
+ for t in [t for b in blocks for t in b.tokens if t.type == "math_block"]:
470
+ t.content = _EQREF.sub(refer, t.content)
471
+ for piece in _inlines(blocks, abstracts):
472
+ for c in piece.children or []:
473
+ if c.type == "text": c.content = _EQREF.sub(refer, c.content)
474
+ elif c.type == "math_inline" and _EQREF.fullmatch(c.content.strip()):
475
+ c.type, c.content = "text", _EQREF.sub(refer, c.content.strip())
476
+ elif c.type == "math_inline": c.content = _EQREF.sub(refer, c.content)
477
+
478
+ def unnumbered(b:Block, journal:Journal) -> bool:
479
+ """
480
+ Whether chapter `b` stands without a number: one the journal so names, an appendix, a part,
481
+ or the place of a thesis's references.
482
+ """
483
+ name = plain(b.text).strip()
484
+ return references(b, journal) or any(p and re.fullmatch(p, name, re.I)
485
+ for p in (journal.unnumbered, journal.appendix, journal.parts))
486
+
487
+ def part(b:Block, journal:Journal) -> bool:
488
+ """Whether `b` opens a part of the text, as `Część I. Autoreferat`, the contents set apart."""
489
+ return (b.kind == "heading" and b.level == 1 and bool(journal.parts)
490
+ and bool(re.fullmatch(journal.parts, plain(b.text).strip(), re.I)))
491
+
492
+ def references(b:Block, journal:Journal) -> bool:
493
+ """Whether `b` is a thesis chapter named as references, which stand in its place."""
494
+ return (b.kind == "heading" and b.level == 1 and journal.school is not None
495
+ and bool(_DROPPED.fullmatch(plain(b.text).strip())))
496
+
497
+ def list_of(b:Block, journal:Journal) -> str:
498
+ """
499
+ `figure` or `table` for a thesis chapter named as their list, `Wykaz rysunków` or
500
+ `List of tables`, which lists them where it stands; `""` for any other block.
501
+ """
502
+ if b.kind != "heading" or b.level != 1 or journal.school is None: return ""
503
+ name = plain(b.text).strip()
504
+ return next((kind for kind, names in _LISTS.items() if names.fullmatch(name)), "")
505
+
506
+ def appendix(b:Block, journal:Journal) -> bool:
507
+ """Whether `b` opens a chapter the journal sets after the references, as an appendix."""
508
+ return (b.kind == "heading" and b.level == 1 and bool(journal.appendix)
509
+ and bool(re.fullmatch(journal.appendix, plain(b.text).strip(), re.I)))
510
+
511
+ def _head_label(counts:list[int], level:int, journal:Journal) -> str:
512
+ """`II.`, `B.`, `2)` or, nested, `2.1.`: the number the level's `Head` asks for."""
513
+ fmt = journal.head(level).number
514
+ if not fmt: return ""
515
+ if journal.nested: return ".".join(str(n) for n in counts[:level]) + fmt[1:]
516
+ return counter(counts[level - 1], fmt[0]) + fmt[1:]
517
+
518
+ def _mention(m:re.Match, maps:dict, shown:dict, facts:dict) -> str:
519
+ """
520
+ `Tables 1 and 5` as the journal numbers the tables they mean, `shown` by their numbers
521
+ in reading order, the word bound to the first.
522
+ """
523
+ word = m["word"]
524
+ kind = "table" if m["table"] else "figure"
525
+ parts = re.split(f"({_NUMBER})", m["list"]) # what comes before each number, then the number
526
+ out, last = [], None
527
+ for k in range(1, len(parts), 2):
528
+ number, ranged = parts[k], last is not None and bool(_RANGE.fullmatch(parts[k - 1]))
529
+ if ranged: # a range names every number inside it
530
+ facts["mentioned"] |= {(kind, maps[kind][x]) for x in _inside(last, number)
531
+ if x in maps[kind]}
532
+ n = maps[kind].get(number)
533
+ if n is None: facts["missing"].append(f"{word} {number}")
534
+ else: facts["mentioned"].add((kind, n))
535
+ out.append((EN_DASH if ranged else parts[k - 1]) + (shown[kind][n] if n else number))
536
+ last = number
537
+ return f"{word}{NBSP}{''.join(out)}"
538
+
539
+ def _refer(m:re.Match, facts:dict) -> str:
540
+ """
541
+ A mention of the formula its label names, as its number: in brackets for `eqref`, `(2.1)`,
542
+ alone for `ref`, `2.1`; one naming no formula stays as written, for the preflight.
543
+ """
544
+ number = facts["labels"].get(m[2])
545
+ if number is None:
546
+ facts["unlabelled"].append((m[0], m[2]))
547
+ return m[0]
548
+ facts["referred"].add(m[2])
549
+ return f"({number})" if m[1] else number
550
+
551
+ def _inside(first:str, last:str) -> list[str]:
552
+ """Numbers a range names between its ends: `2`–`5` names 3 and 4, `2.1`–`2.4` 2.2 and 2.3."""
553
+ *head, low = first.split(".")
554
+ *tail, high = last.split(".")
555
+ if head != tail: return []
556
+ return [".".join(head + [str(x)]) for x in range(int(low) + 1, int(high))]
557
+
558
+ def _run_in(blocks:list[Block], journal:Journal):
559
+ """Headings of a run-in level opening the paragraph after them, in the level's face."""
560
+ for b in [b for b in blocks if b.kind == "heading" and journal.head(b.level).run_in]:
561
+ i = blocks.index(b)
562
+ after = blocks[i + 1] if i + 1 < len(blocks) else None
563
+ if after is None or after.kind != "para": continue
564
+ mode = journal.head(b.level).mode
565
+ tags = (["strong"] if "Bold" in mode else []) + (["em"] if "Italic" in mode else [])
566
+ lead = f"{b.label} {plain(b.text).strip()}: ".lstrip()
567
+ after.tokens[1].children = [Token(f"{t}_open", t, 1) for t in tags] + [text(lead)] + [
568
+ Token(f"{t}_close", t, -1) for t in reversed(tags)] + (after.tokens[1].children or [])
569
+ blocks.remove(b)
570
+
571
+ #--------------------------------------------------------------------------------------- Typography
572
+
573
+ def _unstopped(inline:Token):
574
+ """A caption without the full stop it ends with, for a journal whose captions end without one."""
575
+ texts = [c for c in inline.children or [] if c.type == "text" and c.content.strip()]
576
+ last = texts[-1] if texts else None
577
+ if last is None: return
578
+ words = last.content.rstrip()
579
+ if words.endswith(".") and not words.endswith(".."): last.content = words[:-1]
580
+
581
+ def _polish(inline:Token, journal:Journal, facts:dict, bold:bool, text:bool):
582
+ """
583
+ Typography of the journal's language on the text of `inline`, code left alone,
584
+ links kept where the journal keeps them, and bold where `bold` says.
585
+ Bold set plain is counted for the preflight, and bold kept in the `text`, outside tables.
586
+ """
587
+ dropped = set() if journal.links else {"link_open", "link_close"}
588
+ if not bold: dropped |= {"strong_open", "strong_close"}
589
+ out, prev = [], " "
590
+ for c in inline.children or []:
591
+ if c.type in dropped:
592
+ facts["plain"] += c.type == "strong_open"
593
+ continue
594
+ facts["bold"] += text and c.type == "strong_open"
595
+ if c.type == "text":
596
+ c.content = bind(smarten(c.content, journal.lang, prev, journal.dash))
597
+ prev = c.content[-1:] or prev
598
+ elif c.type == "code_inline": prev = "x"
599
+ out.append(c)
600
+ inline.children = out