From 94e90bd180a3b2a02327df86f196a8112abe2251 Mon Sep 17 00:00:00 2001 From: Naco Siren Date: Tue, 29 Sep 2026 16:21:03 -0700 Subject: [PATCH] Keep layer effects and live text through size changes and adjustments Canvas Size, Image Size, Crop and Trim rebuilt the document's layers from a snapshot without their effects, live text or live shapes: every layer lost its effects, and text and shape layers became plain pixels. They now build the layers from one shared list, the same one opening a project uses, and Canvas Size (which Crop and Trim go through) carries effects over. Image Size resamples the pixels, so it scales the effects' sizes, distances and blurs with them, as Photoshop's Scale Styles does; it still turns text and shapes into pixels. Hue/Saturation, Levels, Invert and merging a transformed selection back into its layer rebuilt that layer without its effects too. They keep them now, as the brush and the filters already did. Co-Authored-By: Claude Opus 5.5 --- .../Document/EditorSession+Projects.swift | 23 ++- Compositor/Document/FloatingSelection.swift | 3 +- Compositor/Document/HueSaturation.swift | 3 +- Compositor/Document/LayerEffects.swift | 14 ++ Compositor/Document/Levels.swift | 3 +- Compositor/Document/SelectionEdits.swift | 3 +- Compositor/IO/CanvasResizer.swift | 2 +- Compositor/IO/ImageResizer.swift | 8 +- .../LayerEffectsSurviveTests.swift | 156 ++++++++++++++++++ docs/project-format.md | 4 +- 10 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 CompositorTests/LayerEffectsSurviveTests.swift diff --git a/Compositor/Document/EditorSession+Projects.swift b/Compositor/Document/EditorSession+Projects.swift index 30d483510..d125a6ba1 100644 --- a/Compositor/Document/EditorSession+Projects.swift +++ b/Compositor/Document/EditorSession+Projects.swift @@ -25,13 +25,7 @@ extension EditorSession { let manifest = snapshot.manifest transformEdit = nil document = CanvasDocument(id: manifest.documentID, width: manifest.width, height: manifest.height, - layers: manifest.layers.map { - ImageLayer(id: $0.id, asset: snapshot.images[$0.id], name: $0.name, - isVisible: $0.isVisible, transform: $0.transform, parentID: $0.parentID, isGroup: $0.isGroup == true, opacity: $0.opacity ?? 1, blendMode: $0.blendMode ?? .normal, mask: snapshot.mask(for: $0), maskSourceID: $0.maskSourceID, adjustment: $0.adjustment, - shape: LayerShape.loaded($0.shape, image: snapshot.images[$0.id]?.image), - effects: $0.effects, - text: LayerText.loaded($0.text, image: snapshot.images[$0.id]?.image)) - }, resolution: manifest.resolution ?? 72, guides: manifest.guides ?? []) + layers: snapshot.documentLayers, resolution: manifest.resolution ?? 72, guides: manifest.guides ?? []) activeLayerID = manifest.activeLayerID projectURL = url renamingLayerID = nil @@ -77,3 +71,18 @@ extension EditorSession { createDocument(width: width, height: height, emptyLayer: true) } } + +extension ProjectSnapshot { + /// The layers the manifest records, each with its pixels, mask, effects and live text or shape. Opening a project + /// and the edits that rebuild the document from a snapshot (canvas and image size, crop, trim) all build them here, + /// so none of them leaves part of a layer behind. + var documentLayers: [ImageLayer] { + manifest.layers.map { + ImageLayer(id: $0.id, asset: images[$0.id], name: $0.name, + isVisible: $0.isVisible, transform: $0.transform, parentID: $0.parentID, isGroup: $0.isGroup == true, opacity: $0.opacity ?? 1, blendMode: $0.blendMode ?? .normal, mask: mask(for: $0), maskSourceID: $0.maskSourceID, adjustment: $0.adjustment, + shape: LayerShape.loaded($0.shape, image: images[$0.id]?.image), + effects: $0.effects, + text: LayerText.loaded($0.text, image: images[$0.id]?.image)) + } + } +} diff --git a/Compositor/Document/FloatingSelection.swift b/Compositor/Document/FloatingSelection.swift index 66ab73982..1546e4b6a 100644 --- a/Compositor/Document/FloatingSelection.swift +++ b/Compositor/Document/FloatingSelection.swift @@ -97,7 +97,8 @@ extension EditorSession { guard let index = document?.layers.firstIndex(where: { $0.id == source.id }) else { throw ProjectError.invalid } document?.layers[index] = ImageLayer(id: source.id, asset: merged.asset, name: source.name, isVisible: source.isVisible, transform: merged.transform, parentID: source.parentID, isGroup: false, - opacity: source.opacity, blendMode: source.blendMode, mask: merged.mask, maskSourceID: source.maskSourceID) + opacity: source.opacity, blendMode: source.blendMode, mask: merged.mask, maskSourceID: source.maskSourceID, + effects: source.effects) document?.selection = moved activeLayerID = source.id } catch { diff --git a/Compositor/Document/HueSaturation.swift b/Compositor/Document/HueSaturation.swift index 84ff9ca24..13b3b28ba 100644 --- a/Compositor/Document/HueSaturation.swift +++ b/Compositor/Document/HueSaturation.swift @@ -536,7 +536,8 @@ extension EditorSession { document?.layers[index] = ImageLayer(id: current.id, asset: ImportedImage(image: adjusted.image, thumbnail: adjusted.thumbnail ?? adjusted.image, name: current.name), name: current.name, isVisible: current.isVisible, transform: current.transform, parentID: current.parentID, - isGroup: false, opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID) + isGroup: false, opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID, + effects: current.effects) endEdit() } diff --git a/Compositor/Document/LayerEffects.swift b/Compositor/Document/LayerEffects.swift index 312ab3300..06fdaa458 100644 --- a/Compositor/Document/LayerEffects.swift +++ b/Compositor/Document/LayerEffects.swift @@ -137,6 +137,20 @@ nonisolated struct LayerEffects: Codable, Equatable, Sendable { && (colorOverlay?.isValid ?? true) && (innerShadow?.isValid ?? true) && (outerGlow?.isValid ?? true) && (innerGlow?.isValid ?? true) } + /// The effects for an image resampled by `factor`: every size, distance and blur in pixels scaled with it, held to + /// the ranges `isValid` accepts. + func scaled(by factor: CGFloat) -> LayerEffects { + func scale(_ value: CGFloat?, upTo limit: CGFloat) -> CGFloat { min(limit, (value ?? 0) * factor) } + var result = self + result.stroke?.size = scale(stroke?.size, upTo: 500) + result.shadow?.distance = scale(shadow?.distance, upTo: 5000) + result.shadow?.blur = scale(shadow?.blur, upTo: 500) + result.innerShadow?.distance = scale(innerShadow?.distance, upTo: 5000) + result.innerShadow?.blur = scale(innerShadow?.blur, upTo: 500) + result.outerGlow?.size = scale(outerGlow?.size, upTo: 500) + result.innerGlow?.size = scale(innerGlow?.size, upTo: 500) + return result + } var kinds: [LayerEffectKind] { LayerEffectKind.allCases.filter { contains($0) } } func contains(_ kind: LayerEffectKind) -> Bool { switch kind { diff --git a/Compositor/Document/Levels.swift b/Compositor/Document/Levels.swift index 0343ad5d1..35a040583 100644 --- a/Compositor/Document/Levels.swift +++ b/Compositor/Document/Levels.swift @@ -212,7 +212,8 @@ extension EditorSession { beginEdit("Levels") document?.layers[index] = ImageLayer(id: current.id, asset: asset, name: current.name, isVisible: current.isVisible, transform: current.transform, parentID: current.parentID, isGroup: false, - opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID) + opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID, + effects: current.effects) endEdit() } catch { brushError = error.localizedDescription } } diff --git a/Compositor/Document/SelectionEdits.swift b/Compositor/Document/SelectionEdits.swift index 11bedbacb..f2ed1dee1 100644 --- a/Compositor/Document/SelectionEdits.swift +++ b/Compositor/Document/SelectionEdits.swift @@ -132,7 +132,8 @@ extension EditorSession { } else { self.document?.layers[index] = ImageLayer(id: current.id, asset: asset, name: current.name, isVisible: current.isVisible, transform: current.transform, parentID: current.parentID, isGroup: false, - opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID) + opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID, + effects: current.effects) } endEdit() brushRevision += 1 diff --git a/Compositor/IO/CanvasResizer.swift b/Compositor/IO/CanvasResizer.swift index 63f64fb9f..d81576f59 100644 --- a/Compositor/IO/CanvasResizer.swift +++ b/Compositor/IO/CanvasResizer.swift @@ -28,7 +28,7 @@ actor CanvasResizer { moved.origin.x += offset.x moved.origin.y += offset.y return moved - }, maskLinked: layer.maskLinked, shape: layer.shape, text: layer.text)) + }, maskLinked: layer.maskLinked, shape: layer.shape, effects: layer.effects, text: layer.text)) } var images = snapshot.images // A colored extension is separate bottom-layer content. The old canvas diff --git a/Compositor/IO/ImageResizer.swift b/Compositor/IO/ImageResizer.swift index acfa87318..7a8be9d9a 100644 --- a/Compositor/IO/ImageResizer.swift +++ b/Compositor/IO/ImageResizer.swift @@ -25,6 +25,9 @@ actor ImageResizer { guard options.width * options.height <= DocumentLimits.maxSurfacePixels else { throw ProjectError.tooLarge } let sx = CGFloat(options.width) / CGFloat(old.width) let sy = CGFloat(options.height) / CGFloat(old.height) + // Effects are sized in pixels, so they scale with the pixels, as Photoshop's Scale Styles does: by the mean of + // the two factors when the image is stretched. Live text and shapes are resampled into plain pixels. + let effectScale = (sx * sy).squareRoot() manifest.guides = old.guides?.map { $0.scaled(x: sx, y: sy) } var images: [UUID: ImportedImage] = [:] var masks: [UUID: ImportedImage] = [:] @@ -95,7 +98,7 @@ actor ImageResizer { manifest.layers.append(ProjectLayerRecord(id: layer.id, name: layer.name, isVisible: layer.isVisible, transform: transform, imageFile: layer.imageFile, parentID: layer.parentID, isGroup: layer.isGroup, opacity: layer.opacity, blendMode: layer.blendMode, maskFile: layer.maskFile, maskEnabled: layer.maskEnabled, maskSourceID: layer.maskSourceID, adjustment: layer.adjustment, maskPlacement: layer.maskPlacement.map { $0.placing($0.unitToDocument.concatenating(CGAffineTransform(scaleX: sx, y: sy))) }, - maskLinked: layer.maskLinked)) + maskLinked: layer.maskLinked, effects: layer.effects?.scaled(by: effectScale))) } return ProjectSnapshot(manifest: manifest, images: images, masks: masks) } @@ -111,8 +114,7 @@ extension EditorSession { beginEdit(actionName) let m = snapshot.manifest document = CanvasDocument(id: m.documentID, width: m.width, height: m.height, - layers: m.layers.map { ImageLayer(id: $0.id, asset: snapshot.images[$0.id], name: $0.name, - isVisible: $0.isVisible, transform: $0.transform, parentID: $0.parentID, isGroup: $0.isGroup == true, opacity: $0.opacity ?? 1, blendMode: $0.blendMode ?? .normal, mask: snapshot.mask(for: $0), maskSourceID: $0.maskSourceID, adjustment: $0.adjustment) }, resolution: m.resolution ?? 72, guides: m.guides ?? []) + layers: snapshot.documentLayers, resolution: m.resolution ?? 72, guides: m.guides ?? []) endEdit() viewport.fit(documentSize: document!.size) } diff --git a/CompositorTests/LayerEffectsSurviveTests.swift b/CompositorTests/LayerEffectsSurviveTests.swift new file mode 100644 index 000000000..128af581e --- /dev/null +++ b/CompositorTests/LayerEffectsSurviveTests.swift @@ -0,0 +1,156 @@ +import AppKit +import Testing +@testable import Compositor + +/// A layer's effects, and a text or shape layer's live style, stay with it through the edits that rebuild the layer: +/// canvas and image size, crop, trim, Hue/Saturation, Levels, Invert and transforming a selection. +@MainActor +struct LayerEffectsSurviveTests { + private let effects = LayerEffects(stroke: StrokeEffect(size: 4), shadow: ShadowEffect(distance: 20, blur: 10), + outerGlow: OuterGlowEffect(size: 8)) + + /// What a layer carries beyond its pixels. + private struct Extras: Equatable { + var effects: LayerEffects? + var text: LayerTextStyle? + var shape: LayerShapeStyle? + } + private func extras(_ session: EditorSession, _ ids: [UUID]) -> [Extras?] { + ids.map { id in + session.document?.layers.first { $0.id == id } + .map { Extras(effects: $0.effects, text: $0.liveText?.style, shape: $0.liveShape?.style) } + } + } + + /// A transparent document with a text layer and a shape layer, both carrying `effects`. + private func textAndShape() throws -> (session: EditorSession, ids: [UUID]) { + let session = EditorSession() + session.createDocument(width: 200, height: 120, emptyLayer: true) + session.selectTool(.type) + session.beginText(at: CGPoint(x: 30, y: 60)) + var draft = try #require(session.textDraft) + draft.style.content = "Text" + #expect(session.applyText(draft)) + let text = try #require(session.activeLayerID) + session.selectTool(.shape) + session.beginShape(at: CGPoint(x: 120, y: 30)) + session.dragShape(to: CGPoint(x: 170, y: 80), square: false, fromCenter: false) + session.finishShape() + let shape = try #require(session.activeLayerID) + try #require(shape != text) + for id in [text, shape] { + let index = try #require(session.document?.layers.firstIndex { $0.id == id }) + session.document?.layers[index].effects = effects + } + let before = extras(session, [text, shape]) + try #require(before[0]?.text != nil && before[1]?.shape != nil) + return (session, [text, shape]) + } + + /// A gray square on a transparent document, its layer carrying `effects`. + private func pixelLayer() throws -> EditorSession { + let session = EditorSession() + session.createDocument(width: 48, height: 48) + let context = try BrushRaster.context(width: 24, height: 24, mask: false) + context.setFillColor(CGColor(srgbRed: 0.7, green: 0.4, blue: 0.2, alpha: 1)) + context.fill(CGRect(x: 3, y: 3, width: 18, height: 18)) + let image = try #require(context.makeImage()) + session.insert(ImportedImage(image: image, thumbnail: image, name: "Sample")) + let index = try #require(session.document?.layers.firstIndex { $0.id == session.activeLayerID }) + session.document?.layers[index].effects = effects + return session + } + + @Test func canvasSizeKeepsEffectsTextAndShapes() async throws { + let (session, ids) = try textAndShape() + let before = extras(session, ids) + let resized = try await CanvasResizer.shared.resize(try #require(session.projectSnapshot()), + to: CanvasSizeOptions(width: 260, height: 160, anchor: 4)) + session.applyDocumentSize(resized, actionName: "Canvas Size") + #expect(session.document?.width == 260) + #expect(extras(session, ids) == before) + } + + @Test func cropKeepsEffectsTextAndShapes() async throws { + let (session, ids) = try textAndShape() + let before = extras(session, ids) + session.selectTool(.crop) + session.cropRect = CGRect(x: 10, y: 10, width: 180, height: 100) + await session.commitCrop() + #expect(session.document?.width == 180) + #expect(extras(session, ids) == before) + } + + @Test func trimKeepsEffectsTextAndShapes() async throws { + let (session, ids) = try textAndShape() + let before = extras(session, ids) + #expect(try await session.trim()) + #expect((session.document?.width ?? 200) < 200) + #expect(extras(session, ids) == before) + } + + @Test func resolutionOnlyImageSizeKeepsEffectsTextAndShapes() async throws { + let (session, ids) = try textAndShape() + let before = extras(session, ids) + let resized = try await ImageResizer.shared.resize(try #require(session.projectSnapshot()), + to: ImageSizeOptions(width: 200, height: 120, resolution: 300)) + session.applyImageSize(resized) + #expect(session.document?.resolution == 300) + #expect(extras(session, ids) == before) + } + + /// The layer's pixels are resampled, so its effects are scaled with them, as Photoshop's Scale Styles does. + @Test func imageSizeKeepsEffectsScaledWithTheImage() async throws { + let session = try pixelLayer() + let resized = try await ImageResizer.shared.resize(try #require(session.projectSnapshot()), + to: ImageSizeOptions(width: 24, height: 24, resolution: 72)) + session.applyImageSize(resized) + #expect(session.document?.width == 24) + let kept = try #require(session.activeLayer?.effects) + #expect(kept.stroke?.size == 2 && kept.outerGlow?.size == 4) + #expect(kept.shadow?.distance == 10 && kept.shadow?.blur == 5 && kept.shadow?.angle == effects.shadow?.angle) + } + + @Test func hueSaturationKeepsEffects() async throws { + let session = try pixelLayer() + var settings = HueSaturationSettings() + settings.adjustments[.master] = RangeAdjustment(hue: 90) + session.beginHueSaturation() + session.updateHueSaturation(settings, preview: true) + await session.hueSaturationTask?.value + await session.commitHueSaturation() + #expect(session.history.undoName == "Hue/Saturation") + #expect(session.activeLayer?.effects == effects) + } + + @Test func levelsKeepsEffects() async throws { + let session = try pixelLayer() + var settings = LevelsSettings() + settings.current = LevelRange(black: 64, gamma: 1, white: 128) + session.beginLevels() + session.updateLevels(settings, preview: true) + await session.commitLevels() + #expect(session.history.undoName == "Levels") + #expect(session.activeLayer?.effects == effects) + } + + @Test func invertKeepsEffects() async throws { + let session = try pixelLayer() + await session.invertPixels() + #expect(session.history.undoName == "Invert") + #expect(session.activeLayer?.effects == effects) + } + + @Test func transformingASelectionKeepsEffects() async throws { + let session = try pixelLayer() + let source = try #require(session.activeLayerID) + session.applySelection(CGPath(rect: CGRect(x: 14, y: 14, width: 10, height: 10), transform: nil), mode: .replace, name: "Select") + await session.beginSelectionTransform() + var draft = try #require(session.transformEdit?.draft) + draft.origin.x += 8 + session.previewTransform(draft) + session.commitTransform() + #expect(session.history.undoName == "Transform Selection") + #expect(session.document?.layers.first { $0.id == source }?.effects == effects) + } +} diff --git a/docs/project-format.md b/docs/project-format.md index 67eb878e7..a133fd7b4 100644 --- a/docs/project-format.md +++ b/docs/project-format.md @@ -45,8 +45,8 @@ Later fields are optional and not gated on the version, so older readers ignore ### Editable text -Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication, masks and canvas-size changes preserve it; destructive pixel operations rasterize text and omit the metadata on the next save. Missing fonts use the system font when edited, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). From version 11, optional `fontRuns` lists letters set in another face than `fontName`: the same `location` and `length`, plus `fontName`. Runs of either kind are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color or face. +Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication, masks, canvas-size changes, crop, trim and resolution-only image-size changes preserve it; destructive pixel operations, resampling included, rasterize text and omit the metadata on the next save. Missing fonts use the system font when edited, while the saved PNG preserves the original appearance until then. From version 10, optional `colorRuns` lists letters painted in another color than the text's own `red`/`green`/`blue`: each run has `location` and `length` in UTF-16 units of the content, plus `red`, `green` and `blue` (0–1). From version 11, optional `fontRuns` lists letters set in another face than `fontName`: the same `location` and `length`, plus `fontName`. Runs of either kind are sorted, do not overlap, have a positive length and end within the content; letters outside every run use the text's color or face. ### Layer effects -An optional `effects` record contains independent `stroke`, `shadow`, `colorOverlay`, `innerShadow`, `outerGlow` and `innerGlow` records. Stroke carries a size (0–500 layer pixels), a color, an opacity and an `inside` flag choosing which side of the edge it sits on; drop shadow and inner shadow each carry an angle, a distance, a blur, a color and an opacity; color overlay carries a color and an opacity; outer glow and inner glow each carry a size (0–500 layer pixels), a color and an opacity. Each supports optional `enabled` visibility (missing means visible); hidden effects keep all parameters and remain listed under their layer. Effects, including their visibility, are saved and participate in document undo. Canvas previews run on a serial background worker with a shared pixel budget; exports render the full-resolution effects. A record omitting an effect means that layer does not have it, so older readers see the effects they understand and ignore the rest. +An optional `effects` record contains independent `stroke`, `shadow`, `colorOverlay`, `innerShadow`, `outerGlow` and `innerGlow` records. Stroke carries a size (0–500 layer pixels), a color, an opacity and an `inside` flag choosing which side of the edge it sits on; drop shadow and inner shadow each carry an angle, a distance, a blur, a color and an opacity; color overlay carries a color and an opacity; outer glow and inner glow each carry a size (0–500 layer pixels), a color and an opacity. Each supports optional `enabled` visibility (missing means visible); hidden effects keep all parameters and remain listed under their layer. Effects, including their visibility, are saved and participate in document undo. They stay with their layer through every edit, pixel edits included; Image Size scales their sizes, distances and blurs with the image. Canvas previews run on a serial background worker with a shared pixel budget; exports render the full-resolution effects. A record omitting an effect means that layer does not have it, so older readers see the effects they understand and ignore the rest.