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.
- codendium-1.0.0.dist-info/METADATA +332 -0
- codendium-1.0.0.dist-info/RECORD +45 -0
- codendium-1.0.0.dist-info/WHEEL +5 -0
- codendium-1.0.0.dist-info/entry_points.txt +6 -0
- codendium-1.0.0.dist-info/licenses/LICENSE +201 -0
- codendium-1.0.0.dist-info/top_level.txt +1 -0
- copyright_deposit/__init__.py +15 -0
- copyright_deposit/__main__.py +34 -0
- copyright_deposit/assets/__init__.py +5 -0
- copyright_deposit/assets/fonts/README.md +31 -0
- copyright_deposit/assets/logo.svg +26 -0
- copyright_deposit/cli.py +314 -0
- copyright_deposit/config.py +269 -0
- copyright_deposit/core/__init__.py +1 -0
- copyright_deposit/core/deposit.py +117 -0
- copyright_deposit/core/discovery.py +260 -0
- copyright_deposit/core/encoding.py +136 -0
- copyright_deposit/core/languages.py +190 -0
- copyright_deposit/core/layout.py +349 -0
- copyright_deposit/core/lineranges.py +219 -0
- copyright_deposit/core/manifest.py +282 -0
- copyright_deposit/core/metrics.py +279 -0
- copyright_deposit/core/ordering.py +349 -0
- copyright_deposit/core/pipeline.py +386 -0
- copyright_deposit/core/redaction.py +162 -0
- copyright_deposit/core/render.py +242 -0
- copyright_deposit/core/scanning/__init__.py +61 -0
- copyright_deposit/core/scanning/secrets.py +181 -0
- copyright_deposit/core/scanning/thirdparty.py +190 -0
- copyright_deposit/core/strip/__init__.py +337 -0
- copyright_deposit/core/strip/cfamily_strip.py +235 -0
- copyright_deposit/core/strip/pygments_strip.py +85 -0
- copyright_deposit/core/strip/python_strip.py +131 -0
- copyright_deposit/gui/__init__.py +1 -0
- copyright_deposit/gui/app.py +34 -0
- copyright_deposit/gui/branding.py +83 -0
- copyright_deposit/gui/history.py +192 -0
- copyright_deposit/gui/main_window.py +617 -0
- copyright_deposit/gui/panels/__init__.py +1 -0
- copyright_deposit/gui/panels/estimate.py +166 -0
- copyright_deposit/gui/panels/files.py +635 -0
- copyright_deposit/gui/panels/identification.py +193 -0
- copyright_deposit/gui/panels/options.py +445 -0
- copyright_deposit/gui/panels/preflight.py +260 -0
- 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
|
+
)
|