Source code for pycsamt.geology.patterns

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Pattern/swatch packs for the Interpretation legend's ``pattern_id`` /
``pattern_source`` fields (see :class:`pycsamt.geology.lithology.RockEntry`).

pycsamt never vendors a third-party geologic pattern set (e.g. the USGS
FGDC "Digital Cartographic Standard for Geologic Map Symbolization" AI
pack) -- only the machinery to **import one the user already downloaded**
and use it. :class:`PatternLibrary` copies whatever is handed to it
(a folder, a ``.zip``, or a single PDF/AI sheet) into a small persistent
cache so the app keeps working after the original download is deleted,
mirroring the fetch/cache convention
:class:`pycsamt.geology.rock_providers.RemoteRockPropertyProvider`
already uses for the rock database (``~/.pycsamt/geology_patterns``,
override with ``$PYCSAMT_PATTERN_CACHE``).

A named, third-party pattern *sheet* (a page of many drawn symbols, as
FGDC-style packs ship them) carries no machine-readable per-symbol crop
box or label -- that information exists only as a human reading the
page. So this module deliberately does not attempt to auto-slice one:
:meth:`PatternLibrary.import_pack` rasterizes each PDF/AI page to a
reference image (via `PyMuPDF <https://pymupdf.readthedocs.io/>`_, an
optional dependency -- install the ``patterns`` extra), and
:meth:`PatternLibrary.add_tile_from_crop` lets the application crop a
named rectangle out of that image by hand, the same way a person would
pick a swatch out of a printed legend sheet. :func:`builtin_pack` ships
a small procedurally generated pattern set (diagonal lines, cross-hatch,
dots, brick, ...) that needs no import at all.
"""

from __future__ import annotations

import io
import json
import os
import re
import zipfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Union

__all__ = [
    "PatternPack",
    "PatternSheet",
    "PatternTile",
    "PatternLibrary",
    "PatternImportError",
    "builtin_pack",
    "tile_stencil_array",
]

PathLike = Union[str, Path]

BUILTIN_PACK_ID = "builtin"
_RASTER_SUFFIXES = {".pdf", ".ai"}
_MANIFEST_NAME = "library.json"
_DPI = 200


[docs] class PatternImportError(ValueError): """Raised when a pattern-pack source cannot be imported."""
[docs] @dataclass class PatternSheet: """One rasterized reference page (a source PDF/AI page rendered to PNG), the surface :meth:`PatternLibrary.add_tile_from_crop` crops named swatches out of.""" id: str file: str # path relative to the pack directory source_file: str page: int width: int height: int
[docs] @dataclass class PatternTile: """One named, usable swatch -- a small PNG, optionally a plain solid colour instead of an image.""" id: str name: str file: str # path relative to the pack directory ("" for a solid colour) color: str | None = None sheet_id: str | None = None bbox: tuple[int, int, int, int] | None = None
[docs] @dataclass class PatternPack: """A named collection of :class:`PatternSheet` / :class:`PatternTile`, persisted under one directory in the pattern cache.""" id: str name: str source: str imported_at: str directory: Path sheets: list[PatternSheet] = field(default_factory=list) tiles: list[PatternTile] = field(default_factory=list)
[docs] def tile(self, tile_id: str) -> PatternTile | None: return next((t for t in self.tiles if t.id == tile_id), None)
[docs] def sheet(self, sheet_id: str) -> PatternSheet | None: return next((s for s in self.sheets if s.id == sheet_id), None)
# --------------------------------------------------------------------------- # PatternLibrary # ---------------------------------------------------------------------------
[docs] class PatternLibrary: """Manages persisted pattern packs under one cache directory.""" def __init__(self, cache_dir: PathLike | None = None) -> None: self.cache_dir = self._resolve_cache_dir(cache_dir) @staticmethod def _resolve_cache_dir(cache_dir: PathLike | None) -> Path: if cache_dir is not None: return Path(cache_dir) env = os.environ.get("PYCSAMT_PATTERN_CACHE") if env: return Path(env) return Path.home() / ".pycsamt" / "geology_patterns" # ------------------------------------------------------------------ # discovery # ------------------------------------------------------------------
[docs] def list_packs(self) -> list[dict[str, Any]]: """Return ``{id, name, n_sheets, n_tiles}`` for every persisted pack, plus the always-available built-in pack first.""" out = [ { "id": BUILTIN_PACK_ID, "name": "Built-in patterns", "n_sheets": 0, "n_tiles": len(builtin_pack().tiles), } ] if not self.cache_dir.is_dir(): return out for entry in sorted(self.cache_dir.iterdir()): manifest = entry / _MANIFEST_NAME if entry.name == BUILTIN_PACK_ID or not manifest.is_file(): continue try: data = json.loads(manifest.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue out.append( { "id": data.get("pack_id", entry.name), "name": data.get("name", entry.name), "n_sheets": len(data.get("sheets", [])), "n_tiles": len(data.get("tiles", [])), } ) return out
[docs] def load_pack(self, pack_id: str) -> PatternPack: """Load a persisted pack by id, materializing :data:`builtin_pack` into the cache on first request so every pack (built-in or imported) is read through the same on-disk path.""" if pack_id == BUILTIN_PACK_ID: return self._materialize_builtin() directory = self.cache_dir / _slug(pack_id) manifest_path = directory / _MANIFEST_NAME if not manifest_path.is_file(): raise PatternImportError(f"unknown pattern pack {pack_id!r}") data = json.loads(manifest_path.read_text(encoding="utf-8")) return _pack_from_manifest(data, directory)
# ------------------------------------------------------------------ # import # ------------------------------------------------------------------
[docs] def import_pack( self, source: PathLike | bytes, *, name: str | None = None, filename: str | None = None, ) -> PatternPack: """Copy *source* into the persistent cache and rasterize any PDF/AI sheet it contains. *source* is a folder, a ``.zip`` archive, a single PDF/AI file path, or raw ``bytes`` (with *filename* giving its name/kind, for an uploaded file). Returns the resulting :class:`PatternPack`; raises :class:`PatternImportError` on anything unusable, and raises :class:`ImportError` (never silently degrades) when a PDF/AI sheet needs rasterizing but ``pymupdf`` is not installed. """ pack_name = name or ( Path(filename).stem if filename else Path(str(source)).stem if isinstance(source, (str, Path)) else "pattern-pack" ) pack_id = _slug(pack_name) or f"pack-{_now_slug()}" directory = self.cache_dir / pack_id source_dir = directory / "source" sheets_dir = directory / "sheets" source_dir.mkdir(parents=True, exist_ok=True) sheets_dir.mkdir(parents=True, exist_ok=True) raster_files = list( self._stage_source(source, filename, source_dir) ) if not raster_files: raise PatternImportError( "no .pdf/.ai/.png/.jpg pattern sheet found in this source" ) sheets: list[PatternSheet] = [] n = 0 for staged in raster_files: if staged.suffix.lower() in _RASTER_SUFFIXES: for page_png, page_no, w, h in _rasterize_pdf(staged): n += 1 sheet_id = f"sheet-{n:04d}" out_path = sheets_dir / f"{sheet_id}.png" out_path.write_bytes(page_png) sheets.append( PatternSheet( id=sheet_id, file=f"sheets/{sheet_id}.png", source_file=staged.name, page=page_no, width=w, height=h, ) ) elif staged.suffix.lower() in {".png", ".jpg", ".jpeg"}: n += 1 sheet_id = f"sheet-{n:04d}" out_path = sheets_dir / f"{sheet_id}.png" out_path.write_bytes(staged.read_bytes()) w, h = _png_size(out_path) sheets.append( PatternSheet( id=sheet_id, file=f"sheets/{sheet_id}.png", source_file=staged.name, page=1, width=w, height=h, ) ) pack = PatternPack( id=pack_id, name=pack_name, source=filename or str(source), imported_at=_now(), directory=directory, sheets=sheets, tiles=[], ) self._write_manifest(pack) return pack
def _stage_source( self, source: PathLike | bytes, filename: str | None, source_dir: Path, ) -> list[Path]: """Copy *source* under *source_dir* and return the staged PDF/AI/PNG/JPG files worth rasterizing.""" if isinstance(source, bytes): name = filename or "upload" if name.lower().endswith(".zip") or source[:2] == b"PK": return self._extract_zip_bytes(source, source_dir) target = source_dir / Path(name).name target.write_bytes(source) return [target] if target.suffix.lower() in ( _RASTER_SUFFIXES | {".png", ".jpg", ".jpeg"} ) else [] path = Path(source) if not path.exists(): raise PatternImportError(f"source not found: {path}") if path.is_dir(): staged = [] for p in sorted(path.rglob("*")): if p.is_file() and p.suffix.lower() in ( _RASTER_SUFFIXES | {".png", ".jpg", ".jpeg"} ): target = source_dir / p.relative_to(path) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(p.read_bytes()) staged.append(target) return staged if path.suffix.lower() == ".zip": return self._extract_zip_bytes(path.read_bytes(), source_dir) target = source_dir / path.name target.write_bytes(path.read_bytes()) return [target] if target.suffix.lower() in ( _RASTER_SUFFIXES | {".png", ".jpg", ".jpeg"} ) else [] @staticmethod def _extract_zip_bytes(raw: bytes, source_dir: Path) -> list[Path]: staged: list[Path] = [] try: with zipfile.ZipFile(io.BytesIO(raw)) as zf: for info in zf.infolist(): if info.is_dir(): continue name = Path(info.filename) if name.suffix.lower() not in ( _RASTER_SUFFIXES | {".png", ".jpg", ".jpeg"} ): continue # Flatten into source_dir, guarding against a # zip-slip path escaping it. safe_name = _safe_member_name(name) target = source_dir / safe_name target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(zf.read(info)) staged.append(target) except zipfile.BadZipFile as error: raise PatternImportError("not a valid .zip archive") from error return staged # ------------------------------------------------------------------ # tiles (named crops) # ------------------------------------------------------------------
[docs] def add_tile_from_crop( self, pack_id: str, sheet_id: str, bbox: tuple[float, float, float, float], name: str, ) -> PatternTile: """Crop *bbox* (pixel ``x0, y0, x1, y1``) out of *sheet_id* and save it as a new named tile in the pack.""" try: from PIL import Image except ImportError as error: # pragma: no cover - Pillow is core raise ImportError("Pillow is required to crop a pattern sheet") from error if pack_id == BUILTIN_PACK_ID: raise PatternImportError("the built-in pack cannot be edited") pack = self.load_pack(pack_id) sheet = pack.sheet(sheet_id) if sheet is None: raise PatternImportError(f"unknown sheet {sheet_id!r}") if not str(name).strip(): raise PatternImportError("a swatch needs a name") x0, y0, x1, y1 = bbox x0, x1 = sorted((max(0, int(x0)), max(0, int(x1)))) y0, y1 = sorted((max(0, int(y0)), max(0, int(y1)))) if x1 - x0 < 4 or y1 - y0 < 4: raise PatternImportError("crop is too small") image = Image.open(pack.directory / sheet.file) crop = image.crop( (x0, y0, min(x1, image.width), min(y1, image.height)) ) tile_id = f"tile-{len(pack.tiles) + 1:04d}" tiles_dir = pack.directory / "tiles" tiles_dir.mkdir(exist_ok=True) out_path = tiles_dir / f"{tile_id}.png" crop.convert("RGBA").save(out_path) tile = PatternTile( id=tile_id, name=str(name).strip(), file=f"tiles/{tile_id}.png", sheet_id=sheet_id, bbox=(x0, y0, x1, y1), ) pack.tiles.append(tile) self._write_manifest(pack) return tile
[docs] def delete_tile(self, pack_id: str, tile_id: str) -> None: if pack_id == BUILTIN_PACK_ID: raise PatternImportError("the built-in pack cannot be edited") pack = self.load_pack(pack_id) tile = pack.tile(tile_id) if tile is None: return pack.tiles = [t for t in pack.tiles if t.id != tile_id] if tile.file: (pack.directory / tile.file).unlink(missing_ok=True) self._write_manifest(pack)
# ------------------------------------------------------------------ # helpers # ------------------------------------------------------------------ def _write_manifest(self, pack: PatternPack) -> None: pack.directory.mkdir(parents=True, exist_ok=True) payload = { "pack_id": pack.id, "name": pack.name, "source": pack.source, "imported_at": pack.imported_at, "sheets": [vars(s) for s in pack.sheets], "tiles": [ {**vars(t), "bbox": list(t.bbox) if t.bbox else None} for t in pack.tiles ], } (pack.directory / _MANIFEST_NAME).write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" ) def _materialize_builtin(self) -> PatternPack: pack = builtin_pack() directory = self.cache_dir / BUILTIN_PACK_ID tiles_dir = directory / "tiles" tiles_dir.mkdir(parents=True, exist_ok=True) manifest_path = directory / _MANIFEST_NAME if manifest_path.is_file(): data = json.loads(manifest_path.read_text(encoding="utf-8")) if len(data.get("tiles", [])) == len(pack.tiles): return _pack_from_manifest(data, directory) for tile in pack.tiles: (directory / tile.file).write_bytes( _builtin_tile_png_bytes(tile.name) ) pack.directory = directory self._write_manifest(pack) return pack
# --------------------------------------------------------------------------- # built-in procedurally generated patterns # --------------------------------------------------------------------------- _BUILTIN_SPECS = ( "diagonal-lines", "cross-hatch", "dots", "brick", "dashed-lines", "grid", "triangles", "stipple", )
[docs] def builtin_pack() -> PatternPack: """The always-available, zero-setup pattern pack -- a small set of neutral procedurally generated hatch/stipple/brick/dot tiles, not derived from any third-party source.""" tiles = [ PatternTile( id=f"builtin-{i + 1:02d}", name=name, file=f"tiles/builtin-{i + 1:02d}.png", ) for i, name in enumerate(_BUILTIN_SPECS) ] return PatternPack( id=BUILTIN_PACK_ID, name="Built-in patterns", source="", imported_at=_now(), directory=Path(), sheets=[], tiles=tiles, )
def _builtin_tile_png_bytes(spec_name: str, size: int = 64) -> bytes: from PIL import Image, ImageDraw img = Image.new("RGBA", (size, size), (255, 255, 255, 255)) draw = ImageDraw.Draw(img) fg = (30, 30, 30, 255) if spec_name == "diagonal-lines": for x in range(-size, size * 2, 10): draw.line([(x, 0), (x + size, size)], fill=fg, width=2) elif spec_name == "cross-hatch": for x in range(-size, size * 2, 10): draw.line([(x, 0), (x + size, size)], fill=fg, width=1) draw.line([(x + size, 0), (x, size)], fill=fg, width=1) elif spec_name == "dots": for y in range(4, size, 12): for x in range(4, size, 12): draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=fg) elif spec_name == "brick": for y in range(0, size, 16): draw.line([(0, y), (size, y)], fill=fg, width=2) for i, y in enumerate(range(0, size, 16)): off = 0 if (i % 2 == 0) else 16 for x in range(off, size, 32): draw.line([(x, y), (x, y + 16)], fill=fg, width=2) elif spec_name == "dashed-lines": for y in range(4, size, 10): for x in range(0, size, 12): draw.line([(x, y), (x + 6, y)], fill=fg, width=2) elif spec_name == "grid": for x in range(0, size, 10): draw.line([(x, 0), (x, size)], fill=fg, width=1) for y in range(0, size, 10): draw.line([(0, y), (size, y)], fill=fg, width=1) elif spec_name == "triangles": for y in range(0, size, 16): for x in range(0, size, 16): draw.polygon( [(x, y + 14), (x + 7, y + 2), (x + 14, y + 14)], outline=fg, ) elif spec_name == "stipple": import random rng = random.Random(hash(spec_name) & 0xFFFF) for _ in range(140): x, y = rng.randrange(size), rng.randrange(size) draw.point((x, y), fill=fg) buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() # --------------------------------------------------------------------------- # PDF/AI rasterization # --------------------------------------------------------------------------- def _rasterize_pdf(path: Path) -> list[tuple[bytes, int, int, int]]: """Render every page of a PDF/AI(PDF-compatible) file to PNG bytes via PyMuPDF. Raises :class:`ImportError` (not a silent skip) if ``pymupdf`` is not installed -- rasterizing is the whole point of importing a sheet-based pack.""" try: import pymupdf as fitz # the modern import name; `fitz` itself # is a deprecated compatibility alias PyMuPDF >=1.23 still ships # but warns on at import time. except ImportError: try: import fitz # pragma: no cover - pre-1.23 PyMuPDF only except ImportError as error: raise ImportError( "Importing a PDF/AI pattern sheet requires PyMuPDF -- " "install it with `pip install pycsamt[patterns]` " "(or `pip install pymupdf`)." ) from error out = [] zoom = _DPI / 72.0 matrix = fitz.Matrix(zoom, zoom) with fitz.open(path) as doc: for i, page in enumerate(doc, start=1): pix = page.get_pixmap(matrix=matrix) out.append((pix.tobytes("png"), i, pix.width, pix.height)) return out def _png_size(path: Path) -> tuple[int, int]: from PIL import Image with Image.open(path) as img: return img.width, img.height
[docs] def tile_stencil_array(path: PathLike): """Read a tile PNG as an ink-density *stencil*: an ``H x W`` array of alpha-weighted darkness in ``[0, 1]`` (``0`` = no ink / paper, ``1`` = full ink), read from :attr:`PatternTile.file`. The tile's own colour is discarded on purpose -- a pattern is applied tinted by the *legend's* assigned colour (see :func:`pycsamt.map.styles.pattern_band_stops`), not by whatever colour the source pack happened to draw its swatch in, so the same hatch/stipple/brick shape reads consistently whatever unit it is assigned to. """ import numpy as np from PIL import Image with Image.open(path) as img: rgba = np.asarray(img.convert("RGBA"), dtype=np.float32) r, g, b, a = rgba[..., 0], rgba[..., 1], rgba[..., 2], rgba[..., 3] luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0 alpha = a / 255.0 return alpha * (1.0 - luminance)
# --------------------------------------------------------------------------- # small helpers # --------------------------------------------------------------------------- def _pack_from_manifest(data: dict, directory: Path) -> PatternPack: sheets = [PatternSheet(**s) for s in data.get("sheets", [])] tiles = [ PatternTile( **{**t, "bbox": tuple(t["bbox"]) if t.get("bbox") else None} ) for t in data.get("tiles", []) ] return PatternPack( id=data.get("pack_id", directory.name), name=data.get("name", directory.name), source=data.get("source", ""), imported_at=data.get("imported_at", ""), directory=directory, sheets=sheets, tiles=tiles, ) def _slug(text: str) -> str: return re.sub(r"[^a-z0-9_-]+", "-", str(text).strip().casefold()).strip("-")[:60] def _safe_member_name(name: Path) -> Path: parts = [p for p in name.parts if p not in ("..", "", ".")] return Path(*parts) if parts else Path(name.name) def _now() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def _now_slug() -> str: return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")