Add existing to tracked
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python
|
||||
"""Audit finished Side Cards assets for structural and two-way consistency."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from build_deck import (
|
||||
ACE_PIP_SCALE,
|
||||
COURT_PIP_SCALE,
|
||||
INDEX_SUIT_SCALE,
|
||||
NUMBER_PIP_SCALE,
|
||||
RANK_BASELINE,
|
||||
RANK_FONT_PATH,
|
||||
rank_paths,
|
||||
)
|
||||
from size_test import render_svg
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
CARD_DIR = ROOT / "exports" / "cards"
|
||||
COURT_ART_DIR = ROOT / "exports" / "art" / "courts"
|
||||
RANKS = ("a", "2", "3", "4", "5", "6", "7", "8", "9", "10", "j", "q", "k")
|
||||
SUITS = ("s", "h", "d", "c")
|
||||
COURTS = ("j", "q", "k")
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
RANK_FONT_SHA256 = "e6f84f63a76e65bb43c4fe6445e59167a0298a30f172833e5d6e6e08adcf1c8b"
|
||||
|
||||
|
||||
def local_name(element):
|
||||
return element.tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def by_id(root, wanted):
|
||||
return next((element for element in root.iter()
|
||||
if element.attrib.get("id") == wanted), None)
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise SystemExit("AUDIT FAILED: " + message)
|
||||
|
||||
|
||||
def outline_signature(element):
|
||||
return (
|
||||
element.attrib.get("class"),
|
||||
element.attrib.get("transform"),
|
||||
[(child.attrib.get("d"), child.attrib.get("transform"))
|
||||
for child in element if local_name(child) == "path"],
|
||||
)
|
||||
|
||||
|
||||
def audit_club_geometry(root, path):
|
||||
"""Require the traditional bilateral Club construction."""
|
||||
club = by_id(root, "suit-C")
|
||||
if club is None:
|
||||
fail("missing Club definition in " + path.name)
|
||||
circles = [element for element in club
|
||||
if local_name(element) == "circle"]
|
||||
stems = [element for element in club if local_name(element) == "path"]
|
||||
if len(circles) != 4 or len(stems) != 1:
|
||||
fail("Club is not three lobes, a centre boss, and one stem in "
|
||||
+ path.name)
|
||||
geometry = sorted((float(circle.attrib["cx"]),
|
||||
float(circle.attrib["cy"]),
|
||||
float(circle.attrib["r"])) for circle in circles)
|
||||
expected = sorted(((0.0, -15.0, 15.0),
|
||||
(-16.5, 6.0, 15.0),
|
||||
(16.5, 6.0, 15.0),
|
||||
(0.0, -2.0, 12.0)))
|
||||
if geometry != expected:
|
||||
fail("Club has drifted from its bilateral traditional geometry in "
|
||||
+ path.name)
|
||||
if stems[0].attrib.get("d") != \
|
||||
"M-7 8C-6 19-11 25-17 30H17C11 25 6 19 7 8Z":
|
||||
fail("Club stem is no longer short, centred, and flared in "
|
||||
+ path.name)
|
||||
left, right = (-16.5, 6.0), (16.5, 6.0)
|
||||
lower_gap = math.dist(left, right) - 30.0
|
||||
boss = (0.0, -2.0)
|
||||
if lower_gap <= 0 or any(math.dist(boss, point) >= 27.0
|
||||
for point in (left, right)):
|
||||
fail("Club centre boss no longer bridges the lower lobes in "
|
||||
+ path.name)
|
||||
|
||||
|
||||
def audit_card_corner_geometry(root, path):
|
||||
"""Keep the SVG card edge circular and concentric at poker-card scale."""
|
||||
clip = by_id(root, "card-clip")
|
||||
clip_rect = None if clip is None else next(
|
||||
(element for element in clip if local_name(element) == "rect"), None)
|
||||
if clip_rect is None or float(clip_rect.attrib.get("rx", 0)) != 24.0:
|
||||
fail("outer card clip is not radius 24 in " + path.name)
|
||||
outline = next((element for element in root.iter()
|
||||
if local_name(element) == "rect"
|
||||
and element.attrib.get("x") == "2"
|
||||
and element.attrib.get("y") == "2"
|
||||
and element.attrib.get("width") == "496"
|
||||
and element.attrib.get("height") == "696"), None)
|
||||
if outline is None or float(outline.attrib.get("rx", 0)) != 22.0:
|
||||
fail("inset card outline is not concentric radius 22 in " + path.name)
|
||||
|
||||
|
||||
def audit_svg_set():
|
||||
expected = {rank + suit + ".svg" for suit in SUITS for rank in RANKS}
|
||||
expected.add("back.svg")
|
||||
actual = {path.name for path in CARD_DIR.glob("*.svg")}
|
||||
if actual != expected:
|
||||
fail("wrong SVG set; missing=%s extra=%s" % (
|
||||
sorted(expected - actual), sorted(actual - expected)))
|
||||
|
||||
for path in sorted(CARD_DIR.glob("*.svg")):
|
||||
try:
|
||||
root = ElementTree.parse(path).getroot()
|
||||
except ElementTree.ParseError as exc:
|
||||
fail("malformed %s: %s" % (path.name, exc))
|
||||
|
||||
audit_club_geometry(root, path)
|
||||
audit_card_corner_geometry(root, path)
|
||||
|
||||
for element in root.iter():
|
||||
if local_name(element) != "image":
|
||||
continue
|
||||
hrefs = [value for key, value in element.attrib.items()
|
||||
if key.rsplit("}", 1)[-1] == "href"]
|
||||
if not hrefs or any(not value.startswith("data:image/") for value in hrefs):
|
||||
fail("external or missing image reference in " + path.name)
|
||||
|
||||
if path.stem[-1:] not in SUITS:
|
||||
continue
|
||||
rank = path.stem[:-1]
|
||||
top_index = by_id(root, "index-top")
|
||||
bottom_index = by_id(root, "index-bottom")
|
||||
if top_index is None or bottom_index is None:
|
||||
fail("missing index pair in " + path.name)
|
||||
if bottom_index.attrib.get("transform") != "rotate(180 250 350)":
|
||||
fail("bottom index is not an exact card-centre rotation in " + path.name)
|
||||
if any(local_name(element) == "text" for element in root.iter()):
|
||||
fail("font-dependent text remains in " + path.name)
|
||||
outline = next((element for element in top_index.iter()
|
||||
if "rank-outline" in
|
||||
element.attrib.get("class", "").split()), None)
|
||||
if outline is None:
|
||||
fail("vector rank outline missing in " + path.name)
|
||||
transform = outline.attrib.get("transform", "")
|
||||
baseline_match = re.match(
|
||||
r"translate\([\d.-]+ ([\d.-]+)\) scale\(", transform)
|
||||
if not baseline_match or abs(float(baseline_match.group(1))
|
||||
- RANK_BASELINE) > 0.001:
|
||||
fail("rank is not aligned to the shared baseline in " + path.name)
|
||||
if "rank-q-weight" in outline.attrib.get("class", "").split():
|
||||
fail("synthetic Q weight remains in " + path.name)
|
||||
expected_outline = ElementTree.fromstring(rank_paths(rank.upper()))
|
||||
if outline_signature(outline) != outline_signature(expected_outline):
|
||||
fail("rank outline does not match Roboto Slab source in " + path.name)
|
||||
index_suits = [element for element in top_index.iter()
|
||||
if local_name(element) == "use"]
|
||||
expected_index_transform = "translate(48 127) rotate(0) scale(%s)" % \
|
||||
INDEX_SUIT_SCALE[path.stem[-1].upper()]
|
||||
if len(index_suits) != 1 or index_suits[0].attrib.get("transform") != \
|
||||
expected_index_transform:
|
||||
fail("mini index suit size or position drifted in " + path.name)
|
||||
|
||||
if rank in COURTS:
|
||||
audit_court_svg(path, root)
|
||||
else:
|
||||
pips = by_id(root, "pips")
|
||||
count = sum(local_name(element) == "use" for element in pips.iter())
|
||||
expected_count = 1 if rank == "a" else int(rank)
|
||||
if count != expected_count:
|
||||
fail("%s has %d pips, expected %d" % (
|
||||
path.name, count, expected_count))
|
||||
scale = (ACE_PIP_SCALE if rank == "a" else NUMBER_PIP_SCALE)[
|
||||
path.stem[-1].upper()]
|
||||
expected_scale = "scale(%s)" % scale
|
||||
if any(expected_scale not in element.attrib.get("transform", "")
|
||||
for element in pips.iter()
|
||||
if local_name(element) == "use"):
|
||||
fail("field-pip scale drifted in " + path.name)
|
||||
|
||||
|
||||
def audit_court_svg(path, root):
|
||||
frame = by_id(root, "court-frame")
|
||||
if frame is None:
|
||||
fail("missing court frame in " + path.name)
|
||||
rect = next((element for element in frame
|
||||
if local_name(element) == "rect"), None)
|
||||
if rect is None:
|
||||
fail("missing court frame rectangle in " + path.name)
|
||||
x = float(rect.attrib["x"])
|
||||
y = float(rect.attrib["y"])
|
||||
width = float(rect.attrib["width"])
|
||||
height = float(rect.attrib["height"])
|
||||
if 2 * x + width != 500 or 2 * y + height != 700:
|
||||
fail("off-centre court frame in " + path.name)
|
||||
|
||||
children = list(root)
|
||||
frame_position = children.index(frame)
|
||||
image_position = next((i for i, child in enumerate(children)
|
||||
if any(local_name(element) == "image"
|
||||
for element in child.iter())), None)
|
||||
if image_position is None or frame_position >= image_position:
|
||||
fail("court figure is not layered in front of frame in " + path.name)
|
||||
image = next(element for element in root.iter()
|
||||
if local_name(element) == "image")
|
||||
transform = image.attrib.get("transform", "")
|
||||
match = re.fullmatch(
|
||||
r"translate\(250 350\) scale\(([\d.]+) ([\d.]+)\) "
|
||||
r"translate\(-250 -350\)", transform)
|
||||
if not match or any(not 0 < float(value) <= 1 for value in match.groups()):
|
||||
fail("court art is not scaled about the card centre in " + path.name)
|
||||
scale_x, scale_y = (float(value) for value in match.groups())
|
||||
if abs(scale_x - scale_y) > 0.000001:
|
||||
fail("court art is non-uniformly scaled in " + path.name)
|
||||
|
||||
marks = by_id(root, "court-suit-marks")
|
||||
transforms = [element.attrib.get("transform", "") for element in marks
|
||||
if local_name(element) == "use"]
|
||||
suit = path.stem[-1]
|
||||
top_x = 132 if suit in ("s", "c") else 137
|
||||
bottom_x = 500 - top_x
|
||||
expected_scale = "scale(%s)" % COURT_PIP_SCALE[suit.upper()]
|
||||
if len(transforms) != 2 or "translate(%d 137)" % top_x not in transforms[0] \
|
||||
or "translate(%d 563) rotate(180)" % bottom_x not in transforms[1] \
|
||||
or expected_scale not in transforms[0] \
|
||||
or expected_scale not in transforms[1]:
|
||||
fail("court suit marks are not a mirrored pair in " + path.name)
|
||||
|
||||
|
||||
def audit_court_art():
|
||||
expected = {rank + suit + ".png" for suit in SUITS for rank in COURTS}
|
||||
actual = {path.name for path in COURT_ART_DIR.glob("*.png")}
|
||||
if actual != expected:
|
||||
fail("wrong processed court-art set")
|
||||
for path in sorted(COURT_ART_DIR.glob("*.png")):
|
||||
rgba = np.asarray(Image.open(path).convert("RGBA"))
|
||||
delta = np.abs(rgba.astype(np.int16)
|
||||
- np.rot90(rgba, 2).astype(np.int16))
|
||||
if int(delta.max()) > 1:
|
||||
fail("court art is not rotationally exact: " + path.name)
|
||||
alpha = rgba[..., 3]
|
||||
count, _, stats, _ = cv2.connectedComponentsWithStats(
|
||||
(alpha > 0).astype("uint8"), 8)
|
||||
if count != 2:
|
||||
islands = sorted(int(value) for value in
|
||||
stats[1:, cv2.CC_STAT_AREA])
|
||||
fail("detached court-art specks remain in %s: %s" %
|
||||
(path.name, islands))
|
||||
edge = np.concatenate((alpha[0], alpha[-1], alpha[:, 0], alpha[:, -1]))
|
||||
if np.any(edge):
|
||||
fail("opaque perimeter residue in " + path.name)
|
||||
|
||||
|
||||
def audit_manifest_and_gallery():
|
||||
manifest = json.loads((ROOT / "exports" / "manifest.json").read_text())
|
||||
if manifest.get("count") != 52 or len(manifest.get("cards", [])) != 52:
|
||||
fail("manifest does not describe 52 card faces")
|
||||
gallery = (ROOT / "gallery.html").read_text()
|
||||
if "exports/cards/back.svg" not in gallery:
|
||||
fail("gallery does not include back.svg")
|
||||
if "border-radius: 4.8% / 3.4286%" not in gallery:
|
||||
fail("gallery radius does not match the circular SVG card edge")
|
||||
font_path = Path(RANK_FONT_PATH)
|
||||
if hashlib.sha256(font_path.read_bytes()).hexdigest() != RANK_FONT_SHA256:
|
||||
fail("Roboto Slab source font fingerprint changed")
|
||||
if not (ROOT / "assets" / "fonts" / "RobotoSlab-Apache-2.0.txt").exists():
|
||||
fail("Roboto Slab Apache-2.0 license is missing")
|
||||
|
||||
|
||||
def isolated_layer_mask(path, layer):
|
||||
"""Render one SVG layer against green and return its painted silhouette."""
|
||||
tree = ElementTree.parse(path)
|
||||
root = tree.getroot()
|
||||
children = list(root)
|
||||
definitions = next(element for element in children
|
||||
if local_name(element) == "defs")
|
||||
if layer == "suit":
|
||||
target = next(element for element in children
|
||||
if element.attrib.get("id") == "court-suit-marks")
|
||||
elif layer == "index-suit":
|
||||
index = next(element for element in children
|
||||
if element.attrib.get("id") == "index-top")
|
||||
target = next(element for element in index.iter()
|
||||
if local_name(element) == "use")
|
||||
elif layer == "index-rank":
|
||||
index = next(element for element in children
|
||||
if element.attrib.get("id") == "index-top")
|
||||
target = next(element for element in index.iter()
|
||||
if "rank-outline" in
|
||||
element.attrib.get("class", "").split())
|
||||
elif layer == "pips":
|
||||
target = next(element for element in children
|
||||
if element.attrib.get("id") == "pips")
|
||||
else:
|
||||
target = next(element for element in children
|
||||
if any(local_name(child) == "image"
|
||||
for child in element.iter()))
|
||||
for element in list(root):
|
||||
root.remove(element)
|
||||
root.append(definitions)
|
||||
root.append(ElementTree.Element("{%s}rect" % SVG_NS, {
|
||||
"width": "500", "height": "700", "fill": "#00ff00"}))
|
||||
root.append(target)
|
||||
with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as handle:
|
||||
temporary = Path(handle.name)
|
||||
try:
|
||||
tree.write(temporary, encoding="utf-8", xml_declaration=True)
|
||||
rendered = np.asarray(render_svg(str(temporary), 500)).astype("int16")
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
difference = np.max(np.abs(rendered - np.array([0, 255, 0])), axis=2)
|
||||
mask = (difference > 20).astype("uint8")
|
||||
count, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8)
|
||||
cleaned = np.zeros_like(mask, dtype=bool)
|
||||
for label in range(1, count):
|
||||
if stats[label, cv2.CC_STAT_AREA] >= 20:
|
||||
cleaned |= labels == label
|
||||
return cleaned
|
||||
|
||||
|
||||
def mask_metrics(mask):
|
||||
"""Return painted area, width and height for one rendered silhouette."""
|
||||
y, x = np.nonzero(mask)
|
||||
if not len(x):
|
||||
fail("empty rendered pip silhouette")
|
||||
return {
|
||||
"area": int(len(x)),
|
||||
"width": int(x.max() - x.min() + 1),
|
||||
"height": int(y.max() - y.min() + 1),
|
||||
}
|
||||
|
||||
|
||||
def audit_rendered_pips():
|
||||
"""Guard optical suit balance, not merely source transform values."""
|
||||
field = {}
|
||||
aces = {}
|
||||
for suit in SUITS:
|
||||
field_mask = isolated_layer_mask(
|
||||
CARD_DIR / ("2" + suit + ".svg"), "pips")
|
||||
field_mask[350:] = False
|
||||
field[suit] = mask_metrics(field_mask)
|
||||
aces[suit] = mask_metrics(isolated_layer_mask(
|
||||
CARD_DIR / ("a" + suit + ".svg"), "pips"))
|
||||
|
||||
heart_aspect = field["h"]["width"] / field["h"]["height"]
|
||||
heart_to_spade = field["h"]["area"] / field["s"]["area"]
|
||||
diamond_to_spade = field["d"]["area"] / field["s"]["area"]
|
||||
field_areas = [value["area"] for value in field.values()]
|
||||
field_spread = max(field_areas) / min(field_areas)
|
||||
ace_areas = [value["area"] for value in aces.values()]
|
||||
ace_spread = max(ace_areas) / min(ace_areas)
|
||||
if not 0.90 <= heart_aspect <= 1.05:
|
||||
fail("Heart field pip is not approximately as tall as it is wide")
|
||||
if not 0.95 <= heart_to_spade <= 1.05:
|
||||
fail("Heart field-pip ink is not balanced against Spade")
|
||||
if not 0.90 <= diamond_to_spade <= 1.05:
|
||||
fail("Diamond field-pip ink is not balanced against Spade")
|
||||
if field_spread > 1.12:
|
||||
fail("number-card field-pip ink varies by more than twelve percent")
|
||||
if ace_spread > 1.08:
|
||||
fail("Ace pip ink varies by more than eight percent")
|
||||
print("rendered pips: Heart %dx%d (area %.1f%% of Spade), "
|
||||
"Diamond area %.1f%% of Spade, field spread %.1f%%, "
|
||||
"Ace spread %.1f%%" % (
|
||||
field["h"]["width"], field["h"]["height"],
|
||||
heart_to_spade * 100, diamond_to_spade * 100,
|
||||
(field_spread - 1) * 100, (ace_spread - 1) * 100))
|
||||
|
||||
|
||||
def audit_rendered_indices():
|
||||
"""Check the mini suit marks that repeat in every card corner."""
|
||||
metrics = {}
|
||||
for suit in SUITS:
|
||||
metrics[suit] = mask_metrics(isolated_layer_mask(
|
||||
CARD_DIR / ("a" + suit + ".svg"), "index-suit"))
|
||||
areas = [value["area"] for value in metrics.values()]
|
||||
widths = [value["width"] for value in metrics.values()]
|
||||
heights = [value["height"] for value in metrics.values()]
|
||||
ink_spread = max(areas) / min(areas)
|
||||
width_spread = max(widths) / min(widths)
|
||||
height_spread = max(heights) / min(heights)
|
||||
# Dense silhouettes such as Clubs should not be shrunk merely to equalise
|
||||
# ink. Apparent footprint is the readability constraint; Diamond remains
|
||||
# naturally taller while all four marks retain comparable width.
|
||||
if ink_spread > 1.25 or width_spread > 1.20 or height_spread > 1.30:
|
||||
fail("corner suit marks no longer have comparable apparent footprints")
|
||||
rank_heights = {}
|
||||
for rank in ("a", "10", "j", "q", "k"):
|
||||
mask = isolated_layer_mask(
|
||||
CARD_DIR / (rank + "s.svg"), "index-rank")
|
||||
mask[int(RANK_BASELINE):] = False
|
||||
rank_heights[rank] = mask_metrics(mask)["height"]
|
||||
cap_spread = max(rank_heights.values()) / min(rank_heights.values())
|
||||
if cap_spread > 1.08:
|
||||
fail("representative corner-rank cap heights vary by more than eight percent")
|
||||
print("rendered corner suits: ink spread %.1f%%, width %d-%dpx, "
|
||||
"height %d-%dpx (S/H/D/C areas %d/%d/%d/%d); "
|
||||
"cap heights A/10/J/Q/K %d/%d/%d/%d/%d" % (
|
||||
(ink_spread - 1) * 100, min(widths), max(widths),
|
||||
min(heights), max(heights),
|
||||
*(metrics[suit]["area"] for suit in SUITS),
|
||||
*(rank_heights[rank] for rank in ("a", "10", "j", "q", "k"))))
|
||||
|
||||
|
||||
def audit_rendered_layout():
|
||||
"""Check the visual relationships that source-coordinate checks miss."""
|
||||
suit_masks = {}
|
||||
for suit in SUITS:
|
||||
mask = isolated_layer_mask(CARD_DIR / ("j" + suit + ".svg"), "suit")
|
||||
mask[350:] = False
|
||||
mask[:, 250:] = False
|
||||
suit_masks[suit] = mask
|
||||
court_suit_areas = [int(suit_masks[suit].sum()) for suit in SUITS]
|
||||
court_suit_spread = max(court_suit_areas) / min(court_suit_areas)
|
||||
if court_suit_spread > 1.08:
|
||||
fail("court suit-mark ink varies by more than eight percent")
|
||||
|
||||
metrics = {}
|
||||
for suit in SUITS:
|
||||
for rank in COURTS:
|
||||
code = rank + suit
|
||||
figure = isolated_layer_mask(CARD_DIR / (code + ".svg"), "figure")
|
||||
upper_y, upper_x = np.nonzero(figure[:350])
|
||||
if not len(upper_x):
|
||||
fail("empty rendered court figure: " + code)
|
||||
distance = cv2.distanceTransform(
|
||||
(~figure).astype("uint8"), cv2.DIST_L2, 5)
|
||||
clearance = max(0.0, float(distance[suit_masks[suit]].min()) - 1.0)
|
||||
metrics[code] = {
|
||||
"clearance": clearance,
|
||||
"width": int(upper_x.max() - upper_x.min() + 1),
|
||||
"area": int(len(upper_x)),
|
||||
}
|
||||
|
||||
all_values = list(metrics.values())
|
||||
areas = [value["area"] for value in all_values]
|
||||
widths = [value["width"] for value in all_values]
|
||||
if max(areas) / min(areas) > 1.06:
|
||||
fail("rendered court painted areas vary by more than six percent")
|
||||
width_ratio = max(widths) / min(widths)
|
||||
if width_ratio > 1.08:
|
||||
fail("rendered court widths vary by more than eight percent")
|
||||
if min(value["clearance"] for value in all_values) < 2.5:
|
||||
fail("a court figure crowds its suit emblem")
|
||||
king_clearances = sorted(metrics["k" + suit]["clearance"] for suit in SUITS)
|
||||
king_median = (king_clearances[1] + king_clearances[2]) / 2
|
||||
if min(king_clearances) < 5 or king_median < 7:
|
||||
fail("King suit clearance is still inconsistent")
|
||||
print("rendered layout: court-suit spread %.1f%%, "
|
||||
"figure width %d-%dpx (%.1f%% spread), "
|
||||
"painted-area spread %.1f%%, "
|
||||
"King clearance %.1f-%.1fpx (median %.1fpx)" % (
|
||||
(court_suit_spread - 1) * 100,
|
||||
min(widths), max(widths),
|
||||
(width_ratio - 1) * 100,
|
||||
(max(areas) / min(areas) - 1) * 100,
|
||||
min(king_clearances), max(king_clearances), king_median))
|
||||
|
||||
|
||||
def main():
|
||||
audit_svg_set()
|
||||
audit_court_art()
|
||||
audit_manifest_and_gallery()
|
||||
audit_rendered_pips()
|
||||
audit_rendered_indices()
|
||||
audit_rendered_layout()
|
||||
print("audit passed: 52 faces + back, centred two-way geometry, correct pips,")
|
||||
print("embedded assets, mirrored court art, clean perimeters, and gallery back")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user