Created
February 28, 2026 15:25
-
-
Save majora2007/d8c8f065537edb7d110490aac37e6233 to your computer and use it in GitHub Desktop.
Built to migrate Kavita from PX -> REM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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