codendium 1.0.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 (45) hide show
  1. codendium-1.0.0.dist-info/METADATA +332 -0
  2. codendium-1.0.0.dist-info/RECORD +45 -0
  3. codendium-1.0.0.dist-info/WHEEL +5 -0
  4. codendium-1.0.0.dist-info/entry_points.txt +6 -0
  5. codendium-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. codendium-1.0.0.dist-info/top_level.txt +1 -0
  7. copyright_deposit/__init__.py +15 -0
  8. copyright_deposit/__main__.py +34 -0
  9. copyright_deposit/assets/__init__.py +5 -0
  10. copyright_deposit/assets/fonts/README.md +31 -0
  11. copyright_deposit/assets/logo.svg +26 -0
  12. copyright_deposit/cli.py +314 -0
  13. copyright_deposit/config.py +269 -0
  14. copyright_deposit/core/__init__.py +1 -0
  15. copyright_deposit/core/deposit.py +117 -0
  16. copyright_deposit/core/discovery.py +260 -0
  17. copyright_deposit/core/encoding.py +136 -0
  18. copyright_deposit/core/languages.py +190 -0
  19. copyright_deposit/core/layout.py +349 -0
  20. copyright_deposit/core/lineranges.py +219 -0
  21. copyright_deposit/core/manifest.py +282 -0
  22. copyright_deposit/core/metrics.py +279 -0
  23. copyright_deposit/core/ordering.py +349 -0
  24. copyright_deposit/core/pipeline.py +386 -0
  25. copyright_deposit/core/redaction.py +162 -0
  26. copyright_deposit/core/render.py +242 -0
  27. copyright_deposit/core/scanning/__init__.py +61 -0
  28. copyright_deposit/core/scanning/secrets.py +181 -0
  29. copyright_deposit/core/scanning/thirdparty.py +190 -0
  30. copyright_deposit/core/strip/__init__.py +337 -0
  31. copyright_deposit/core/strip/cfamily_strip.py +235 -0
  32. copyright_deposit/core/strip/pygments_strip.py +85 -0
  33. copyright_deposit/core/strip/python_strip.py +131 -0
  34. copyright_deposit/gui/__init__.py +1 -0
  35. copyright_deposit/gui/app.py +34 -0
  36. copyright_deposit/gui/branding.py +83 -0
  37. copyright_deposit/gui/history.py +192 -0
  38. copyright_deposit/gui/main_window.py +617 -0
  39. copyright_deposit/gui/panels/__init__.py +1 -0
  40. copyright_deposit/gui/panels/estimate.py +166 -0
  41. copyright_deposit/gui/panels/files.py +635 -0
  42. copyright_deposit/gui/panels/identification.py +193 -0
  43. copyright_deposit/gui/panels/options.py +445 -0
  44. copyright_deposit/gui/panels/preflight.py +260 -0
  45. copyright_deposit/gui/workers.py +96 -0
@@ -0,0 +1,282 @@
1
+ """Audit trail for a build.
2
+
3
+ Writes a machine-readable manifest and a human-readable summary recording
4
+ exactly what went into the deposit: every file, its SHA-256, where it
5
+ landed in the PDF, and every warning raised along the way. The summary also
6
+ contains the sentence to give the Copyright Office describing the deposit.
7
+
8
+ Secret findings are recorded by masked excerpt only - the manifest must not
9
+ become a second copy of the credential.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from .. import DISPLAY_NAME, __version__
19
+ from .pipeline import EstimateResult
20
+
21
+
22
+ def build_manifest(estimate: EstimateResult, outputs: list[str]) -> dict:
23
+ settings = estimate.settings
24
+ layout = estimate.layout
25
+ selection = estimate.selection
26
+ ranges = {r.rel_path: r for r in layout.file_ranges}
27
+ listed = {
28
+ item.rel_path: item.source for item in estimate.plan.items
29
+ }
30
+
31
+ files = []
32
+ for order, prepared in enumerate(estimate.prepared, start=1):
33
+ page_range = ranges.get(prepared.rel_path)
34
+ files.append(
35
+ {
36
+ "order": order,
37
+ "path": prepared.rel_path,
38
+ "sha256": prepared.sha256,
39
+ "language": prepared.language,
40
+ "encoding": prepared.encoding,
41
+ "source_lines": len(prepared.lines),
42
+ "total_lines_in_file": prepared.total_lines,
43
+ "rendered_lines": page_range.rendered_lines if page_range else 0,
44
+ "first_page": page_range.first_page if page_range else None,
45
+ "last_page": page_range.last_page if page_range else None,
46
+ "strip_method": prepared.strip.method,
47
+ "lines_removed": prepared.strip.removed_lines,
48
+ "partial": prepared.selection.partial,
49
+ "line_ranges": prepared.range_spec,
50
+ "line_range_label": prepared.selection.label,
51
+ "lines_omitted_by_range": prepared.selection.omitted_lines,
52
+ "redacted_chars": prepared.redaction.redacted_chars,
53
+ "origin": listed.get(prepared.rel_path, "unlisted"),
54
+ "warnings": prepared.warnings,
55
+ }
56
+ )
57
+
58
+ return {
59
+ "tool": {"name": DISPLAY_NAME, "version": __version__},
60
+ "generated_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
61
+ "settings_fingerprint": settings.fingerprint(),
62
+ "source_root": estimate.discovery.root,
63
+ "identification": settings.header.lines(),
64
+ "page_grid": estimate.geometry_note,
65
+ "totals": {
66
+ "files": len(estimate.prepared),
67
+ "partial_files": sum(1 for p in estimate.prepared if p.selection.partial),
68
+ "source_lines": layout.source_lines,
69
+ "rendered_lines": layout.rendered_lines,
70
+ "wrapped_lines": layout.wrapped_lines,
71
+ "pages": layout.page_count,
72
+ "glyph_replacements": layout.glyph_replacements,
73
+ },
74
+ "deposit": {
75
+ "mode": selection.mode,
76
+ "total_pages": selection.total_pages,
77
+ "deposited_pages": selection.deposited_pages,
78
+ "omitted_pages": list(selection.omitted) if selection.omitted else None,
79
+ "filing_statement": selection.filing_statement(),
80
+ "files_entirely_omitted": estimate.omitted_files(),
81
+ "files_partially_shown": estimate.split_files(),
82
+ },
83
+ "redaction": {
84
+ "enabled": settings.redaction.enabled,
85
+ "redacted_chars": estimate.redaction_report.redacted_chars,
86
+ "total_chars": estimate.redaction_report.total_chars,
87
+ "ratio": round(estimate.redaction_report.ratio, 6),
88
+ "within_721_7_limit": estimate.redaction_report.compliant,
89
+ },
90
+ "order": {
91
+ "entries": settings.order_entries,
92
+ "unmatched": estimate.plan.unmatched,
93
+ "ambiguities": [
94
+ {"entry": a.entry, "candidates": a.candidates, "chosen": a.chosen}
95
+ for a in estimate.plan.ambiguities
96
+ ],
97
+ "excluded": estimate.plan.excluded,
98
+ },
99
+ "files": files,
100
+ "skipped": [
101
+ {"path": s.rel_path, "reason": s.reason, "size": s.size}
102
+ for s in estimate.discovery.skipped
103
+ ],
104
+ "findings": {
105
+ "secrets": [
106
+ {
107
+ "id": f.id,
108
+ "location": f.location(),
109
+ "rule": f.rule,
110
+ "detail": f.detail,
111
+ "severity": f.severity,
112
+ "masked_excerpt": f.excerpt,
113
+ }
114
+ for f in estimate.scan.sorted_secrets()
115
+ ],
116
+ "third_party": [
117
+ {
118
+ "id": f.id,
119
+ "location": f.location(),
120
+ "rule": f.rule,
121
+ "detail": f.detail,
122
+ "severity": f.severity,
123
+ }
124
+ for f in estimate.scan.sorted_third_party()
125
+ ],
126
+ },
127
+ "warnings": estimate.warnings,
128
+ "outputs": outputs,
129
+ "settings": settings.to_dict(),
130
+ }
131
+
132
+
133
+ def format_summary(manifest: dict) -> str:
134
+ lines: list[str] = []
135
+ add = lines.append
136
+
137
+ add(f"{manifest['tool']['name'].upper()} - DEPOSIT BUILD SUMMARY")
138
+ add("=" * 72)
139
+ add(f"Generated : {manifest['generated_utc']}")
140
+ add(f"Tool : {manifest['tool']['name']} {manifest['tool']['version']}")
141
+ add(f"Source : {manifest['source_root']}")
142
+ add(f"Page grid : {manifest['page_grid']}")
143
+ add("")
144
+
145
+ add("IDENTIFICATION BLOCK (first page of the deposit)")
146
+ add("-" * 72)
147
+ for line in manifest["identification"]:
148
+ add(f" {line}")
149
+ add("")
150
+
151
+ totals = manifest["totals"]
152
+ deposit = manifest["deposit"]
153
+ add("DEPOSIT")
154
+ add("-" * 72)
155
+ add(f" Files included : {totals['files']}")
156
+ add(f" Lines rendered : {totals['rendered_lines']} ({totals['wrapped_lines']} wrapped)")
157
+ add(f" Complete program : {deposit['total_pages']} page(s)")
158
+ add(f" Pages deposited : {deposit['deposited_pages']}")
159
+ if deposit["omitted_pages"]:
160
+ first, last = deposit["omitted_pages"]
161
+ add(f" Pages omitted : {first}-{last}")
162
+ add("")
163
+ add(" Statement for the application:")
164
+ for chunk in _wrap(deposit["filing_statement"], 66):
165
+ add(f" {chunk}")
166
+ add("")
167
+
168
+ if deposit["files_entirely_omitted"]:
169
+ add(" Files that fall entirely inside the omitted pages:")
170
+ for path in deposit["files_entirely_omitted"]:
171
+ add(f" - {path}")
172
+ add("")
173
+
174
+ redaction = manifest["redaction"]
175
+ if redaction["enabled"] and redaction["redacted_chars"]:
176
+ add("REDACTION (Compendium sec. 721.7)")
177
+ add("-" * 72)
178
+ add(
179
+ f" Blocked out: {redaction['redacted_chars']} of {redaction['total_chars']} "
180
+ f"characters ({redaction['ratio'] * 100:.1f}%)"
181
+ )
182
+ add(f" Within the 49% limit: {'yes' if redaction['within_721_7_limit'] else 'NO'}")
183
+ add("")
184
+
185
+ add("FILE ORDER")
186
+ add("-" * 72)
187
+ add(f" {'#':>3} {'pages':>11} {'lines':>6} path")
188
+ for entry in manifest["files"]:
189
+ pages = (
190
+ f"{entry['first_page']}-{entry['last_page']}"
191
+ if entry["first_page"] is not None
192
+ else "-"
193
+ )
194
+ marker = " " if entry["origin"] != "unlisted" else "*"
195
+ suffix = " [PARTIAL]" if entry.get("partial") else ""
196
+ add(
197
+ f" {entry['order']:>3}{marker} {pages:>11} {entry['rendered_lines']:>6} "
198
+ f"{entry['path']}{suffix}"
199
+ )
200
+ add(" (* = not named in the order list; appended automatically)")
201
+ add("")
202
+
203
+ partial = [entry for entry in manifest["files"] if entry.get("partial")]
204
+ if partial:
205
+ add("PARTIAL FILES (only the listed lines were deposited)")
206
+ add("-" * 72)
207
+ for entry in partial:
208
+ add(f" {entry['path']}")
209
+ add(
210
+ f" lines {entry['line_range_label']} included; "
211
+ f"{entry['lines_omitted_by_range']} line(s) omitted"
212
+ )
213
+ add("")
214
+
215
+ secrets = manifest["findings"]["secrets"]
216
+ third_party = manifest["findings"]["third_party"]
217
+ if secrets:
218
+ add("SECRET / PII FINDINGS")
219
+ add("-" * 72)
220
+ for finding in secrets:
221
+ add(f" [{finding['severity']:>6}] {finding['location']} - {finding['detail']} ({finding['masked_excerpt']})")
222
+ add("")
223
+ if third_party:
224
+ add("THIRD-PARTY INDICATORS")
225
+ add("-" * 72)
226
+ for finding in third_party:
227
+ add(f" [{finding['severity']:>6}] {finding['location']} - {finding['detail']}")
228
+ add("")
229
+
230
+ if manifest["skipped"]:
231
+ add("SKIPPED FILES")
232
+ add("-" * 72)
233
+ for entry in manifest["skipped"]:
234
+ add(f" {entry['path']} - {entry['reason']}")
235
+ add("")
236
+
237
+ if manifest["warnings"]:
238
+ add("WARNINGS")
239
+ add("-" * 72)
240
+ for warning in manifest["warnings"]:
241
+ add(f" - {warning}")
242
+ add("")
243
+
244
+ add("OUTPUTS")
245
+ add("-" * 72)
246
+ for path in manifest["outputs"]:
247
+ add(f" {path}")
248
+ add("")
249
+ return "\n".join(lines)
250
+
251
+
252
+ def _wrap(text: str, width: int) -> list[str]:
253
+ words = text.split()
254
+ lines: list[str] = []
255
+ current = ""
256
+ for word in words:
257
+ if current and len(current) + 1 + len(word) > width:
258
+ lines.append(current)
259
+ current = word
260
+ else:
261
+ current = f"{current} {word}".strip()
262
+ if current:
263
+ lines.append(current)
264
+ return lines or [""]
265
+
266
+
267
+ def write_manifest(
268
+ estimate: EstimateResult,
269
+ out_dir: str | Path,
270
+ base: str,
271
+ outputs: list[str],
272
+ ) -> tuple[str, str]:
273
+ out_dir = Path(out_dir)
274
+ out_dir.mkdir(parents=True, exist_ok=True)
275
+
276
+ manifest = build_manifest(estimate, outputs)
277
+ manifest_path = out_dir / f"{base}_manifest.json"
278
+ summary_path = out_dir / f"{base}_summary.txt"
279
+
280
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
281
+ summary_path.write_text(format_summary(manifest), encoding="utf-8")
282
+ return str(manifest_path), str(summary_path)
@@ -0,0 +1,279 @@
1
+ """Page geometry and font metrics.
2
+
3
+ The whole design rests on one property: with a monospaced font and a fixed
4
+ number of lines per page, **page count is a pure function of the character
5
+ stream**. Nothing here consults the renderer, so the estimator and the
6
+ renderer cannot disagree.
7
+
8
+ Font policy: Courier is a PDF base-14 face, so it needs no embedding, is
9
+ guaranteed monospaced, renders identically everywhere and carries no
10
+ redistribution question. A TrueType file may be supplied instead; drop one
11
+ in ``assets/fonts`` or name a path in the settings.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from ..config import LayoutOptions
21
+
22
+ PAGE_SIZES: dict[str, tuple[float, float]] = {
23
+ "letter": (612.0, 792.0), # 8.5 x 11 in
24
+ "a4": (595.276, 841.890),
25
+ }
26
+
27
+ BASE14_MONO = {"Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"}
28
+
29
+ HEADER_BAND = 20.0
30
+ FOOTER_BAND = 22.0
31
+
32
+ _ASSET_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class FontSpec:
37
+ regular: str
38
+ bold: str
39
+ size: float
40
+ char_width: float
41
+ embedded: bool
42
+ warnings: tuple[str, ...] = ()
43
+
44
+ def supports(self, ch: str) -> bool:
45
+ return _font_supports(self.regular, ch)
46
+
47
+ def sanitize(self, text: str) -> tuple[str, int]:
48
+ """Replace glyphs the font cannot draw. Returns (text, replacements)."""
49
+ if text.isascii():
50
+ return text, 0
51
+ out: list[str] = []
52
+ replaced = 0
53
+ for ch in text:
54
+ if ch.isascii() or _font_supports(self.regular, ch):
55
+ out.append(ch)
56
+ else:
57
+ out.append("?")
58
+ replaced += 1
59
+ return "".join(out), replaced
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class PageGeometry:
64
+ width: float
65
+ height: float
66
+ margin: float
67
+ lines_per_page: int
68
+ leading: float
69
+ text_top: float
70
+ text_bottom: float
71
+ total_columns: int
72
+ gutter_columns: int
73
+ font: FontSpec
74
+ running_header: bool
75
+ page_numbers: bool
76
+
77
+ @property
78
+ def code_columns(self) -> int:
79
+ return max(8, self.total_columns - self.gutter_columns)
80
+
81
+ def baseline(self, row: int) -> float:
82
+ """Baseline y for the given 0-based row on a page."""
83
+ return self.text_top - self.leading * row - self.font.size
84
+
85
+ @property
86
+ def text_left(self) -> float:
87
+ return self.margin
88
+
89
+ def column_x(self, column: int) -> float:
90
+ return self.margin + column * self.font.char_width
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Font registration
95
+ # ---------------------------------------------------------------------------
96
+
97
+ _registered: dict[str, bool] = {}
98
+ _coverage_cache: dict[str, set[int] | None] = {}
99
+
100
+
101
+ def _register_ttf(path: Path) -> tuple[str, str] | None:
102
+ """Register a TTF (and a bold sibling if present). Returns font names."""
103
+ from reportlab.pdfbase import pdfmetrics
104
+ from reportlab.pdfbase.ttfonts import TTFont
105
+
106
+ family = path.stem
107
+ if _registered.get(family):
108
+ return family, _registered_bold(family)
109
+ try:
110
+ pdfmetrics.registerFont(TTFont(family, str(path)))
111
+ except Exception:
112
+ return None
113
+ _registered[family] = True
114
+
115
+ bold_name = family
116
+ for candidate in (
117
+ path.with_name(path.stem + "-Bold" + path.suffix),
118
+ path.with_name(path.stem + "Bd" + path.suffix),
119
+ path.with_name(path.stem.replace("-Regular", "") + "-Bold" + path.suffix),
120
+ ):
121
+ if candidate.is_file():
122
+ try:
123
+ pdfmetrics.registerFont(TTFont(family + "-Bold", str(candidate)))
124
+ bold_name = family + "-Bold"
125
+ _registered[family + "-Bold"] = True
126
+ break
127
+ except Exception:
128
+ continue
129
+ return family, bold_name
130
+
131
+
132
+ def _registered_bold(family: str) -> str:
133
+ return family + "-Bold" if _registered.get(family + "-Bold") else family
134
+
135
+
136
+ def _bundled_font() -> Path | None:
137
+ if not _ASSET_DIR.is_dir():
138
+ return None
139
+ for path in sorted(_ASSET_DIR.glob("*.ttf")):
140
+ if "bold" not in path.stem.lower() and "italic" not in path.stem.lower():
141
+ return path
142
+ return None
143
+
144
+
145
+ def _font_supports(font_name: str, ch: str) -> bool:
146
+ coverage = _coverage_cache.get(font_name)
147
+ if coverage is None and font_name not in _coverage_cache:
148
+ coverage = _load_coverage(font_name)
149
+ _coverage_cache[font_name] = coverage
150
+ if coverage is None:
151
+ # Base-14 fonts use WinAnsi; cp1252 encodability is the real test.
152
+ try:
153
+ ch.encode("cp1252")
154
+ return True
155
+ except UnicodeEncodeError:
156
+ return False
157
+ return ord(ch) in coverage
158
+
159
+
160
+ def _load_coverage(font_name: str) -> set[int] | None:
161
+ from reportlab.pdfbase import pdfmetrics
162
+
163
+ try:
164
+ font = pdfmetrics.getFont(font_name)
165
+ except Exception:
166
+ return None
167
+ face = getattr(font, "face", None)
168
+ char_to_glyph = getattr(face, "charToGlyph", None)
169
+ if not char_to_glyph:
170
+ return None
171
+ return set(char_to_glyph.keys())
172
+
173
+
174
+ def resolve_font(options: LayoutOptions) -> FontSpec:
175
+ """Pick the deposit font, preferring an explicit TTF, then a bundled one."""
176
+ warnings: list[str] = []
177
+ name = (options.font_name or "Courier").strip()
178
+ regular = "Courier"
179
+ bold = "Courier-Bold"
180
+ embedded = False
181
+
182
+ candidate: Path | None = None
183
+ if name.lower().endswith(".ttf"):
184
+ candidate = Path(name)
185
+ if not candidate.is_file():
186
+ warnings.append(f"Font file not found: {name}; using Courier.")
187
+ candidate = None
188
+ elif name in BASE14_MONO:
189
+ candidate = None
190
+ else:
191
+ asset = _ASSET_DIR / f"{name}.ttf"
192
+ candidate = asset if asset.is_file() else None
193
+ if candidate is None and name not in BASE14_MONO:
194
+ candidate = _bundled_font()
195
+
196
+ if candidate is None and name in ("", "auto"):
197
+ candidate = _bundled_font()
198
+
199
+ if candidate is not None:
200
+ registered = _register_ttf(candidate)
201
+ if registered:
202
+ regular, bold = registered
203
+ embedded = True
204
+ else:
205
+ warnings.append(f"Could not embed {candidate.name}; using Courier.")
206
+
207
+ from reportlab.pdfbase import pdfmetrics
208
+
209
+ size = float(options.font_size)
210
+ narrow = pdfmetrics.stringWidth("i", regular, size)
211
+ wide = pdfmetrics.stringWidth("W", regular, size)
212
+ if abs(narrow - wide) > 0.01:
213
+ warnings.append(
214
+ f"'{regular}' is not monospaced; the Copyright Office requires a "
215
+ "monospaced face. Falling back to Courier."
216
+ )
217
+ regular, bold, embedded = "Courier", "Courier-Bold", False
218
+ wide = pdfmetrics.stringWidth("W", regular, size)
219
+
220
+ return FontSpec(
221
+ regular=regular,
222
+ bold=bold,
223
+ size=size,
224
+ char_width=wide,
225
+ embedded=embedded,
226
+ warnings=tuple(warnings),
227
+ )
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Geometry
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ def build_geometry(options: LayoutOptions, max_line_number: int = 0) -> PageGeometry:
236
+ """Derive the fixed page grid from the layout options."""
237
+ font = resolve_font(options)
238
+ width, height = PAGE_SIZES.get(options.page_size.lower(), PAGE_SIZES["letter"])
239
+ margin = float(options.margin)
240
+
241
+ header_band = HEADER_BAND if options.running_header else 0.0
242
+ footer_band = FOOTER_BAND if options.page_numbers else 0.0
243
+ text_top = height - margin - header_band
244
+ text_bottom = margin + footer_band
245
+
246
+ lines_per_page = max(1, int(options.lines_per_page))
247
+ leading = (text_top - text_bottom) / lines_per_page
248
+
249
+ usable_width = width - 2 * margin
250
+ total_columns = max(20, int(math.floor(usable_width / font.char_width + 1e-9)))
251
+
252
+ gutter = 0
253
+ if options.show_line_numbers:
254
+ gutter = max(4, len(str(max(max_line_number, 1)))) + 1
255
+
256
+ return PageGeometry(
257
+ width=width,
258
+ height=height,
259
+ margin=margin,
260
+ lines_per_page=lines_per_page,
261
+ leading=leading,
262
+ text_top=text_top,
263
+ text_bottom=text_bottom,
264
+ total_columns=total_columns,
265
+ gutter_columns=gutter,
266
+ font=font,
267
+ running_header=options.running_header,
268
+ page_numbers=options.page_numbers,
269
+ )
270
+
271
+
272
+ def describe(geometry: PageGeometry) -> str:
273
+ """One-line summary shown in the GUI and written to the manifest."""
274
+ return (
275
+ f"{geometry.font.regular} {geometry.font.size:g}pt, "
276
+ f"{geometry.lines_per_page} lines/page, "
277
+ f"{geometry.code_columns} columns, "
278
+ f"leading {geometry.leading:.2f}pt"
279
+ )