diff --git a/ppl2psd.py b/ppl2psd.py index 34dcae0..0943dd0 100644 --- a/ppl2psd.py +++ b/ppl2psd.py @@ -219,7 +219,6 @@ def render_bitmap(ly: dict, img_path: Path, cw: int, ch: int Image.LANCZOS) L, T = math.floor(l), math.floor(t) layer = layer.crop((ix0 - L, iy0 - T, ix1 - L, iy1 - T)) - apply_opacity(layer, ly.get("alpha", 255)) return layer, (ix0, iy0) @@ -269,7 +268,6 @@ def render_shape(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tupl outline=255, width=thickness) img.paste(stroke_img, (0, 0), ring) - apply_opacity(img, ly.get("alpha", 255)) # shadow (ppl renders a soft offset shadow per layer) sd = float(ly.get("shadowDistance", "0") or 0) sb = float(ly.get("shadowBlur", "0") or 0) @@ -352,7 +350,11 @@ def render_text(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tuple return adv * c grad = parse_gradient(ly.get("textColor", "")) - fill = grad["colors"][0][1] if grad["colors"] else (0, 0, 0, 255) + if grad["type"] == "gradient": + fill = (255, 255, 255, 255) # base color; gradient is applied after + grad = grad # keep for the mask pass + else: + fill = grad["colors"][0][1] if grad["colors"] else (0, 0, 0, 255) # --- measure natural text size (multi-line aware, per-char spacing) --- lines = text.split("\n") @@ -428,7 +430,11 @@ def render_text(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tuple return None img = img.crop((ix0 - left, iy0 - top, ix1 - left, iy1 - top)) - apply_opacity(img, ly.get("alpha", 255)) + + # text gradient fill: paint a gradient over the glyph alpha + if grad["type"] == "gradient": + gimg = make_linear_gradient(img.width, img.height, grad) + img.paste(gimg, (0, 0), img.getchannel("A")) # soft shadow (used by rasterized output / previews) sd = float(ly.get("shadowDistance", "0") or 0) @@ -453,6 +459,16 @@ def apply_opacity(img: Image.Image, alpha: str) -> None: img.putalpha(alpha_ch) +def _opacity_paste(canvas: Image.Image, img: Image.Image, pos, opacity: int) -> None: + """Composite a layer onto the merged/preview canvas, applying the layer + opacity (PSD opacity field is applied by Photoshop itself, but the + in-tool merged preview must apply it manually).""" + if opacity < 255: + img = img.copy() + img.putalpha(img.getchannel("A").point(lambda p: p * opacity // 255)) + canvas.paste(img, pos, img) + + # -------------------------------------------------------------------------- # PSD writer # -------------------------------------------------------------------------- @@ -744,7 +760,7 @@ def build_psd(layers, width: int, height: int, out_path: str, workers: int, for layer in layers: name, opacity, visible, (lx, ly), img = layer[:5] if img is not None and visible and opacity > 0: - merged.paste(img, (lx, ly), img) + _opacity_paste(merged, img, (lx, ly), opacity) merged_rgb = None # keep the RGBA merged image for the 4-channel output # Merged image data is written RAW with a single compression field and # all planes in planar order (R G B A). 4 channels because the document @@ -784,9 +800,10 @@ def build_psd(layers, width: int, height: int, out_path: str, workers: int, # Main # -------------------------------------------------------------------------- def _safe_workers(workers: int) -> int: - """Never use every CPU core: leave one for the system, otherwise the - RLE-encoding workers can stall the whole device.""" - available = max(1, (os.cpu_count() or 2) - 1) + """Keep at least half the CPU cores free - on phones the RLE-encoding + workers plus PIL's internal threading can otherwise stall the whole + device (a previous cores-1 cap still froze the host).""" + available = max(1, (os.cpu_count() or 2) // 2) return max(1, min(workers, available)) @@ -919,7 +936,7 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None, for layer in rendered: name, opacity, visible, (lx, ly), img = layer[:5] if img is not None and visible and opacity > 0: - canvas.paste(img, (lx, ly), img) + _opacity_paste(canvas, img, (lx, ly), opacity) # downscale for easy viewing preview = canvas.convert("RGB") if preview.width > 2000: @@ -937,8 +954,8 @@ def main(): ap.add_argument("-o", "--output", help="output .psd path") ap.add_argument("--preview", help="also write a downscaled composite PNG") ap.add_argument("-j", "--workers", type=int, - default=max(1, (os.cpu_count() or 2) - 1), - help="parallel workers for RLE encoding (default: cores-1)") + default=max(1, (os.cpu_count() or 2) // 2), + help="parallel workers for RLE encoding (default: cores/2)") ap.add_argument("--type-layer", action="store_true", help="convert text layers to editable TypeLayers (TySh) " "instead of rasterized pixels")