mdformat-sembr 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.
@@ -0,0 +1,14 @@
1
+ """mdformat-sembr: Semantic Line Breaks as CommonMark soft breaks.
2
+
3
+ The entry point ``mdformat.parser_extension`` -> ``sembr`` resolves to the
4
+ ``_plugin`` attribute of this package (see ``pyproject.toml``). Importing it here
5
+ exposes the interface object as ``mdformat_sembr._plugin``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from mdformat_sembr import _plugin
11
+ from mdformat_sembr._sembr import insert_breaks
12
+
13
+ __all__ = ["_plugin", "insert_breaks"]
14
+ __version__ = "0.1.0"
@@ -0,0 +1,133 @@
1
+ """mdformat parser-extension interface for the SemBr plugin.
2
+
3
+ This module *is* the plugin interface object referenced by the entry point
4
+ ``mdformat_sembr:_plugin``. It exposes the members required by
5
+ ``mdformat.plugins.ParserExtensionInterface`` at module level.
6
+
7
+ We do not change the parser or override any renderer; all work happens in a
8
+ postprocessor registered on the ``paragraph`` node type. At that point inline
9
+ formatting is already resolved into the rendered string, so we operate on final
10
+ text and protect a few inline constructs by regex.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from collections.abc import Mapping
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from mdformat_sembr._sembr import (
20
+ DEFAULT_ABBREVIATIONS,
21
+ DEFAULT_CLAUSE_CHARS,
22
+ DEFAULT_MIN_CHARS,
23
+ insert_breaks,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from markdown_it import MarkdownIt
28
+ from mdformat.renderer import RenderContext, RenderTreeNode
29
+
30
+ #: SemBr soft breaks never alter the rendered output, so the AST is unchanged.
31
+ #: This lets mdformat's built-in ``is_md_equal`` validator gate correctness.
32
+ CHANGES_AST = False
33
+
34
+
35
+ def update_mdit(mdit: "MarkdownIt") -> None:
36
+ """No parser change is needed for SemBr."""
37
+ # Intentionally a no-op.
38
+ pass
39
+
40
+
41
+ def _plugin_options(context: "RenderContext") -> Mapping[str, Any]:
42
+ """Return the merged ``[plugin.sembr]`` / CLI options mapping."""
43
+ mdformat_opts = context.options.get("mdformat", {})
44
+ plugin_opts = mdformat_opts.get("plugin", {})
45
+ return plugin_opts.get("sembr", {}) or {}
46
+
47
+
48
+ def _postprocess_paragraph(
49
+ text: str,
50
+ node: "RenderTreeNode",
51
+ context: "RenderContext",
52
+ ) -> str:
53
+ """Insert SemBr soft breaks into an already-rendered paragraph string."""
54
+ opts = _plugin_options(context)
55
+
56
+ min_chars = opts.get("min_chars", DEFAULT_MIN_CHARS)
57
+ abbreviations = opts.get("abbreviations", None)
58
+ break_clauses = bool(opts.get("break_clauses", False))
59
+ clause_chars = opts.get("clause_chars", DEFAULT_CLAUSE_CHARS)
60
+
61
+ return insert_breaks(
62
+ text,
63
+ min_chars=int(min_chars),
64
+ abbreviations=abbreviations,
65
+ break_clauses=break_clauses,
66
+ clause_chars=clause_chars,
67
+ )
68
+
69
+
70
+ def add_cli_argument_group(group: argparse._ArgumentGroup) -> None:
71
+ """Register CLI options, mirrored to TOML ``[plugin.sembr]``.
72
+
73
+ Values are stored under ``mdit.options["mdformat"]["plugin"]["sembr"]`` and
74
+ merged with the TOML config. ``dest`` names deliberately match the TOML keys
75
+ so CLI values merge cleanly over TOML.
76
+ """
77
+ group.add_argument(
78
+ "--sembr-min-chars",
79
+ dest="min_chars",
80
+ type=int,
81
+ default=None,
82
+ metavar="N",
83
+ help=(
84
+ "minimum length of the segment before a break is allowed "
85
+ f"(default: {DEFAULT_MIN_CHARS})"
86
+ ),
87
+ )
88
+ group.add_argument(
89
+ "--sembr-abbreviations",
90
+ dest="abbreviations",
91
+ action="append",
92
+ default=None,
93
+ metavar="ABBR",
94
+ help=(
95
+ "abbreviation after which no sentence break is inserted; "
96
+ "repeat to add several (replaces the default list)"
97
+ ),
98
+ )
99
+ group.add_argument(
100
+ "--sembr-break-clauses",
101
+ dest="break_clauses",
102
+ action="store_true",
103
+ default=None,
104
+ help="also break after clause punctuation (Iteration 2; off by default)",
105
+ )
106
+ group.add_argument(
107
+ "--sembr-clause-chars",
108
+ dest="clause_chars",
109
+ default=None,
110
+ metavar="CHARS",
111
+ help=(
112
+ "clause punctuation set used when --sembr-break-clauses is on "
113
+ f"(default: {DEFAULT_CLAUSE_CHARS!r})"
114
+ ),
115
+ )
116
+
117
+
118
+ #: A mapping from ``RenderTreeNode.type`` to a ``Render`` function. Empty: we do
119
+ #: not override rendering.
120
+ RENDERERS: Mapping[str, Any] = {}
121
+
122
+ #: A mapping from ``RenderTreeNode.type`` to a collaborative ``Postprocess``.
123
+ POSTPROCESSORS: Mapping[str, Any] = {"paragraph": _postprocess_paragraph}
124
+
125
+
126
+ __all__ = [
127
+ "CHANGES_AST",
128
+ "RENDERERS",
129
+ "POSTPROCESSORS",
130
+ "update_mdit",
131
+ "add_cli_argument_group",
132
+ "DEFAULT_ABBREVIATIONS",
133
+ ]
@@ -0,0 +1,228 @@
1
+ """Deterministic Semantic Line Break (sembr.org) insertion.
2
+
3
+ This module holds the pure, rule-based break logic. It is intentionally free of
4
+ any mdformat imports so it can be unit-tested in isolation and reused by the
5
+ plugin's paragraph postprocessor.
6
+
7
+ The sentence-boundary regex, abbreviation list, inline-code masking and
8
+ abbreviation guard are ported from the project's original
9
+ ``.github/hooks/markdown-format/markdown-format.py`` script.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections.abc import Iterable
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Defaults (ported from the original markdown-format.py hook script)
19
+ # ---------------------------------------------------------------------------
20
+
21
+ #: Minimum length of the segment preceding a break. Prevents splitting short
22
+ #: enumerations and fragments.
23
+ DEFAULT_MIN_CHARS = 15
24
+
25
+ #: Clause punctuation used by Iteration 2 (only when ``break_clauses`` is on).
26
+ DEFAULT_CLAUSE_CHARS = ",;:\u2014" # comma, semicolon, colon, em dash
27
+
28
+ #: Common abbreviations whose trailing dot must NOT end a sentence.
29
+ DEFAULT_ABBREVIATIONS: frozenset[str] = frozenset(
30
+ {
31
+ "e.g", "i.e", "etc", "vs", "cf", "viz", "al", "approx", "incl", "excl",
32
+ "Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "St",
33
+ "Inc", "Ltd", "Co", "Corp", "U.S", "U.K", "U.N", "E.U",
34
+ "Fig", "fig", "no", "No", "vol", "Vol", "ch", "Ch",
35
+ "p", "pp", "para", "sec", "Sect",
36
+ }
37
+ )
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Regexes
41
+ # ---------------------------------------------------------------------------
42
+
43
+ # Sentence-terminator (. ! ?) — including "?!"/"!?" runs — followed by an
44
+ # optional closing quote/bracket, whitespace, and a likely sentence start.
45
+ # The negative lookbehind avoids breaking inside an ellipsis ("...").
46
+ _SENTENCE_BOUNDARY = re.compile(
47
+ r'(?<=[.!?])(?<!\.\.\.)["\')\]]?\s+(?=["\'(\[`*_]?[A-Z0-9])'
48
+ )
49
+
50
+ # Placeholder markers use NUL bytes which never occur in Markdown source text.
51
+ _PLACEHOLDER = "\x00{kind}{index}\x00"
52
+ _PLACEHOLDER_RE = re.compile(r"\x00([A-Z]+)(\d+)\x00")
53
+
54
+ # Protected inline constructs. Order matters: images/links before bare code so
55
+ # a link label containing backticks is masked as one unit.
56
+ _PROTECTED_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
57
+ # Inline code spans: one or more backticks, no embedded newline.
58
+ ("CODE", re.compile(r"`+[^`\n]*`+")),
59
+ # Images and links: optional leading '!', label in [...], target in (...).
60
+ ("LINK", re.compile(r"!?\[[^\]\n]*\]\([^)\n]*\)")),
61
+ # Reference-style links / footnote references: [text][id] or [^id].
62
+ ("REF", re.compile(r"!?\[[^\]\n]*\]\[[^\]\n]*\]")),
63
+ ("FOOT", re.compile(r"\[\^[^\]\n]+\]")),
64
+ )
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Masking of protected regions
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def _mask(text: str) -> tuple[str, dict[str, str]]:
72
+ """Replace protected inline spans with opaque NUL-delimited tokens.
73
+
74
+ Returns the masked text and a mapping of token -> original span, so the
75
+ break regex can never match inside code, links, images or footnote refs.
76
+ """
77
+ store: dict[str, str] = {}
78
+ counter = 0
79
+
80
+ for kind, pattern in _PROTECTED_PATTERNS:
81
+
82
+ def repl(m: re.Match[str], _kind: str = kind) -> str:
83
+ nonlocal counter
84
+ token = _PLACEHOLDER.format(kind=_kind, index=counter)
85
+ store[token] = m.group(0)
86
+ counter += 1
87
+ return token
88
+
89
+ text = pattern.sub(repl, text)
90
+
91
+ return text, store
92
+
93
+
94
+ def _unmask(text: str, store: dict[str, str]) -> str:
95
+ """Reverse :func:`_mask`, restoring original spans from placeholder tokens."""
96
+ if not store:
97
+ return text
98
+
99
+ def repl(m: re.Match[str]) -> str:
100
+ return store.get(m.group(0), m.group(0))
101
+
102
+ # Repeat until stable in case a restored span contained another token
103
+ # (protected spans never nest in practice, but this keeps it robust).
104
+ prev = None
105
+ while prev != text:
106
+ prev = text
107
+ text = _PLACEHOLDER_RE.sub(repl, text)
108
+ return text
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Abbreviation guard
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def _is_abbreviation_before(text: str, idx: int, abbreviations: frozenset[str]) -> bool:
116
+ """Return True if the period just before ``idx`` belongs to an abbreviation."""
117
+ j = idx - 1
118
+ if j < 0 or text[j] != ".":
119
+ return False
120
+ start = j
121
+ while start > 0 and (text[start - 1].isalnum() or text[start - 1] == "."):
122
+ start -= 1
123
+ token = text[start:j] # word chars before the trailing dot
124
+ return token in abbreviations
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Core break logic
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def _collapse_whitespace(text: str) -> str:
132
+ """Collapse all runs of whitespace (including newlines) to single spaces.
133
+
134
+ Collapse-then-rebreak is what makes the transform deterministic and
135
+ idempotent regardless of any existing soft breaks in the input.
136
+ """
137
+ return re.sub(r"\s+", " ", text).strip()
138
+
139
+
140
+ def _split_points(masked: str, abbreviations: frozenset[str]) -> list[int]:
141
+ """Return sorted cut indices for sentence boundaries in ``masked`` text.
142
+
143
+ Each index is the position *after* which a break should be inserted (i.e.
144
+ the start of the whitespace run following a sentence terminator).
145
+ """
146
+ points: list[int] = []
147
+ for m in _SENTENCE_BOUNDARY.finditer(masked):
148
+ if _is_abbreviation_before(masked, m.start(), abbreviations):
149
+ continue
150
+ points.append(m.start())
151
+ return points
152
+
153
+
154
+ def _clause_split_points(masked: str, clause_chars: str) -> list[int]:
155
+ """Return cut indices after independent-clause punctuation."""
156
+ if not clause_chars:
157
+ return []
158
+ escaped = re.escape(clause_chars)
159
+ # Clause punctuation followed by whitespace and a non-space continuation.
160
+ pattern = re.compile(rf"(?<=[{escaped}])\s+(?=\S)")
161
+ return [m.start() for m in pattern.finditer(masked)]
162
+
163
+
164
+ def _apply_breaks(masked: str, cut_points: Iterable[int], min_chars: int) -> str:
165
+ """Insert newlines at ``cut_points`` subject to the ``min_chars`` threshold.
166
+
167
+ The threshold is measured against the current line segment: a break is only
168
+ inserted if the text since the previous break is at least ``min_chars`` long.
169
+ """
170
+ unique_points = sorted(set(cut_points))
171
+ if not unique_points:
172
+ return masked
173
+
174
+ out: list[str] = []
175
+ last = 0
176
+ line_start = 0
177
+ for point in unique_points:
178
+ segment_len = len(masked[line_start:point].strip())
179
+ if segment_len < min_chars:
180
+ continue
181
+ out.append(masked[last:point].rstrip())
182
+ out.append("\n")
183
+ # Skip the whitespace run that followed the boundary.
184
+ next_start = point
185
+ while next_start < len(masked) and masked[next_start].isspace():
186
+ next_start += 1
187
+ last = next_start
188
+ line_start = next_start
189
+ out.append(masked[last:])
190
+ return "".join(out)
191
+
192
+
193
+ def insert_breaks(
194
+ text: str,
195
+ *,
196
+ min_chars: int = DEFAULT_MIN_CHARS,
197
+ abbreviations: Iterable[str] | None = None,
198
+ break_clauses: bool = False,
199
+ clause_chars: str = DEFAULT_CLAUSE_CHARS,
200
+ ) -> str:
201
+ """Insert SemBr soft breaks into a single rendered paragraph string.
202
+
203
+ Sentence boundaries (``.``/``!``/``?``) always break (Iteration 1). When
204
+ ``break_clauses`` is true, clause punctuation in ``clause_chars`` also breaks
205
+ (Iteration 2). Protected inline regions (code, links, images, footnote refs)
206
+ and abbreviations are never split.
207
+
208
+ Only bare ``\\n`` soft breaks are emitted — never hard breaks. Rendered HTML
209
+ output is therefore unchanged. The transform is deterministic and idempotent.
210
+ """
211
+ abbrev = (
212
+ DEFAULT_ABBREVIATIONS
213
+ if abbreviations is None
214
+ else frozenset(abbreviations)
215
+ )
216
+
217
+ collapsed = _collapse_whitespace(text)
218
+ if not collapsed:
219
+ return collapsed
220
+
221
+ masked, store = _mask(collapsed)
222
+
223
+ cut_points = _split_points(masked, abbrev)
224
+ if break_clauses:
225
+ cut_points += _clause_split_points(masked, clause_chars)
226
+
227
+ broken = _apply_breaks(masked, cut_points, min_chars)
228
+ return _unmask(broken, store)
File without changes
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdformat-sembr
3
+ Version: 0.1.0
4
+ Summary: mdformat plugin that inserts Semantic Line Breaks (sembr.org) as CommonMark soft breaks
5
+ Project-URL: Homepage, https://codeberg.org/bugrasan/mdformat-sembr
6
+ Project-URL: GitHub Mirror, https://github.com/bugrasan/mdformat-sembr
7
+ Author: bugrasan
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: formatter,markdown,mdformat,semantic line breaks,sembr
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: mdformat>=1.0
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=7; extra == 'test'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # mdformat-sembr
26
+
27
+ An [mdformat](https://mdformat.readthedocs.io) parser-extension plugin that inserts
28
+ [Semantic Line Breaks](https://sembr.org) (SemBr) as CommonMark **soft breaks**.
29
+
30
+ SemBr is a convention for adding line breaks in Markdown source at sentence and
31
+ clause boundaries. Because the breaks are CommonMark *soft* breaks (a bare `\n`
32
+ inside a paragraph), they render to a single space — the rendered HTML output is
33
+ unchanged, only the source becomes more diff-friendly.
34
+
35
+ The plugin is fully deterministic: no ML, no network, no LLM calls. The same input
36
+ always produces the same output.
37
+
38
+ ## Why
39
+
40
+ Moving SemBr logic out of an LLM/agent loop into a token-free, reproducible
41
+ formatter pass makes authored Markdown consistent and cheap to maintain.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ # uv (recommended) — --with is repeatable (or comma-separate the plugins)
47
+ uv tool install mdformat --with mdformat-sembr --with mdformat-frontmatter
48
+
49
+ # pipx — install the app, then inject the plugins into its environment
50
+ pipx install mdformat
51
+ pipx inject mdformat mdformat-sembr mdformat-frontmatter
52
+
53
+ # local development
54
+ pip install -e .
55
+ ```
56
+
57
+ `mdformat-frontmatter` is optional: install it only if your Markdown uses
58
+ YAML/TOML frontmatter and you want mdformat to preserve/format it. It composes
59
+ with `mdformat-sembr` (frontmatter is a separate node type and is never broken).
60
+
61
+ ## Usage
62
+
63
+ ```bash
64
+ mdformat --version # should list "mdformat_sembr"
65
+ echo "First sentence. Second sentence." | mdformat -
66
+ ```
67
+
68
+ From Python:
69
+
70
+ ```python
71
+ import mdformat
72
+
73
+ mdformat.text("First sentence. Second sentence.\n", extensions={"sembr"})
74
+ # 'First sentence.\nSecond sentence.\n'
75
+ ```
76
+
77
+ ## How it works
78
+
79
+ The plugin registers a **postprocessor** on the `paragraph` node type. At that point
80
+ inline formatting (emphasis, links, inline code) is already resolved into the string,
81
+ so it operates on the final rendered text and only protects a few inline constructs by
82
+ regex. Block-level elements (headings, code blocks, tables, frontmatter, HTML blocks)
83
+ are separate node types and are never touched.
84
+
85
+ `CHANGES_AST = False`: soft breaks are AST-safe by design, so mdformat's built-in
86
+ `is_md_equal` validator gates correctness. If validation ever fails, the break logic is
87
+ wrong — it is never worked around with `--no-validate` or hard breaks.
88
+
89
+ ## Configuration
90
+
91
+ Configure via `[plugin.sembr]` in `.mdformat.toml`, or via CLI flags. CLI values merge
92
+ over TOML.
93
+
94
+ | Option | Type | Default | Meaning |
95
+ | --------------- | ----------- | --------- | -------------------------------------------------------------- |
96
+ | `min_chars` | int | `15` | Minimum length of the segment before a break is allowed. |
97
+ | `abbreviations` | list[str] | see below | Tokens after which no sentence break is inserted. |
98
+ | `break_clauses` | bool | `false` | Enable clause-level breaks (SemBr "SHOULD"). Off by default. |
99
+ | `clause_chars` | str | `",;:—"` | Clause punctuation set (only used when `break_clauses` true). |
100
+
101
+ CLI flags: `--sembr-min-chars`, `--sembr-abbreviations`, `--sembr-break-clauses`,
102
+ `--sembr-clause-chars`.
103
+
104
+ `.mdformat.toml` example:
105
+
106
+ ```toml
107
+ [plugin.sembr]
108
+ min_chars = 20
109
+ break_clauses = true
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,9 @@
1
+ mdformat_sembr/__init__.py,sha256=2angvPWFxzexnHG8AEkcDZIK6y88YAkcef9qWhQPUqU,471
2
+ mdformat_sembr/_plugin.py,sha256=Mm3godzv0eUJHYLaGDehvz7led88L1bjnDHwMaqWiyo,4119
3
+ mdformat_sembr/_sembr.py,sha256=ouOVnHJTGvnw8-iR6_1UdySYhcAyuoTYQZ1trr3EbSE,8493
4
+ mdformat_sembr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ mdformat_sembr-0.1.0.dist-info/METADATA,sha256=8rydE6eWbkXL92uxa2Kd44opWsxiOqoGt4-3PCd4W2o,4314
6
+ mdformat_sembr-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ mdformat_sembr-0.1.0.dist-info/entry_points.txt,sha256=cZs7zd0X63vlwUoEMiZQzx_C30mo6WXjcCHiqLnx1ko,59
8
+ mdformat_sembr-0.1.0.dist-info/licenses/LICENSE,sha256=xEWxditjeckt1SLFDqL2rKbJaqS72hzBGez4K1QSxYI,1084
9
+ mdformat_sembr-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [mdformat.parser_extension]
2
+ sembr = mdformat_sembr:_plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mdformat-sembr contributors
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.