Compare commits

..

5 Commits

Author SHA1 Message Date
imple baeb325922 Improve explanations for DP0* callsigns 2026-07-02 17:57:30 +02:00
imple 4cb6412d4f Scale SVG images bigger 2026-06-24 13:46:39 +02:00
imple 3179f54681 Pull explanations from 50ohm.de when available 2026-06-24 13:09:52 +02:00
imple 4a14bbb989 Drop answer-letter references from explanations 2026-06-22 11:44:34 +02:00
imple 989d6d6888 Update documentation 2026-06-22 11:44:10 +02:00
6 changed files with 1374 additions and 555 deletions
+5 -2
View File
@@ -163,8 +163,11 @@ decks never collide on import.
- `EXPLANATIONS.md` — the editorial contract for agents asked to add
or improve per-question explanations. The schema, the workflows
("explain everything unexplained", "improve everything below
confidence 7"), and the source/confidence guidance live there.
`explanations.json` is an empty `{}` until agents populate it.
confidence 7"), the source/confidence guidance, and the **MathJax
typesetting rules (§4a) with a verification sweep** live there. The
`$...$` typesetting is the most error-prone part of the file — read
§4a before touching any formula, and fix every occurrence in both the
explanation body and the `Hilfsmittel:` note.
- Start from `DESIGN.md` — it has the JSON schema, the question/answer
conventions (answer A is always correct upstream → consumers shuffle
before display), the LaTeX-in-questions caveat, the exam-structure
+154 -9
View File
@@ -27,7 +27,8 @@ entry is purely additive — no regenerate ceremony beyond
### Per-entry schema
Every entry MUST have exactly these four fields:
Every entry MUST have these four required fields, and MAY carry the one
optional field below:
| Field | Type | Constraint |
|---------------|---------|--------------------------------------------|
@@ -35,13 +36,22 @@ Every entry MUST have exactly these four fields:
| `explanation` | string | Non-empty. **English.** Correct & helpful, WHY-focused |
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
| `confidence` | integer | `1..10` inclusive. See scale in §5 |
| `provenance` | string | *Optional.* Only allowed value: `"50ohm-loesungsweg"` |
Extra keys are rejected by `load_explanations()` — the build fails
with `unknown fields [...]` listing them. The loader is similarly
strict about types: a JSON `true` will not satisfy the integer
contract for `revision` or `confidence`. If you need to track
editorial metadata that isn't shown on the card, propose a schema
change rather than smuggling fields in.
`provenance` records **how the text was produced**, which `source` (a
citation) does not. Set it to `"50ohm-loesungsweg"` only on entries
that are genuinely a translation/condensation of a 50ohm.de worked
solution (`contents/solutions/<ID>.md` in `DARC-e-V/50ohm-contents-dl`).
The build uses it — and *not* the `source` domain — to decide whether
to show the CC BY 4.0 derivative-work credit on the card (CC BY
requires naming the author team and indicating modification). Do **not**
add it just because an entry cites a 50ohm.de study page; merely citing
a page is not a derivative work. Any other value, or any other extra
field, is rejected by `load_explanations()` with
`unknown fields [...]` / `provenance must be one of [...]`. The loader
is also strict about types: a JSON `true` will not satisfy the integer
contract for `revision` or `confidence`. To track other editorial
metadata, propose a schema change rather than smuggling fields in.
Top-level keys (the question numbers) must also match the catalog
exactly. An entry keyed on a number that no live question carries
@@ -156,13 +166,137 @@ source citation.
rule of thumb.
- **Math.** Use inline `$...$`; it's rewritten to MathJax on the
card, same as for question text. Don't use display math `$$...$$`
(the deck has no MathJax support for those).
(the deck has no MathJax support for those). Getting the *inside* of
`$...$` right is its own discipline — see **§4a Math typesetting**
below. This is the single most error-prone part of this file.
- **Inline emphasis.** `<u>...</u>` is the only HTML you should
type by hand. Everything else gets HTML-escaped.
- **No spoilers about other answers.** If a distractor is a common
trap, naming the trap is fine; quoting the distractor's text is
noise.
### 4a. Math typesetting (MathJax) — get this right
Everything between `$...$` is TeX. MathJax renders **bare multi-letter
runs as a product of italic variables**, so `ohm` shows as *o·h·m*,
`log10` as *l·o·g·10*, `P_{PEP}` as *P·(P·E·P)*. A whole multi-round
review on this repo was spent fixing exactly these. The rules below are
the checklist; wrong → right.
**Units** — never leave a unit as bare letters; wrap in `\text{}` (or use
the symbol) and put a thin space `\,` after the number:
| Wrong (in `$...$`) | Right |
|---|---|
| `0.01 ohm`, `10 kOhm`, `5 MOhm` | `0.01\,\Omega`, `10\,\text{k}\Omega`, `5\,\text{M}\Omega` |
| `3 microhenry`, `3 uH` | `3\,\mu\text{H}` |
| `1 uF`, `100 nF`, `47 pF` | `1\,\mu\text{F}`, `100\,\text{nF}`, `47\,\text{pF}` |
| `5 mA`, `2 mV`, `1 kHz`, `28 MHz` | `5\,\text{mA}`, `2\,\text{mV}`, `1\,\text{kHz}`, `28\,\text{MHz}` |
| `12 dB`, `8 dBi`, `46.8 bit/s` | `12\,\text{dB}`, `8\,\text{dBi}`, `46.8\,\text{bit/s}` |
| `5 mm2`, `2.5 A/mm2` | `5\,\text{mm}^2`, `2.5\,\text{A/mm}^2` |
| `360 degrees`, `45°` | `360^\circ`, `45^\circ` |
| `20 A`, `12 V`, `100 W`, `5 m`, `50 s` | `20\,\text{A}`, `12\,\text{V}`, `100\,\text{W}`, `5\,\text{m}`, `50\,\text{s}` |
**Operators, functions, Greek, powers:**
| Wrong | Right | Why |
|---|---|---|
| `log10(x)` | `\log_{10}(x)` | bare `log` is *l·o·g* |
| `10^(20/10)` | `10^{20/10}` | `^(` superscripts only the `(`; braces group |
| `lambda`, `pi`, `tau`, `omega`, `rho`, `mu` | `\lambda`, `\pi`, `\tau`, … | bare = italic letters |
| `sin`, `cos`, `ln` | `\sin`, `\cos`, `\ln` | |
| `2 x 3`, `R1 || R2` | `2 \cdot 3`, `R_1 \parallel R_2` | `x`/`||` are not operators |
| `62.5%` | `62.5\%` | **bare `%` is a TeX comment — it silently eats the rest of the math** |
| `4,200,000` | `4{,}200{,}000` | bare `,` gets TeX punctuation spacing |
**Subscripts.** Descriptive multi-letter labels/acronyms are roman; a
single-letter subscript stays italic:
| Wrong | Right |
|---|---|
| `P_{EIRP}`, `U_{peak}`, `f_{mod}`, `V_{BE}`, `f_{sum}` | `P_\mathrm{EIRP}`, `U_\mathrm{peak}`, `f_\mathrm{mod}`, `V_\mathrm{BE}`, `f_\mathrm{sum}` |
| `R1/R2 = R3/R4` | `R_1/R_2 = R_3/R_4` |
| keep italic: `U_F`, `f_c`, `X_C`, `R_g` | (single letter = variable, leave as-is) |
A standalone acronym used as a variable (e.g. `MUF` on a formula's LHS)
also goes roman: `\mathrm{MUF}`.
**Dimensional constants keep their unit.** The field-strength constant is
`30\,\Omega`, so write `\sqrt{30\,\Omega \cdot P_\mathrm{EIRP}}` and
`(E\cdot d)^2/(30\,\Omega)`, not a bare `30`. Same idea for the dB-level
exponent: `10^{g/(10\,\text{dB})}` (the `(...)` groups the denominator —
`10^{g/10\,\text{dB}}` parses as `(g/10)·dB`).
**Unit vs. variable — don't blindly unit-ize a single letter.** `A` is
amperes in `20 A` but *area* in `N^2 A/l`; `s`/`m` are seconds/metres in
`50 s` / `5 m` but could be variables elsewhere. The tell: a *free-standing
number* immediately before the letter (`20 A`) means a unit; an exponent
base (`N^2 A`) or a symbolic factor (`S \cdot A`) means a variable. When
unsure, read the sentence.
**What stays prose — do NOT force into `$...$`:**
- Band/wavelength designations: `5/8-wave`, `20/15/10 m trap dipole`,
`the 80 m band`.
- Conceptual word-equations: `1st overtone = 2nd harmonic`,
`Region 1 = Europe/Africa`.
- Units mentioned adjectivally in a sentence: "a 50 ohm antenna",
"the 28 V/m limit", "about 11.7 V/m".
But a **worked computation with an `=`** (e.g. `230 V / 20 = 11.5 V`)
belongs in `$...$`, fully typeset.
### Verifying it — the process lessons
These review rounds kept finding *peers* of an already-fixed defect.
Avoid the repeat:
1. **Fix the whole record, not a substring.** The same formula usually
appears in *both* the explanation body *and* the `Hilfsmittel:` note.
Search the entire entry and fix every occurrence.
2. **Fix the whole class, file-wide — not just the IDs someone listed.**
If `P_{EIRP}` is wrong in one card it is wrong in twenty; sweep the
whole file for the pattern.
3. **Verify with a positive sweep, not a blacklist.** A list of
known-bad tokens always misses a new category (it missed `degrees`,
`bit`, `||`, bare seconds…). Instead: strip `\text{}`/`\mathrm{}` and
the known macros, then flag *every remaining* ≥2-letter run inside
`$...$` — each survivor must be a deliberate variable product
(`LC`, `jX`, `RC`, `di/dt`) or it's a bug.
4. **Don't claim "0 / exhaustive" unless the check actually covers that
category.** Report what you checked, not a blanket adjective.
A ready-made sweep:
```sh
python3 -c '
import json, re
d = json.load(open("explanations.json"))
span = re.compile(r"\$[^$]*\$")
def strip(s):
s = re.sub(r"\\(?:text|mathrm|operatorname)\{[^{}]*\}", " ", s)
return re.sub(r"\\[A-Za-z]+", " ", s) # drop macros (\sqrt, \pi, \cdot, ...)
for k, e in d.items():
ex = e["explanation"] if isinstance(e, dict) else ""
if ex.count("$") % 2 or ex.count("{") != ex.count("}"):
print("BALANCE", k)
for m in span.finditer(ex):
s = m.group(0)
if re.search(r"(?<!\\)%", s) or "||" in s or "°" in s \
or re.search(r"(?<!\\)\blog10\b|10\^\(", s) \
or re.search(r"_\{[A-Za-z][A-Za-z,]*\}", s) \
or re.search(r"(?<=\d),(?=\d\d\d)", s):
print("ISSUE", k, m.group(0)[:60]); break
for tok in re.findall(r"[A-Za-z]{2,}", strip(s)):
# whitelist genuine products / kept tokens; investigate the rest
if tok not in {"LC","RC","RA","UI","CU","PR","jX","fL","fC","di","dt"}:
print("TOKEN?", k, tok, "|", m.group(0)[:60])
'
```
Anything it prints is either a real defect or a new legitimate product to
add to the whitelist — look, don't assume.
### What "WHY-focused" means in practice
| Less useful | More useful |
@@ -455,7 +589,18 @@ Before saving, confirm:
- [ ] `confidence` honestly reflects how well the source supports
the explanation.
- [ ] Keys remain in alphabetical order in the file.
- [ ] `python3 -m unittest test_amateurfunk_anki` passes.
- [ ] **Math typesetting (§4a):** every formula is in `$...$`; units are
`\text{}`/symbols with a leading `\,`; no bare `log10`, `10^(`,
Greek words, `%`, `||`, multi-letter `_{...}` subscripts, or raw
thousands commas; dimensional constants keep their unit. Fix every
occurrence in **both** the body and the `Hilfsmittel:` note.
- [ ] The §4a verification sweep prints nothing unexpected (run it after
any batch of math edits — don't trust a per-ID fix to have caught
the peers).
- [ ] `python3 -m unittest test_amateurfunk_anki` passes. (The full deck
suite is `test_amateurfunk_fetch test_amateurfunk_anki
test_amateurfunk_shorthand test_amateurfunk_technical` — 95 tests;
the two-module subset that validates explanation schema is 65.)
---
+241 -12
View File
@@ -63,6 +63,17 @@ DEFAULT_EXPLANATIONS_PATH = Path("explanations.json")
# EXPLANATIONS.md for the full editorial contract.
EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
# Optional, validated provenance marker. A `source` URL is only a
# citation; it does NOT establish that the explanation is a derivative
# work. `provenance` records *how the text was produced* so the CC BY
# derivative-work credit is shown only for records that genuinely are
# one. The single recognised value marks an explanation translated and
# condensed from a 50ohm.de worked solution
# (contents/solutions/<ID>.md in DARC-e-V/50ohm-contents-dl).
PROVENANCE_50OHM_SOLUTION = "50ohm-loesungsweg"
ALLOWED_PROVENANCE = frozenset({PROVENANCE_50OHM_SOLUTION})
OPTIONAL_EXPLANATION_FIELDS = ("provenance",)
# Confidence values strictly below this threshold are surfaced on
# the card as a "low confidence" badge — a learner-facing warning
# that the explanation may be incomplete or weakly sourced. Matches
@@ -70,6 +81,49 @@ EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
# below 7 is review-worthy").
LOW_CONFIDENCE_THRESHOLD = 7
# ---- Attribution (licensing) ----------------------------------------------
# Two sources are redistributed in these decks and each carries an
# attribution obligation:
# * the questions/figures come from the BNetzA question catalog under
# Datenlizenz Deutschland Namensnennung 2.0 (DL-DE→BY-2.0); and
# * the English explanations are translated and condensed from the
# worked solutions on 50ohm.de, which is CC BY 4.0 and requires both
# naming the author team and indicating that changes were made.
# DECK_ATTRIBUTION goes in every deck's description (the whole-deck
# notice); EXPLANATION_CC_BY_CREDIT is the per-card line appended to
# each 50ohm-derived explanation so the "modified" indication travels
# with the modified text itself.
EXPLANATION_CC_BY_CREDIT = (
"Explanation translated &amp; condensed from the worked solution on "
'<a href="https://50ohm.de/">50ohm.de</a> — '
"© 50ohm.de-Autorenteam (AJW-Referat, DARC e.&nbsp;V.), "
'<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>. '
"Question © Bundesnetzagentur, "
'<a href="https://www.govdata.de/dl-de/by-2-0">DL-DE→BY-2.0</a>.'
)
DECK_ATTRIBUTION = (
"Amateurfunk-Lernkarten / amateur-radio exam flashcards.<br><br>"
"<b>Questions &amp; figures:</b> official question catalog "
"&quot;Prüfungsfragen zum Erwerb von "
"Amateurfunkprüfungsbescheinigungen&quot;, Bundesnetzagentur, "
"3. Auflage, März 2024 "
"(https://www.bundesnetzagentur.de/amateurfunk). Licensed under "
"Datenlizenz Deutschland Namensnennung Version 2.0 "
"(https://www.govdata.de/dl-de/by-2-0).<br><br>"
"<b>Explanations:</b> written for this project. Where an explanation "
"is marked as derived from a 50ohm.de worked solution "
"(&quot;Lösungsweg&quot;), that text is © 50ohm.de-Autorenteam, "
"koordiniert durch das AJW-Referat des DARC e. V., licensed under "
"CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), and has "
"been modified — translated from German and shortened. Other "
"explanations cite their own sources.<br><br>"
"<b>Exam-aid references</b> cite the BNetzA &quot;Hilfsmittel&quot; "
"formula sheet.<br><br>"
"This deck is an independent project and is not endorsed by the "
"Bundesnetzagentur or by the DARC e. V."
)
# Exit codes. The builder is much simpler than the fetcher — there is
# no "operator must resolve local state" case here, so two codes are
# enough.
@@ -111,6 +165,23 @@ TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse"
# inside the package as a deck tree.
TOPIC_SPLIT_CLASSES = frozenset({"E", "A"})
# The catalog's figures come in two kinds: vector SVGs (the bulk; mostly
# small — well under ~200 px) and a handful of raster PNGs (all 1024×685). We
# enlarge only the SVGs: they scale crisply, they're the ones that read tiny
# on a wide desktop card, and the PNGs are already at roughly the size we're
# aiming for. The enlargement is `MEDIA_SCALE`× the native size, with
# `MEDIA_MAX_WIDTH` × `MEDIA_MAX_HEIGHT` acting as an *enlargement ceiling* —
# it bounds how far a below-cap figure may grow (a figure already at/above a
# cap is left at native size; we never shrink). The ceiling is the PNGs'
# ballpark (1024×768) so the largest SVG schematics (up to 635×481 px in the
# 3rd edition: the NG20x block diagrams, EB309/EB310) grow to a comparable
# size rather than staying cramped, while folding in native *height* still
# stops tall-but-narrow figures from growing off the bottom of the card.
# See `MediaRegistry.image_html`.
MEDIA_SCALE = 2
MEDIA_MAX_WIDTH = 1024
MEDIA_MAX_HEIGHT = 768
# Fallback build epoch if neither the manifest nor `--epoch` supplies
# one. Picked as 0 so missing-metadata builds are still deterministic
# and obviously wrong (timestamps would all show 1970).
@@ -246,12 +317,15 @@ def load_explanations(path):
def _validate_explanation(key, value):
"""Raise `AnkiBuildError` if one explanation entry is malformed.
Strict shape check: the four documented fields are required, no
extras allowed, and integer fields reject `bool` (which would
pass `isinstance(..., int)` because `bool` subclasses `int` in
Python). Extras are rejected so a stray `"note"` or `"author"`
field can't accumulate silently and drift from the documented
schema in EXPLANATIONS.md.
Strict shape check: the four documented fields are required and the
optional `provenance` field is allowed (and, if present, must be a
string from `ALLOWED_PROVENANCE`); any *other* extra field is
rejected, so a stray `"note"` or `"author"` field can't accumulate
silently and drift from the documented schema in EXPLANATIONS.md.
Integer fields reject `bool` (which would pass `isinstance(..., int)`
because `bool` subclasses `int` in Python), and every malformed
value — including an unhashable `provenance` — surfaces as an
`AnkiBuildError`, never a raw `TypeError`.
"""
if not isinstance(value, dict):
raise AnkiBuildError(
@@ -262,11 +336,24 @@ def _validate_explanation(key, value):
raise AnkiBuildError(
f"explanation {key!r} missing required fields: {missing}"
)
extra = sorted(set(value) - set(EXPLANATION_FIELDS))
extra = sorted(
set(value) - set(EXPLANATION_FIELDS) - set(OPTIONAL_EXPLANATION_FIELDS)
)
if extra:
raise AnkiBuildError(
f"explanation {key!r}: unknown fields {extra}"
)
if "provenance" in value and (
not isinstance(value["provenance"], str)
or value["provenance"] not in ALLOWED_PROVENANCE
):
# The isinstance check must come first: an unhashable JSON value
# (a list or object) would make the `not in` set test raise a raw
# TypeError instead of the documented AnkiBuildError.
raise AnkiBuildError(
f"explanation {key!r}: provenance must be one of "
f"{sorted(ALLOWED_PROVENANCE)}"
)
if type(value["revision"]) is not int or value["revision"] < 1:
raise AnkiBuildError(
f"explanation {key!r}: revision must be a positive integer"
@@ -619,11 +706,23 @@ def render_explanation(explanation):
'title="This explanation is weakly sourced or incomplete">'
'low confidence</span>'
)
# CC BY 4.0 requires naming the author and indicating modifications.
# Only explanations actually derived from a 50ohm.de worked solution
# carry this credit — gated on the explicit `provenance` marker, NOT
# on the `source` URL (a citation is not provenance; many records
# merely cite a 50ohm study page without being a derivative work).
credit = ""
if explanation.get("provenance") == PROVENANCE_50OHM_SOLUTION:
credit = (
'<div class="af-explanation-credit">'
f'{EXPLANATION_CC_BY_CREDIT}</div>'
)
return (
'<div class="af-explanation">'
f'<div class="af-explanation-header">Explanation{badge}</div>'
f'<div class="af-explanation-body">{body}</div>'
f'<div class="af-explanation-source">Source: {source_html}</div>'
f'{credit}'
'</div>'
)
@@ -755,6 +854,8 @@ class MediaRegistry:
self.used_paths = set()
# references that didn't resolve (recorded for diagnostics)
self.missing = []
# Path → intrinsic (w, h) in px (memoised; None when unreadable)
self._size_cache = {}
self._index_media_dir()
def _index_media_dir(self):
@@ -796,12 +897,43 @@ class MediaRegistry:
return None
def image_html(self, reference):
"""Return an `<img>` tag for `reference`, or `""` if unresolved."""
"""Return an `<img>` tag for `reference`, or `""` if unresolved.
Most catalog SVGs carry small intrinsic dimensions, so on a wide
desktop card they render barely legible (a small fixed-px image
occupies only a sliver of the column; `max-width: 100%` only
ever shrinks, never enlarges). We raise the *preferred* width
via an explicit `width` attribute so the figure grows on every
viewport until the card's `max-width: 100%` cap engages.
Only SVGs are scaled — the catalog's PNGs are already large
(1024×685), so `_intrinsic_svg_size` returns `None` for them and
they fall through to native size. The enlargement factor is
`scale_for_size` — `MEDIA_SCALE`×, clamped at the
`MEDIA_MAX_WIDTH` × `MEDIA_MAX_HEIGHT` enlargement ceiling.
`height: auto` in the CSS keeps the aspect ratio. When the size
can't be read (non-SVG, or an unparseable SVG) we fall back to
native.
"""
path = self.resolve(reference)
if path is None:
return ""
filename = html.escape(path.name, quote=True)
return f'<img src="{filename}" class="af-media">'
size = self._intrinsic_svg_size(path)
if size is None:
return f'<img src="{filename}" class="af-media">'
width, height = size
scale = scale_for_size(width, height)
if scale <= 1.0:
return f'<img src="{filename}" class="af-media">'
scaled = round(width * scale)
return f'<img src="{filename}" class="af-media" width="{scaled}">'
def _intrinsic_svg_size(self, path):
"""Return the SVG's `(width, height)` in px (memoised; SVG only)."""
if path not in self._size_cache:
self._size_cache[path] = intrinsic_svg_size(path)
return self._size_cache[path]
# ============================================================================
@@ -1028,6 +1160,74 @@ def media_bytes_for_package(path):
return svg_with_white_background(text).encode("utf-8")
def scale_for_size(width, height):
"""Return the display scale for a figure of native `width`×`height`.
`MEDIA_SCALE`× for legibility, but never so much that the enlarged
figure would grow past the `MEDIA_MAX_WIDTH` / `MEDIA_MAX_HEIGHT`
ceiling, and never below 1.0 (we only ever enlarge, never shrink —
shrinking is the CSS `max-width`'s job). A figure already at/above
either cap yields exactly 1.0, i.e. "render at native size" — so a
native figure larger than the ceiling is left as-is, not shrunk.
"""
if width <= 0 or height <= 0:
return 1.0
scale = min(
MEDIA_SCALE,
MEDIA_MAX_WIDTH / width,
MEDIA_MAX_HEIGHT / height,
)
return max(scale, 1.0)
def _svg_dimension(svg_tag, name):
"""Parse a `width`/`height` length (px or unitless) off an `<svg>` tag.
The numeric pattern is a well-formed decimal (`12`, `12.7`, `.5`) so
malformed values like `1.2.3` or a lone `.` simply don't match and
yield `None` rather than reaching `float()`. We still guard the
conversion with `try/except` so no parse can ever abort the build.
"""
attr = re.search(
rf'\b{name}\s*=\s*"(\d+(?:\.\d+)?|\.\d+)\s*(?:px)?"',
svg_tag,
flags=re.IGNORECASE,
)
if not attr:
return None
try:
return float(attr.group(1))
except ValueError:
return None
def intrinsic_svg_size(path):
"""Return an SVG's intrinsic `(width, height)` in CSS px, or `None`.
Read from the `width`/`height` attributes on the root `<svg>` tag
(the catalog's SVGs use unitless user units, which map 1:1 to px).
Only `.svg` files are handled — we deliberately don't size raster
figures, because the catalog's PNGs are already large and only the
SVGs need enlarging. Anything we can't parse confidently — a non-SVG
file, a percentage dimension, a missing attribute — returns `None`
so the caller falls back to the image's native size.
"""
if path.suffix.lower() != ".svg":
return None
try:
text = path.read_bytes().decode("utf-8-sig", errors="replace")
except OSError:
return None
match = re.search(r"<svg\b[^>]*>", text, flags=re.IGNORECASE)
if not match:
return None
width = _svg_dimension(match.group(0), "width")
height = _svg_dimension(match.group(0), "height")
if width is None or height is None:
return None
return (width, height)
def svg_with_white_background(svg_text):
"""Inject a white background `<rect>` after the opening `<svg>`.
@@ -1218,8 +1418,15 @@ def insert_collection_metadata(conn, decks, cur_deck_id, model_id, now):
package can ship a deck tree, not just one deck. `cur_deck_id` is
the deck made current in `col.conf`.
"""
# The attribution notice goes on the root (current) deck only, so it
# shows once for the package rather than on every sub-deck.
decks_json = {
str(deck_id): deck_json(deck_id, deck_name, now)
str(deck_id): deck_json(
deck_id,
deck_name,
now,
desc=DECK_ATTRIBUTION if deck_id == cur_deck_id else "",
)
for deck_name, deck_id in decks.items()
}
conn.execute(
@@ -1278,11 +1485,14 @@ def collection_conf(deck_id):
}
def deck_json(deck_id, deck_name, now):
def deck_json(deck_id, deck_name, now, desc=""):
"""Return one deck entry for `col.decks`.
The `Amateurfunk::Foo` naming gives the user a single top-level
Amateurfunk node in Anki's deck browser with three child decks.
`desc` is the deck description shown in Anki's deck overview; we use
it on the root deck to carry the source-attribution notice (see
DECK_ATTRIBUTION).
"""
return {
"id": deck_id,
@@ -1297,7 +1507,7 @@ def deck_json(deck_id, deck_name, now):
"browserCollapsed": False,
"dyn": 0,
"conf": 1,
"desc": "",
"desc": desc,
"extendNew": 0,
"extendRev": 0,
}
@@ -1502,6 +1712,17 @@ CARD_CSS = """
color: #1a73e8;
text-decoration: none;
}
.af-explanation-credit {
font-family: Arial, sans-serif;
font-style: normal;
font-size: 11px;
color: #999;
margin-top: 0.35rem;
}
.af-explanation-credit a {
color: #1a73e8;
text-decoration: none;
}
.nightMode .af-explanation,
.card.nightMode .af-explanation {
border-top-color: #555;
@@ -1519,6 +1740,14 @@ CARD_CSS = """
.card.nightMode .af-explanation-source a {
color: #8ab4f8;
}
.nightMode .af-explanation-credit,
.card.nightMode .af-explanation-credit {
color: #999;
}
.nightMode .af-explanation-credit a,
.card.nightMode .af-explanation-credit a {
color: #8ab4f8;
}
.nightMode .af-explanation-low-confidence,
.card.nightMode .af-explanation-low-confidence {
background: #4a3510;
+700 -530
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -55,7 +55,9 @@ those are memory items.
- **S. 5 — Tabelle "Rufzeichen mit 2- oder 3-buchstabigen Suffixen":**
the main **call-sign-series → class/use** table. DA0 = Klubstation;
DL1DL9 = person-bound class A; DO1DO9 = class E; DN9 = class N;
DP0DP1 = exterritorial class A; etc.
DP is intended for exterritorial-location call signs; DP0DP1 =
class A with use categories KS, RL/FB and SZ; DP2 = class E with the
same use categories; DP8 = class N with the same use categories.
- **S. 6** — club-station suffixes (1-char) table.
- **S. 7** — club-station suffixes (47-digit); **§ 5** Kurzzeitzulassungen
ausländischer Funkamateure (visitors in Germany sign **DL/** class A,
+270
View File
@@ -420,6 +420,50 @@ class TestAnkiBuild(unittest.TestCase):
self.assertNotIn("af-explanation-low-confidence", back_high)
self.assertNotIn("low confidence", back_high)
def test_cc_by_credit_gated_on_provenance_not_source_domain(self):
"""The CC BY derivative-work credit must follow explicit
provenance, not the citation domain: an explanation merely
citing a 50ohm.de page is NOT a derivative work and must not
carry the credit, while one marked as derived from a worked
solution must."""
derived = {
"revision": 2,
"explanation": "Worked-solution port.",
"source": "https://50ohm.de/NEA_x.html#AD106",
"confidence": 8,
"provenance": aa.PROVENANCE_50OHM_SOLUTION,
}
cited_only = {
"revision": 1,
"explanation": "Independently written, just cites a study page.",
"source": "https://50ohm.de/NEA_x.html#XX101",
"confidence": 8,
}
derived_html = aa.render_explanation(derived)
cited_html = aa.render_explanation(cited_only)
self.assertIn("af-explanation-credit", derived_html)
self.assertIn("CC BY 4.0", derived_html)
self.assertIn("50ohm.de-Autorenteam", derived_html)
self.assertNotIn("af-explanation-credit", cited_html)
self.assertNotIn("translated", cited_html)
def test_root_deck_description_carries_attribution(self):
self._build_all()
apkg = self.out_dir / "amateurfunk-betriebliche-kenntnisse.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / "b")
conn = sqlite3.connect(db_path)
try:
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
finally:
conn.close()
descs = [d["desc"] for d in decks.values() if d["desc"]]
self.assertEqual(len(descs), 1) # only the root deck carries it
desc = descs[0]
self.assertIn("CC BY 4.0", desc)
self.assertIn("govdata.de/dl-de/by-2-0", desc)
self.assertIn("50ohm.de-Autorenteam", desc)
self.assertIn("Bundesnetzagentur", desc)
def test_missing_explanation_leaves_card_unchanged(self):
self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
@@ -501,6 +545,48 @@ class TestAnkiBuild(unittest.TestCase):
self._build_all()
self.assertIn("author", str(ctx.exception))
def test_schema_accepts_valid_provenance(self):
path = self.root / "exp_provenance.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": aa.PROVENANCE_50OHM_SOLUTION,
}}),
encoding="utf-8",
)
loaded = aa.load_explanations(path)
self.assertEqual(
loaded["NA101"]["provenance"], aa.PROVENANCE_50OHM_SOLUTION,
)
def test_schema_rejects_unknown_provenance_value(self):
path = self.root / "exp_badprov.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": "made-up",
}}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
aa.load_explanations(path)
def test_schema_rejects_unhashable_provenance(self):
# A malformed but valid-JSON provenance value (list/object) is
# unhashable; the validator must surface AnkiBuildError, never a
# raw TypeError from the set-membership test.
for bad in ([], {}):
path = self.root / "exp_unhashable.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": bad,
}}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
aa.load_explanations(path)
def test_invalid_explanation_schema_raises_build_error(self):
self.explanations_path.write_text(
json.dumps({
@@ -577,6 +663,162 @@ class TestAnkiBuild(unittest.TestCase):
self.assertFalse(all(byte_equal))
class TestIntrinsicSvgSize(unittest.TestCase):
"""Parsing of native figure dimensions — SVG only."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
def _write(self, name, body):
path = self.root / name
path.write_text(body, encoding="utf-8")
return path
def test_svg_unitless_width_and_height(self):
p = self._write("a.svg", '<svg width="115.5" height="120"><g/></svg>')
self.assertEqual(aa.intrinsic_svg_size(p), (115.5, 120.0))
def test_svg_px_suffix_is_tolerated(self):
p = self._write("a.svg", '<svg width="200px" height="80px"></svg>')
self.assertEqual(aa.intrinsic_svg_size(p), (200.0, 80.0))
def test_svg_percentage_dimension_is_rejected(self):
p = self._write("a.svg", '<svg width="100%" height="100%"></svg>')
self.assertIsNone(aa.intrinsic_svg_size(p))
def test_svg_missing_dimension_returns_none(self):
p = self._write("a.svg", '<svg width="200"></svg>')
self.assertIsNone(aa.intrinsic_svg_size(p))
def test_svg_without_root_tag_returns_none(self):
p = self._write("a.svg", "not an svg at all")
self.assertIsNone(aa.intrinsic_svg_size(p))
def test_svg_leading_decimal_is_accepted(self):
p = self._write("a.svg", '<svg width=".5" height="10"></svg>')
self.assertEqual(aa.intrinsic_svg_size(p), (0.5, 10.0))
def test_svg_malformed_number_returns_none_not_raises(self):
# Permissive matching used to let "1.2.3" reach float() and abort
# the build; now it falls back to None like any other bad value.
for bad in ('1.2.3', '.', '1.', '1..2'):
p = self._write("a.svg", f'<svg width="{bad}" height="10"></svg>')
self.assertIsNone(aa.intrinsic_svg_size(p), bad)
def test_non_svg_is_not_sized(self):
# A PNG with a perfectly valid-looking name is still not sized:
# we deliberately only enlarge SVGs.
p = self.root / "a.png"
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
self.assertIsNone(aa.intrinsic_svg_size(p))
class TestScaleForSize(unittest.TestCase):
"""The clamped enlargement factor."""
def test_small_figure_gets_full_multiplier(self):
self.assertEqual(aa.scale_for_size(115, 120), aa.MEDIA_SCALE)
def test_wide_figure_is_clamped_by_max_width(self):
self.assertEqual(aa.scale_for_size(aa.MEDIA_MAX_WIDTH, 50), 1.0)
self.assertAlmostEqual(
aa.scale_for_size(aa.MEDIA_MAX_WIDTH / 2, 50), 2.0
)
# Between half-cap and cap: enlarged only until width hits the cap.
wide = aa.MEDIA_MAX_WIDTH * 0.75
self.assertAlmostEqual(
aa.scale_for_size(wide, 50), aa.MEDIA_MAX_WIDTH / wide
)
def test_tall_figure_is_clamped_by_max_height(self):
self.assertEqual(aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT), 1.0)
self.assertAlmostEqual(
aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT / 2), 2.0
)
tall = aa.MEDIA_MAX_HEIGHT * 0.75
self.assertAlmostEqual(
aa.scale_for_size(50, tall), aa.MEDIA_MAX_HEIGHT / tall
)
def test_large_catalog_schematic_grows_toward_cap(self):
# NG205-like (635x460): with the 1024x768 cap it now enlarges,
# bounded by width — up to 1024 px wide.
self.assertAlmostEqual(
aa.scale_for_size(635, 460), aa.MEDIA_MAX_WIDTH / 635
)
def test_never_shrinks_below_native(self):
self.assertEqual(aa.scale_for_size(5000, 5000), 1.0)
def test_degenerate_size_is_native(self):
self.assertEqual(aa.scale_for_size(0, 0), 1.0)
class TestImageHtmlScaling(unittest.TestCase):
"""End-to-end `<img>` emission and the per-path size cache."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.media_dir = Path(self.tmp.name) / "svgs"
self.media_dir.mkdir()
def _write(self, name, body):
(self.media_dir / name).write_text(body, encoding="utf-8")
def test_small_figure_emits_doubled_width(self):
self._write("S_q.svg", '<svg width="115" height="120"></svg>')
media = aa.MediaRegistry(self.media_dir)
self.assertIn('width="230"', media.image_html("S_q"))
def test_figure_at_cap_emits_no_width(self):
# Exactly at the cap → scale 1.0 → native size, no width.
self._write(
"L_q.svg",
f'<svg width="{aa.MEDIA_MAX_WIDTH}" '
f'height="{aa.MEDIA_MAX_HEIGHT}"></svg>',
)
media = aa.MediaRegistry(self.media_dir)
html = media.image_html("L_q")
self.assertIn('class="af-media"', html)
self.assertNotIn("width=", html)
def test_png_is_not_scaled(self):
# Only a PNG for this stem (no .svg sibling): emitted at native
# size with no width, because we never size raster figures.
(self.media_dir / "P_q.png").write_bytes(
b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
)
media = aa.MediaRegistry(self.media_dir)
html = media.image_html("P_q")
self.assertIn('src="P_q.png"', html)
self.assertNotIn("width=", html)
def test_unreadable_size_falls_back_to_no_width(self):
self._write("U_q.svg", '<svg width="100%" height="100%"></svg>')
media = aa.MediaRegistry(self.media_dir)
self.assertNotIn("width=", media.image_html("U_q"))
def test_malformed_number_falls_back_without_raising(self):
self._write("M_q.svg", '<svg width="1.2.3" height="10"></svg>')
media = aa.MediaRegistry(self.media_dir)
html = media.image_html("M_q") # must not raise
self.assertIn('src="M_q.svg"', html)
self.assertNotIn("width=", html)
def test_size_is_read_once_per_path(self):
self._write("S_q.svg", '<svg width="115" height="120"></svg>')
media = aa.MediaRegistry(self.media_dir)
with patch.object(
aa, "intrinsic_svg_size", wraps=aa.intrinsic_svg_size
) as spy:
media.image_html("S_q")
media.image_html("S_q")
self.assertEqual(spy.call_count, 1)
class TestRealExplanationsFile(unittest.TestCase):
"""Validate the repo's actual explanations.json against the live catalog.
@@ -617,6 +859,34 @@ class TestRealExplanationsFile(unittest.TestCase):
)
self.assertEqual([], offenders)
def test_safety_distance_block_uses_transmitter_power_not_erp(self):
"""AK106-AK112 must feed the EIRP formula with transmitter power.
The relation is `P_EIRP = P_S · 10^((g_d+2.15-a)/10)` with `P_S`
the transmitter (Sender) power; writing `P_EIRP = P_ERP · 10^…`
double-counts the antenna gain (ERP already includes it). The
upstream 50ohm solutions carry exactly this mislabel, so guard
the whole block against it regressing back in.
"""
repo = Path(__file__).resolve().parent
explanations_path = repo / "explanations.json"
if not explanations_path.exists():
self.skipTest("explanations.json not available")
explanations = aa.load_explanations(explanations_path)
block = [f"AK1{n:02d}" for n in range(6, 13)] # AK106..AK112
for key in block:
if key not in explanations:
continue
body = explanations[key]["explanation"]
self.assertIn(
"P_S", body,
f"{key}: EIRP should be computed from transmitter power P_S",
)
self.assertNotIn(
r"P_\mathrm{EIRP} = P_\mathrm{ERP}", body,
f"{key}: EIRP must not be derived by scaling ERP by the gain",
)
if __name__ == "__main__":
unittest.main()