Skip to content

Instantly share code, notes, and snippets.

@majora2007
Created February 28, 2026 15:25
Show Gist options
  • Select an option

  • Save majora2007/d8c8f065537edb7d110490aac37e6233 to your computer and use it in GitHub Desktop.

Select an option

Save majora2007/d8c8f065537edb7d110490aac37e6233 to your computer and use it in GitHub Desktop.
Built to migrate Kavita from PX -> REM
#!/usr/bin/env python3
"""
px-to-rem.py — Convert CSS/SCSS px values to rem units.
Usage:
python px-to-rem.py # Apply to all SCSS/HTML files + write report.txt
python px-to-rem.py --dry-run # Preview changes, no writes
python px-to-rem.py --file=path.scss # Single file
python px-to-rem.py --base=16 # Override base font size (default: 16)
"""
import re
import sys
import os
import argparse
from pathlib import Path
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
BASE = 16 # default base font-size in px
# Files/dirs to skip entirely
SKIP_FILES = {
"src/theme/_variables.scss",
"src/theme/variables/_variables.scss",
}
SKIP_PATTERNS = [
re.compile(r"_variables\.scss$"),
]
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
# Matches CSS/SCSS pixel values: 10px, -10px, 10.5px, -10.5px
PX_RE = re.compile(r"(-?\d+(?:\.\d+)?)px")
# Detects hairline border/outline lines (keep 1px on these)
HAIRLINE_RE = re.compile(
r"(?:border(?:-(?:top|right|bottom|left|width))?|outline(?:-width)?)"
r"\s*:\s*[^;{]*\b1px\b",
re.IGNORECASE,
)
# Matches static style="..." attributes in HTML (not [style.xxx.px]="...")
STYLE_ATTR_RE = re.compile(r'(?<!\[)style="([^"]*)"')
# ---------------------------------------------------------------------------
# Core conversion
# ---------------------------------------------------------------------------
def format_rem(px_val: float, base: int) -> str:
"""Convert a pixel float to a rem string.
Examples: 14 -> '0.875rem', 16 -> '1rem', 0 -> '0'
"""
if px_val == 0:
return "0"
result = f"{px_val / base:.4f}".rstrip("0").rstrip(".")
# Ensure leading zero for values like .875 -> 0.875
if result.startswith("."):
result = "0" + result
elif result.startswith("-."):
result = "-0" + result[1:]
return result + "rem"
def should_skip_file(path: Path) -> bool:
"""Return True if this file should not be converted at all."""
path_str = str(path).replace("\\", "/")
for skip in SKIP_FILES:
if path_str.endswith(skip):
return True
for pat in SKIP_PATTERNS:
if pat.search(path_str):
return True
return False
def convert_px_in_value(text: str, base: int, is_hairline: bool = False) -> str:
"""Replace all Npx occurrences in a CSS value string."""
def replacer(m: re.Match) -> str:
val = float(m.group(1))
if val == 0:
return "0"
if is_hairline and val == 1:
return m.group(0) # keep 1px on hairline borders
return format_rem(val, base)
return PX_RE.sub(replacer, text)
# ---------------------------------------------------------------------------
# SCSS/CSS line processing
# ---------------------------------------------------------------------------
def process_scss_line(line: str, base: int) -> str:
"""Process a single SCSS/CSS line, applying conversion rules."""
# 1. Pure comment line (leading whitespace + //)
stripped = line.lstrip()
if stripped.startswith("//"):
return line
# 2. Split at first // to separate code from comment
comment_part = ""
code_part = line
comment_idx = line.find("//")
if comment_idx != -1:
code_part = line[:comment_idx]
comment_part = line[comment_idx:] # includes "//"
# 3. Skip media query lines
if "@media" in code_part:
return line
# 4. Skip --html-font-size
if "--html-font-size" in code_part:
return line
# 5. Skip $grid-breakpoints variables
if "$grid-breakpoints" in code_part:
return line
# 6. Skip --setting-*-breakpoint (unitless number consumed by JS)
if "--setting-" in code_part and "-breakpoint" in code_part:
return line
# 7. Detect hairline border lines
is_hairline = bool(HAIRLINE_RE.search(code_part))
# 8. Convert px values in code part
new_code = convert_px_in_value(code_part, base, is_hairline)
return new_code + comment_part
def process_scss_content(content: str, base: int) -> str:
"""Process entire SCSS/CSS file content."""
lines = content.split("\n")
new_lines = [process_scss_line(line, base) for line in lines]
return "\n".join(new_lines)
# ---------------------------------------------------------------------------
# HTML processing
# ---------------------------------------------------------------------------
def process_html_line(line: str, base: int) -> str:
"""Process a single HTML line, converting px in static style attributes."""
# Only handle static style="..." (not [style.xxx.px]="...")
def style_replacer(m: re.Match) -> str:
original_style = m.group(1)
new_style = convert_px_in_value(original_style, base)
return f'style="{new_style}"'
return STYLE_ATTR_RE.sub(style_replacer, line)
def process_html_content(content: str, base: int) -> str:
"""Process entire HTML file content."""
lines = content.split("\n")
new_lines = [process_html_line(line, base) for line in lines]
return "\n".join(new_lines)
# ---------------------------------------------------------------------------
# File discovery
# ---------------------------------------------------------------------------
def find_scss_files(root: Path):
"""Yield all .scss and .css files under root."""
for ext in ("**/*.scss", "**/*.css"):
for p in root.glob(ext):
if not should_skip_file(p):
yield p
def find_html_files(root: Path):
"""Yield all .html files under root."""
for p in root.glob("**/*.html"):
yield p
# ---------------------------------------------------------------------------
# Processing
# ---------------------------------------------------------------------------
def process_file(path: Path, base: int, dry_run: bool, report_lines: list) -> bool:
"""Process a single file. Returns True if changes were made."""
try:
content = path.read_text(encoding="utf-8")
except Exception as e:
report_lines.append(f"ERROR reading {path}: {e}")
return False
suffix = path.suffix.lower()
if suffix in (".scss", ".css"):
new_content = process_scss_content(content, base)
elif suffix == ".html":
new_content = process_html_content(content, base)
else:
return False
if new_content == content:
return False
# Count changes
orig_count = len(PX_RE.findall(content))
new_count = len(PX_RE.findall(new_content))
changed = orig_count - new_count
rel_path = str(path).replace("\\", "/")
report_lines.append(f" {rel_path}: {changed} px->rem conversion(s)")
if not dry_run:
try:
path.write_text(new_content, encoding="utf-8")
except Exception as e:
report_lines.append(f"ERROR writing {path}: {e}")
return False
return True
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Convert px to rem in SCSS/HTML files")
parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing")
parser.add_argument("--file", help="Process a single file")
parser.add_argument("--base", type=int, default=BASE, help="Base font size in px (default: 16)")
args = parser.parse_args()
base = args.base
dry_run = args.dry_run
report_lines = []
total_files = 0
total_changed = 0
if dry_run:
print("DRY RUN — no files will be written\n")
if args.file:
# Single file mode
path = Path(args.file)
if not path.exists():
print(f"File not found: {path}")
sys.exit(1)
if should_skip_file(path):
print(f"Skipping (excluded): {path}")
sys.exit(0)
report_lines.append("=== Single File Mode ===")
changed = process_file(path, base, dry_run, report_lines)
if changed:
total_changed += 1
print(f"{'[DRY RUN] Would convert' if dry_run else 'Converted'}: {path}")
else:
print(f"No changes: {path}")
else:
# Batch mode — process entire src/ directory
script_dir = Path(__file__).parent
src_dir = script_dir / "src"
if not src_dir.exists():
print(f"src/ directory not found at {src_dir}")
sys.exit(1)
report_lines.append("=== SCSS/CSS Files ===")
scss_files = sorted(find_scss_files(src_dir))
for path in scss_files:
total_files += 1
changed = process_file(path, base, dry_run, report_lines)
if changed:
total_changed += 1
report_lines.append("\n=== HTML Files ===")
html_files = sorted(find_html_files(src_dir))
for path in html_files:
total_files += 1
changed = process_file(path, base, dry_run, report_lines)
if changed:
total_changed += 1
# Summary
summary = (
f"\nSummary: {total_changed} file(s) {'would be ' if dry_run else ''}changed "
f"(out of {total_files} scanned)"
)
report_lines.append(summary)
output = "\n".join(report_lines)
# Write to stdout safely (replace unencodable chars)
sys.stdout.buffer.write(output.encode(sys.stdout.encoding or "utf-8", errors="replace") + b"\n")
if not dry_run and not args.file:
report_path = Path(__file__).parent / "report.txt"
report_path.write_text("\n".join(report_lines), encoding="utf-8")
print(f"\nReport written to: {report_path}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment