Source code for psd2svg.core.adjustment

"""Mixin for adjustment layers conversion.

Due to the limited support of BackgroundImage in SVG filters, our approach wraps
the backdrop elements into a symbol, and use two <use> elements to apply the filter.

When we have a layer structure like this::

    <layer 1 />
    <layer 2 />
    <adjustment />

We convert it into the following SVG structure::

    <symbol id="backdrop">
        <image id="layer1" ... />
        <image id="layer2" ... />
    </symbol>
    <filter id="adjustment"></filter>
    <use href="#backdrop" />
    <use href="#backdrop" filter="url(#adjustment)" />


This approach may have limitations when the backdrop has transparency, as the
stacked use elements may not produce the intended visual result.
"""

import logging
import xml.etree.ElementTree as ET
from collections import OrderedDict

import numpy as np
from psd_tools.api import adjustments, layers
from psd_tools.psd.adjustments import LevelRecord

from psd2svg import svg_utils
from psd2svg.core.base import ConverterProtocol

logger = logging.getLogger(__name__)


[docs] class AdjustmentConverter(ConverterProtocol): """Mixin for adjustment layers."""
[docs] def add_invert_adjustment( self, layer: adjustments.Invert, **attrib: str ) -> ET.Element | None: """Add an invert adjustment layer to the svg document.""" filter, use = self._create_filter(layer, name="invert", **attrib) with self.set_current(filter): fe_component = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_component): self.create_node("feFuncR", type="table", tableValues="1 0") self.create_node("feFuncG", type="table", tableValues="1 0") self.create_node("feFuncB", type="table", tableValues="1 0") return use
[docs] def add_posterize_adjustment( self, layer: adjustments.Posterize, **attrib: str ) -> ET.Element | None: """Add a posterize adjustment layer to the svg document.""" # Validate and clamp levels to valid range levels = layer.posterize if levels < 2: logger.warning( f"Posterize levels {levels} is below minimum (2), clamping to 2" ) levels = 2 elif levels > 256: logger.warning( f"Posterize levels {levels} exceeds maximum (256), clamping to 256" ) levels = 256 # Generate discrete table values: [0/(n-1), 1/(n-1), ..., (n-1)/(n-1)] table_values = [i / (levels - 1) for i in range(levels)] table_values_str = svg_utils.seq2str(table_values, sep=" ") filter, use = self._create_filter(layer, name="posterize", **attrib) with self.set_current(filter): fe_component = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_component): # Apply discrete posterization to RGB channels self.create_node( "feFuncR", type="discrete", tableValues=table_values_str ) self.create_node( "feFuncG", type="discrete", tableValues=table_values_str ) self.create_node( "feFuncB", type="discrete", tableValues=table_values_str ) return use
[docs] def add_threshold_adjustment( self, layer: adjustments.Threshold, **attrib: str ) -> ET.Element | None: """Add a threshold adjustment layer to the svg document. Threshold converts the image to high-contrast black and white by comparing each pixel's luminance against a threshold value. Pixels below the threshold become black (0), and pixels at or above become white (255). Args: layer: The Threshold adjustment layer to convert. attrib: Additional attributes for the SVG element. Returns: The SVG use element with the filter applied, or None if invalid. """ # Extract threshold value (0-255) threshold = layer.threshold # Log for debugging logger.debug(f"Threshold adjustment '{layer.name}': threshold={threshold}") # Validate threshold value (warn but don't clamp except for > 255) if threshold < 1: logger.warning( f"Threshold value {threshold} is below minimum (1), " f"resulting in all-white output" ) elif threshold > 255: logger.warning( f"Threshold value {threshold} exceeds maximum (255), clamping to 255" ) threshold = 255 # Generate lookup table: 256 values where LUT[i] = 0 if i < threshold else 1 table_values = [0.0 if i < threshold else 1.0 for i in range(256)] table_values_str = svg_utils.seq2str(table_values, sep=" ") # Create filter structure filter, use = self._create_filter(layer, name="threshold", **attrib) with self.set_current(filter): # Step 1: Convert to grayscale using luminance formula # ITU-R BT.601: L = 0.299*R + 0.587*G + 0.114*B # This desaturates the image to grayscale self.create_node( "feColorMatrix", type="saturate", values=0, color_interpolation_filters="sRGB", ) # Step 2: Apply threshold to the grayscale result fe_component = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_component): # Apply threshold to all RGB channels # (they're identical after desaturation) self.create_node("feFuncR", type="table", tableValues=table_values_str) self.create_node("feFuncG", type="table", tableValues=table_values_str) self.create_node("feFuncB", type="table", tableValues=table_values_str) return use
[docs] def add_hue_saturation_adjustment( self, layer: adjustments.HueSaturation, **attrib: str ) -> ET.Element | None: """Add a hue/saturation adjustment layer to the svg document. Supports two modes: - Normal mode: Adjusts hue, saturation, and lightness using master values - Colorize mode: Desaturates then applies colorization tint Note: Per-range color adjustments (layer.data) are not yet supported. """ # Extract adjustment parameters hue, saturation, lightness = layer.master enable_colorization = layer.enable_colorization # Check if this is a no-op adjustment if enable_colorization == 0 and hue == 0 and saturation == 0 and lightness == 0: logger.info( f"HueSaturation adjustment '{layer.name}' has no effect, skipping" ) return None # Create filter structure filter, use = self._create_filter(layer, name="huesaturation", **attrib) with self.set_current(filter): if enable_colorization == 1: # Colorize mode: desaturate, then apply colorization self._apply_colorize_mode(layer) else: # Normal mode: apply master adjustments self._apply_normal_huesaturation(hue, saturation, lightness) return use
[docs] def add_exposure_adjustment( self, layer: adjustments.Exposure, **attrib: str ) -> ET.Element | None: """Add an exposure adjustment layer to the svg document. Applies exposure, offset, and gamma correction to simulate Photoshop's Exposure adjustment layer. Operations are applied in linear RGB space to match Photoshop's behavior. The three parameters are applied in sequence: 1. Exposure: output = input × 2^exposure 2. Offset: output = input + offset 3. Gamma: output = input^(1/gamma) Args: layer: The Exposure adjustment layer to convert. attrib: Additional attributes for the SVG element. Returns: The SVG use element with the filter applied, or None if no-op. """ # Extract parameters exposure = layer.exposure offset = layer.exposure_offset gamma = layer.gamma # Log parameters for debugging logger.debug( f"Exposure adjustment '{layer.name}': " f"exposure={exposure}, offset={offset}, gamma={gamma}" ) # Check if this is a no-op adjustment (within floating-point tolerance) if abs(exposure) < 1e-6 and abs(offset) < 1e-6 and abs(gamma - 1.0) < 1e-6: logger.info(f"Exposure adjustment '{layer.name}' has no effect, skipping") return None # Validate parameters (warn but don't clamp) if not (-20.0 <= exposure <= 20.0): logger.warning( f"Exposure value {exposure} is outside expected range [-20, +20]" ) if not (-0.5 <= offset <= 0.5): logger.warning( f"Offset value {offset} is outside expected range [-0.5, +0.5]" ) if not (0.01 <= gamma <= 9.99): logger.warning( f"Gamma value {gamma} is outside expected range [0.01, 9.99]" ) # Create filter structure filter, use = self._create_filter(layer, name="exposure", **attrib) with self.set_current(filter): # Stage 1: Apply exposure (multiply by 2^exposure) if abs(exposure) >= 1e-6: # Only apply if non-zero exposure_scale = 2**exposure fe_exposure = self.create_node( "feComponentTransfer", color_interpolation_filters="linearRGB" ) with self.set_current(fe_exposure): for func in ["feFuncR", "feFuncG", "feFuncB"]: self.create_node( func, type="linear", slope=exposure_scale, intercept=0 ) # Stage 2: Apply offset (add offset value) if abs(offset) >= 1e-6: # Only apply if non-zero fe_offset = self.create_node( "feComponentTransfer", color_interpolation_filters="linearRGB" ) with self.set_current(fe_offset): for func in ["feFuncR", "feFuncG", "feFuncB"]: self.create_node(func, type="linear", slope=1, intercept=offset) # Stage 3: Apply gamma (power function with exponent 1/gamma) if abs(gamma - 1.0) >= 1e-6: # Only apply if not 1.0 gamma_exponent = 1.0 / gamma fe_gamma = self.create_node( "feComponentTransfer", color_interpolation_filters="linearRGB" ) with self.set_current(fe_gamma): for func in ["feFuncR", "feFuncG", "feFuncB"]: self.create_node(func, type="gamma", exponent=gamma_exponent) return use
[docs] def add_brightness_contrast_adjustment( self, layer: adjustments.BrightnessContrast, **attrib: str ) -> ET.Element | None: """Add a brightness/contrast adjustment layer to the svg document. Applies brightness and contrast adjustments using SVG feComponentTransfer filters with linear transfer functions. Operations are applied sequentially: brightness first, then contrast. Brightness adds a constant value to all RGB channels: output = input + (brightness / 255) Contrast scales values around the midpoint (0.5): output = (input - 0.5) * factor + 0.5 where factor = (259 * (contrast + 255)) / (255 * (259 - contrast)) Note: Photoshop's modern (non-legacy) brightness/contrast uses a complex curves-based algorithm. Our linear approximation works well for most cases but may have higher error for extreme negative brightness values. Args: layer: The BrightnessContrast adjustment layer to convert. attrib: Additional attributes for the SVG element. Returns: The SVG use element with the filter applied, or None if no-op. """ # Extract parameters brightness = layer.brightness contrast = layer.contrast # Log parameters for debugging logger.debug( f"BrightnessContrast adjustment '{layer.name}': " f"brightness={brightness}, contrast={contrast}" ) # Early return for no-op if brightness == 0 and contrast == 0: logger.info( f"BrightnessContrast adjustment '{layer.name}' has no effect, skipping" ) return None # Parameter validation (warn but don't clamp) if not (-150 <= brightness <= 150): logger.warning( f"Brightness value {brightness} is outside expected range [-150, +150]" ) if not (-50 <= contrast <= 100): logger.warning( f"Contrast value {contrast} is outside expected range [-50, +100]" ) # Create filter structure filter, use = self._create_filter(layer, name="brightnesscontrast", **attrib) with self.set_current(filter): # Stage 1: Apply brightness (if non-zero) if brightness != 0: brightness_offset = brightness / 255.0 fe_brightness = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_brightness): for func in ["feFuncR", "feFuncG", "feFuncB"]: self.create_node( func, type="linear", slope=1.0, intercept=brightness_offset ) # Stage 2: Apply contrast (if non-zero) if contrast != 0: # Calculate contrast factor using legacy-style formula # This formula: (259 * (contrast + 255)) / (255 * (259 - contrast)) # Matches Photoshop's behavior better than the simple modern formula numerator = 259.0 * (contrast + 255.0) denominator = 255.0 * (259.0 - contrast) contrast_factor = numerator / denominator # Calculate intercept: 0.5 * (1 - factor) contrast_intercept = 0.5 * (1.0 - contrast_factor) fe_contrast = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_contrast): for func in ["feFuncR", "feFuncG", "feFuncB"]: self.create_node( func, type="linear", slope=contrast_factor, intercept=contrast_intercept, ) return use
[docs] def add_color_balance_adjustment( self, layer: adjustments.ColorBalance, **attrib: str ) -> ET.Element | None: """Add a color balance adjustment layer to the svg document. Applies color balance adjustments to shadows, midtones, and highlights using SVG feComponentTransfer filters with lookup tables. Note: Uses grayscale approximation for luminance due to SVG's independent channel processing. Accuracy ~95% for typical adjustments. Args: layer: The ColorBalance adjustment layer to convert. attrib: Additional attributes for the SVG element. Returns: The SVG use element with the filter applied, or None if no-op. """ # Extract parameters shadows = layer.shadows midtones = layer.midtones highlights = layer.highlights preserve_luminosity = layer.luminosity == 1 # 1=enabled, 0=disabled # Log parameters logger.debug( f"ColorBalance adjustment '{layer.name}': " f"shadows={shadows}, midtones={midtones}, highlights={highlights}, " f"preserve_luminosity={preserve_luminosity}" ) # Check for no-op if shadows == (0, 0, 0) and midtones == (0, 0, 0) and highlights == (0, 0, 0): logger.info( f"ColorBalance adjustment '{layer.name}' has no effect, skipping" ) return None # Warn about preserve luminosity limitation if preserve_luminosity: logger.warning( f"ColorBalance adjustment '{layer.name}': " "Preserve Luminosity is not fully supported in SVG. " "Results may differ from Photoshop." ) # Warn about extreme adjustments with reduced accuracy max_abs_value = max( max(abs(v) for v in shadows), max(abs(v) for v in midtones), max(abs(v) for v in highlights), ) if max_abs_value >= 80: logger.info( f"ColorBalance adjustment '{layer.name}': " f"Extreme adjustment values (max |{max_abs_value}|) detected. " "Accuracy may be reduced (65-85%) due to SVG's per-channel " "luminance approximation. For critical color accuracy, flatten " "this adjustment in Photoshop before conversion." ) # Create filter structure filter, use = self._create_filter(layer, name="colorbalance", **attrib) # Generate lookup tables lut_r = self._generate_colorbalance_lut(shadows, midtones, highlights, 0) lut_g = self._generate_colorbalance_lut(shadows, midtones, highlights, 1) lut_b = self._generate_colorbalance_lut(shadows, midtones, highlights, 2) # Convert to SVG format lut_r_str = svg_utils.seq2str(lut_r, sep=" ") lut_g_str = svg_utils.seq2str(lut_g, sep=" ") lut_b_str = svg_utils.seq2str(lut_b, sep=" ") # Create filter with self.set_current(filter): fe_component = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_component): self.create_node("feFuncR", type="table", tableValues=lut_r_str) self.create_node("feFuncG", type="table", tableValues=lut_g_str) self.create_node("feFuncB", type="table", tableValues=lut_b_str) return use
[docs] def add_black_and_white_adjustment( self, layer: adjustments.BlackAndWhite, **attrib: str ) -> ET.Element | None: """Add a black and white adjustment layer to the svg document. Note: This adjustment layer type is not yet implemented. """ logger.warning( f"Black and White adjustment layer is not yet implemented: " f"'{layer.name}' ({layer.kind})" ) return None
[docs] def add_channel_mixer_adjustment( self, layer: adjustments.ChannelMixer, **attrib: str ) -> ET.Element | None: """Add a channel mixer adjustment layer to the svg document. Note: This adjustment layer type is not yet implemented. """ logger.warning( f"Channel Mixer adjustment layer is not yet implemented: " f"'{layer.name}' ({layer.kind})" ) return None
[docs] def add_color_lookup_adjustment( self, layer: adjustments.ColorLookup, **attrib: str ) -> ET.Element | None: """Add a color lookup adjustment layer to the svg document. Note: This adjustment layer type is not yet implemented. """ logger.warning( f"Color Lookup adjustment layer is not yet implemented: " f"'{layer.name}' ({layer.kind})" ) return None
[docs] def add_curves_adjustment( self, layer: adjustments.Curves, **attrib: str ) -> ET.Element | None: """Add a curves adjustment layer to the svg document. Applies tonal curve adjustments using SVG feComponentTransfer filters with lookup tables generated from control points. Supports both composite (RGB) curves and per-channel (R/G/B) curves, matching Photoshop's curve application order. Args: layer: The Curves adjustment layer to convert. attrib: Additional attributes for the SVG element. Returns: The SVG use element with the filter applied, or None if identity. """ # Validate curve data if not hasattr(layer.data, "extra") or not layer.data.extra: logger.warning( f"Curves adjustment '{layer.name}' has no curve data, skipping" ) return None # Check for identity curves (optimization) is_identity = True for item in layer.data.extra: # type: ignore[attr-defined] if len(item.points) != 2 or item.points != [(0, 0), (255, 255)]: is_identity = False break if is_identity: logger.info(f"Curves adjustment '{layer.name}' is identity curve, skipping") return None # Log curve info for debugging logger.debug( f"Curves adjustment '{layer.name}': " f"Processing {len(layer.data.extra)} curve(s)" # type: ignore[arg-type] ) # Generate lookup tables lut_r, lut_g, lut_b = self._generate_curves_luts(layer) # Convert to SVG format lut_r_str = svg_utils.seq2str(lut_r, sep=" ") lut_g_str = svg_utils.seq2str(lut_g, sep=" ") lut_b_str = svg_utils.seq2str(lut_b, sep=" ") # Create filter structure filter, use = self._create_filter(layer, name="curves", **attrib) # Build SVG filter primitives with self.set_current(filter): fe_component = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_component): self.create_node("feFuncR", type="table", tableValues=lut_r_str) self.create_node("feFuncG", type="table", tableValues=lut_g_str) self.create_node("feFuncB", type="table", tableValues=lut_b_str) return use
[docs] def add_gradient_map_adjustment( self, layer: adjustments.GradientMap, **attrib: str ) -> ET.Element | None: """Add a gradient map adjustment layer to the svg document. Note: This adjustment layer type is not yet implemented. """ logger.warning( f"Gradient Map adjustment layer is not yet implemented: " f"'{layer.name}' ({layer.kind})" ) return None
[docs] def add_levels_adjustment( self, layer: adjustments.Levels, **attrib: str ) -> ET.Element | None: """Add a levels adjustment layer to the svg document. Applies tonal adjustments using input/output ranges and gamma correction. Supports both composite (RGB) and per-channel (R/G/B) adjustments, matching Photoshop's application order. Args: layer: The Levels adjustment layer to convert. attrib: Additional attributes for the SVG element. Returns: The SVG use element with the filter applied, or None if identity. """ # Validate data structure if not hasattr(layer, "data") or len(layer.data) < 4: logger.warning( f"Levels adjustment '{layer.name}' has insufficient data, skipping" ) return None # Check for identity (optimization) is_identity = True for i in range(4): record = layer.data[i] if not ( record.input_floor == 0 and record.input_ceiling == 255 and record.output_floor == 0 and record.output_ceiling == 255 and record.gamma == 100 ): is_identity = False break if is_identity: logger.info(f"Levels adjustment '{layer.name}' is identity, skipping") return None # Log for debugging logger.debug( f"Levels adjustment '{layer.name}': " f"Processing composite RGB and per-channel adjustments" ) # Generate lookup tables lut_r, lut_g, lut_b = self._generate_levels_luts(layer) # Convert to SVG format lut_r_str = svg_utils.seq2str(lut_r, sep=" ") lut_g_str = svg_utils.seq2str(lut_g, sep=" ") lut_b_str = svg_utils.seq2str(lut_b, sep=" ") # Create filter structure filter, use = self._create_filter(layer, name="levels", **attrib) # Build SVG filter primitives with self.set_current(filter): fe_component = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_component): self.create_node("feFuncR", type="table", tableValues=lut_r_str) self.create_node("feFuncG", type="table", tableValues=lut_g_str) self.create_node("feFuncB", type="table", tableValues=lut_b_str) return use
[docs] def add_photo_filter_adjustment( self, layer: adjustments.PhotoFilter, **attrib: str ) -> ET.Element | None: """Add a photo filter adjustment layer to the svg document. Note: This adjustment layer type is not yet implemented. """ logger.warning( f"Photo Filter adjustment layer is not yet implemented: " f"'{layer.name}' ({layer.kind})" ) return None
[docs] def add_selective_color_adjustment( self, layer: adjustments.SelectiveColor, **attrib: str ) -> ET.Element | None: """Add a selective color adjustment layer to the svg document. Note: This adjustment layer type is not yet implemented. """ logger.warning( f"Selective Color adjustment layer is not yet implemented: " f"'{layer.name}' ({layer.kind})" ) return None
[docs] def add_vibrance_adjustment( self, layer: adjustments.Vibrance, **attrib: str ) -> ET.Element | None: """Add a vibrance adjustment layer to the svg document. Note: This adjustment layer type is not yet implemented. """ logger.warning( f"Vibrance adjustment layer is not yet implemented: " f"'{layer.name}' ({layer.kind})" ) return None
def _generate_colorbalance_lut( self, shadows: tuple[int, int, int], midtones: tuple[int, int, int], highlights: tuple[int, int, int], channel_idx: int, ) -> list[float]: """Generate 256-value lookup table for color balance adjustment. Uses grayscale approximation: assumes R≈G≈B for luminance. Args: shadows: (cyan-red, magenta-green, yellow-blue) for shadows midtones: Same for midtones highlights: Same for highlights channel_idx: 0 for R, 1 for G, 2 for B Returns: List of 256 float values in [0, 1] range. """ lut = [] for i in range(256): # Normalize input to [0, 1] input_val = i / 255.0 # Grayscale approximation: luminance ≈ input_val luminance = input_val # Calculate tonal weights if luminance < 0.33: weight_shadows = (0.33 - luminance) / 0.33 else: weight_shadows = 0.0 # Midtones: centered at 0.495 with falloff range of 0.165 # (triangular weighting) mid_distance = abs(luminance - 0.495) weight_midtones = max(0.0, 1.0 - mid_distance / 0.165) if luminance >= 0.66: weight_highlights = (luminance - 0.66) / 0.34 else: weight_highlights = 0.0 # Calculate weighted adjustment adjustment = ( shadows[channel_idx] * weight_shadows + midtones[channel_idx] * weight_midtones + highlights[channel_idx] * weight_highlights ) / 100.0 # Apply adjustment and clamp output_val = max(0.0, min(1.0, input_val + adjustment)) lut.append(output_val) return lut def _interpolate_curve(self, points: list[tuple[int, int]]) -> list[float]: """Interpolate curve control points to 256-value LUT using Catmull-Rom spline. Uses Catmull-Rom spline interpolation for smooth C1 continuous curves with local control. For curves with only 2 points, uses linear interpolation. Args: points: List of (input, output) tuples in 0-255 range. Always includes endpoints (0, 0) and (255, 255). Returns: List of 256 float values in [0, 1] range for SVG. """ if len(points) < 2: # Edge case: invalid curve, return identity logger.warning("Curve has fewer than 2 points, using identity") return list(np.linspace(0, 1, 256)) # IMPORTANT: Photoshop curve points are (output, input), not (input, output)! # We need to swap them to get (input, output) for interpolation # Example: [(0, 0), (160, 95), (255, 255)] means: # input 0 → output 0 # input 95 → output 160 (brightening) # input 255 → output 255 swapped_points = [(p[1], p[0]) for p in points] # Deduplicate points: keep last point for each x value # This handles cases like [(0, 0), (255, 4), (255, 255)] which after # swapping becomes [(0, 0), (4, 255), (255, 255)] deduplicated_points = list( OrderedDict((p[0], p) for p in swapped_points).values() ) if len(deduplicated_points) != len(swapped_points): logger.debug( f"Deduplicated curve points from {len(swapped_points)} to " f"{len(deduplicated_points)} (removed duplicate x values)" ) points = deduplicated_points else: points = swapped_points # Validate we still have enough points after deduplication if len(points) < 2: logger.warning( "Curve has fewer than 2 points after deduplication, using identity" ) return list(np.linspace(0, 1, 256)) # Extract x and y coordinates (now in input, output order) x_points = np.array([p[0] for p in points], dtype=float) y_points = np.array([p[1] for p in points], dtype=float) # Handle 2-point case (identity or linear) if len(points) == 2: # Simple linear interpolation lut = np.interp(np.arange(256), x_points, y_points) # Clamp and normalize lut = np.clip(lut, 0, 255) / 255.0 return lut.tolist() # Catmull-Rom spline for 3+ points # Duplicate first and last points for boundary tangent calculation x_extended = np.concatenate([[x_points[0]], x_points, [x_points[-1]]]) y_extended = np.concatenate([[y_points[0]], y_points, [y_points[-1]]]) # Catmull-Rom basis matrix cr_matrix = np.array( [ [-0.5, 1.5, -1.5, 0.5], [1.0, -2.5, 2.0, -0.5], [-0.5, 0.0, 0.5, 0.0], [0.0, 1.0, 0.0, 0.0], ] ) # Generate interpolated curve lut = np.zeros(256) for i in range(len(points) - 1): # Get four control points for this segment (p0, p1, p2, p3) # We interpolate between p1 and p2 p0_y = y_extended[i] p1_x, p1_y = x_extended[i + 1], y_extended[i + 1] p2_x, p2_y = x_extended[i + 2], y_extended[i + 2] p3_y = y_extended[i + 3] # Determine which output indices correspond to this segment x_start = int(p1_x) x_end = int(p2_x) if x_start > x_end: # Skip segments going backwards (shouldn't happen after deduplication) continue if x_start == x_end: # Single point segment: set the value directly if x_start < 256: lut[x_start] = p2_y continue # Generate samples for this segment for x in range(x_start, min(x_end + 1, 256)): # Parametric position t in [0, 1] within this segment t = (x - p1_x) / (p2_x - p1_x) if p2_x > p1_x else 0 t = np.clip(t, 0, 1) # Catmull-Rom interpolation t_vec = np.array([t**3, t**2, t, 1]) p_vec = np.array([p0_y, p1_y, p2_y, p3_y]) y = t_vec @ cr_matrix @ p_vec lut[x] = y # Clamp and normalize to [0, 1] lut = np.clip(lut, 0, 255) / 255.0 return lut.tolist() def _generate_curves_luts( self, layer: adjustments.Curves ) -> tuple[list[float], list[float], list[float]]: """Generate RGB lookup tables from Curves layer. Handles both composite (RGB) curves and per-channel curves with correct composition order matching Photoshop behavior: composite curve is applied first to all channels, then individual channel curves are applied on top. Args: layer: The Curves adjustment layer. Returns: Tuple of (lut_r, lut_g, lut_b) with 256 float values each in [0, 1]. """ # Parse curves by channel ID curves_by_channel: dict[int, list[tuple[int, int]]] = {} for item in layer.data.extra: # type: ignore[attr-defined] if 0 <= item.channel_id <= 3: curves_by_channel[item.channel_id] = item.points else: logger.warning( f"Curves adjustment '{layer.name}': " f"Unknown channel ID {item.channel_id}, skipping" ) # Start with identity LUTs (0-255 in pixel space) identity = np.arange(256, dtype=float) r_vals = identity.copy() g_vals = identity.copy() b_vals = identity.copy() # Apply composite curve (channel_id=0) to all channels first if 0 in curves_by_channel: logger.debug( f"Curves adjustment '{layer.name}': " f"Applying composite curve with {len(curves_by_channel[0])} points" ) composite_lut_normalized = self._interpolate_curve(curves_by_channel[0]) # Convert back to 0-255 space for composition composite_lut = np.array(composite_lut_normalized) * 255.0 # Apply composite to all channels r_vals = np.interp(r_vals, identity, composite_lut) g_vals = np.interp(g_vals, identity, composite_lut) b_vals = np.interp(b_vals, identity, composite_lut) # Apply per-channel curves on top of composite for channel_id, channel_name in [(1, "Red"), (2, "Green"), (3, "Blue")]: if channel_id in curves_by_channel: logger.debug( f"Curves adjustment '{layer.name}': " f"Applying {channel_name} curve with " f"{len(curves_by_channel[channel_id])} points" ) channel_lut_normalized = self._interpolate_curve( curves_by_channel[channel_id] ) # Convert back to 0-255 space channel_lut = np.array(channel_lut_normalized) * 255.0 # Apply to the corresponding channel if channel_id == 1: r_vals = np.interp(r_vals, identity, channel_lut) elif channel_id == 2: g_vals = np.interp(g_vals, identity, channel_lut) elif channel_id == 3: b_vals = np.interp(b_vals, identity, channel_lut) # Clamp and normalize to [0, 1] for SVG r_lut = np.clip(r_vals, 0, 255) / 255.0 g_lut = np.clip(g_vals, 0, 255) / 255.0 b_lut = np.clip(b_vals, 0, 255) / 255.0 return r_lut.tolist(), g_lut.tolist(), b_lut.tolist() def _generate_levels_luts( self, layer: adjustments.Levels ) -> tuple[list[float], list[float], list[float]]: """Generate RGB lookup tables from Levels layer. Handles both composite (RGB) and per-channel adjustments with correct composition order matching Photoshop behavior: composite adjustment is applied first to all channels, then individual channel adjustments are applied on top. Args: layer: The Levels adjustment layer. Returns: Tuple of (lut_r, lut_g, lut_b) with 256 float values each in [0, 1]. """ # Start with identity LUTs (0-255 in pixel space) identity = np.arange(256, dtype=float) r_vals = identity.copy() g_vals = identity.copy() b_vals = identity.copy() # Apply composite RGB adjustment (record 0) to all channels first composite_record = layer.data[0] composite_lut = self._apply_levels_to_lut( identity, composite_record, channel_name="RGB" ) # Apply composite to all channels r_vals = composite_lut.copy() g_vals = composite_lut.copy() b_vals = composite_lut.copy() # Apply per-channel adjustments on top for channel_id, channel_name in [(1, "Red"), (2, "Green"), (3, "Blue")]: record = layer.data[channel_id] # Check if this channel has non-identity adjustment if ( record.input_floor != 0 or record.input_ceiling != 255 or record.output_floor != 0 or record.output_ceiling != 255 or record.gamma != 100 ): logger.debug( f"Levels adjustment '{layer.name}': " f"Applying {channel_name} channel adjustment" ) # Apply levels adjustment to this channel if channel_id == 1: r_vals = self._apply_levels_to_lut(r_vals, record, channel_name) elif channel_id == 2: g_vals = self._apply_levels_to_lut(g_vals, record, channel_name) elif channel_id == 3: b_vals = self._apply_levels_to_lut(b_vals, record, channel_name) # Clamp and normalize to [0, 1] for SVG r_lut = np.clip(r_vals, 0, 255) / 255.0 g_lut = np.clip(g_vals, 0, 255) / 255.0 b_lut = np.clip(b_vals, 0, 255) / 255.0 return r_lut.tolist(), g_lut.tolist(), b_lut.tolist() def _apply_levels_to_lut( self, input_lut: np.ndarray, record: LevelRecord, channel_name: str = "RGB" ) -> np.ndarray: """Apply levels transformation to a lookup table. Applies the Photoshop Levels algorithm: 1. Normalize input: (value - input_floor) / (input_ceiling - input_floor) 2. Apply gamma: normalized ^ (100/gamma) 3. Scale to output: result * (output_ceiling - output_floor) + output_floor 4. Clamp to [0, 255] Args: input_lut: Input LUT values in 0-255 range (256 float values). record: LevelRecord with adjustment parameters. channel_name: Channel name for logging (e.g., "RGB", "Red"). Returns: Output LUT values in 0-255 range (256 float values). """ # Extract parameters input_floor = float(record.input_floor) input_ceiling = float(record.input_ceiling) output_floor = float(record.output_floor) output_ceiling = float(record.output_ceiling) gamma = float(record.gamma) # Handle edge case: input_ceiling == input_floor (division by zero) if abs(input_ceiling - input_floor) < 1e-6: logger.warning( f"Levels adjustment: {channel_name} channel has " f"input_ceiling == input_floor ({input_ceiling}), " f"using extreme mapping" ) # All values below input_floor -> output_floor # All values at or above input_floor -> output_ceiling output_lut = np.where(input_lut < input_floor, output_floor, output_ceiling) return output_lut # Handle edge case: gamma <= 0 (invalid) if gamma <= 0: logger.warning( f"Levels adjustment: {channel_name} channel has " f"invalid gamma ({gamma}), using gamma=100" ) gamma = 100.0 # Step 1: Normalize input to [0, 1] range normalized = (input_lut - input_floor) / (input_ceiling - input_floor) # Clamp normalized values to [0, 1] before gamma normalized = np.clip(normalized, 0.0, 1.0) # Step 2: Apply gamma correction gamma_exponent = 100.0 / gamma gamma_corrected = np.power(normalized, gamma_exponent) # Step 3: Scale to output range output_lut = gamma_corrected * (output_ceiling - output_floor) + output_floor # Step 4: Clamp to valid range [0, 255] output_lut = np.clip(output_lut, 0.0, 255.0) return output_lut def _apply_normal_huesaturation( self, hue: int, saturation: int, lightness: int ) -> None: """Apply normal mode hue/saturation adjustments.""" # Apply lightness decrease first (darkening) if lightness < 0: self._apply_lightness_adjustment(lightness) # Apply hue rotation if hue != 0: self.create_node( "feColorMatrix", type="hueRotate", values=hue, color_interpolation_filters="sRGB", ) # Apply saturation adjustment if saturation != 0: saturate_factor = 1 + (saturation / 100) self.create_node( "feColorMatrix", type="saturate", values=saturate_factor, color_interpolation_filters="sRGB", ) # Apply lightness increase (brightening) if lightness > 0: self._apply_lightness_adjustment(lightness) def _apply_colorize_mode(self, layer: adjustments.HueSaturation) -> None: """Apply colorize mode adjustments.""" hue, saturation, lightness = layer.colorization # Step 1: Desaturate completely self.create_node( "feColorMatrix", type="saturate", values=0, color_interpolation_filters="sRGB", ) # Step 2: Apply colorization hue rotation if hue != 0: self.create_node( "feColorMatrix", type="hueRotate", values=hue, color_interpolation_filters="sRGB", ) # Step 3: Apply colorization saturation (absolute, not delta) # Note: Photoshop's colorization saturation is 0-100, not -100 to +100 saturate_factor = 1 + (saturation / 100) self.create_node( "feColorMatrix", type="saturate", values=saturate_factor, color_interpolation_filters="sRGB", ) # Step 4: Apply lightness adjustment if lightness != 0: self._apply_lightness_adjustment(lightness) def _apply_lightness_adjustment(self, lightness: int) -> None: """Apply lightness adjustment using feComponentTransfer. Args: lightness: Lightness value from -100 (darkest) to +100 (brightest). """ if lightness < 0: # Darken: compress whites toward black slope: float = 1 + (lightness / 100) intercept: float = 0 else: # Brighten: compress blacks toward white slope = 1 - (lightness / 100) intercept = lightness / 100 fe_component = self.create_node( "feComponentTransfer", color_interpolation_filters="sRGB" ) with self.set_current(fe_component): for func in ["feFuncR", "feFuncG", "feFuncB"]: self.create_node(func, type="linear", slope=slope, intercept=intercept) def _create_filter( self, layer: layers.AdjustmentLayer, name: str, **attrib: str ) -> tuple[ET.Element, ET.Element]: """Create SVG filter structure for the adjustment layer.""" wrapper = self._wrap_backdrop("symbol", id=self.auto_id("backdrop")) filter = self.create_node("filter", id=self.auto_id(name)) # Backdrop use. self.create_node( "use", href=svg_utils.get_uri(wrapper), ) # Apply filter to the use. use = self.create_node( "use", href=svg_utils.get_uri(wrapper), filter=svg_utils.get_funciri(filter), class_=name, **attrib, # type: ignore[arg-type] # Clipping context etc. ) self.set_layer_attributes(layer, use) use = self.apply_mask(layer, use) return filter, use def _wrap_backdrop(self, tag: str = "symbol", **attrib: str) -> ET.Element: """Wrap previous nodes into a container node for adjustment application.""" # NOTE: Find the appropriate container in the clipping context, # as the parent is mask or clipPath. if self.current.tag == "clipPath" or self.current.tag == "mask": logger.warning( "Wrapping backdrop inside clipping/mask context is not supported yet." ) siblings = list(self.current) if not siblings: logger.warning("No backdrop elements found to wrap for adjustment.") wrapper = self.create_node(tag, **attrib) # type: ignore[arg-type] for node in siblings: self.current.remove(node) wrapper.append(node) return wrapper