mirror of
https://github.com/LostArtefacts/TR2X.git
synced 2025-01-09 06:50:23 +00:00
748 lines
20 KiB
Python
Executable File
748 lines
20 KiB
Python
Executable File
#!/usr/bin/python3
|
|
import re
|
|
from collections.abc import Callable, Iterable
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
from enum import StrEnum, auto
|
|
from functools import cache
|
|
from itertools import groupby
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
DOCUMENT_MARGIN = Decimal(2)
|
|
GRID_MAX_SQUARES = 50
|
|
GRID_SQUARE_SIZE = Decimal(12)
|
|
GRID_SQUARE_MARGIN = Decimal(3)
|
|
PROGRESS_BAR_SIZE = Decimal(6)
|
|
LEGEND_SQUARE_SIZE = GRID_SQUARE_SIZE
|
|
LEGEND_SQUARE_MARGIN = GRID_SQUARE_MARGIN
|
|
LEGEND_ROW_PADDING = Decimal(3)
|
|
LEGEND_MARGIN = Decimal(15)
|
|
TEXT_SIZE = Decimal(15)
|
|
TEXT_MARGIN = Decimal(5)
|
|
SECTION_MARGIN = GRID_SQUARE_SIZE + LEGEND_MARGIN
|
|
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
|
PROGRESS_TXT_FILE = DOCS_DIR / "progress.txt"
|
|
PROGRESS_SVG_FILE = DOCS_DIR / "progress.svg"
|
|
GRID_WIDTH = (
|
|
GRID_MAX_SQUARES * (GRID_SQUARE_SIZE + GRID_SQUARE_MARGIN)
|
|
- GRID_SQUARE_MARGIN
|
|
)
|
|
ZERO = Decimal(0)
|
|
EPSILON = Decimal("0.1")
|
|
|
|
|
|
class SumMode(StrEnum):
|
|
BYTES = auto()
|
|
COUNT = auto()
|
|
|
|
|
|
class FunctionStatus(StrEnum):
|
|
DECOMPILED = auto()
|
|
KNOWN = auto()
|
|
TODO = auto()
|
|
UNUSED = auto()
|
|
|
|
|
|
@dataclass
|
|
class MyFraction:
|
|
"""Fraction that doesn't normalize (eg 8/12 doesn't become 2/3)"""
|
|
|
|
numerator: int
|
|
denominator: int
|
|
|
|
|
|
def format_decimal(source: Decimal) -> str:
|
|
return str(source.quantize(Decimal("1.11"))).replace(".00", "")
|
|
|
|
|
|
def format_percent(source: MyFraction) -> str:
|
|
return format_decimal(fraction_to_decimal(source) * 100) + "%"
|
|
|
|
|
|
def fraction_to_decimal(source: MyFraction) -> Decimal:
|
|
return Decimal(source.numerator) / Decimal(source.denominator)
|
|
|
|
|
|
@dataclass
|
|
class Box:
|
|
x1: Decimal
|
|
y1: Decimal
|
|
x2: Decimal
|
|
y2: Decimal
|
|
|
|
@property
|
|
def dx(self) -> Decimal:
|
|
return self.x2 - self.x1
|
|
|
|
@property
|
|
def dy(self) -> Decimal:
|
|
return self.y2 - self.y1
|
|
|
|
|
|
@dataclass
|
|
class SquarifyResult(Box):
|
|
item: Any
|
|
|
|
|
|
class Squarify:
|
|
def __init__(
|
|
self,
|
|
items: Iterable[Any],
|
|
key: Callable[[Any], Decimal],
|
|
box: Box,
|
|
normalize: bool = True,
|
|
) -> None:
|
|
self.key: Callable[[Any], Decimal] = key
|
|
|
|
if normalize:
|
|
total_size = sum(map(key, items))
|
|
total_area = box.dx * box.dy
|
|
|
|
def normalized_key(item: Any) -> Decimal:
|
|
return key(item) * total_area / total_size
|
|
|
|
self.key = normalized_key
|
|
|
|
self.items = list(sorted(items, key=self.key, reverse=True))
|
|
self.box = box
|
|
|
|
def layoutrow(self, items: Iterable[Any]) -> Iterable[SquarifyResult]:
|
|
covered_area = sum(self.key(item) for item in items)
|
|
dx = covered_area / self.box.dy
|
|
y = self.box.y1
|
|
for item in items:
|
|
yield SquarifyResult(
|
|
item=item,
|
|
x1=self.box.x1,
|
|
y1=y,
|
|
x2=self.box.x1 + dx,
|
|
y2=y + self.key(item) / dx,
|
|
)
|
|
y += self.key(item) / dx
|
|
|
|
def layoutcol(self, items: Iterable[Any]) -> Iterable[SquarifyResult]:
|
|
covered_area = sum(self.key(item) for item in items)
|
|
dy = covered_area / self.box.dx
|
|
x = self.box.x1
|
|
for item in items:
|
|
yield SquarifyResult(
|
|
item=item,
|
|
x1=x,
|
|
y1=self.box.y1,
|
|
x2=x + self.key(item) / dy,
|
|
y2=self.box.y1 + dy,
|
|
)
|
|
x += self.key(item) / dy
|
|
|
|
def layout(self, items: Iterable[Any]) -> Iterable[SquarifyResult]:
|
|
yield from (
|
|
self.layoutrow(items)
|
|
if self.box.dx >= self.box.dy
|
|
else self.layoutcol(items)
|
|
)
|
|
|
|
def leftoverrow(self, items: Iterable[Any]) -> Box:
|
|
covered_area = sum(self.key(item) for item in items)
|
|
dx = covered_area / self.box.dy
|
|
return Box(
|
|
x1=self.box.x1 + dx,
|
|
y1=self.box.y1,
|
|
x2=self.box.x1 + self.box.dx,
|
|
y2=self.box.y1 + self.box.dy,
|
|
)
|
|
|
|
def leftovercol(self, items: Iterable[Any]) -> Box:
|
|
covered_area = sum(self.key(item) for item in items)
|
|
dy = covered_area / self.box.dx
|
|
return Box(
|
|
x1=self.box.x1,
|
|
y1=self.box.y1 + dy,
|
|
x2=self.box.x1 + self.box.dx,
|
|
y2=self.box.y1 + self.box.dy,
|
|
)
|
|
|
|
def leftover(self, items: Iterable[Any]) -> Box:
|
|
return (
|
|
self.leftoverrow(items)
|
|
if self.box.dx >= self.box.dy
|
|
else self.leftovercol(items)
|
|
)
|
|
|
|
def worst_ratio(self, items: Iterable[Any]) -> Decimal:
|
|
return max(
|
|
max(result.dx / result.dy, result.dy / result.dx)
|
|
for result in self.layout(items)
|
|
)
|
|
|
|
def run(self, items: list[Any] | None = None) -> Iterable[SquarifyResult]:
|
|
if not items:
|
|
items = self.items
|
|
|
|
if len(items) == 0:
|
|
return
|
|
|
|
if len(items) == 1:
|
|
yield from self.layout(items)
|
|
return
|
|
|
|
i = 1
|
|
while i < len(items) and self.worst_ratio(
|
|
items[:i]
|
|
) >= self.worst_ratio(items[: i + 1]):
|
|
i += 1
|
|
current = items[:i]
|
|
remaining = items[i:]
|
|
|
|
leftover_box = self.leftover(current)
|
|
yield from self.layout(current)
|
|
yield from Squarify(
|
|
remaining, key=self.key, box=leftover_box, normalize=False
|
|
).run()
|
|
|
|
|
|
def squarify(
|
|
items: list[Any], key: Callable[[Any], Decimal], box: Box
|
|
) -> Iterable[SquarifyResult]:
|
|
yield from Squarify(items, key, box).run()
|
|
|
|
|
|
@dataclass
|
|
class Function:
|
|
signature: str
|
|
offset: int
|
|
size: int
|
|
flags: str
|
|
|
|
@property
|
|
def is_decompiled(self) -> bool:
|
|
return "+" in self.flags
|
|
|
|
@property
|
|
def is_called(self) -> bool:
|
|
return "*" in self.flags
|
|
|
|
@property
|
|
def is_unused(self) -> bool:
|
|
return "x" in self.flags
|
|
|
|
@property
|
|
def is_known(self) -> bool:
|
|
return not re.search(r"(\s|^)sub_", self.signature)
|
|
|
|
@property
|
|
def status(self) -> FunctionStatus:
|
|
if self.is_decompiled:
|
|
return FunctionStatus.DECOMPILED
|
|
elif self.is_unused:
|
|
return FunctionStatus.UNUSED
|
|
elif self.is_known:
|
|
return FunctionStatus.KNOWN
|
|
return FunctionStatus.TODO
|
|
|
|
|
|
def collect_functions() -> Iterable[Function]:
|
|
in_functions = False
|
|
for line in PROGRESS_TXT_FILE.open():
|
|
line = line.strip()
|
|
if line == "# FUNCTIONS":
|
|
in_functions = True
|
|
elif re.match("^# [A-Z]*$", line):
|
|
in_functions = False
|
|
if not in_functions:
|
|
continue
|
|
|
|
if line.startswith("#") or not line:
|
|
continue
|
|
offset, size, flags, func_signature = re.split(
|
|
r"\s+", line, maxsplit=3
|
|
)
|
|
if not offset.replace("-", ""):
|
|
continue
|
|
yield Function(
|
|
signature=func_signature,
|
|
offset=int(offset, 16),
|
|
size=int(size, 16),
|
|
flags=flags,
|
|
)
|
|
|
|
|
|
class Shape:
|
|
box: Box = ...
|
|
|
|
def render(self) -> str:
|
|
raise NotImplementedError("not implemented")
|
|
|
|
|
|
class Container(Shape):
|
|
def __init__(self):
|
|
self.children: list[Shape] = []
|
|
|
|
def __post_init__(self) -> None:
|
|
self.box = get_common_bbox(self.children)
|
|
|
|
def render(self) -> str:
|
|
return "\n".join(child.render() for child in self.children)
|
|
|
|
|
|
def get_common_bbox(shapes: list[Shape]) -> Box:
|
|
return Box(
|
|
x1=min(shape.box.x1 for shape in shapes),
|
|
y1=min(shape.box.y1 for shape in shapes),
|
|
x2=max(shape.box.x2 for shape in shapes),
|
|
y2=max(shape.box.y2 for shape in shapes),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Rectangle(Shape):
|
|
x: Decimal
|
|
y: Decimal
|
|
class_name: str
|
|
dx: Decimal
|
|
dy: Decimal
|
|
title: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
self.box = Box(
|
|
x1=self.x,
|
|
y1=self.y,
|
|
x2=self.x + self.dx,
|
|
y2=self.y + self.dy,
|
|
)
|
|
|
|
def render(self) -> str:
|
|
return (
|
|
f"<rect "
|
|
f'width="{format_decimal(max(self.dx, EPSILON))}" '
|
|
f'height="{format_decimal(max(self.dy, EPSILON))}" '
|
|
f'x="{format_decimal(self.x)}" '
|
|
f'y="{format_decimal(self.y)}" '
|
|
f'class="{self.class_name}"'
|
|
+ (f"><title>{self.title}</title></rect>" if self.title else "/>")
|
|
)
|
|
|
|
|
|
class Square(Rectangle):
|
|
def __init__(
|
|
self,
|
|
x: Decimal,
|
|
y: Decimal,
|
|
class_name: str,
|
|
size: Decimal = GRID_SQUARE_SIZE,
|
|
title: str | None = None,
|
|
) -> None:
|
|
super().__init__(
|
|
x=x, y=y, class_name=class_name, dx=size, dy=size, title=title
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Text(Shape):
|
|
x: Decimal
|
|
y: Decimal
|
|
text: str
|
|
class_name: str | None = None
|
|
size: Decimal = TEXT_SIZE
|
|
|
|
def __post_init__(self) -> None:
|
|
self.style = ""
|
|
if self.size != TEXT_SIZE:
|
|
self.style = f"font-size: {self.size}px; "
|
|
self.box = Box(x1=self.x, y1=self.y, x2=self.x, y2=self.y + self.size)
|
|
|
|
def render(self) -> str:
|
|
return (
|
|
f"<text "
|
|
+ (f'class="{self.class_name}" ' if self.class_name else "")
|
|
+ (f'style="{self.style}" ' if self.style else "")
|
|
+ f'x="{format_decimal(self.x)}" '
|
|
f'y="{format_decimal(self.y + self.size/2)}">'
|
|
f"{self.text}"
|
|
f"</text>"
|
|
)
|
|
|
|
|
|
class LegendText(Container):
|
|
def __init__(self, class_name: str, text: str) -> None:
|
|
super().__init__()
|
|
text_shape = Text(
|
|
x=LEGEND_SQUARE_SIZE + TEXT_MARGIN,
|
|
y=ZERO,
|
|
text=text,
|
|
)
|
|
|
|
square_shape = Square(
|
|
x=ZERO,
|
|
y=(text_shape.size - LEGEND_SQUARE_SIZE) / 2,
|
|
class_name=class_name,
|
|
size=LEGEND_SQUARE_SIZE,
|
|
)
|
|
|
|
self.children.extend([text_shape, square_shape])
|
|
|
|
self.__post_init__()
|
|
|
|
|
|
@dataclass
|
|
class TranslateShape(Container):
|
|
def __init__(
|
|
self, children: list[Shape], x: Decimal = ZERO, y: Decimal = ZERO
|
|
) -> None:
|
|
self.children = children
|
|
self.x = x
|
|
self.y = y
|
|
|
|
super().__post_init__()
|
|
|
|
self.box.x1 += self.x
|
|
self.box.x2 += self.x
|
|
self.box.y1 += self.y
|
|
self.box.y2 += self.y
|
|
|
|
def render(self) -> str:
|
|
return (
|
|
f'<g transform="translate({format_decimal(self.x)} {format_decimal(self.y)})">\n'
|
|
f"{super().render()}\n"
|
|
"</g>"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class LegendSection(Container):
|
|
def __init__(self, all_functions: list[Function]) -> None:
|
|
super().__init__()
|
|
|
|
functions_status_map = get_functions_status_map(all_functions)
|
|
|
|
self.title = Text(x=ZERO, y=0, text="Legend:")
|
|
self.children.append(self.title)
|
|
|
|
captions = {
|
|
FunctionStatus.DECOMPILED: "Function fully decompiled",
|
|
FunctionStatus.UNUSED: "Function not used by the game",
|
|
FunctionStatus.KNOWN: "Function not yet decompiled, but with a known signature",
|
|
FunctionStatus.TODO: "Function not yet decompiled, with an unknown signature",
|
|
}
|
|
|
|
y = self.title.size + TEXT_MARGIN
|
|
for status in FunctionStatus:
|
|
caption = captions[status]
|
|
functions = functions_status_map[status]
|
|
legend_text = TranslateShape(
|
|
[LegendText(class_name=status.value, text=caption)], y=y
|
|
)
|
|
self.children.append(legend_text)
|
|
y = legend_text.box.y2 + LEGEND_ROW_PADDING
|
|
|
|
self.__post_init__()
|
|
|
|
|
|
class FunctionGrid(Container):
|
|
def __init__(self, all_functions: list[Function]) -> None:
|
|
super().__init__()
|
|
for i, function in enumerate(all_functions):
|
|
x = (i % GRID_MAX_SQUARES) * (
|
|
GRID_SQUARE_SIZE + GRID_SQUARE_MARGIN
|
|
)
|
|
y = (i // GRID_MAX_SQUARES) * (
|
|
GRID_SQUARE_SIZE + GRID_SQUARE_MARGIN
|
|
)
|
|
self.children.append(
|
|
Square(
|
|
x=x,
|
|
y=y,
|
|
class_name=function.status.value,
|
|
title=function.signature,
|
|
)
|
|
)
|
|
|
|
self.__post_init__()
|
|
|
|
|
|
class FunctionTreeGrid(Container):
|
|
def __init__(self, all_functions: list[Function]) -> None:
|
|
super().__init__()
|
|
box = Box(
|
|
x1=ZERO,
|
|
y1=ZERO,
|
|
x2=GRID_WIDTH + GRID_SQUARE_MARGIN,
|
|
y2=(
|
|
(
|
|
(len(all_functions) + GRID_MAX_SQUARES - 1)
|
|
// GRID_MAX_SQUARES
|
|
)
|
|
* (GRID_SQUARE_SIZE + GRID_SQUARE_MARGIN)
|
|
) + GRID_SQUARE_MARGIN,
|
|
)
|
|
|
|
for result in squarify(
|
|
all_functions, key=lambda function: Decimal(function.size), box=box
|
|
):
|
|
result.x2 = max(ZERO, result.x2 - GRID_SQUARE_MARGIN)
|
|
result.y2 = max(ZERO, result.y2 - GRID_SQUARE_MARGIN)
|
|
|
|
self.children.append(
|
|
Rectangle(
|
|
x=result.x1,
|
|
y=result.y1,
|
|
dx=result.dx,
|
|
dy=result.dy,
|
|
class_name=result.item.status.value,
|
|
title=result.item.signature,
|
|
)
|
|
)
|
|
|
|
self.__post_init__()
|
|
|
|
|
|
def get_function_done_percentage(
|
|
chosen_functions: list[Function],
|
|
all_functions: list[Function],
|
|
mode: SumMode,
|
|
) -> MyFraction:
|
|
match mode:
|
|
case SumMode.COUNT:
|
|
return MyFraction(len(chosen_functions), len(all_functions))
|
|
case SumMode.BYTES:
|
|
return MyFraction(
|
|
sum(function.size for function in chosen_functions),
|
|
sum(function.size for function in all_functions),
|
|
)
|
|
case _:
|
|
assert False
|
|
|
|
|
|
class ProgressBar(Container):
|
|
def __init__(self, all_functions: list[Function], mode: SumMode) -> None:
|
|
super().__init__()
|
|
lx2 = ZERO
|
|
|
|
def sorter(function):
|
|
return list(FunctionStatus).index(function.status)
|
|
|
|
for status_value, group in groupby(
|
|
sorted(all_functions, key=sorter), sorter
|
|
):
|
|
chosen_functions = list(group)
|
|
if not chosen_functions:
|
|
continue
|
|
|
|
status = chosen_functions[0].status
|
|
fraction = get_function_done_percentage(
|
|
chosen_functions, all_functions, mode=mode
|
|
)
|
|
ratio = fraction_to_decimal(fraction)
|
|
lx1 = lx2
|
|
lx2 += ratio * GRID_WIDTH
|
|
self.children.append(
|
|
Rectangle(
|
|
x=lx1,
|
|
y=ZERO,
|
|
dx=lx2 - lx1,
|
|
dy=PROGRESS_BAR_SIZE,
|
|
class_name=status,
|
|
)
|
|
)
|
|
|
|
self.__post_init__()
|
|
|
|
|
|
def get_functions_status_map(
|
|
all_functions: list[Function],
|
|
) -> dict[FunctionStatus, list[Function]]:
|
|
functions_status_map: dict[FunctionStatus, list[Function]] = {
|
|
status: [] for status in FunctionStatus
|
|
}
|
|
for function in all_functions:
|
|
functions_status_map[function.status].append(function)
|
|
return functions_status_map
|
|
|
|
|
|
def get_progress_parts(
|
|
all_functions: list[Function],
|
|
) -> dict[FunctionStatus, tuple[list[Function], dict[SumMode, MyFraction]]]:
|
|
functions_status_map = get_functions_status_map(all_functions)
|
|
|
|
return {
|
|
status: (
|
|
functions_status_map[status],
|
|
{
|
|
mode: get_function_done_percentage(
|
|
functions_status_map[status], all_functions, mode=mode
|
|
)
|
|
for mode in SumMode
|
|
},
|
|
)
|
|
for status in FunctionStatus
|
|
}
|
|
|
|
|
|
class GridSection(Container):
|
|
def __init__(
|
|
self, header: str, all_functions: list[Function], mode: SumMode
|
|
) -> None:
|
|
super().__init__()
|
|
header_shape = Text(x=ZERO, y=ZERO, text=header)
|
|
|
|
y = header_shape.size + TEXT_MARGIN
|
|
x2 = GRID_WIDTH
|
|
|
|
progress_bar = TranslateShape(
|
|
[ProgressBar(all_functions, mode=mode)], y=y
|
|
)
|
|
|
|
y = progress_bar.box.y2 + TEXT_MARGIN
|
|
|
|
if mode == SumMode.COUNT:
|
|
grid = TranslateShape([FunctionGrid(all_functions)], y=y)
|
|
else:
|
|
grid = TranslateShape([FunctionTreeGrid(all_functions)], y=y)
|
|
|
|
progress_parts = get_progress_parts(all_functions)
|
|
|
|
progress_text = Text(
|
|
x=GRID_WIDTH,
|
|
y=Decimal(3),
|
|
size=Decimal(12),
|
|
class_name=FunctionStatus.TODO.value,
|
|
text=(
|
|
'<tspan text-anchor="end">'
|
|
+ " · ".join(
|
|
f'<tspan class="{status.value}">'
|
|
+ f"{format_percent(progress_parts[status][1][mode])}"
|
|
+ (
|
|
f" ({progress_parts[status][1][mode].numerator})"
|
|
if mode == SumMode.COUNT
|
|
else ""
|
|
)
|
|
+ "</tspan>"
|
|
for status in FunctionStatus
|
|
)
|
|
+ "</tspan>"
|
|
),
|
|
)
|
|
|
|
self.children.extend(
|
|
[
|
|
header_shape,
|
|
progress_text,
|
|
progress_bar,
|
|
grid,
|
|
]
|
|
)
|
|
|
|
self.__post_init__()
|
|
|
|
|
|
class ProgressSVG(Container):
|
|
def __init__(self, all_functions: list[Function]) -> None:
|
|
super().__init__()
|
|
|
|
y = ZERO
|
|
|
|
section1 = GridSection(
|
|
header="Tomb2.exe progress according to the physical function order:",
|
|
all_functions=all_functions,
|
|
mode=SumMode.COUNT,
|
|
)
|
|
|
|
section2 = GridSection(
|
|
header="Tomb2.exe progress according to the function sizes:",
|
|
all_functions=all_functions,
|
|
mode=SumMode.BYTES,
|
|
)
|
|
|
|
legend = LegendSection(all_functions)
|
|
|
|
section1_wrap = TranslateShape(
|
|
[section1], y=legend.box.y2 + SECTION_MARGIN
|
|
)
|
|
section2_wrap = TranslateShape(
|
|
[section2], y=section1_wrap.box.y2 + SECTION_MARGIN
|
|
)
|
|
legend_wrap = TranslateShape([legend])
|
|
|
|
self.children.extend(
|
|
[
|
|
legend_wrap,
|
|
section1_wrap,
|
|
section2_wrap,
|
|
]
|
|
)
|
|
|
|
super().__post_init__()
|
|
|
|
def render(self) -> str:
|
|
x1 = -DOCUMENT_MARGIN
|
|
y1 = -DOCUMENT_MARGIN
|
|
x2 = self.box.dx + DOCUMENT_MARGIN * 2
|
|
y2 = self.box.dy + DOCUMENT_MARGIN * 2
|
|
return (
|
|
f"""<svg version="1.1"
|
|
viewBox="{format_decimal(x1)} {format_decimal(y1)} {format_decimal(x2)} {format_decimal(y2)}"
|
|
xmlns="http://www.w3.org/2000/svg">
|
|
<style>
|
|
.todo {{
|
|
--bg: rgba(255, 255, 255, 0.25);
|
|
--fg: rgba(100, 100, 100, 0.5);
|
|
}}
|
|
.known {{
|
|
--bg: rgba(255, 255, 0, 0.25);
|
|
--fg: rgba(250, 150, 0, 0.85);
|
|
}}
|
|
.decompiled {{
|
|
--bg: rgba(0, 255, 0, 0.5);
|
|
--fg: rgba(0, 150, 0, 0.85);
|
|
}}
|
|
.unused {{
|
|
--bg: rgba(255, 0, 0, 0.05);
|
|
--fg: rgba(250, 0, 0, 0.5);
|
|
}}
|
|
rect {{
|
|
fill: var(--bg);
|
|
stroke: var(--fg);
|
|
stroke-width: 0.5;
|
|
}}
|
|
rect.unused {{
|
|
fill: url("#unused-cross");
|
|
}}
|
|
text {{
|
|
alignment-baseline: central;
|
|
font-family: sans-serif;
|
|
font-size: {TEXT_SIZE}px;
|
|
fill: var(--fg, black);
|
|
stroke: none;
|
|
}}
|
|
@media (prefers-color-scheme: dark) {{
|
|
text {{
|
|
fill: var(--fg, white);
|
|
}}
|
|
}}
|
|
tspan {{
|
|
alignment-baseline: central;
|
|
fill: var(--fg, inherit);
|
|
}}
|
|
</style>
|
|
<defs>
|
|
<pattern id='unused-cross' x='0' y='0' width='100%' height='100%' viewBox='0 0 12 12' preserveAspectRatio='none'>
|
|
<path d='M0,0L12,12M0,12L12,0' stroke-width='0.5' stroke='red'/>
|
|
</pattern>
|
|
</defs>
|
|
"""
|
|
+ super().render()
|
|
+ "\n</svg>"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
functions = list(collect_functions())
|
|
|
|
with PROGRESS_SVG_FILE.open("w") as handle:
|
|
svg = ProgressSVG(functions)
|
|
print(svg.render(), file=handle)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|