From 7a218dbf08c67b484b06038b0bf1033bb53dd2f4 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Sat, 25 Jul 2026 16:57:52 +0900 Subject: [PATCH 01/13] Add `PaletteEditView` --- .../project.pbxproj | 18 +++ .../xcschemes/PaletteEditView.xcscheme | 80 ++++++++++ PaletteEditView/Package.swift | 28 ++++ .../Alpha/AlphaPaletteEditView.swift | 93 +++++++++++ .../Models/AlphaPaletteAlphaSource.swift | 13 ++ .../Alpha/Models/AlphaPaletteState.swift | 28 ++++ .../PaletteEditView/Color/ColorGridView.swift | 42 +++++ .../Color/ColorPaletteEditView.swift | 126 +++++++++++++++ .../Color/ColorSlidersView.swift | 95 +++++++++++ .../Color/ColorSpectrumView.swift | 42 +++++ .../Models/ColorPaletteColorSource.swift | 14 ++ .../Color/Models/ColorPaletteSegment.swift | 13 ++ .../Color/Models/ColorPaletteState.swift | 48 ++++++ .../Color/ViewItems/HexColorRow.swift | 42 +++++ .../Color/ViewItems/PickerImageView.swift | 96 ++++++++++++ .../Color/ViewItems/SegmentPicker.swift | 43 +++++ .../Commons/AlphaEditSection.swift | 68 ++++++++ .../Commons/BottomActionToolbar.swift | 61 ++++++++ .../Commons/GradientSlider.swift | 147 ++++++++++++++++++ .../Commons/SliderWithStepper.swift | 70 +++++++++ .../Extensions/UIColorExtensions.swift | 88 +++++++++++ .../Extensions/UIImage+Palette.swift | 45 ++++++ .../Resources/Images.xcassets/Contents.json | 9 ++ .../grid.imageset/Contents.json | 15 ++ .../Images.xcassets/grid.imageset/grid.png | Bin 0 -> 6657 bytes .../spectrum.imageset/Contents.json | 15 ++ .../spectrum.imageset/spectrum.png | Bin 0 -> 107729 bytes .../Models/AlphaPaletteStateTests.swift | 65 ++++++++ .../Models/ColorPaletteStateTests.swift | 100 ++++++++++++ 29 files changed, 1504 insertions(+) create mode 100644 PaletteEditView/.swiftpm/xcode/xcshareddata/xcschemes/PaletteEditView.xcscheme create mode 100644 PaletteEditView/Package.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Alpha/Models/AlphaPaletteAlphaSource.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Alpha/Models/AlphaPaletteState.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/ColorGridView.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/ColorSlidersView.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/ColorSpectrumView.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteColorSource.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteSegment.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteState.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/ViewItems/HexColorRow.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Color/ViewItems/SegmentPicker.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Commons/AlphaEditSection.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Commons/BottomActionToolbar.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Commons/GradientSlider.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Commons/SliderWithStepper.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift create mode 100644 PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/Contents.json create mode 100644 PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/grid.imageset/Contents.json create mode 100644 PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/grid.imageset/grid.png create mode 100644 PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/spectrum.imageset/Contents.json create mode 100644 PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/spectrum.imageset/spectrum.png create mode 100644 PaletteEditView/Tests/PaletteEditViewTests/Models/AlphaPaletteStateTests.swift create mode 100644 PaletteEditView/Tests/PaletteEditViewTests/Models/ColorPaletteStateTests.swift diff --git a/HandDrawingSwiftMetal.xcodeproj/project.pbxproj b/HandDrawingSwiftMetal.xcodeproj/project.pbxproj index 123b530a..20340a4a 100644 --- a/HandDrawingSwiftMetal.xcodeproj/project.pbxproj +++ b/HandDrawingSwiftMetal.xcodeproj/project.pbxproj @@ -103,6 +103,7 @@ D4E1ECF42752388E00D4A23D /* HandDrawingSwiftMetalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E1ECF32752388E00D4A23D /* HandDrawingSwiftMetalTests.swift */; }; D4E1ECFE2752388E00D4A23D /* HandDrawingSwiftMetalUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E1ECFD2752388E00D4A23D /* HandDrawingSwiftMetalUITests.swift */; }; D4E1ED002752388E00D4A23D /* HandDrawingSwiftMetalUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E1ECFF2752388E00D4A23D /* HandDrawingSwiftMetalUITestsLaunchTests.swift */; }; + D4A1B0052F96000500C55A4C /* PaletteEditView in Frameworks */ = {isa = PBXBuildFile; productRef = D4A1B0032F96000300C55A4C /* PaletteEditView */; }; D4F0BF062E38F79600C3E441 /* CanvasView in Frameworks */ = {isa = PBXBuildFile; productRef = D4F0BF052E38F79600C3E441 /* CanvasView */; }; D4F5256D2EAC611F00439FFF /* LocalFileRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F5256C2EAC611F00439FFF /* LocalFileRepository.swift */; }; D4F5256F2EAC614B00439FFF /* FileInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F5256E2EAC614B00439FFF /* FileInput.swift */; }; @@ -228,6 +229,7 @@ D4E1ECF92752388E00D4A23D /* HandDrawingSwiftMetalUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HandDrawingSwiftMetalUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D4E1ECFD2752388E00D4A23D /* HandDrawingSwiftMetalUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandDrawingSwiftMetalUITests.swift; sourceTree = ""; }; D4E1ECFF2752388E00D4A23D /* HandDrawingSwiftMetalUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandDrawingSwiftMetalUITestsLaunchTests.swift; sourceTree = ""; }; + D4A64A4F2FF9282C00BFC24A /* PaletteEditView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PaletteEditView; sourceTree = ""; }; D4F0BF042E38F78900C3E441 /* CanvasView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = CanvasView; sourceTree = ""; }; D4F362E32FA5227E001B5544 /* HandDrawingSwiftMetalTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HandDrawingSwiftMetalTests.xctestplan; sourceTree = ""; }; D4F5256C2EAC611F00439FFF /* LocalFileRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileRepository.swift; sourceTree = ""; }; @@ -246,6 +248,7 @@ D46A2CE52E424E8900D638AE /* TextureLayerView in Frameworks */, D4F0BF062E38F79600C3E441 /* CanvasView in Frameworks */, D442BCFD2F93220E00C55A4C /* TextureLayerCanvasView in Frameworks */, + D4A1B0052F96000500C55A4C /* PaletteEditView in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -542,6 +545,7 @@ D4E1ECD02752388C00D4A23D = { isa = PBXGroup; children = ( + D4A64A4F2FF9282C00BFC24A /* PaletteEditView */, D49432EC2F9319F0003A25BA /* TextureLayerCanvasView */, D4F0BF042E38F78900C3E441 /* CanvasView */, D46A2CE32E424E7000D638AE /* TextureLayerView */, @@ -656,6 +660,7 @@ D4F0BF052E38F79600C3E441 /* CanvasView */, D46A2CE42E424E8900D638AE /* TextureLayerView */, D442BCFC2F93220E00C55A4C /* TextureLayerCanvasView */, + D4A1B0032F96000300C55A4C /* PaletteEditView */, ); productName = HandDrawingSwiftMetal; productReference = D4E1ECD92752388C00D4A23D /* HandDrawingSwiftMetal.app */; @@ -731,6 +736,7 @@ mainGroup = D4E1ECD02752388C00D4A23D; packageReferences = ( D47FD5C02E2D1CD900F3F514 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + D4A1B0022F96000200C55A4C /* XCLocalSwiftPackageReference "PaletteEditView" */, ); productRefGroup = D4E1ECDA2752388C00D4A23D /* Products */; projectDirPath = ""; @@ -1257,6 +1263,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + D4A1B0022F96000200C55A4C /* XCLocalSwiftPackageReference "PaletteEditView" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = PaletteEditView; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ D47FD5C02E2D1CD900F3F514 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { isa = XCRemoteSwiftPackageReference; @@ -1286,6 +1299,11 @@ isa = XCSwiftPackageProductDependency; productName = CanvasView; }; + D4A1B0032F96000300C55A4C /* PaletteEditView */ = { + isa = XCSwiftPackageProductDependency; + package = D4A1B0022F96000200C55A4C /* XCLocalSwiftPackageReference "PaletteEditView" */; + productName = PaletteEditView; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/PaletteEditView/.swiftpm/xcode/xcshareddata/xcschemes/PaletteEditView.xcscheme b/PaletteEditView/.swiftpm/xcode/xcshareddata/xcschemes/PaletteEditView.xcscheme new file mode 100644 index 00000000..0bc7d707 --- /dev/null +++ b/PaletteEditView/.swiftpm/xcode/xcshareddata/xcschemes/PaletteEditView.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PaletteEditView/Package.swift b/PaletteEditView/Package.swift new file mode 100644 index 00000000..2e8f6fce --- /dev/null +++ b/PaletteEditView/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.1 + +import PackageDescription + +let package = Package( + name: "PaletteEditView", + platforms: [ + .iOS(.v15) + ], + products: [ + .library( + name: "PaletteEditView", + targets: ["PaletteEditView"] + ), + ], + targets: [ + .target( + name: "PaletteEditView", + resources: [ + .process("Resources") + ] + ), + .testTarget( + name: "PaletteEditViewTests", + dependencies: ["PaletteEditView"] + ), + ] +) diff --git a/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift b/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift new file mode 100644 index 00000000..2ba55acb --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift @@ -0,0 +1,93 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +/// Popup card height for ``AlphaPaletteEditView``. +public let alphaPaletteEditViewHeight: CGFloat = 190 + +public struct AlphaPaletteEditView: View { + @ObservedObject private var alphaSource: AlphaSource + @ObservedObject private var paletteState: AlphaPaletteState + + private let onRemove: (() -> Void)? + private let onDuplicate: (() -> Void)? + + public init( + alphaSource: AlphaSource, + paletteState: AlphaPaletteState, + onRemove: (() -> Void)? = nil, + onDuplicate: (() -> Void)? = nil + ) { + self._alphaSource = .init(wrappedValue: alphaSource) + self._paletteState = .init(wrappedValue: paletteState) + self.onRemove = onRemove + self.onDuplicate = onDuplicate + } + + public var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + + AlphaEditSection( + alpha: Binding( + get: { paletteState.alpha }, + set: { paletteState.updateAlpha($0) } + ), + gradientColors: [ + UIColor.black.withAlphaComponent(0), + UIColor.black.withAlphaComponent(1) + ], + onRemove: onRemove, + onDuplicate: onDuplicate + ) + } + .frame(maxWidth: .infinity) + .onAppear { + updatePaletteState(from: alphaSource.selectedAlpha) + } + .onChange(of: alphaSource.selectedIndex) { _ in + updatePaletteState(from: alphaSource.selectedAlpha) + } + .onReceive(alphaSource.objectWillChange) { _ in + updatePaletteState(from: alphaSource.selectedAlpha) + } + } +} + +private extension AlphaPaletteEditView { + func updatePaletteState(from alpha: Int) { + guard paletteState.alpha != alpha else { return } + paletteState.setAlpha(alpha) + } +} + +#if DEBUG +private final class PreviewAlphaSource: AlphaPaletteAlphaSource { + @Published var selectedIndex = 0 + + var selectedAlpha: Int { 128 } +} + +private struct AlphaPalettePreview: View { + @StateObject private var paletteState = AlphaPaletteState() + @StateObject private var alphaSource = PreviewAlphaSource() + + var body: some View { + AlphaPaletteEditView( + alphaSource: alphaSource, + paletteState: paletteState + ) + .frame(width: 350, height: 200) + .padding() + } +} + +#Preview { + AlphaPalettePreview() +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Alpha/Models/AlphaPaletteAlphaSource.swift b/PaletteEditView/Sources/PaletteEditView/Alpha/Models/AlphaPaletteAlphaSource.swift new file mode 100644 index 00000000..cd7c5e95 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Alpha/Models/AlphaPaletteAlphaSource.swift @@ -0,0 +1,13 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import Combine + +@MainActor +public protocol AlphaPaletteAlphaSource: ObservableObject { + var selectedAlpha: Int { get } + var selectedIndex: Int { get } +} diff --git a/PaletteEditView/Sources/PaletteEditView/Alpha/Models/AlphaPaletteState.swift b/PaletteEditView/Sources/PaletteEditView/Alpha/Models/AlphaPaletteState.swift new file mode 100644 index 00000000..337b793a --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Alpha/Models/AlphaPaletteState.swift @@ -0,0 +1,28 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import Combine + +@MainActor +public final class AlphaPaletteState: ObservableObject { + + @Published public var alpha: Int = 255 + + private let onChanged: ((Int) -> Void)? + + public init(onChanged: ((Int) -> Void)? = nil) { + self.onChanged = onChanged + } + + public func setAlpha(_ value: Int) { + alpha = min(max(0, value), 255) + } + + public func updateAlpha(_ value: Int) { + alpha = min(max(0, value), 255) + onChanged?(alpha) + } +} diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ColorGridView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ColorGridView.swift new file mode 100644 index 00000000..bebb1dfc --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/ColorGridView.swift @@ -0,0 +1,42 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +struct ColorGridView: View { + @Binding var rgbColor: UIColor + + var body: some View { + PickerImageView( + imageResource: .grid, + rgbColor: $rgbColor + ) + } +} + +#if DEBUG +private struct ColorGridPreview: View { + @State private var rgbColor: UIColor = .red + + var body: some View { + VStack(spacing: 44) { + ColorGridView(rgbColor: $rgbColor) + .frame(height: 360) + + Circle() + .fill(Color(uiColor: rgbColor)) + .frame(width: 44, height: 44) + } + .padding() + } +} + +#Preview { + ColorGridPreview() + .frame(width: 350, height: 400) +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift new file mode 100644 index 00000000..0aaa4ce0 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift @@ -0,0 +1,126 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +/// Popup card height for ``ColorPaletteEditView``. +public let colorPaletteEditViewHeight: CGFloat = 550 + +public struct ColorPaletteEditView: View { + @ObservedObject private var colorSource: ColorSource + @ObservedObject private var paletteState: ColorPaletteState + + private let onRemove: (() -> Void)? + private let onDuplicate: (() -> Void)? + + @State private var isApplyingExternalColor = false + + public init( + colorSource: ColorSource, + paletteState: ColorPaletteState, + onRemove: (() -> Void)? = nil, + onDuplicate: (() -> Void)? = nil + ) { + self._colorSource = .init(wrappedValue: colorSource) + self._paletteState = .init(wrappedValue: paletteState) + self.onRemove = onRemove + self.onDuplicate = onDuplicate + } + + public var body: some View { + VStack(spacing: 0) { + VStack(spacing: 16) { + SegmentPicker( + selection: $paletteState.selectedSegment + ) + + Group { + switch paletteState.selectedSegment { + case .grid: + ColorGridView(rgbColor: $paletteState.rgbColor) + case .spectrum: + ColorSpectrumView(rgbColor: $paletteState.rgbColor) + case .sliders: + ColorSlidersView(rgbColor: $paletteState.rgbColor) + } + } + } + + Spacer(minLength: 0) + + AlphaEditSection( + alpha: Binding( + get: { paletteState.alpha }, + set: { paletteState.updateAlpha($0) } + ), + gradientColors: [ + UIColor(paletteColor: paletteState.rgbColor, alpha: 0), + UIColor(paletteColor: paletteState.rgbColor, alpha: 255) + ], + onRemove: onRemove, + onDuplicate: onDuplicate + ) + } + .frame(maxWidth: .infinity) + .onAppear { + updatePaletteState(from: colorSource.selectedColor) + } + .onChange(of: colorSource.selectedIndex) { _ in + updatePaletteState(from: colorSource.selectedColor) + } + .onReceive(colorSource.objectWillChange) { _ in + updatePaletteState(from: colorSource.selectedColor) + } + .onChange(of: paletteState.rgbColor) { _ in + guard !isApplyingExternalColor else { return } + paletteState.updateColor() + } + } +} + +private extension ColorPaletteEditView { + func updatePaletteState(from color: UIColor) { + let incoming = color.paletteRGBAComponents() + let current = paletteState.color.paletteRGBAComponents() + guard + incoming.red != current.red + || incoming.green != current.green + || incoming.blue != current.blue + || incoming.alpha != current.alpha + else { return } + + isApplyingExternalColor = true + paletteState.setColor(color) + isApplyingExternalColor = false + } +} + +#if DEBUG +private final class PreviewColorSource: ColorPaletteColorSource { + @Published var selectedIndex = 0 + + var selectedColor: UIColor { .red } +} + +private struct ColorPalettePreview: View { + @StateObject private var paletteState = ColorPaletteState() + @StateObject private var colorSource = PreviewColorSource() + + var body: some View { + ColorPaletteEditView( + colorSource: colorSource, + paletteState: paletteState + ) + .frame(width: 350, height: 400) + .padding() + } +} + +#Preview { + ColorPalettePreview() +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ColorSlidersView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ColorSlidersView.swift new file mode 100644 index 00000000..fa02a1a8 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/ColorSlidersView.swift @@ -0,0 +1,95 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +struct ColorSlidersView: View { + @Binding var rgbColor: UIColor + + @State private var red = 0 + @State private var green = 0 + @State private var blue = 0 + + var body: some View { + VStack(spacing: 18) { + HexColorRow(red: red, green: green, blue: blue) + + SliderWithStepper( + title: "Red", + value: $red, + gradientColors: [ + UIColor(paletteRed: 0, green: green, blue: blue, alpha: 255), + UIColor(paletteRed: 255, green: green, blue: blue, alpha: 255) + ] + ) + + SliderWithStepper( + title: "Green", + value: $green, + gradientColors: [ + UIColor(paletteRed: red, green: 0, blue: blue, alpha: 255), + UIColor(paletteRed: red, green: 255, blue: blue, alpha: 255) + ] + ) + + SliderWithStepper( + title: "Blue", + value: $blue, + gradientColors: [ + UIColor(paletteRed: red, green: green, blue: 0, alpha: 255), + UIColor(paletteRed: red, green: green, blue: 255, alpha: 255) + ] + ) + } + .onAppear { + updateSlidersFromColor() + } + .onChange(of: rgbColor) { _ in + let components = rgbColor.paletteRGBComponents() + if components.red != red || components.green != green || components.blue != blue { + updateSlidersFromColor() + } + } + .onChange(of: red) { _ in updateColorFromSliders() } + .onChange(of: green) { _ in updateColorFromSliders() } + .onChange(of: blue) { _ in updateColorFromSliders() } + } +} + +private extension ColorSlidersView { + + func updateSlidersFromColor() { + let components = rgbColor.paletteRGBComponents() + red = components.red + green = components.green + blue = components.blue + } + + func updateColorFromSliders() { + let updated = UIColor(paletteRed: red, green: green, blue: blue, alpha: 255) + let current = rgbColor.paletteRGBComponents() + if current.red != red || current.green != green || current.blue != blue { + rgbColor = updated + } + } +} + +#if DEBUG +private struct ColorSlidersPreview: View { + @State private var rgbColor: UIColor = .red + + var body: some View { + ColorSlidersView(rgbColor: $rgbColor) + .frame(width: 350) + .padding() + } +} + +#Preview { + ColorSlidersPreview() +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ColorSpectrumView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ColorSpectrumView.swift new file mode 100644 index 00000000..994f57ca --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/ColorSpectrumView.swift @@ -0,0 +1,42 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +struct ColorSpectrumView: View { + @Binding var rgbColor: UIColor + + var body: some View { + PickerImageView( + imageResource: .spectrum, + rgbColor: $rgbColor + ) + } +} + +#if DEBUG +private struct ColorSpectrumPreview: View { + @State private var rgbColor: UIColor = .red + + var body: some View { + VStack(spacing: 44) { + ColorSpectrumView(rgbColor: $rgbColor) + .frame(height: 360) + + Circle() + .fill(Color(uiColor: rgbColor)) + .frame(width: 44, height: 44) + } + .padding() + } +} + +#Preview { + ColorSpectrumPreview() + .frame(width: 350, height: 400) +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteColorSource.swift b/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteColorSource.swift new file mode 100644 index 00000000..707569bd --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteColorSource.swift @@ -0,0 +1,14 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import Combine +import UIKit + +@MainActor +public protocol ColorPaletteColorSource: ObservableObject { + var selectedColor: UIColor { get } + var selectedIndex: Int { get } +} diff --git a/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteSegment.swift b/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteSegment.swift new file mode 100644 index 00000000..c3304e0f --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteSegment.swift @@ -0,0 +1,13 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +public enum ColorPaletteSegment: String, CaseIterable, Identifiable, Sendable { + case grid + case spectrum + case sliders + + public var id: String { rawValue } +} diff --git a/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteState.swift b/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteState.swift new file mode 100644 index 00000000..3533536b --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/Models/ColorPaletteState.swift @@ -0,0 +1,48 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import UIKit + +@MainActor +public final class ColorPaletteState: ObservableObject { + @Published public var selectedSegment: ColorPaletteSegment = .grid + + @Published public var rgbColor: UIColor = .black + @Published public var alpha: Int = 255 + + public var color: UIColor { + UIColor( + paletteColor: rgbColor, + alpha: alpha + ) + } + + private let onChanged: ((UIColor) -> Void)? + + public init(onChanged: ((UIColor) -> Void)? = nil) { + self.onChanged = onChanged + } + + public func setColor(_ color: UIColor) { + let components = color.paletteRGBAComponents() + rgbColor = UIColor( + paletteRed: components.red, + green: components.green, + blue: components.blue, + alpha: 255 + ) + alpha = components.alpha + } + + public func updateAlpha(_ value: Int) { + alpha = min(max(0, value), 255) + onChanged?(color) + } + + public func updateColor() { + onChanged?(color) + } +} diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/HexColorRow.swift b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/HexColorRow.swift new file mode 100644 index 00000000..d0e2a101 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/HexColorRow.swift @@ -0,0 +1,42 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/25. +// + +import SwiftUI + +struct HexColorRow: View { + private let red: Int + private let green: Int + private let blue: Int + + private let height: CGFloat = 20 + + init(red: Int, green: Int, blue: Int) { + self.red = red + self.green = green + self.blue = blue + } + + var body: some View { + HStack { + Text("Hex Color") + + Spacer() + + Text( + "#" + String(format: "%02X%02X%02X", red, green, blue) + ) + } + .font(.system(size: 14)) + .frame(height: height) + } +} + +#if DEBUG +#Preview { + HexColorRow(red: 255, green: 64, blue: 128) + .padding() +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift new file mode 100644 index 00000000..b9af0d90 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift @@ -0,0 +1,96 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +struct PickerImageView: View { + let imageResource: ImageResource + + @Binding var rgbColor: UIColor + + init( + imageResource: ImageResource, + rgbColor: Binding + ) { + self.imageResource = imageResource + self._rgbColor = rgbColor + } + + var body: some View { + let image = UIImage(resource: imageResource) + let aspectRatio: CGFloat = { + guard image.size.height > 0 else { return 1 } + return image.size.width / image.size.height + }() + + Image(imageResource) + .resizable() + .aspectRatio(aspectRatio, contentMode: .fit) + .overlay { + GeometryReader { geometry in + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + pickColor( + from: value.location, + image: image, + viewSize: geometry.size + ) + } + ) + } + } + } + + private func pickColor(from location: CGPoint, image: UIImage, viewSize: CGSize) { + guard let rgb = image.paletteColor( + at: location, + in: viewSize + )?.paletteRGBComponents() else { return } + + rgbColor = UIColor( + paletteRed: rgb.red, + green: rgb.green, + blue: rgb.blue, + alpha: 255 + ) + } +} + +#if DEBUG +private struct PickerImagePreview: View { + + let imageResource: ImageResource + + @State private var rgbColor: UIColor = .red + + var body: some View { + VStack(spacing: 44) { + PickerImageView( + imageResource: imageResource, + rgbColor: $rgbColor + ) + Circle() + .fill(Color(uiColor: rgbColor)) + .frame(width: 44, height: 44) + } + .padding() + } +} + +#Preview("Grid") { + PickerImagePreview(imageResource: .grid) + .frame(width: 350, height: 400) +} + +#Preview("Spectrum") { + PickerImagePreview(imageResource: .spectrum) + .frame(width: 350, height: 400) +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/SegmentPicker.swift b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/SegmentPicker.swift new file mode 100644 index 00000000..bfb08016 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/SegmentPicker.swift @@ -0,0 +1,43 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/25. +// + +import SwiftUI + +struct SegmentPicker: View { + + @Binding private var selection: ColorPaletteSegment + + private let height: CGFloat = 32 + + init(selection: Binding) { + self._selection = selection + } + + var body: some View { + Picker("Segment", selection: $selection) { + ForEach(ColorPaletteSegment.allCases) { segment in + Text(segment.rawValue).tag(segment) + } + } + .pickerStyle(.segmented) + .frame(height: height) + } +} + +#if DEBUG +#Preview { + ColorPaletteSegmentPickerPreview() +} + +private struct ColorPaletteSegmentPickerPreview: View { + @State private var selection: ColorPaletteSegment = .grid + + var body: some View { + SegmentPicker(selection: $selection) + .padding() + } +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Commons/AlphaEditSection.swift b/PaletteEditView/Sources/PaletteEditView/Commons/AlphaEditSection.swift new file mode 100644 index 00000000..3dd5e72a --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Commons/AlphaEditSection.swift @@ -0,0 +1,68 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/25. +// + +import SwiftUI +import UIKit + +struct AlphaEditSection: View { + @Binding private var alpha: Int + + private let gradientColors: [UIColor] + private let onRemove: (() -> Void)? + private let onDuplicate: (() -> Void)? + + private let spacing: CGFloat = 16 + + init( + alpha: Binding, + gradientColors: [UIColor], + onRemove: (() -> Void)? = nil, + onDuplicate: (() -> Void)? = nil + ) { + self._alpha = alpha + self.gradientColors = gradientColors + self.onRemove = onRemove + self.onDuplicate = onDuplicate + } + + var body: some View { + VStack(spacing: spacing) { + SliderWithStepper( + title: "Alpha", + value: $alpha, + gradientColors: gradientColors + ) + + BottomActionToolbar( + onRemove: { onRemove?() }, + onDuplicate: { onDuplicate?() } + ) + } + } +} + +#if DEBUG +#Preview { + AlphaEditSectionPreview() +} + +private struct AlphaEditSectionPreview: View { + @State private var alpha = 128 + + var body: some View { + AlphaEditSection( + alpha: $alpha, + gradientColors: [ + UIColor.black.withAlphaComponent(0), + UIColor.black.withAlphaComponent(1) + ], + onRemove: {}, + onDuplicate: {} + ) + .padding() + } +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Commons/BottomActionToolbar.swift b/PaletteEditView/Sources/PaletteEditView/Commons/BottomActionToolbar.swift new file mode 100644 index 00000000..5b7dfcac --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Commons/BottomActionToolbar.swift @@ -0,0 +1,61 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +struct BottomActionToolbar: View { + private let onRemove: () -> Void + private let onDuplicate: () -> Void + + private let buttonSize: CGFloat = 22 + private let buttonSpacing: CGFloat = 16 + private let height: CGFloat = 38 + + init( + onRemove: @escaping () -> Void, + onDuplicate: @escaping () -> Void + ) { + self.onRemove = onRemove + self.onDuplicate = onDuplicate + } + + var body: some View { + HStack(alignment: .center, spacing: buttonSpacing) { + Button(action: onRemove) { + Image(systemName: "trash") + .toolbarIcon(size: buttonSize, color: .systemRed) + } + + Button(action: onDuplicate) { + Image(systemName: "plus.rectangle.on.rectangle") + .toolbarIcon(size: buttonSize) + } + + Spacer() + } + .padding(8) + .frame(height: height) + } +} + +private extension Image { + func toolbarIcon(size: CGFloat, color: UIColor = .systemBlue) -> some View { + resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundColor(Color(uiColor: color)) + } +} + +#if DEBUG +#Preview { + BottomActionToolbar( + onRemove: {}, + onDuplicate: {} + ) +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Commons/GradientSlider.swift b/PaletteEditView/Sources/PaletteEditView/Commons/GradientSlider.swift new file mode 100644 index 00000000..3cc4a8b9 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Commons/GradientSlider.swift @@ -0,0 +1,147 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +/// Layout sizes for SwiftUI's Liquid Glass slider handle. +private enum GradientSliderLayout { + static let uniformInset: CGFloat = 4 + static let thumbSize: CGFloat = 20 + + static var trackHeight: CGFloat { thumbSize + uniformInset * 2 } +} + +struct GradientSlider: View { + @Binding var value: Int + + private let gradientColors: [UIColor] + + init( + value: Binding, + gradientColors: [UIColor] + ) { + self._value = value + self.gradientColors = gradientColors + } + + var body: some View { + ZStack { + trackBackground + + GradientSliderControl(value: $value) + .padding(.horizontal, GradientSliderLayout.uniformInset) + } + .frame(height: GradientSliderLayout.trackHeight) + } +} + +private extension GradientSlider { + var trackBackground: some View { + ZStack { + CheckerboardBackground() + LinearGradient( + colors: gradientColors.map { Color(uiColor: $0) }, + startPoint: .leading, + endPoint: .trailing + ) + } + .clipShape(Capsule()) + } +} + +/// Uses `UISlider` so the system track stays hidden while keeping the Liquid Glass handle. +private struct GradientSliderControl: UIViewRepresentable { + @Binding var value: Int + + func makeCoordinator() -> Coordinator { + Coordinator(value: $value) + } + + func makeUIView(context: Context) -> UISlider { + let slider = UISlider() + slider.minimumValue = 0 + slider.maximumValue = 255 + slider.minimumTrackTintColor = .clear + slider.maximumTrackTintColor = .clear + slider.addTarget( + context.coordinator, + action: #selector(Coordinator.valueChanged(_:)), + for: .valueChanged + ) + return slider + } + + func updateUIView(_ slider: UISlider, context: Context) { + let targetValue = Float(value) + let boundsChanged = context.coordinator.lastBounds != slider.bounds + context.coordinator.lastBounds = slider.bounds + + guard !slider.isTracking else { return } + + if slider.value != targetValue || boundsChanged { + slider.setValue(targetValue, animated: false) + } + } + + final class Coordinator: NSObject { + private var value: Binding + var lastBounds: CGRect = .zero + + init(value: Binding) { + self.value = value + } + + @MainActor + @objc func valueChanged(_ sender: UISlider) { + value.wrappedValue = Int(sender.value.rounded()) + } + } +} + +private struct CheckerboardBackground: View { + private let patternSize: CGFloat = 8 + + var body: some View { + Canvas { context, size in + let light = Color.white + let dark = Color(white: 0.92) + + var y: CGFloat = 0 + while y < size.height { + var x: CGFloat = 0 + while x < size.width { + let isLight = (Int(x / patternSize) + Int(y / patternSize)).isMultiple(of: 2) + let rect = CGRect(x: x, y: y, width: patternSize, height: patternSize) + context.fill(Path(rect), with: .color(isLight ? light : dark)) + x += patternSize + } + y += patternSize + } + } + } +} + +#if DEBUG +private struct GradientSliderPreview: View { + @State private var value = 255 + + var body: some View { + GradientSlider( + value: $value, + gradientColors: [ + UIColor(paletteRed: 0, green: 0, blue: 0, alpha: 255), + UIColor(paletteRed: 255, green: 0, blue: 0, alpha: 255) + ] + ) + .padding() + } +} + +#Preview { + GradientSliderPreview() +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Commons/SliderWithStepper.swift b/PaletteEditView/Sources/PaletteEditView/Commons/SliderWithStepper.swift new file mode 100644 index 00000000..479b73e4 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Commons/SliderWithStepper.swift @@ -0,0 +1,70 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import SwiftUI +import UIKit + +struct SliderWithStepper: View { + @Binding var value: Int + + private let title: String + + private let gradientColors: [UIColor] + + private let sliderToControlsSpacing: CGFloat = 8 + + init( + title: String, + value: Binding, + gradientColors: [UIColor] + ) { + self.title = title + self._value = value + self.gradientColors = gradientColors + } + + var body: some View { + VStack(spacing: sliderToControlsSpacing) { + GradientSlider( + value: $value, + gradientColors: gradientColors + ) + + Stepper(value: $value, in: 0...255) { + HStack(spacing: 0) { + Text(title) + .frame(width: 64, alignment: .leading) + Text(value, format: .number) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .foregroundStyle(.primary) + } + } +} + +#if DEBUG +private struct SliderWithStepperPreview: View { + @State private var value = 128 + + var body: some View { + SliderWithStepper( + title: "Red", + value: $value, + gradientColors: [ + UIColor(paletteRed: 0, green: 0, blue: 0, alpha: 255), + UIColor(paletteRed: 255, green: 0, blue: 0, alpha: 255) + ] + ) + .padding() + } +} + +#Preview { + SliderWithStepperPreview() +} +#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift b/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift new file mode 100644 index 00000000..6cfcf983 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift @@ -0,0 +1,88 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import UIKit + +public extension UIColor { + var paletteAlpha: CGFloat { + paletteRGBAComponents().alphaCGFloat + } + + convenience init(paletteColor color: UIColor, alpha: Int) { + let components = color.paletteRGBAComponents() + self.init( + paletteRed: components.red, + green: components.green, + blue: components.blue, + alpha: alpha + ) + } + + convenience init(paletteRed red: Int, green: Int, blue: Int, alpha: Int) { + self.init( + red: CGFloat(min(max(0, red), 255)) / 255.0, + green: CGFloat(min(max(0, green), 255)) / 255.0, + blue: CGFloat(min(max(0, blue), 255)) / 255.0, + alpha: CGFloat(min(max(0, alpha), 255)) / 255.0 + ) + } + + func paletteRGBComponents() -> (red: Int, green: Int, blue: Int, alpha: Int) { + let components = paletteRGBAComponents() + return (components.red, components.green, components.blue, components.alpha) + } + + func paletteRGBAComponents() -> PaletteRGBAComponents { + guard + let converted = cgColor.converted( + to: CGColorSpaceCreateDeviceRGB(), + intent: .defaultIntent, + options: nil + ), + let values = converted.components + else { + return PaletteRGBAComponents(red: 0, green: 0, blue: 0, alpha: 255) + } + + switch values.count { + case 2: + let white = component255(values[0]) + let alpha = component255(values[1]) + return PaletteRGBAComponents(red: white, green: white, blue: white, alpha: alpha) + case 4: + return PaletteRGBAComponents( + red: component255(values[0]), + green: component255(values[1]), + blue: component255(values[2]), + alpha: component255(values[3]) + ) + default: + return PaletteRGBAComponents(red: 0, green: 0, blue: 0, alpha: 255) + } + } +} + +public struct PaletteRGBAComponents { + public let red: Int + public let green: Int + public let blue: Int + public let alpha: Int + + public init(red: Int, green: Int, blue: Int, alpha: Int) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + public var alphaCGFloat: CGFloat { + CGFloat(alpha) / 255.0 + } +} + +private func component255(_ value: CGFloat) -> Int { + Int(min(max(value * 255.0, 0), 255).rounded()) +} diff --git a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift new file mode 100644 index 00000000..50983668 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift @@ -0,0 +1,45 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import UIKit + +extension UIImage { + func paletteColor( + at location: CGPoint, + in viewSize: CGSize + ) -> UIColor? { + guard viewSize.width > 0, viewSize.height > 0 else { return nil } + + let imageLocation = CGPoint( + x: location.x * (size.width / viewSize.width), + y: location.y * (size.height / viewSize.height) + ) + + guard + imageLocation.x >= 0, + imageLocation.y >= 0, + imageLocation.x < size.width, + imageLocation.y < size.height, + let cgImage, + let dataProvider = cgImage.dataProvider, + let providerData = dataProvider.data + else { + return nil + } + + let data = CFDataGetBytePtr(providerData) + let pixelInfo = (Int(imageLocation.x) + Int(imageLocation.y) * Int(size.width)) * 4 + + guard let data else { return nil } + + let red = CGFloat(data[pixelInfo]) / 255.0 + let green = CGFloat(data[pixelInfo + 1]) / 255.0 + let blue = CGFloat(data[pixelInfo + 2]) / 255.0 + let alpha = CGFloat(data[pixelInfo + 3]) / 255.0 + + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } +} diff --git a/PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/Contents.json b/PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/Contents.json new file mode 100644 index 00000000..25a219f5 --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "generate-swift-asset-symbol-extensions" : "enabled" + } +} diff --git a/PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/grid.imageset/Contents.json b/PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/grid.imageset/Contents.json new file mode 100644 index 00000000..770e1cff --- /dev/null +++ b/PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/grid.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "grid.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/grid.imageset/grid.png b/PaletteEditView/Sources/PaletteEditView/Resources/Images.xcassets/grid.imageset/grid.png new file mode 100644 index 0000000000000000000000000000000000000000..a8f9f9f460d67621ec35e8cc476554a71b409120 GIT binary patch literal 6657 zcmeAS@N?(olHy`uVBq!ia0y~yU@>A~V6Nw21B&RJ_#wl%K4mdm@L zsyO+%x!&7rx7R)5}Q+u7p;&wsynrj8}LymH&kkFDW{pZ>k+pYU6+ zzr6P0)emo;zER&$+rIpD?EDJVKd+xWd*i;Lv~D6OB}zJh^2!9olu^!T7!aKfHs0I) z{X}kWL1F%PbGt=FHvBI+_Ycb0Q_B(Z;yHKa97V9(ZQY zUtah4(z`z&58q)oIPrLL|FRo9m-Mf%HGTi1vCi)7rKd4-&hDyN!|$ISwYx|D-MTbj z`n((;?H1H@g1E~`Nqnyz&AU+*Lnw9T#J zcM_<8eDdw*mEZ1_1%E607IIf@``@0qz5mPQ%4K@b9=%&~n7#UchUwLNsxfwR@;?j3 z{ma>Rc5&`LyII*Eoc8V6Vfggj#F~kWz%-b`AqgZM+9r&0M#F&Abg<_8xwu_x1^wdQ z{yWxs*ZR+8P=c(gusME#)%L#q+_K*u{_p0UH7?WSKll8O?XBg-XJZdNeSEfNvG|5v zKaZBocHh1I`~974`cJOi?_YMucG76!I9ex=TQ5eekK0=k&0O;@?YX+%yKnF9=l@|j zR#94EarlgD?XLg3JFoM9c@%!xC-%(aZqN4qt(L#;`xJ|BjLHYr%J*I!PT%Y+_b%oC z?vrBrkAEINS9yE1cqFwn7_CXrYST6K)t6IyKRo)G_1#_X&AH9HgWtb2C%kSBrI{5IYjPvx8zeV@nD$80wUu*qg zdw$E=j}JjgetW;~7QFZG_g^4q#%P0aw82PbgE8aA?c2ZY^JP2O-Y-)D6>1Egu6{1- HoD!MUiq+G>Rwsr}3OzVGjS|9Sq%C!Z%*?&n_5bze{1OC2~X6Oif3l`E`&J=1t~<;qpr zD_7{k80l%BNcR5bzjEcymA^Ee7zAW&5vHr|DOO4J8fM;bytwp8(jEu86uIc`+a(S_Ten<}0UA>z8J2m%2aGs81DNYozcbL)Vth}}sNBsTebW%ASy|tc$ zGT6=SsXGPUIrpELUiYap-;Wc`3Rt8q^&w}DO1repfaRC&mz%938n26YZNG(G8jH<` z)od&5oRi43Z*`Ba45L){T9T_rFjHQuu#3*F^=#20Ow-x)qJURO z|Mz+Q?##B<{p=HPO;}g9)^DI|3D8to)Cf?%q{~sCosFg@o$6{M8iup`|A+m*rT2Hx zX$_mwniGA?1G^7*cWkH1B)BJ2h)^m#?k zo#}XV1=h-Zaz*N+tVt_0zb@JJEGZrg1_|{$j;XO&E;Ws%Gu_4>mvL>%XNoq{cy_HVwQsv z?dtBPLoWAJRQBRbO~#!D zy8e>&d%+ps{k7J=TJ-BL>izsQx{J=HEsWr$Ic8jtd@ESy&z0_Cl?-JDg}ky=B=A|{ zF$d>l7%<|TgLiyeVKS^oxe~KB|4WtTD7eb~khA9btebzDBF{Q{e_vdjkCz`5$YiPM z6kmgeH=AqlbPy}&&jk(8tUP0;b~Q5)O=wA0O?|lnpLmjR-cG+oa77LK{CN|wq=U%2 zUFRtExX13~4NRGO&Y^n-lAZS$22n|sX&VeQw4@gHqVK5H*7wk3E$jR6AQK;CSaRG~Tfh!> zv0bF!x}hv=vOLcVMd3gGUGlHj7@(BNl_Kbi>B*5VX#!V=f|Os`1%c(UcCd1Nk5*Q1 z-HhAA%A0Y|X0}bkcBrBkkwFwy>dU`~A8&>TFP%2XUNZXX75tFxt`{EN)8P3bG4x>V zfvu-B|J8)i?_IN6)&G8vP2*Ra{BB*b{uFFJ&=m*ds!khe_DtMPbBPGrv@WYr=u`0u zRnFI!%sM@{tUL`3+xc|L;B!M|9Lkxu(s3y(>wc(4eIvxpE^M7@C;uK8(O|0!3NX@h za4l5Q0ku0=er{;u1rBLmUZ3dc{2>h;M7t2QrN_*ox5{z} z(77{y+F<$mEGc;}TP<;q7jV7Ogx^}Xb*=g7+C}hATG-PBM-+F`n(Fe>=;=hSpd0+>47FuEuMY25pi?A3X>oRWx+63N3kzq`ZnJVwC`ep-e26dtaTs>hxfOKS7X z#X2RD<5wp%cM7dm|D3h(=(a?Uv1}F!rxi|ydm97sIMQQDJ4?JAX~{KVdu*5Xvnt6v zNj9TNx&*3D%UpKAQ1viXHwa$5_Ujmf&hyt%J9uMJ@G}emXl)Iv3kx}SnJ=ue9k(_7 zIp94*4|g!&<<;qEul(+b6Jv`Irt26p*cY(qChs#oP(@#PWnel zbtbue?tk4fxEYQM-*>1Je z`Zp-3?I~v-%rqrkF8iki;r)Ykg?o1Z6i6>opiDh* z17S)$W}SL4SH79L6x+jCn6Po&Z&ALj3)O|LR!;Ub`?&8Y)k4!%{7saHF+WqZFzK|S z9Ts+ZdhBAXizdHA))L|iE8hV*TyG;6N^OlAMX@@RaNvXZ=y=1y8)Jpo!_&XIyKBlk z{l*2lJA=iPSik#>!KR8Qb9On^=_X<73(Db|tNt4!Wu{{dO54f~!xqJuI&ZdB<$Mbt z_#v+jcC!k0;=ETR(vzr_I8K@8yO<*DUblZzAD=A4e!ZY*!E&NHnxXn7T$;V(ohcej z`_~EH1`)QMR%DYA^}!-oIODL`u5O2g%nq4AWZS*dP4xpG~$8yWyvLNQv z{G@M>V<}_vJuuAjzqz= zW~fpvTK=tU{#Fg6Qhu>K$yFIT?%)qnWRsP%ocF&F10^)}XGvLl=;gV>dp0Wt==;ULBW1I?C=_C4Lb>(?vZI4vnEK(k~ zr1-}*@fs8WA8a&>4%8<`+G&*qT6aG4qlaSffzrR+1~DUAm^tN@ui$Ur141sPHeOFX ztq}TdCy3)Lvp&f4HPV+?HdwNY^u_WQoWZ(>JSX;l%E|DrcK)P~(Jx4z64uGrF~X1u zW4YM=0G7l+T0@&nIz_(uThFWCry;)IOV+0z@AY6Gv1Z~2kb@F=*V6vnwsNbtFMs5? zA6X2ZoS1NOYOcKuQ=Mg|qB=CT{ohq~V7UtyK4TRb70uLHn2`$@L8~zjm`8L%Rykw0 zg^QvbcrNdG(52+h1B-2lPNkTGTK7RufB{0PO0> z$f3JNkP%SQlyYV{UrZSYrs%gg^NrP(uO-Yu_DVuv1CZL@+9}7V!J)yR${KdH4*p)Y zPfv3Bh2!0xWCE;;5>F0y@u9vuDu@PXIv3$(;+04q8GtNnJZnmIeX0%T%_D;WvxEAZxVO}0ak&)$z!%bdjhpCPEcNQBLrWSTHK!eLAu_Z6G z$)~zdRxu;IMIY4q5Bs*nr;wy4_vcTzx4$g^cYZ!Yq~>A!gI}ru=gJxho=I5n z42ihjwUJf7B-3(xByLHqtur7@q&WB@P9Rz{)pTK_x-m?Gh3POjC1+4|xG*$2=T;!q zKAm4BWUZ;He`4ai>=Bk>=G5b%v!cwETxGx9pIO*Ok;=(@`sx5jnLIncGy$g@F|ho& za9&Qfyr=SaGGH<9h85IgeK4N9f&jawrS^1rX+#fi;HNONnb~zPX>=lF-T?T3(qrn3 zs1H6YGmbJ%b~ebceJMTs^rKAjUe-X6Kjw8F<%5<_Ui-wvVVQ1db}Qz*SBd*A`w+@% zyBy0i$u9rU=ulrPkr77n#WHY(%!ot}L{2y$?fC>GSg%t0E7^Zu{uQoTvSU4`u7#OJ zo)+$17h&{169q7iPsrC&{ZS4Egr>P{!j|BUs%d`LR_5v$dRf|l24}FEXrK64+;`$wllQ!-%U7gnTl;o(Q z;>r;I--hnosokB|SLuIUBs9lZ%%}E3^NsmdCiZjE04V!MEjn>%&9@SM>3p~47Np{s z_S^s2lv4=~VjmQ*Zg+e2IY{c^C$B|WZA5h3$V_aD?o5!%cD%pkzfFg}Yt%!%9Lqah z%bv3=#glEk{E|I3uQRMs;Cj)s+Hbsb5exO`8&MQ|r5VAKmo?i->ke~pYFHN-&AJkj zgJuqKtU5F-{i9))y}{W3c8t%Hg;;@A*s6WYxP-pi<}5Df^kSyz>qX=#&fow!&mO~8 zvhv*v%gGkeZlfbMKUWy7Ur?|jtFYtbbg$Xm$|@B^BK=_>CRZHv`TVrwXWO%jlz2RbU$IkFmc?DQ~;Q~9^ zo`oqUoHS4>vI06Q?6&5_rfY~^4f3&hyak$68!VmW7rp^pX7FHj(+Wx zmCQwI9)!H_OTyk`kFblW^h$$xlLvy}fcda;zh747i)No4*Wrhp`%eKe9cR{Zx%APf zR|norTC6?oD^mI0*@fS&!FnwJzF4W2SI97{?#0FmC;T=|Y?Gd6j2ehs63AID%b*X1 zPB|!7&u}$KWDFY{fw`ZdGjM$QPA-Dr?ovdWt)91 z&HSfKKzHP0nY;_)JBOIRaxRYEkzdK()VYS_2CTwTO>?4A&+hPceSIACKqP7t@aikP zr*9RzJ!Wcuz|BhPdVH^{bq{mR>=2CLn6gmGox)t}en$Dy)qXtzvtIY0y|pFX#PY+y zu7&c~wJm6ex8E*ICHd51<*=T(&lN&Jw-gw@83!X9=?6dANnXZsSmvV;J%Y0y91rC< zEnl}c28HJ+=c_K^y^Y>zz^;robebk&`guceFAqmbL*MI!>v|-9Y72I>v^j3XLeJ{=bqB#oP3zE zb}Xauf+3J6-Z_Vt+mIlOj_rXDid)bU%4$@&p1I5GB+3(c-MB^UxwPx;?@3yCvbc zG#zy6-Uyaz{@1QkJu6ClsD=mn>1Dv+uBG!QNatNIOtROHzT@nAj9bmH69grkxaF3= z#sn>C@*WrLwY4$wvBO3^uW5MOc^w(ku33RDd!d`S-tU9_d8 zUXCm(B?Gnbqt{!7WwR>RL57ox`!f;=C9(N0268yavAg8c&wm$0|FSTEJe>R%i{jEVf#9icL?)%?ix$9>GvjSc8>5irIQAVFGH=d`3mx|_M4(-Yr!Y3et)Q) z77;vp(XUZ&HvSJ6d9u&A0mqy<7B~j5R1ghj8wxF%416q{edEZ~Hj9VH@2hHI`%nv6 ztTx8qrhH0g%TSH^at#Q*6ynI*}u%SebbcW#Z81Vw+S%^*3E4-JgsCO(&{*N6{8l@O_!8o}@4ZC#bE)*XXr`dXOjWm>#Bw0s#*;Ei=WczX@Qxt{ShaNNPVe3SiZkF`J= zVKYh9oCxA=8`{z1N+9OEbmhoWI}CJyP!K)UQX9=JVSH)x4-p(E&+eog%&GS4cdIRe z8qqM=Y;yWTA=qz2R*TL%ybKKICh)JIp3dlM_$qilA~2du@|RScb@0i0$Y)PkIRtf| zI_<&vA6CM)!o*!p>Ht7V3-pi;x?>zO^W!d?;ueTp2RqD8-Q- z?(h`13HC~CUUQt#n!!`q5EM)6tosK5+bsD5fL&IXBn>YGrdrLGb=Pv9oDyS-*3sy? z{xk?7LkfWfM;dyHg>m68T$5#|j5bUgaPD6f%+Pld&SRhaF!_?nS&*b3IEQ3D;2Ik= zo#im(x98VmJq-N4b0*8r*|9phEpbVp6t?smUu`l$(<@aP{+Ssmyuj;dAlzP7zVDuud-!;-uus=)}f+&oMf;#BYNbhZrj0E1EQhC zyC*fgh!;VY^Lty>)eC{ascJulJhP>d-q|%t7>3dZ^;>TLw7x1QCUWzTfbEo!b?h~= z{D*6^x=d@q45sUjs5|*@`!k&#_*nH4{bc@xwX$n-kCY606aGr53duCM%kd5GPbYhq z@ItqsOkJWl=T~x9ctVf2?B4p*)vI_HnXVt-qfA$T^nv9NnL#I=JJkC4pCR)g>0W4e z_>-(9MtU;p`mm93c6x8dS24>PkMP8ttceYtm6~~{KU#|u<+w6}&B^*3d5CmL&JQz% zlxI9eIkbL?sETKbA@`b%Zeb>$ssKy&&Qi@%}wJDM}9WMn(G{9KQENL4gr zT|TdGZyr`OWHGz#e1{?n%Zqt3r-$$6ifA_HWaS+BwkEO!(D2lL6Dl;!-xnfK*Ul^O zRyN$&v;T!m@NEGrk0*rU2SFG&L-isnQA(^x`sN|lnYf!572Xej4a@N~dAQc@=qSy+ z36gJkwe9~{{J>LReNPWMvSDM8r+2Lw)Po{Pp2~>aLP%oBmtnN*SgS`%xKYr??WuQhai92_n zEgbUQ@f1p0Iha!s^NSw-2J=+W)_9cb+Xa!X_Wv{9lwfRxjjw4JH=@)MPc~F;6_sbr zn}!@p!Z%7B9pHpNsLlHgrZ&K1S9=?}O3Gz&a>(22xZ1zXf5MX-|4|%)yMVOq`N+sx z;bsMr=V)HT951YatL3_UWb4UpsWcl~@p0$89-$(;>v}%j>GT}N>BU)gy-w+|a}?72yvJ}wKstaaEop;`ESxP^0`-D7!n z1VvEYlZpOkO*?TlRw|KrvV|KmtFxz<_2-M~&s3Qvndy$}HEmQ4GxcZAp7w`W?9IsS z&cP^`5m_nYROcJ6eg`_LX-zPDJ?YKuAGtNS|DYO86RtI<*R|(n;1TKT$g!Jl^q|EI zZUFtazCC?ntP_o2u*FUhW7k)(O6KZE@OmeEwP2l)n2*IdEmo#;u6H@Q9IeauQyJZB zzu5$reKktR(w7rDoaFa29R2}Y{1Vu{H3|MlAsANWGM>d%^YP7m-XTskH#v%9>F`jm zdjCbeZkDZkg`LTs9>?9hRk_DP^j)X52{F94N`jDO+CdgBgqJR{3K4o006m+R8P?ne zm$hv|AqPz(cjCMaiRik1nZxy?)cNV#E68IfdmU56l%7Cp`R;_j%xRVf1C9CJ7s~Y| zs7j!7+s4X)IN#dUA4X%cLQ4L2;Jm3V$SV{(rbHoFXQrkXv=WM(lSx+Mix=z-4B19# zi#-A;B%$&ktGqB?s_N8$-n0V}U27o{lh-mbvA&W~W2$ydUtnCbPkLP~p57bx>}OH$7DcxX8e0Cmm zjtip9>G61&GB+8zy}m4wzyAS}BCG4;jT{~>tnG(_*F*l9OXw%mF3q_o1W)L}<(L)^ zRf29JBBn#$?_8)5Tyz?KCx=9D7FyKt#g@#q0RNy&k5}R=TthmY`ygM9k*p-7blB6t zkHQ(TGVB)3jwmQPGG6exF3c{swhd93yD-t85G zIqLJ$Kd4z%S&56iH2Ul)A-`uUDl+SfpaLzppRpjt{{4shLXQxf>y?H`I(aFphT z#5o?Zd62mx>jHfzbM+!JqQgmp=Nyn0`{2hY@VrqTVWPpX(%?O`jWhDd?kg3tv90D` zV-rXn{w2F7#(gdY_F^OY>g0~Wfn`c1v+0|2MG9$@Ch+#Q`OU#$A?Le1@g&efZ_FZysQ4pGy+>~3DXJv!^DDkX^PjT3_pi*3kMC&41}$2sAl`Id)g!Xzud#vhv{4e6 z{#N(A=$_Ns;!lPi=|0sLw5#wfUo0Dw=t!)mQ#~=Egg#GxaY*jw5YxyNw5?ygw}gO0 zaQBm=V60(QB07%bI>QBIN?NgB@c;4N<6kD9%&9)W7K51)cds3zqK$! z;CPF7HUe|DE||GOsuY`Y5^Q*6aV#>Ev=O4TR1x%SR{n-=fP$q>mql7IPUin=0d8!G zeYiF#zP1b_GEiGOh*&a=p1z}R%7`^7wr@|6rIl+HS(#S&fd1LRc@8f#qlm4N^aP{h z>)}F#=2E|=94tf7Kg#WkJ7>hINwZm!`|xiQ!jtYJ7VEK;xQH#Mo zj%xQPY1Cv0U03Xe9Zk=n=J!;K*{o}}7fVg9~fK4#JqC&il3l5S$>^g0jz5<)jv)7JA zG#2Yz^HAh&gGl%KGm>5g<4;OvHW8xLewp)k2{3*Q<`s=!rTc>LUxZ+rgHjBN_>0hM zc2Cx)LCnujO(tLC+Q){=_op2>Im_t*mky&wjV#fzo&=0wyz8GV|>izb<)^NMkf*XW`eZpiE(09GLkp7MzOu^LkHzpF~3M>7>l{jkLr!} zBbI_KD!4?MZ|L|h0~(#sEP2K@!<155d15rUVLGF?tt1*hW)v8u?`(O{f^%&#>uC7c z%YtO%$qZHig?+TE&4Aymp@S1+&nV(;3tyyMk%aZJ_d0L~l(~o^DO0`dtdyi+6=mI|k+Ifmxf6yIeJk(ygH{Pk8t9b&Vk z36sxgfMNmLRcakV^4;HlMr7=z4A+d#Gp0cg9a?)#SCT!4;JIc@*(=fkibwZ|_^htXkQ=k|njRlLV06O5 zVVTTBzfvd2Z>GD9eF_+qC^I;hh+y}R=CcvFRrtXL-%2p`W0jNKgV?1D_wp5XadDm64~knAc+~Dq8q%AkQh-;bo^s;2I)Hyh@s}%WG_!-)idf z_FFFZ$un(HoF}%I1(jAjFSBUrX!4m{$Y97dd*<$!1SZD!s?={@4162ZAg)_>4m9R+6?hH?$XJ=1uxP{mJAi|P!z>S{|<{h+~LWMr4`&bws* zoqE^NDqimbgXn(az^(Jp_d>A|Ze+&j@w(K|a&zW(@~PVz!EaFhS7@xQaobQE$Ks)3 zRqkJG)INVZ&>))L8gZ)DHe(%(ZV9rArbj11d@o$$JfAt$IlK=F_ zyVaL(=wphYb&h8Jp-G^A8D9~8vv~Ren_aQ7xCF~;Vq;VOIs~E>dXS0yC0Wak)Excx zn^V8NG-_9z4);iin#D1~J;km?N5P4ws5d0Rg4nhsZx`Vf4`)Ny{2t%nE^<83*du+? z{)h{=mZidPocI?|Sq8>lCUQyHN`3~*eUKpvF;6qEOd4hR$B;+^l`xG*iXZ^**)w5V z1N`pz4F03@(~953ndj4{{_ewH`A>y2Y1S+A2-ApJkqzM7>@1bmON}nEi0YT zeJ#z7CBagM?39q9zC_b8WT>n68UD2Yg`5`GnWT6b1%4fZY3MG<@+;OdaTGav`up$) z!t4&VVU?iJ&d!%KaHjWSh~}o2!KZWE?f6wGDIa0s_kpXfLo?IH;5?U|A4KV1m7k4u zM$Jx`1&L!o4`=z$!TP1UriZVyC68=#fO!be?1%n{aPQIc*yC^@$FqmIl>?ozuG!o~ zP=<{)k%b{DCE0~%n@PXj#=suS|GeyZOi@$dxEsTN0AY|X(J%w`g=&6IN+(1*If#C| z&!z@(qPMMwk&!eA^}vk$35ijS!0lB@gJ#*0we!qT)$wRw3(AQRmX|Ejg9$GA|Zeyb!jHoXZrrLy1dm3 zQpqzBDd6QMAR@=V@wfZn_{*+2Lk!vAT_MYf`={&T!*_Wrzk4^5*K2M19`r31_Dt%o-ZZe`B)1b_NV3))%HsJXZ&m6O^Q5Ml}h!S1FRT}IC4~wJ3 z?VS%-GQYUHt|HUayYT*XY`U&Vlt{I%qz4@by$Q=}` z#+L3sZ0Jyh7G!M?3$^>wd*4Vz5mgi1mP2dXl5O7F+hS)Jp#$Y8X5!}N5c9pmFJd*W zDRhH1LxVE|cNKrf7pS^|hRQxMqhR)C zRzj&&4bqo>zzGuI&iZ zm~^*o8(xzZYvEtXlSW)^=UQQO%fV^g&vqL@{(on2_*~7L%Y1C#2jDk<7hh zzUEJMfrV{to`Ml{9f{x}PXGs-x=>XqN*Z~szX|nu;%Lm#kj7~Gas{vCm@t$-r!;2v zIimiTg9m>(g>Z>yujB;qu#s>!LMP9)8!lK1ZUsMQ1HifS6+NsWq7>*FmdUS%(y36v z-Buhv*sY7;hL5UExzhCndNd3tUBs=}t&xeHyz2SPJr$4GbuWlgE$^Gg?i zOnQV8s>G#)NST~E%y6{v-x5~AW!%ag+6=Dw-S>vKn60IsXbnO4g{QHCF5(lGInfcc zDRB-jmqaGu28}Iqsz6==iemY<{4}%uCjFf5OtZ0m)&1!A+I#`(sl_^JEK_oZUTQZ! zd|JV#^~KWEXBm(vHipx$p=~rWZH#7posCl~JreejkA`*#)d>1Ima+zcE7n{)$!|aW^JBbX(8k&7^p?l@cux&p{FR(YlwPFah0WXEay!y z&Igt|+HhL*v;+?=f48mFHZdty0%^nDE zNgCyC54h#F7k)ar?N_~x_j2^cnq8fqHkLsNE(qm#0@&hx`5nKMjb`wR-0s@xZOHn> zGW6GJZ*y#$(EdZDd+lOPpJJF%f>XslH~sjPA<3E5E-~bI+KTkJTEFvR$mh97DqHyI z4e(L0p6}(AJtgG563>i@6e4=8=s}=HguJTJSz(UMNdJ$m84nGLsk8|kteR%p9o=rD zieM`xZu>!aL_Lg?C|5Bhs}pcJ803EI;@$snz4Nse^7VfR8g`=e7O8M$LMg&N_Z1ah zxpZIqiSL$rW{9v4jg0o$17xNM?SB!oA~*_Bao$!BYO;n3KUU5skkbBcSc?okNUc?} zrH`GpZ-5LCA$|BTYAldKbFpGiwaWC>fh}>?m?&(SDZ?5R^Lrv91-%HHICwb3+4WQ;7 zhSQZv2mC7^{){UeoVZ|s!338=%y54%JTyE*pShB5qry&YYYTFQl!5@t)-Eef!*=~@%XT-};P>>85;z=7> zcls1^ZD4F_lNSA8rdv?8x^cU$u_pIi2&<>lHw>2SZd2dG$iRoL2VaNE zk;I&j(?br$6U)+;CoGqv+azW6S@$P%Vl(tT;@Af9pi8l4>Lw@Q)6BTi=7kqlkQDr7 zQSj8Gx#hXBrnNm-k*!DUmGK}Zl|(9O0Gq;MY(w$C@q6C~Gy^2G9^K3|6wBz31>VK2 zc00{SWm-&=?UP7PP_#_5`W?c~H*4h#IVs|$ks(9BN`Mh@-Y=W&nv)y%`SZr1%$tq6 z0>kL`z93`GdJM2Q=ZDd}kl!hX_Vsbwy6Yy9b@A=j)-L18Gh7YsrW2%^=7a?=)@yae z4T(vQn_EblFYVtmQSCu_52b3&{Kt_A>|HBG0LkKH*B6jXYNw6t; zoJzqyUcZvTUxy>Td&JQBoyIB6NYc)?b*ze*pcne7D14Q3KaVFB>jKUiM4yi}a6^ya z;|KsVDgb>XFhdSqU9?_%de6>^WbmKUsnL38 ztQ`5^i366LYi;XJ`17laME5}|o+Y*!EC?(a4#&LmStjwv8wT4e>!+SpTW&Qe6a&Z! z?ojQkzSIB2jGBj==8R2i_!v;4+WS~u?+}VXSaSJh)N{C@(epKxKCt4ed0**&3%ar zAPHC;vpl^L-roc$&+x8DT8tB8`>H-MF$Ij{HH4Q;osLPm5q<5F?; z6rMe~@5za9ApP{5rQG;qtBkzZU;dFNUYc5rsRdXrJ*`cI1ZQ>QD%;SZ34rK&$;Ru8 zhWcnxOjS#wH<|(%72>_dtIAwfRm!c!sW{934`0jsO6g`UqoQH(P88C&%;H1>V$=cH zn(c=kGS0J2g>%5M1L?)#UQc5J^Aqgq2lvh(-kYj^4Cs z;EIlUksxz_V$T2|2w1o%grVatbyjNHnx3sZtSf zKS*A8>YrH@X;SXfC#crZ${23j(TkM!ZeS5KEtQD7+h!en=rZJ{W3V#U+Es%1=;gHn zap96l&ps;q$=LA=H?@@&KMM4s+yPP+xzX3& zLD~);`*Ni`CHY|QN|Z`=wC}Ni=;599vA-q}e2DUR+Ct52aMADvq&e-_Z%I;CKl^H^ zcZnObzRwRQP;wrEOrXu9C)1*TEMoA2Vfj<^wA{Pz{j`H#HwuI0QlMiHzB z-@DxLX?&hShie^@CTzA_2aU(Ifn5ge{bcrk02ta}+VAo5Fj33-ZU2gtyM8!8;<5t} zD1q-ld)i=wbk*fmkllX_oC_|Kr=EKp3Y85d3GJ^eNvlr%Xe^!}5wOmoU)y})$N2oT zCczS69x}dL>EyvNK(r*<30{FyG2&f8RAm&O$}{UXsBNy$CPn@e$nF4(*vbj6+bcxw z$Q}NYrsA5nHY9qiskXdS*@Rg1b)+Rtu`-T~!VS7jxC~jF=?$g^Clx{}<8ebZ$YxQi z?R3+XWtR5b0$vXBM;){WL5%J(Md8*#H^$Jni%RliU+JTnjFOH6@bAdBEDQ?6Savqn zYt!`Ol6)n=J0M9r6AfZwN+r%tH7a6^ot2wsMX4K~RlRZAi&OvlUnDzew`oFXB7lxK zg48+;61CK8A@7%VD}LcKZSkt?_*PF3O4_mjaV8KeEhgCM8*a=}XM*d!OM?HDfy!#V zf86ZKEDQ(h#1`v0INkY=UaX9cjGr{Mdf2a{po+}#6kn9vUrKXixgJAr`&fEHq5g=) zFTZW3iLlviJ=v_@fiwPEg_5xNGPIb>|5^*zV-aGd$H@eTA51WZ(1?}>!0$F>FNBPhM&4vjhF0tyr!=Zxm**?Twt zGJn4y3rZ`@Yq^3A1<=R7OLsQH-F7`MPNq!;pf20nnq86YxN9ztiKyo-+B@0(&8W9` z0=qx(u9(ub+i2MXxVZxViAm=uDy@H(*;Nz}l^(c-qav;*Y?KDz-3@M#pSmzfC0stV z769hWxbH8TZLU=9KHEO$GmUai$f|w`rfIu#{X-_OnM;Kv(#T`T`gvs9t=<}S?RbFT z3T9(VrDj184FH(Z`TZygVP@he@&UhDcyQ9=Q++Ke6t;ydoibh=-+DX<+ zTqry)F@m*dmr=^zq|fN*ZKh}XA9?y1s2GkI!hKB;hUIvl0mOiq(cs-?S}vsZCI`i} zWYuRO4odc3K?yB-81m!8W z|1cv4qh}~?;}z5Bywrf@<47!1>;hv>vAZ-$J5TMu6OX0ikG;CK@FgPGe&wp(MN%05 z`DpW+h!SJygF{F(bbR75eO5^veN7OQ+o9zUFY_yZ+n1#R%^Ul}%fcoG9$g9ECLtdZ z#yDsGnkJfjIPsN{?|+a%dY9N$ouF0|y<48pG7F39sOUBGAi!5<*c<$cO_NFL3ko9y z#f}3M3DHGBZtN*J)_@qt`_MpPajS(&{_d-N-;h8)6$BTu=H@_gUEQY-uaZ247ZfB% zr~22eU1BG_N+E*@rmTa*VA|wYEMs#bF9S*zveVqqVJYTngUjWvq5N(rf%UfJKSjlD zfe>Hwf8=J6{i3>kpG1zbV~Rm$g_^shsZ@%|z2XTMjru&=-IWe9rvEW%-9)(cVXxa~ z`gfT&LGt$%7b~d9Hp1D-iR|lM!9xA8x|}voy@BPH8XHD1%3 zNyI0c1PjzdtkxEnj^Ju6Z7{~rl$chGg5;+GTTd(E64YbjejvW6E+qBVcp;`)v=0O|DqOYu3>l=O$EhA8 zpNrWhW_xVjYseLAB}yTyWtXQU^^!aDJ>W5&Zp9lPsf(o}qK zDX504?%_@~StWKfbt-=PE%lY(EQzkm{)fpdOXB*m*Lcve*io9kv>t|evoN!f034_O zFlR4p!&dV{>3RPW zomMS%)YZveSRjic$^Z|>1r#{9S(C1%au|nuMJ);Zpi!ru6pc;cM9C#v!z3(ENl@oE z6S4rk1ybPWLg!~Q77Xn{IeTfZydg?U6Me@y0$z^X z;gJ_c(2jw9LA-fnFp*uAE;y}^G-qjB^4L=fz0?(>e5%K~46yvK&~;d}9=+UPt)~r* zeSFKol)h&Ix!FigLz>3GmnXSRmsUxZaot(6PBF=v5Od98$83&VVZsfvW+2w}{` z&L+Qa9l@iExrV68*-f$Y8=SN*CrR>;&wJ@Q&iNRw(wp0Lw`@dU7|_Pm7s)L1-_}DP z<`ldbx4~psyM9K*n99(kfqE&=c_ZKaUo8MlD<;+N_v4l&*McWNkc+oY=HHGx!u?4~@c#<_32I zYV#!*dAEFRQYv|gh?&Oj0PlmcN5Ec!EYlG`wLRo~$Mc3Emd|NO;AxWaRH2{p#AI}| zI@I=A{cD2=p?1RnsSexDzD`g=0gL1>nWt)I{XK*1>(Hy2>tC;7+K69Gh|gZo#EY^r zbHbp;09^MG&=$ zh}dqmwMAXkTA`$^5qs38R$@d{YYS?|Ose+&z4i0`{Qdza=kb2dbDr}W4^y5tq2vgo zZYNEZ4QaL%)@n+zwsUK|{>}SMmpN%sw)|H)dmPUFZR34emscxtx@JVNma!+%8&F{G zgsUHGvn^rfDyu&f+6$_qUgpY46_lho>Q{U5Z!51ejVE@ILVA;tHt&Ja^T{4PSumi< zOLLEB|GmA$Z_?&T!QQo+f$5B%EWh{jLDnsaFpo=)4M(Q%&1`|8gv zy+mP|kWbi@n%{JRvlRoh@%)=B1EvQBLS;W5xl*$^fPHZvC&wX{0Go&m|Ec~r)MI5OL#wna zxXadhdqdh+O*HnbrDYu2*dU1Zc(v~PrLujUBzNcfZO^SvMP=iLTts;DvmYe(i%AS+ z)gqz!R?7FWv6!ET=6Vp9boQ$9Uwm$D^ho;@bb`N3`mluB#V*V{4S3&EeR7ptcFi0aU6c>7}5nZ#eeacDK;{O2FVW8ys{%EV{$d^ES;2D-2Ly~&Z8PxZH(v#4-9R3!#j zw+2aFabsmf*>dX}$cEXmjyo_vL~!-J=BKoxveB~xA>4(3`KIxQl7E2ZDbtF7IMo`i z>SLY{M7ubo2q}c{6b~gTiIu@v4e}2z^Yk{Nhj~Nqw(dY5}Oj6Bw} z&u>@$6jGd;U~U={r!A>lW>e@*!=40F%sE-iaX{$J$|UkV%aM#lVgRu=Mq!!JM!Hz> z>B(*10EgJeQ}V13l{d{BCFOyjW+>B(_v26V5#bKM;n8c9t^A!+{Kp`0!wOKCU1f1i z7cg1_R7EIXVXp26JI*;L0TGUjTF-9w_5n6pz2TQ z>I`BROK#NlW!iwAlAcY^H8sW&_BQ03uD>|lIj~(1>oj)GWD4$0dTDY|CihBgm=2Fz z&avwBd!>85fAHBk0qt@gY?l=1@$$(>^sGAbK&a<$B^9o8sfoRY#{5hR-$#!*lRoO+ zS5^+#!;5gAjmTzv=~7m85{O>=>>YS;VSSolSu;$!Fz}xz(+znOy>le}P|O4$LXcfNe2V1$|bd&rn%r#HVT;@gN%QZmLb05($0C+N^Z_;F+D( zr<^P>lwsaL&^?_^a?yM6lD#m}u-TdFBlc0}b#dyI3snsbwn-0b24sEa4s!ri3yMg& z?Y9Ki4UWj|t=dyF)zC7HTR@NICG|bsq>Ckb>xuO7&2dp<%jGYN4coX^6nZARgH7_v zYes%YztH2+Ew?xg-`6b_7-+%|H#ln(akAE@@_HuBa|XE->`gFgt~WmZ)}Wi!{BtA7M0^hsU60OYU7?gWEc@F$(PcV*G^iL_ zsxgxILELU;kGD-o53}2{9v<;M<<;f;^cv)=rCow*iy@DW_$8;TR_VXfFY;myxel?q z;q!ritUtSP7FbxY%dKqg_PUIwd$;COoaCh3w$wG2G+V0!U`oe(2KVWpj62%kSvX}( zISy8K#d8a@-LvwXzFyGidOPt$o*JY4ZR*vAw+#Z5P6VTg=Y4PMW`qTTi(wOYN$u{& zt$ELvV42O45@W3cIYYqFy|F|RNEKcDBjz7~2xsnPvXh|^W<6jc#Mml$`V_)hsE3h? zQ_d}o5!Xg@6*QMf`Np$BL?i=Xt^)vRgfcBG5Ph?GdIY`G&)DzZZkFySZNRJlyUe!9 z0ZH>gMxd^}d*QlPa$)v4*J69nk711!ilS>)Lgk&*hPHu)U`4S~i#s6oubg)|a-J{&1{*xALuYi~gT*9~Y&sKi=+A%5;XIJ5D zw~V(ESCr&Y#92kkd42WzL*i7JSO1l7b}#i5efc=WBh$;oo_kHxJp&wZPvYXZXmKl`6gGKHL!edN1FK8fHwPv89IHd9zZ;nWu9XZ z88|litTFB+T`k!%9*&~Mz6tJ&e3h5KNp+u&a_J4I?ulU^_uVyvi_grwUb6v2oC?;a z@zL8R^9parkJSSbn+(Sd)A%{0+%8vje|CP0hK^M%tf9xEce4fsb4OaOF}Y-Y*N?CZ z6fs9f?P_r?mE}NFJ6nK0)er4?Kb+Jpr>9%v)F!TUR-vUf1Lp{UaYs#++EF93^E5~u zPmYkdcaw2!WtU&%mTzp9_ivzJ(LWB$c(nP|bsNegY|KYP#n#1N+qrC1Oty~XOdr-Q zHzXaZ$(8Pfz~M4UP7@fF1qU>JNsLh zq5GriwVe4;U2(B7fN<<9C@3Yf*X>NKY{B&}i|hYGJ->0&i!E`+Al=^g;QSn_CXhW; zO!W-_5s$<#rah4`-l*Lo$t5Lq60bJ2+c;%v8rpr`n0}4Z9S9tFQY23%=kj!tYB*(G zLqEn1nw(c3#a^UrKku`RjmU)=K2a^%0eN4N`|VY!m@17P z_-;PT#W$CgERC9pXfqFWjS|n7_ylgp-}O~Y-Y*I`0N?r}KkghZ6f2Y@q2oJgew@F} z!me%Rl`bEKM)$2K@|D?d%8H1Q+bhX@!jX`^?#(SrMha?+7!KUo_yikr<>X9)4LP#2 zTyCl}uSVW2xuC1nT&7C1c++P{aD6H9vgNinYfA3=YoTn;;B8|;K^4FF28|HHWMJQ+ zqUpum{}6%@2zege(lO5tfuT%j`xXCq5H(@@FO9#rOTWp%Zkfc)M_0%6rDNj@cs?kw zqqOUX_)eXMNETUfm&@P1B5fr2#L2BVyXBpymy{Bk6LRIX22M_XnF}!^`+ooV0=hXV9ZT!W2}-=8a2}%Qbe8a~`Y%kEwt!lL@Nb+gX;3H zG2kvf+TQus5*#0;JO5qHe!jBE*=f$H7N}9s=`XCG%l|-vJmf^#8+Q=d`R;Gt`?wUr;N_&BZ#rrs@!odZStw~JBxlkt#ZT1Te z1=OtzbpjFM5z_T&CC4eeRh?0YFiNZ1F!qt_+2^e(bz8^^0s!dA-Q-Gq)*KQxl?5=5 z!%=SfTq%M8kPK>pU8eND916{?nO|Uf$UKe%LX@ctE_y z+=2f$DHO5eAJ6vXPP+I{mGg+GM=H+IHV$f}QP#ORjm}goL-W zDMX16ByN%BUe%dxTk-w&hN;?nk)Bm~2-C@?B)kn}Pvt!>Ozi&tZgU{{RRC9aY?i=s zezD@n!%d-?g>g%jXoi7<-vi9c72 zcOdy6pau_+3O&uoq)^k*t8-2VNrs+t+2oeU908D211NOzIgEBzgvGA#Evo-YVXReU z=d*t9TPj=NWWgI0yRbiY(o(PB0kK8n@olxW(_CN~$qb!YP~D8V+fe&@eIPZ)H>ITL zXKr38a2yiuLLXU9nSDDoVCQ#1)S63*+;Xq{nJOn@;HfNt?s5;Br)`pGo8&Tq*67xL zVGG3bKU`PN$wniWJWfq{gtz@R6CA9WI1Jdi@n-HEsK-=-GZrZ84XXyH#g+bKQ3W!w?l5ZOXxoF45xVk6d=1ulj<)1Y&<99qxpCMDR zzZZh_FC?$&R(AY7`9#}z6Dueb%VA;EgQp4+g_YFOFM33zeAYfwJPoxv02izU$dG?| z^x2*KVfyuKP>b>xvw<%`{an-&{Hfls>jN}jx&(|pUl`<-#L$HgCID)fds#B`Oe z$p*n>iyA<6=5Ur1*d{1V#=e z&{`j1w28^+QfFSLFFAxxce7LY0NWDd!R^cP9Cf zWL;!6_1spnRk)t_fB;HdlG|7ACQUQ8&It}c*s=rs>- zFZYAOnLl%^L++@Bw$6ca$i<9ahyQvY0{(X3HtVDDE^PWr2$@e_d{nBXX}<=3o#{i( ziB}2?GT-ZMs?(6oaJ?Gyc(Rv2=zqWNg1z$GLl=FUo>i-gcMVO6RpOM-o@RuF8YBFg zhf~_}Xf=f9O5o>b^!iH=FmzUNKcNamL*?~FQA0zR{LKHEB(_EBbKz3?B10T9ArGAD zsekET{lM(F#&}-ykrRd5{(H<0u(*&Dn%CW0@AWQQUfL1fcCF0wT8iGR&=lTIG}$UH zW@vK#_gDQMOW6x);U76mz7apm#a>=)&u!&U#|!2Y=o9c}!7kwo9!tEQc|;pxpUEY> z)`~C9rfmq=vFs7XY061!)bbyG>c^+vo-859TKzEa6BSw-hVyn3`3)0W9X*ZH_!`{% zZS8jMd3=137mrt_Ft_dx{X29ZVx4_`WTt*lT!s ztKk?R9G-C$JY4}@&G1PRpxWCxDpt!Sojn`fEic7aARdv->2k~7?*{qY)s#a5Ru3hDU#>g&=ZYVKSQ_v|G=6ThQ}Fge!-f@<-OprHR-{Oj%dcjp81*; zs~6wVq~0yB*l#s|6m^#Dyzirc3O8lHa5gP#Q-uFxI7aA3ctb{cYJT(VaHIHY;*z<< z=kXF}?O2XC`_!2EwY|;sEK$MU524fQ8T(TQenovLYgW7({ZV5Gtr=|vlu00!-Qdd)FS)~++K$woh#qc#1|ij;@|hBioMZ-9;$-d(q~vbx)>T9;>?eT$=spG>(x{Hee? ze;s#$QQMHul7|N*INoy*;7WM7DRx%Z=`vU}Zb)HSO)8_?E8edt5uC9Ojb{MdH<(J} zu)hqtAxS`CSG(kbL|J?)Z@EQy=hSQs)UaL*A8pUj37Ka36*a47U^doi%?9=Jy}>lSd6?+Y5J*Pj~4I5;|9GorQUh%j=5hcE;U&9dJ19DaT&b|NEpb=WtBr$Lp(TX>Uv z=DGTs%Gg4#GE~S9U8LgsC&MqqcVH|F>qvF;;K4!U5Cl*|hUTkh?FxCDZ3gP`nSOMf z=EpkxF+VQ&;Pv((e#RK=h13mi9gbW%{!<)E4`RCmJycjM?1@_ITbX1$j5>n)y3{^cxlY6 zx8>9`5O5Bxm`Xn6#0pnzpPhGZb(m&$1?C-V{=MZ!E%+eA7TDjS9QCQoaYlVMi#LL^ zQ}MOgi_^I<<5PK%WNPF_FW)}t@O#rIhwUCum!N6?nix4oY9c|4DdoT}CH%p_xcrhg zGQEJV|2FUDq~Dw{ca-q@E7SdJ8f1cmf6Cf<#*_@ja|xPx#!b^VerB;r6A|R3IX70R z%2CPe5Qm+ap=UnHsvI9%3cCsHQl4@FK!S@qK$5MVc@3Hs3d3e6fbMxP!Rk62fK099 zfeE&fib=py{q4oY$jHHND4sYC7U0Ec%qLQgqz`%pT8k#&dm8GRB!&_q6m1%7K_u3|4}z=z;R1AJEyZ}6jl5WEHuVZ9n< zC)y@?E;+@m83e@wlJRUrRGly*YDV2oA5JMD;;_A4A;^Xdjeww^hq#}70i^<_K35}W z2{3J`GR>K6r8A6Sr;|Ymq8@+^vYT>sx1jwZ+{yU@V~p2UPpMsIoWg%HzM9Vk1QP`s zMhoJz6FCPCUso3HA~*vr$}p&D*lqYu!U$!D6K8c87iYD)XfD@V&<(_F;s!g$F`Jika>$T$$>ALDTIvEoo8C4Fe#7)`-!6c=2ZgocC8YvU+t4&0QZ zs92Bzj{rC>c*sn0-(;g}B_S)AttW94(H}rICe4VLewxp$UfyO)`2tarU{SAGP#Z4_ zwx28+;1xaG{=g}2(J*i70PF0C$!;LIxQVhIowioYaW2_AcR5d|BqeKWQi%yr6(11T zO80*+^Xl@$#S*j8zVXt*Rm{!+unWvb!(BRFmj?4%a#%zz>j7NIdvTp4*b4?7UGZ>& zxrz+^kddUtt4cZ}2QZGy0vzJSN4w+V>=C)8sE)7B+O!W@{Q@0YJ3zk{r8eVi@QLS= z$^K?=N2tZW{*ThVTA)&tSL4g?BeE>kwF?t{5MOpW4>CiTRssm&Dw7XefCM!ct2dEy zQZ4^>UD=A9`O=#U_wH-|R}j?K)cVvR7GBCvJ(t;BP+~smpnKfr z|0U4vIMl)TLAqF}Yiv@mLZG8_of07ctiAzurl*9eFcl^OH+-KsI3u4F`wY~I7tdd@ znLpAOEW9=*4gPv@V3-gi^Dkd)SYf|?h`~1>yDJU)A;_NboWC#T)j(n=@PCn?h43b* zV(3yUt_*Xvx!-M2L)DUdPMaNHur9R~LaJbA?H$~VhMFh~_7+BWX0a14xMQ!rA54zo z`-N*dWKLz*cWl8YKSV7#ddvT~5Z;IJWNrxZyYl4x4qEk!7+u+scUi%ZYz^`II3E1d zpn0M9yv2KO;HpK$0R54}vJDcrEa_Jqcs1@j(knlJ5X7MoM-(x70Z?p|%CM#uF5?L& zoxc=2DZ-mro$vnfa;g!O-_N(XO~!=_5gtCXt@uFY6A&)JuzS$NwS2qc6nqci$oBPd z;cBF*Yvr+f=UwnSZ|Rf<`3gd+Okpk!am3+qos|gXru_WguwxXsGOe?3@}#JuzgGO@ zBm6aBPZz?bP2Jo)M*()OhraePC`6p{s;34dp*lEz;ObyEqS5zQ!b!+jR$execC+hf zZy@%3($%Dyj-|Ol>>|v^7eU8)j>`&zi|ySs&u97nh5jOh|f|*93ZD*RVu7hthNI z1B=~A$wP61V)XO413GouIQAMT!e}U~(h|jHIu#5!ZXTa@=c=pIe15n{>9v>oaqlwk zP@iUY9wJ}&|KK7G;>*Eb2|D%*!yKC550>$IAeh1-1Qd0CrL8uQr8c2!pYYUhK=vxN z+O&D2C)RX7Y0Q@K%w&VMF!j}EL0d^ID9N%D1;dd;C6#ABG?2D)zWa*Ymv}Q^@XN+4xbGqIpAyU%!8XEO z(BY>N>%kepn(1qM2;<3M2rHZQbxhqKxtpZ+27?ByTXH_uZ5OQ}<5$7XpY(vn)iL^6e zIzCqPJHfx6fGZ5}?O^kDXlH)eIL+&72h{J?b@Ju?2)RqqYXwVLgk837` zAt{L;N~iUfhysc6w&ijOhx`EZ^^-WD*fJJRROp8N8eU@ z?Z=-P3{>e6TY!? zp5h6<1k7bnSfWsb&0s?-Vwg`DjURvPCAJ3~FOEONgAFeMr4ofrNEK{dfI$<^xFCzS zCxrpOtDi$#`0PTN><7t&u|`cTEklTEz>UuZTQJ-NCsian*E>_C#IO$)JC$&fcI zd-)XRn&V)o%hUHZAD-FEdlnR>#cbiNy8o9e@0_tMx0QDHhvJ2s0Afu73fh5W0daDD zzjuoM31rrlnt;_XoPkK;d^SLm(ZplVZe4Jnjd9-$=#_<5L5-1XzCJH=R8^gD07F=8IS zp^=bg2zOW0oTAgS@UaQ)kW?9wZz?l3fX)mL1cPs*8~Rcc6Ki^$rLer{H<)Rn(u>{` z1H`~b@r-SeriQ6_MFd3fxp%a44>E zc4lWo$kNjRtV7_nbpG(dxUf9<{5g^c(eJQp$>Rd!hnU3xof_qI)|aj50)`Rbu#&Tg zcfm0?J!wtz=9NiBM7(nel(#r#Vv3&y1*BXymf%VJevskqMXRSPvXrifbuds z8nhnp&v@X=kE7Hj@lUG;@fPoMfRC=GdW5yG5_k(9v0w_^o?tGyWTyMaP+jXjZ5~hf z4{FQTmr>GA-1z5dd06k>SWMM&s`5VyttFL6d>#?<&1=(d*^MsEue4?fyvN&%`6V^Y2xp|6;Qr6QrKRhY;swft z=Kvm$-@Wld&|<=~_^s}x%Cs52WsV*rN?QAq|M&tKl7x&*OGi?(0Dp$}r@R(#sJ%&i zIr)s#0+9SFrN<3d@#)bhuUXs%9rDHiZVF#7p8(cX<`LRUazZ^L7Uwm%{v9}mPDBU* zKiq3cS)nMlHoJ2^eaBkK@pjrvw)sz zQ*wUW4&S0M`mb^N_lR^O7uxxnmKuzPtKGkOUfxZhl9U(+xVwCJzr6HarM}gG@;6Epa--b4pa>+oS_L9*n3QnRg!gJn4fWG(9|kGrgiN9n zvPNRw?ye59bV{`41ulC~-?X`E%J(+Oy*iBBv9;2B76%>K)9%TA*#9Z^k$5>Pj&-x>{4m>}tk8}@% z&dl_rbGUufeL-|7-}9iE-mFpXfNXYDnIDE{`x(#ZY?Xz}U)uwX zB<8WZ`AomGmtW>||L`d-F$G;ARoZ8hGh0US3Nx99vRW{Bb#aPwnVAV2Kz z$=I}Yk&?M;dNg=k@#50fcYd`nolPN&6fEvRAI96{viD0}?#UGRZC9}FVna+K1kWC4 zM6MJxN)p^O1O#0cXs?mIgqgfOM8?KyM~eefVf2>(Xr#1oi~bUOOUOFCZnlhEv%h0d z{fV}Rg^P==hp+era~_u)P;AS=P;;}>jL+$cYmZ#Pb1XT3dJ;UiEbT*DmSNUz6iRi| z!ofb@o2XHKkRRSp^!rH3o3!FZ52-vEs)J%eKN769HVNtDV=l+mX225#4ohk^d&}Po zRNiGYs0&ggg(eq}uaBbGu_eqzvMouDKQS+MRQM+_zF30#h!?`=3Y}JqX?gF7NDhJw z3)ggYUIS{EydtxLCA56gBGZ!{H*88yP7r)@A{BguTT7qZ?SLSIGg&_f1$$1bbX-IC z7YhkQU*p&(RDE5^$V?aW_$JDJNfL10;=U1)ca3!z`_Hi`Kl0S0JtV^83Y7r9L$ugD z?Kbqj;4M5jEXR|nQCiQ;%tp2cmmIA(p+pz+?D-myA!IvmQ$lQWas!W`BMaGG_hEw7J``N90TI=Oi`u^G2Im*1FUc!_~| zyw*tVkM#Ck${z!}nEqh?qrR%P@+Hl^4%TpJ-m^wP&Ln{gBLd5OC{eM167KlL-Pc`k zyPw$6kbzm*mY!VXhG#z$IuG(W@t^oSt2=BSf<(!2Fvl6cA!v(}(>5MMxKG~4GLx6i z08ODYFj`xwF(QWHwlT(~!Iw5tTKKBGL9~d`>-4wsU6VllaA)KGWDyS!o`}V&u3o65Ocxf9rli{r;X;SDgTeElrkoL_ zIXLGWbXW7qSa5;n0~TUz!7OD|37A%!9e!;J!E!V2-rsuhq3n}*Xw}{LuI}&Jfpo|0 z%D}72tr^f#jPr`X_#{CTT*Z%dwD z7krVB?%azGJcjM`yT0mdI%|Y#`bP7Qy!z!>oM``ZY7iJiWWI}a;W$jh6m=eqgVlx( zC7HsmV14pKx-}FzQ_z;zAT}I*XcPZSLJK$#v$drBmk}jn87;G}42PLNe`*_XQTav-Uk729aqKeihkFQ67=1;LJ?-Ta!PHwr3 zcder7A%MvK90ivcl`*(H7HoVR$8J7!o|LXnb*$Xm0lHk*Q*^)72nK@@@*0^lb# zFz(9O!;JYGV(Twl>l+KaEsXJh5v;0Rd;b*UUDp@zcGdK<_uPv(WR49`UUk~Ja2AwJ z&dAm9XXF)!%_;aX9Hat7n93}^IfKmj4R3Qky-Xv<4bGDV?!|J^^43Yh2m1I^lzuTY zM@^~M8pXx4O_dJZIr=x4igtW+p?kA`!*FCO~3h3>80977&N3zQYx z_p5d3JqN-JL9`h+_}AZBW8T7PmH{=^82$)y(XRdrOUTo%B1c*=y*iQMhZ8Ue*X8CJ zVd>1OFXM-BVewNUem-F(`E{-XpwlW%KZ(ain%{BzUvz<8o!Qthvx890$(clR2>qWs z20QDsZlGiAtw4>dd703+d2JJRwCA>nOy1Icq}5Q}#%tK{O##hIZ-Xarb_s_z+D#)v zp34o5p+m~lFL?b68f&eO51%#7M5x9DM#K{njk2&yzVp?He)u7u0bQ3en}2Oz(kUs^ z%ZW3Et;FgqEA_k1Jh-oRUEt_7%-~Pl*L0Wd^kuG9;bDUeO()lt{z>bYSiKR}%bydr z-u!EOqx+BFNTVovHifH4p4&g`G{26B4q1y%7}*R1;-654`}VMC#x_0Vt2*PeBVhNT zR|C`nOP%Yy?RB1A+W$^TS%JX{nGkdr&9R4cMZZ00b@mW6AI84*ewShNji$prSQ_uV z5$3N_p0!jTq_qRDcPV{H`S;LHbMd*=Ca0s4g6Qw_6;Pb#qtVdm@Cb!TSjG?+CvgSKBcU&zS>qeytA z+RU{Tm?E)e$?s6rj*Ae<;mU~E-MG9YtYK_UU$OAHM)YKUTIFNO6qFY~I=-n+!0M9j z&D5@bhEz_3D5`w>OKd%j_5IyRPv9_!qh(s{8l;wQdMB-Go=$}5`8d=4k6*(ojg~?9 zsTaYH>_nlk&K;97$k)oF!t1ol!P>3rowdtsYp>(`&db8|9+>-S!*CNOl*Wr`hNg-v zGHk9M0$2k12+k=e{jz84Q@zT0=}j8M95dxR2pCu-0&96t^ma@=R9g<0^44U-ld0LD ziFyo^;8y6$nmTCIC9joH6{!hIu&$#L=3WOf*b@;9gSjxzXgk2~Z(DwLT7s1V4eYn} z&i>u^AxYSeri04)X{D3jtmcTr?81!M`|V;-Hi<{Y1&+Nv!w16i!OZl0)^tsP;|A>Z z`bg7u$UtzVg31y>cn%oBY~7aJ0|)IOj%~HK_=_$sCm)oL0iI{>l?S&U^-j zlaV_dy=s2hLA0Iv(^)t!7vF-0-I$1q$vCD43>9rlz@BhmTy{*n$L{1du;?O zuEQjq&>sDPZyxJxj2)#OK4aIVE17pju3YAiE)2>tt?%fMD!XX;vTp2?strJ8u7l!e zFft(QUQVyWDnNl#$)-ve`pfUnY1a_SxRjKb8(WhAVtl49nOor=)EEd`3eO!#e39%rquP~TX%(WEftn$FrzWDfL@DMbh805ZbF%BtL1qrgBP#T z(kg^i=DWgspIeGQpZQGtWDS@iww*ULHUNyqws&s>%{P2GNoj7v_lXW8B?ZW=ZZ3VM zXLfb)Xm(~YE2%ScPA{vGm}Jx7SBVLVeX?@!>fP`+8y;G)uGIg(S!|BO^g=7=`=bXD zLPjTm+po+3t4}YWVto++fft})U(S;|X=mze#v`No#1zb+OWY0R=f~$^3`qJrETeQ% zNksss(W`Im;(fqALGeJ zWGiIMhRKG|@bV`T8PLJqsonqxpSzK#;_Z`m^?01nhp$uR@dS&7{XdL|R|Qv*3Bbh5 zK!%|T^FjNopph8Lt=L$@bh*5!4YLPA!a_b_f+yo&-nr^3q&nE7Z<2tK=D-cTf)JwZ z$a5NJ-`YrI7S?!65zd*kU)xuwN;XBuvOJB;m&{I#U7L}1z%6w3#O!aO_yk&ch{cRlt+Q<<6uq z-^ZM=oS*>&+ML-agv+uByuDNfk`ki2n&ySYX~GL z^D`T;sXo=Z5d>w}7<`)VD)h-7Q_uh$=H4kO(=m~`8C|A+(iGTj2hSvn5dp9_P)~Y0 zciZY`l0{ZvW|UzBwU=CxPvEOMG`Rno*ZnkU2__K%023z{&l5FtG7tr|j~?`u_TY9? z0)OqzGwi{l2udbWq>ynFFTlSwLVLcgGfTKHR~2lI5e4+bFu4(dzN7rDn@WyC2)tCU z%c=l+lTS2me;%-$K9S}Mx67^V9SMUXId1&S^4$2VgsP>{3WWmKUCWoGQIma#e(PW% zb>AVYiw0+jwgywGWScDWUr~WiQ@aX%Frkr{Hl@%q0>*gE%nD!=6!qp|QoQZF25~j{`vl-5Q2YqD z4CT?SbLp~IxF~48W8%EJ;x%ms6bgu2Pg0D>KNZz;tR`q?AeBw`Y8wrh5%WZ@g zClg=Vw8RGi-kw;^&eMf^6|RQ|PmQn*D7nm;T0AONj&<^-C^ zG%Ti;Hc0ZgA;S+T-lyF|<_@Pnl**Dd(K{)Jo~HAIXnB1hBJ3x*(m? ztxR3bYm=04#h?ul{PBUdONy1s+0~pjlGEsy%OBPo`?dm$B?9whq6w?;{|!bPzzE^l5 z&N-fX1omdxkc5tNwp0s03B&_K-*tE{@(xBX8ggj=?L39d;9lvp^^QC z9Xe=4cuc=HHoy^KeW2}{pd9v+C3@8HpSv;=xZ&Pk%WP<4}t?x zKe#>4L^Pvqao^L`P5FlWvV)mc@v-VL`*&+`@HhG=Gi@L}ihUB_k*2)R^D$UylodOp z-Q;=79eE|~Qv=;}lggyp7~-_~C8yhSEl{3tPUc;8`Or)Vb052bx3_|6gPMu^>Kqo` zcFbKxinp<6}p-t z>gqdPHHrgfuu|P#Pe~w*sk&5-j6=4PW9gh3U8fqn9dJC}x84RJ_=(7ea6jOF?jnzM z-0xakjrYD&C?>xTuN1a0yaX7Zo6!JFV*h+~q8z@|*ja6pLC?i%w?>^j4WXExFt@eM&94AK%TInQWJAi&91~wcs7tCSd2I^>dNJ<86^q%QR zhF8EoQRopVcEt<2`77RI00Ly!xDQ9M!#!ykU^^jHO$?d2XTU!b#pk1c;mrWPZFxEQ zCV3Jf@Lx-(60p0}BIamJ0%TAuhlOrxMM1kTRJ;+2= zcf**6CQnK#x&N2oRmood5tQe0-A1-5XUN>)qvp=G$@3>Tk9px{R5F837o`Z@e+`;y zYE;?b0|)&>P$5b=9SQ;J*{e(MVZcOrBpsCb(o6c27~NnGE7DD<#;G>mdoZgK1GsHh z6(0bVi#KYC^+Ty_KQo{)A4~+`==izx7n5;43r)YmVC|a>G}!y#+P&d7K2-h3- zm+Ompy=jMI5mCCXME4E8kySU56DWLV!Ct2s3I#i7mOX3$sq9xXJtxuy@Q-!1fY(Lz z35AEkb(5oJl(xcZI>@xSue=pwfd{W=Y zu_t@q_rV?C4PEea{RjU4k^jW)UP*Hii#f}tagiwfDxD5LP^Fml1eY|fXL@OBhw#f- z8hI~laYwAgd)C=gpz?uE&UK1JRrs!CmCM)620uGk%1jMN8C8(kzi;HpPx&{>v64IJ zH6!xvXvp^I>-(t6)N03{=;6LUx!RE)3hL-h?#Tp{QgVCL3De}Af0uoEY9Q|lSor=+ z-PyNOs({gj&wljs$=AR7I#&ecq*?XpBs1XYBV}Ex1{;OG9DeVOVe0PMxb^9cY;u zEFiHjgm#f@l99Hs^g@s6yT8F`zbO((nyKlAVV7VP*#ex6U4^s3SHYlTP=%;;y!zd` zRL~*=KU6$zXl&KtymC-Y9A3C2GGbCGz6n5?X)g_VU##ZN|iFuJIul+T?{<_zad} zen$0~s@tkw7lM@@iZ`YqD_hD`p}69p@D5`@}QAybzk{n}Pcw zramr!Xm|~vXKhiZ1^Oalrg}?==Oyyr0<&*zj5HhJJyC!tQ08pw!!>Sj5GV8vu~PIh z7`s}8%5O0_5L16mk5*`nKk?o_lJRQU0T?jMR z^I={0uyxRxocOKuR*Zs&q z)w4}vg;Lg3cUjV~VEMA>k)1w)01mN4qmP-~B5@y8UMD_NV5?Yfwdk{4oJxNu8T`6dAtL!qgHQ`F*9F!v2~t4{+M<{Hl4#zvpu+f<~Mg*m=M1 zpN$|7=Wje-ojv~4#y23kY_91eO}y)*`v+(=wl+2GiMH#x$V5bE4E)jAz?9I_^8*0VGwlrV+5Az27%6O)8Ni4XiV5I3 zK3@x#xSz6f4RngWC1``}rQ+ zP$(66F8?$t3*>NL_))JX=cQtauSR*o$eB6gj#XuT-|c9D#D7KGN7mrWoo!BI+d$LB z)!jmyNXQMcs-t`&hMM%oY9y#)#5?9{(Ui*Ao3C$Hfp zk~1u{6FZAH(2z5teGWdaER4mMOkq3~63-KpVl4bfsJeZ02K-I&yj*ShV{RNb>Jv+; zJefOVwCej!#=EajT-^1#rzTAc8U};$U-4fKw@Nh@cN8kdKH?mG47xh}M)gpPxaRpU zjYRjC6VasH(k1hqH^76X(n^D)z%3ABS;H^nh$wwHQ7Fhy@^h>6rb5-4-dMPLAOk0D z=3WgqV|wqmdztcm!$TalsSDfTA_;qX`|6{sI=%h0i6H}q?EZ%0QTW~aZIB{~$hrUs zXb9P;;=lE?kDhB|h&t5atby9c--=008f*BYSYS-4$obNJjwv~-`JqRxZ3SaE8(`t~ zb?X<^j5XfeLyf`S2sbDMI&F3>tcQy*_?9+y+L+sRCdb7G-5P!8`Mn`dR`b~b5@|&D z_c@aB8N6F+TN3NU^tp61UIF$mIUt34asp9WM$|aKO*)9fC_O*_NKG zi<)_`lh)+?eeB^gMA-eqNL1c!rLquil!L2UI#NARtAgv|T*q)9zQOm6P>Wr4NAXM7 z9{(!VW;qbY5`X-JVvem(B3A}V*X^+u_AjYVPCTVRm`r#Je$A?ha9lC_nWOu6|CiK@ zz0$OVda%=?Yv(ng55HZ(3Ma*g8iVrIy!&Aec8dd%v3V8cE@JV&#ogRx*E9UK?+7FD zl?NB6v^R22`iW!&P!0HSl@JsDUZ&T|N)u_jYIEG5irkAY!B~wqiHraKbT?T~G{W)k zsGkXt;_AWkB(M{Ix7+{Y>AeHl@S^tdPpfoTReKkosuCq4_UIrwwDt&Uh1fGi?3Nmp zpgwKYo^1s&W6#z|>{KhZ*rHabk)rse@B4m#_YZ#&H}{@r$q8>xn~;w46_0e8EKLhI9-_w`ju(}(5VUf{b`7jpiwyfd zZs?{#na1vXr0Kk`vNcfkDlt+(1%d;!{VHx1iYR;NTlx=FDb%We<>M-amD^jQ{iP$G z4=C{T^C`HCqmuz3U5-pQBqBbEGG^T0wKer%=$iJo#jN$nMK8*HF#*BjtY#`~x4auz zUM#{*2&+AqiF_DKDX2twnMI-`ad|GG`M}3^PC)N@c}n40h0BKf?I~uG>2@K^0C-EB zCGOJTOcgXTHrgGzm-hdEg#f?uvG$#n-R(VC=;ZHQMRE~(KkJ8$sa9;oABsh32dLP| zr`jw#z;W$06yE_Lb8P*vC8si?Yg3oS8%Z7RsV3VJFm#|!qM9A4%ibLhq`LyC2p0kU zjkealL`Ng!uH&w(%GhfA&cW`^=f%)pRT=>sl&rC@CP_t0vngK-*78jSd11Dg<6u_S z+A3g)150$gRszhvk%jQ~er+|(2YkC=m-`9JXr4VHoqkFD&+2D(lap}vvna&rnc%Fy z78#l4qxKmAAa-yj@3==^K`)5e^UiY5f#tAo&Gs~q#A2716?Z<0IcT!IcK|LFsHN)u zc#B4b0!mgM2FJ`-(y26n3Iiyq`}b4+$A69A6&X4tepMxghfM_^#x8~*10&XPfA$vJ zB8b*1+`~noiemfjx7=k>E`dU5qc42tQ||BpR)JME|5AWA;f)Ev{)Q2z%Ow>9rJ+Dc z#**UrK}CS-tHM9oZe@<6o9Y$y+6lr!S;yi`l$NBOP0C7U^4V0y$xU+L?ebh+J90L2 zu&JGwfDY-FWQpy4iN0}5Hz$Br3+%qTA)B#J8SZv9;OGI%v*}ytINO1@ddJ$IK9Js6 zCia%#tp4DbbPtXhRLji=l@gBZVEq7u^mn9}wIehLi_Z+D zSg)*)zY%yBJz~9%{ewvh06egD5WxN-v0ievd++B^%-_uy4Ku6n@7gBgKyA)vbB8Kp zdtVX|6&ef_HBxt@QlO1Cr5zA_qlNt(ZZ)&JMfGuOoHYmfa2|YnOQi7i-h^|@|IPx$!r0)7^3g~;*g%jy9ZdG)2GJt70D-&3rK&b>h3++}Moo&k*LQx&)G_#%I9lLJ59w?u#r# z;Rr*1%BhCs{ItYjLj>x?IoIG%^V7xAg?3u1D@ zirA6_*&|?vX=pV-G)zhmYwq=0m=-yy`Rw~t90}XWUO?13? z@uF51Wo_@GI!B0c`Qaq%oRXdWuOdVUPC;S=mvtbg+r{c`2LkH4B?vDc?WCp!GE zr)8|CdR45E#RLZ43ialKu_UH!eGQtkIgXBI;GvxOeVH>Rt5PKQ7@#$lUjVD*7xWe( z&#(EIos5T925+Vd7zI~+b(lBD^vlch;m)6!Tox&Wb-xbL6JzR^R+?&~{JlfJ(#Kt| ztRUJ~&h3oITAh;qnko4HQ3q!~W$cRc)4hMhNDCt+#jARE_{`%u&Pj zU6xufZ)BuwD}bQ5sPGy8_oKcB>xl?qe4RBOP#nT}uw(1DP}6W5Q&Vb%k~{zW%|uugVC0V;xydXzKqmUYoXaT71RK!2Ikrr2F;{d^0jhf!OkScFn*}YP}^^SfM;R z`cl^M`rO|kS3bK7ec?A`DO61?J2G(JIDIQhGTJn7M0ynHC-w#K6DTJ?|U%_2`b|usAsBes+v^J!`y!ukK-?{8oDbEvvo7fo2q z^jWwm8tSt_iFt=Yw9&-Z{t%yY6KAp`w+jDm8GYDfX{Y#Ao^$zr9KLOXAe~fy-&x_1 zO>M^AZVp=6cYGOiPLUB%fba;eOYAS7>RCAX$9q5y>U)#U&viS z76$Kt#)mNdeW8x2ejozz)T$<>Dmb?o2LHdk>6#-_(_OA2`^8Uw8j29Y=dV7CLqF`M z6Y2?%SIL_rKrF9PAg00!bY0m1tZUhg4<`f2qyT)?DdmRCe}4bB9E5<=B((AZJ>F@2+Rhn5m79e>0HCE~EAjfTIDNFJ+pZlkU!F ztEgaJ;vwX-zh{78IB(g+O$*Xm6i|yKX3{io1`u1brPE6=S;R z^i0u$PI}(86W zn&UzHq!TyF`o-T8tA&#f*fMbV7FgW~_PIHjBlxl0$BJq_{5aP%RHWDe24d~sRXEap zxeBCEa_p8T?@;Hi3%Y29_+Cv$(~g5v*^g8r_2{BdfbDmNNfzM%4hp>1uxIu4h>kzk z7#Ze|;rS%p89j(&I^W^U`s#{gxBJ&k=$leUnn-F^$LhAL<rKu&Wnkh?2EdJ|V z$)uO?&fur)CHD^jxyL6@8cwN^Hpa}&yg#9a!bSf1rMC7VF98FZCuU5#ABSJM0-3Y< z2^l!RYDsrXY@C8!@1362{BIe{AQ;Y8q%itKm!P5^H|RTrz?t@5(IO8>}CmD z&Nt2y!_8U8s1oH584=OXw!=a1P)^IL76L3eZ8?7Oc&7f>^6DksN%{Fek#~}e)#Gqj zt6yHo=rqP40905%BT1=taSa0{rJJx*$YG+=nl6;n5V>#18`I3g4G}{RpX%&&@DI}f zIhtzfEa+!=#spUw!+hTUqx<(;<#ioLV*)?L{K;%gk;GG?)F-=o6y!7(n{rWPRUg!} zrrl*1dNj^E>vTK&SW9$*Hr9v_aA@Qm+TWc!Qa$szD<3GE(;BY1&p{YSQ%qT`1Y9o& z5vz@F11qn;%n$}da+&ap<_J7KwPsBwML|=D3DCrZVfZ?>3Ns8D>W-ly~K>4Yv2sAMym6b zZNdzB?{;va6am$&Q3?20zxXT{I|VcO&r4|ay*|=SDmph+HbI+>2UVdvm7hE!hVS`o zw5?Fo{>Yh*EoE72!^ak~X4h%C*_+(V1_p!fb~Cwm&2#xRP!LnzVaI`gVp6W2iy8jP zCx{vLrC1^!?RMZ)bdN%K=Ri*$M|=?b{tDlz|2U=IH3Wtc`{Fy&p1YmKr}?A9-8BqP zj`gwfbXm-lTrp@&CPzG}F7H&ntu}!Ooz(bpBmLZ=RoAN zK`M|C=20FkV-#Zb7+#4Jdw9{&TMs zoS&{W-$m z!1Q=iOUKd4OKG~A4E+Ty#XKY=r@z`uX2(VwN$Xa2+<6mrb?#1-eu z%fDefWI;o(x@kxqLDG3j1!QhY#2vg&s{I!i{d192vu8?De97i+yyxiZZJJw)(Zkn$ zPxC6wxPqCjCAe00BDUxiX|vt_8ADAuC{-3bvRLl9YO3fJ2ne)*VXOb4Chk65!BhU^ zv6W{O$xrF0i8%tAlPc=JRr&IVquBas{*aIySkjSMQn$59_nhIjo%kZjG#g_+TSp zx%}39lMIIt=lts79UVkrYQCRlXu=DCIVhxB^|9P@>j3@27D|gWOLAxu6l;y|Nn?0~ zWW7cOLK`NS^fl}09Zpuv z)rQtqQd~rMW2E6p?nxB>x~W2;=k;)RVcy?q8g*crrzuf$ql!q$D%^EG}Z-G4XFErwxL_A z>YXC@7bnkVFqKj*$zm{l{p6I(FVi?TkkwCE&60r1T#|aVc0^zvWuV_}VH#tXoP02p z6n@Vup{HRU1x^HhiT6~L{qAhXpbtGcA4__hkhmS`<@p`fI z`F|vxGKHE463H!|H~q)<$5skwF6{j)5f*5qwGixmX>`NVFGWKT2Pi1}Tjbl-d3#?~ zaxY5%EMHn{~VHZ1jSP%AKXBUl~B_oA#N3W+|&-1=t5=ogpQ}X z>P5X5n>eN)|D7gq4ShGo8D4XaP}nQL zi^ePSiAz-{GA=h8vyO({Ay32y8yz;I=Za(RP*qBW`-ghz@=~)BWxH6X{e(4MwEs zVl1FPMh7d!W^g_j@^5`3;NeBz__X0k9#=S$Ub46y<&&s6@oLq95G5|q(|qpS?$BLF ziDw6bK$IC|B+C9`Nrs>qOCn2{mU>FO;-l&(s0TRlK1{sAEpf6Q&y&3g8#n2F4G)D+ z(X|EBYb`UHFWO|gtOBfpp(Ww z_~ox34^d^*egxyr6zkjWQBt;q{X??Q$Q%_ZtqLXoqy_1M!Z>G}e8UTVa)9Zng{G0H zn72YU3b4E2_BlomaX>b417@Tio4FZE)tZN_;xHouKZD8at-&e092vA7cq=E2+-`UD zAsq5W?VOIk1Z#g?Ngye=R|aNl61|Mw69<>1^YPPFT6`@Ujl#{@HgO46VKRZPI$=wu za7M@5IvvMk!EWp@pMVjjMWt5cxEwa_Ra_qqUXg|Vl6;ZU3c^^8VsUg!|8t2{+gzX)`T=h>al5e<2sP2SXhLl4$`Y}K%1oYWJR-mAusz;h2{TV1w!bwwSv{y=jK& zf@`$QBjMEjnb9`EirHto#sZ_=vJB5V@cLmEs->S@6x=5ck`Mve*s)As9VG z>F&^V*1&)@Cvy$`ChY-q*pQYCUF~rDeNRr0^^uqm@wXh1C3`X(l7QeP(S7 z)AVImLx!ZE*~1uOu1z}MF)4AcxjH@nNNakQ(^fb+)k31L#!#c)w(zTU`}ZDsRW7P% zcjzbOIGy|u8fMm^kQ)4?Wsln9+VQZgsYnmBTNs}v+@~Q3dvdi)zS1|x)qT(K?v!!# zDp7?;xXw2Her<{4!EG2K^+=rn`&a(+t4GPryELgAyfkc%>oVwq)w)=r&rCxd9X^$N-VAQV@SoV zS>=@2*e%UKl9EkHYo(FS(@|>6s@F&Dx4HD~qgkbDm%>z1m|ht__PQg*a)pQl*vQ?i z78!IFTL@nntU{e|^A;z5`=xPK2oqhNa%{E;CDuN~a!?EzmqDBXED13trat?QTK8s~ z|EXFvx8^cU7YuEGoO486q-oqIQ`n1LJVM7Njn$8p6aq>ouRry*Gb~UEZRv` z^Ql&9JS`DIE7?&Ph;lJ@pV6yHn=WS`cfaR3KlJOtpl03o%$@YCPg0=yn&Zu%jRJ5!H>^OVxG+1jfK->L@EjZ+i7-KV zwlieZS|8G+H7Em*s#;|1KWG^XCM2y^HKssTloWU*wM(JhTeSmSc8!>AUpfQkKsU)9 zAS0!yxoF?OFXWSaNvEwIuMP%hm*x>(gBxYc6R#;W4=#iRZhQDfMX!LFkrqt0^4 zEOKc^h}5Zmm^5PIYW8h=gereOke}m$Fw9m8&HNc}95*WUgzYojl-I$#lFD?TPH59^ zp$b!v3%tTy&_=7dcDhg5a6J|0yU)K=EjMa)&LrWI!j*2-k4(d4G++79ACO`_pcsw$ zv8V@~7mBNVK`JTRZK6SZ*Lc`EYc(paU~>13wj!`WWT{!TETwty{f(i)R|}K3@Hq|; zWJnb=xT4P3;8XWN*}KIa+dR{(o3t-sa+*vKC)Oixok6x3x33!Y1Gv{((F34r@X$bFY5uhN_--^aC~lDQb#~$ z`A!w?2Ly*^AE9c2Hj(Ww%%K-JG zG2-Xxu$(wkscwa_ApJz7XVG9$-nbC+a&VoR1t!Pk+{@eM0HKeRl7l3)Ze|eLTne0S z)eYRs3rUt2>JNR`U8^B83Y0q1otbSHZ6~|5eX#Bx-&Df*pe)yY3%`b4G`n-Nm<6Dj zM*DEjB)?E4F2}F3cB3J^2`#Xsdauod^dhV|CCKUmZC9wYq1C7G!&Ecx8y9?M_DmdL zOrauSu3(aufptMrcOy?U4wgB?zms@Z%3ad)Lj6;{NITc3dKy9w|2}`MpTDrISV-Q8 z7OIl!zq5j@PG$|}A=#je^5(3_&MElld-wNS@}_MQ>6#;d-$-*c&4F^?E<-PjGv1EE zi1g@aSP@F+RrUVv?fdR0OlJ?*+1LO46MP13l=Au1yA976ZZYy+S{s{*!C!KLAK+L?lG844y#wFzGu%cKB`c%A z`@*Pa<5P!cenecHM=)ihylRiwpl1QUFX4`*M%&Jy<2>u94mXw|R1ukPm(3NioTlvJ zA=f$K$*E?9(yya9idZQd6f3W3c!KHv;a$!>N;A7q!LKOWaxCy~^w{xGbqif4AsDC1 z+=pw6=F-r?F>1eMnYE^L?h7hu8;ZQ4bz_>O$gq2Ra2pTdP81!drx5(_s3hw8W6#g? zz0frm=1U(}*oYmQm*a)%DKp=iXT{z1#@-ZHwOGHgT4}>+wez=c7Ha>R#=66^%&-<7 zGdVkdtMV@8al%yg^)66Ja#5VIN&fQiw#~)QazEIiVU^+HJSr)ZsXknNgLZ#b9kMMf zDUO3kZEI?<9Q!i5hVQ8XYFWn}nj22S@$Bm>{2{6lchO^Z?Pn@S!-lO5T&Rl`bNk zf(Dx#hvsLH>E!lUI|%dgo=SfS*G48V4=&thG+3WZ!ojoJ>kXkq~vUU=k zF`*uCAY}Pldh6L#-%PRFiFsMxZ?>}-|5N*6D?b3$^cCobv2V!i$)%)aVVB^cLeiXh%ae+(s;o@=$Nc)rmrJ9&m~Uxr)dKnk%W|RG2YCDWa{tnCK>Os@&uPTyWygaH zJqm8+lX{=r(!c2?#gsYY6GsPD3?}|u+S&*{aJNFvkwX4FY)C0 z)cNEHpSvBt)VBd1iOQqvnJFHqHe9k)NA0M%!0bOx=aQnJOUYjV;}(;)gE#-wB`==+ z$!=u`vH5o_v&M3_baUcgY`u1d4!nf{z)39$H_fmW?l1Ua>{8~u_ATQ zxABt%b^lmyLiHt+0n#py6AZiZ+{+|l|91R9MNSZXhVebhb8Y+XigRSMUBhRh<=Rs@ zL}9tOi7vr1Yrm1>QQJ|W&Z$-}pv>eek6vq7P&RZkUi*9H(xeXJopm^ig%D({#Bc8Y zXh&Pzqt6USQ(8$L2kJlSMuz+)hA`gUIDfsj<;MXKnqB}${8oO1c6fwDpzAb+H6>se z+}da(Ssz3#<3T{_6gp$ZzT#l|bQPtk-D|=Ny`dh0=@uggze-&U%tDD3IUj@%nKbT~ z9!GY&#ZoRUSB13<9af|w?XIsyl#o-cVxzYttVXN$d8RM<&;or@v<1czJj$xpEIst9 zxZ70`sUfclF*|#^3%$(S@pjD8qW6zg*p@8vz2?hRa82AksktPg`^1USvkL{GQv2Lg zCG*AIOzvlKqtg`d#Pl(^&fjJBDJmystobZt_a)2FD%4W;oq2F6zC=r*ojFx?@{%Y0 zpw=#h)Rs~t?*$!%mE~xDT`jKL}tw-(kn1dy( z!27yT=Cffwy((z9oG7lc&_K{SO)Nu^?ww&4AYM|jlXXq#V4AUaihM( zOO#6GfFEL1qn&N>OBtQ=Jwy8;TLUwM=%tJu5x6_|A-v^-nUGF0q5q6@jo53#FEV^Q zN=83)SO!j&;V`D2Ap9%kNOYp=_~nKx{CHhP&b&z>m%vi%XsKkl!f@6SBWrwrS>rw85sesQ>+c$uq-XYkktlD|BU#Ur%OWY{w7eun;P z4JB0D?~X_#I1Gn4CKo37md7)g*2>&N8qLDY5yW5*uEv2-x{RAOY3P>>y|)_W@kfoU zsbUtH{Io)8t%K}guDps?x0=>lf=?x4s=O;$;!{M72}`n726OD+K((#ZiEs;xa;Hv*V%^TUZQ z_f32ZX?e0$vHH6;?k0WWDMhi3cBDpl1N(b;tcb<#3GPPbi#Gd_o0iTzPWl+v74 zB3rD*;L`7D$QtBOvc#y%koEEHYiqnW9o6I^-A^U%R`ATYU1eodd?-kQ3 z^k!%>S?iiL36sAzPjXl*^bl`&loN$J+M1>aWAG*9|7dBV?^&)?vEH6- z7PTX>dOl|IIfPZ1PhRNI3aM8@#_L!;omJ+7KTMj@*asPOJhxzL((J?5*myk4f^V&d&Q5K++AO0Bh+V z=`mYRV;wiK9kQ}1w_Z&s?Rs@=kD~0G;H&`e)w0|fbz6o`YoFJpR46HYx+gTL)c{0Y z1SkA?P4D#1<30Bi7OpBLSH({#D4fpazjA5p?Yi4_$i=3UiB79#1qsO1lb+4x&^ua^ zj|%UV1?kl_&VvD_Ou8W7tKP?}6?0@&&GNV(XrVe0(%$y_VCn&uOs51uJ4)z0iQ;O) zGMVrO3}J2PW{Cr7AIF;Y&QKImfl2P;zn?wkpoUu;d7&W3zmAYlSQ9ph(Q^e_X3o)H(!dJ97tw~Ov z#E`v%l=1lfJD(PXIZaTb*TZBidQE)AcSXDcybrR4=-V%3Fqrf^T+1~PCs4}QnTXL_ z0jtlYADf}Tb0R23HHED~BO5oXN`&uY+qiYTYDuF8xjtQ#f;VF*8&@phHUgHq?q@VO zTxS+a3zRsnSPQYIlbNs=Xw6|cUjD+dBtM8Q2;Xy1c`#%=o7E^e0Zd7hn_cYP zKS)XYG{hVuS^9ER?9Zk0HGwjfl;Gq#HRa=o0x%94N*8Ey$3C~1Hq$q=Q{`b@|C5q9 zxW%ojA0Sr@^YZtBhEy!H9{>Ae!169auKkaJlRPh;9!KKj+TG#!@LaI6Mq>_><8!RL zc)eip3`2k7D~j{Soa>bWqgj)c_6T!M2C#~0BhNd*eg0*xKW6&rdI3hlBs%Y92JQ~n zh_)(U`{2oiu?`d8z!eYz=Oblhaw{TNaBzWTtiYknqI+_)*&2wySKK`$Xn`Ob(Zh+P zaZc9%lp5_gys2$H3ZQqiWxg@0_PnM}j|#L$7oAi*fL03r4LBZ+7+#4T!>p37?u(? z%B7YNPfaVAW$Ij+iu&%FP+<|)s7r5_V*5v{{q@q2<0aZ_=BLD#WwyE`M)g_TNaB_- zzRQqLn1l9I_iZ6BZWOe0(hy-X1}Np^@GB4(E-ryXK)%>x5nN$YBT&6Adj)?9%QR)c zNFu0qu5M>=N<=UyYCstK>Q`F+D8=W8>5O$GWBzwaxE!8S0K6BPmvOOD%~iOg-v50I zw~LEyHf*n;7BXx9Z%%J3@%5WDvt2D8G&*?S0>NS+3{p1s15^%(Qib}%N2h!;Rd61? zWOWW&O+a~Qh3?w>o%B?2`^sxN9tBW&^Kn2GcCwETrf;dzcS2(DT$c8?MMM?%PTD#s zrt0hPaitI~Q&nHOTEei9%r3ZU%v}x#D~uyLDXMfj$F0m?<`kf`5P}@G(w8+ZT!H&( z97?Ogg%WWOo%LJ@0dM(*G|d6^y?vAd#4$Z*uSD@rgsaN4YzIj)!l5 zDdhYl8B}UAW56R@N|sVWC09~KT35nbnM0V~i;SzkVa{>zm6wApy@Va;owUF&W`yGM zfs^o;uE<;>xkF^wj+FN~>Jb43KsqMILytgBG)tLlFnr7swA?@R^QUpD8`?2d=zIkm z-The}((jYSUW;C{1PK^_cu?7Ne8}SsE$w@-M+_4Fes0+&)+(jJNuO5eMYWjt!+)EI z?uwl!FGoz9a?JxcfI5hn@qiQVTjV%@v5!5v_(Ir~-3}|*4ExloVyg_;;D6T65v$jwRhaGOY8hPU(qkO$9h!H#S}CWs zbVzpd#r`tCA5~n{H5u2Y7C2Uir|Wyx$ShCzLF-B2|2Ybo-DSZSusl|A+hz179G`IHbmBf=J4v4LX+LM zjFh2-#l*xlm-yycyHjF?I31~4(>>@#LWTHp#l|f-zj1aQ@Mo!@yEJDdZQeh8Ia-sfraLJ08B^6|4o(mL$lIh@vb$F5-x}`Y`6>E~36Xw1o0rEF z%^`oKtA73{p&m<}0gJ=lZ2K$pfi5Ag+mOF_pJzT1#5Fg{Y;$Q*um9Ljl zn@EU`H2zB#ZpO%6T&U|ipI8kO1aTJjAmW4hd#`mB@q^He!n7Mj*xoI-WozV>q?@_v z_ZJHVV(6td)zM6lC0;#~Af;N`n00UQAsuhiSWkUv%$#0tRSpJ4){oSE45xx~62}U@ z`nOaWodDYU2K{QaO=lSLl++3aSYGwx1Nnv0kKK1+?{RHTf?sz>HCQojU-|fQr(_t! z69k(%5^qN9lnWp*iCEj^?KM&3G5QZ`%kz~64<7&~LOCWw{!Hw#U zo(3`mM=N>oM#nE4MVbHFqh0aut7+%blZP65PNMt;<$c-_6XEYipCwP!kDwjRoo0L% z{cPKzCubK^qwVNtKAZ01$PY$C`We2VT8HNhTSp2IgBb1ezc?J`fqVt^nU%?e@iYV{1KL-@o0B%2TojeMqA5hx#;IF@~H(o*jx}R$4 z^o%-1^oKX;=Vl+L?DijreV9LRFPm1^m}ar0G?z|$dJp)Ajy-ko)tR>2h>+Nlt({K^ znti7I3o^m}r0y-3m;at4>ztMMsu>_*7aqOHrbN38CPoR1YBHrjE<2iAuERrIh5LlP zB&I{N->xL^&oos`JgSuL2@aJffi;5>J%=o%M z-^r>f+4W4zVrVYOKL|6x9GJuloc)`MJm1qY66EVSS)Q=lU*Dv?z?ziN-bEz(od9_6 zq%aE}=tzg=F(x1NO_|_YTpm;h8`g z$dqlO(uBF|z#cZh-0PF=fzl6y_a=0*i#Oxw_9#kg^r(0kv&Gk)B@iOTPpA3OfI^ZK zTcO7UQQ_9sF*$gmHSpu71uK8eaJ7_)LTZtd%cw8xu<52fX_J?9#6 zcdqc1@MW8|wq$?7$~<8|7X)E-+LbMFY4Q#%cAy&*5TVx0j@9V5aqadP%A)1$_<{~X zD+NS)j#3Hx$I1FAp^s}X#0S|{=tiGlHra#R!udHlr%4#RK-5{Lur%ohBxT59v73t z_gMei9jzAnS7|{($V`@g&2tl0drSXrVyGT^vlvOHGuu()h4B}R*L!Q7VE3Hukt@=d zi07$)lIc?EYh_j&Sy!^(g5MCPnbCOtAJtUGW~O9ijq(Pjp1HE=5;z_ZCvaU~Xt>h1 z6|W<=QicoDkjWGQA>BEi$#42>cBHZ| zBVvC&EJgDoPUG$WII@WMX=}mpnAg)ZS7IAKPk9Gsd*uyv4?TuPyx{gxyy@r;Fm$H} zEfsZ+h!V)aF%}H6m2+wIc{fdtV9yD?@(h&hchcwm<)%Yi`tTQ2Y?lkZTXSog{(=+BTh?!8?+GdD*oC3rl7Fp3M5Z< zJc6(!zDPdq>2MC!@%t8D1DOzA4nZ?JvmM@6+6ijn*nMf17!xhx9)amXkFLvQth#g_Rpl<5zI|u=)Y&z!Ww@?y?vA@a56oR5xJ0O+aJ=3X2`dcw zm*`&@YKbJna+lMyN+jR*<4tMmoy9d&e$-70__oHZIyd{_mR>r~1RVc%AX(&FM~$O zr&(B{bA35OtSDnU1pIe8?8=U_1+Tm4?*n1)VaOb{WlM7*gPdMnJcpMvn&kLgdXtL0 zDMt1FG{1R*ht^xC#CWa`h{C5HaXQyNV@yRf-dn02Xc8INHyORI3U7}SajE-UJKKEu z%>D9_yx1RoP~j7mQr#I$YVR!%)7T6BiAlN{Vst^5w{yWe`O|Q$E-U&t;C0V_2$Ge} zxm@#l99kVESmiT}j!#sSRgI;T;kH34Yc@_o!*Ey$JpdUBCmlabu?|<((12Js&Lqe| zESMV~6h@Vq{FAwA_ZDY1rcNLZisO7g^2`t#GhNk_ZE+NBnoe$9>E{pV@ZY{Kjo(EbfQ()B8iARfEr zt4RezeHQ3+&71nS6>M*OMoNk+j?Ecpcr z8^%^GNfixO5Cy!jTI`9ry?fyG{x|X8yz6(-zOuD4u&DXEDIn3`c1wyfFYO^w%XQ4d zni~DTdx%>y3+Qp0>~|nue2YR;<8^1w(vOX(f*w!H=F9lQi_B-`s7jL`X0`^w-(qeR zI4nm*fmO_VtdZpu+=B=(LFAdlXhXcBhC&_H9>HQx1I4W?Z{ibU_nj=CB$N8{Wp8U` z5(ml_Xac&^bcE^j%!(en=gjTAmAmXB3=3_^X)NUMu8)! zw?MFJJT~A;`HeW=7O;7nV@r-kNRq^&x?=+6Zdr<1X%7_4M_xO=T2-bXFcQMea^=&@ zmrD|r83cvHiD@Z?Gns~)ZC}SLvF|0QjggD)b&+*C|E#?DmZ1;o?0BuD7!v&WDRl9* zg#!2#F98!dQ3nVyao}Iwi=Obw_9z>t39zO}GI^L{58hiDj9Z{nmUX%y8MxolF1<4D z*f3^dsK!8}F$vk&m$qg*+gK5DcFW(KHP&UuT;M{=l*s1sy`j!l3UwNK$~nx`&1SMF zXTEC+f9g4CT`m_AiKJeZ38EYb_q6^OV0WTi{-*ZZRRcv(#{3P3`APu}c>MQ`aSm$h z($(A6$?gj5AB_9YV#+ zf1@!uwa>`UX#!5#C4Y@g%A>@a#uJq4^=>K(&HVUO1h+Kx5&kZ>+q_3f4U3pI8!&+- zLft3fuucZZ?38sQgx(vLb`Hbg23KUTa0o#@Nx4KXf#TAHY131KY$!=9QD;Ns8)t7z z75=X?s9w1YGV>T-Bvhx$>gv}_I9M4vA4qs{Xv`J5^W25Lh$x9xd zbm4ZZ7m(QV8+Ucn`OVAw_6+$2N;Rz32ci3boxso+BYu$34^{K8Z+*=W!~$RXYplGA z0hQcUgn-fcZ@lhpe2*`2Q0-Iu_w(G)LTT0wx^LfWC3I$$_WVw=>I^fw=-`so|HA^L z&jaP(NyeSmYGs8$LM!R@QROx7r#Ct~tzjgSF7B21ecdu-u|>=;1|hGgnxTCil`EYc z9XlC(w8dW|(^U)dV1qeRR3g5!%LP2D(YHGSZ6Gf?44FCoUAFMNX}ubHE)!b~Cj|Nc z=?%Tu@{)%R#YX(R`q&x8v~d>m8UAmL05#FNU+L>A3C=b_rt}IfPcS~~j-m_raNNlW z`NifP9DBD>#y0h*(crc{%N0n4@wA6yo*eSVh^Rqw`mh?X&YSzb3QkR~D4@J{Ibz!v z-UM(lEt;#G>}!hWD=0=2Yp}uZD8)G6|F?2!3x{U(r>ul5v-?n7=y6*^Mq+9GRam2b zgt-anjs24=F)!&ip=I9g7=NVFSk(#uNY&&HrsJSEOyPu*&&qnan}xrrN9z}DZx)N3 zPfj}eNNX7UV)4s7Hi`~)ehft*t~IlZY5juKdO>ZqAIjsk+LZpJQ|O$AgQ8aSCM zfv!f>?YvAAkx1%Gvs^Ajp!ppW>yvpOy+^WN;yrh?(<0F^=Nj#Sjj#F~4Tb}6np)w~ z3T{h$-bxP;x^kP74$SgTZF#>~R`u)e^2yY|Dd9aD25SPQvsQmynZjD=l=}V_vx@Gi z$dsF9ScHWI5K?$fRVLD$HyvlMbg_28VB&}z;D~5l?_ZguD}iPtcWAv`JUWTHWx|G} zhhs0)UL^}~%arS~1HtBp95?&+F#ieib3Yh-B%_eS+BIh=$V)!LSPD)vJQ#pI#h??5 z@Z$Kgxe~<@@Mw!}XTM&E6uwl0U_^^jfDcbLChyg)@a5D?`e%~bz;uU_+2MTiO6Evo8X9*pavc;_UuDPUs1cSb0 z;Q1M7#aGUg_#_LZcr%cXFZIxsdW38M4Xx%_&IpzYAVxMS_Sby27W2DPIsGKeX*bExlt6P#Ke%x=|4=YNs>Q0+LTno0-A($WQvsPCu zSRq*CO4{nP%N%<3(s)b8pN+vDp??oEla%K6@)o8;tzP)19J*0$^n6_qg6v}@g)Na< z^FVFvXM$8Ry{k3$QY?y^ySZYyE|~vyBp>{!@cgjOlSB^oWqE;|KkoTv-M{%-N799U zIZPy`yA4ayqV{933fkX(Kk9V6bA4K=VM~g)F*k%FxP0)Ui(5c_QQybF+svc4P(wHS zljK^1+1OBjZ5Lls!HV*W(jH)ldfh}$YD-7Fz5BQE>{%4_u+&C^H^a6wY8kx;7;n!L zVn{C?DhFDDGSBj8#$6|iV6_(D!D;pA^7LDaRzW+zIV|MH{l&M<3)H#bC(O)6|LqKW zvsX!vY1x{)dSCb)d(KgMV8N9Y7NOG(W%&4H>!#!uP-$GHV7Sv{VE~AsUl!sjn9ud2 z2wU^Ro@046_%Lx=F9f>Y(o!;2>Nmq{o}f{viW=mJ#HGC#O*a8RNU@B#N~clvoB(s` zWMysqXvwHTa@|NtV`7VsxoYxozM{&(;xAcb0h_rqT+#UEk8hQuIvL!c@{Tu#ei|$H zpXY#Yi9eUQJ~`m})+H%%wpL-$i=J^b^kMHTYU&W)x01j)S@q_xJ=PYpPXA4V;Fu zC@hK@Q33|xe=ch|EL%TGHWHc1ar;?$m}{4PH{c67uI=SM=I+-K%-|JsL?EvG$cpD4 z%SDp?ugwGb!r;f78_t^jdQ0QHZ|PID_vW@3v0R|fRHCM3N2xLS+wT)g4L=BKuz0_~ zLpE3@ZeH4*dy&}09_b*xI}Ih&vd*y%kz+fuWo8^F`}frA{rUcWzy9o^9v+Y9W8Uw#+x6bOq-xJAkVF%}GkK0= zJCg2tVNL&C*{?b<*`;4lUjLQ|tXt<2x`6OW|Ly%2EX3`VDT?VLdP3~{<&HOlsLeP{^qKZ`{OiAm>vuK{zo~+n!k%N!ufIeC|otVRx5NSdl<9Ry786< zBUXI~Kx=uZ?%v1Ck$(#l=mUsAf~YQ8{3Zwo@qIbC&QSc+doIn z)M7iMTWM1HvurhwS-SVxLRPH+w!{S6{#E(7gXi~76qKi)brsZ9f76l70Tysk)&wrk z&``NMlG|c_I#Cr>x5VeT3}H!zT)auZ1`*>4Csz3B;c5P^cx?q?AhEY@8vL?;$z>E7 zP@Tlp6b01ulkh}YPs?X7&m3H+6LzLZ`gCihaS{P_)OqsPt0-x)VAq`1EsBx>N>7w08eCDm?` z^mEs}dm#=z`3~iqxsg<1k^qD%7S=Fy`<(_U^!GcBRyt4&O_d@ zV5C}o(TxJC5G=VJ527NoG1<_7n;tPbPKwe z=feZG&)?I8s??~bq-XntFZg$W#FQd25t-D1c)=MUNECnu@wvxD<65C()-j89w@s$m zK}Jn!H3I>UQQfPth-3M($FeyV&AwRT#3L2Ed+MVDn0k^u5K2tg0Hcs%8};5a`9)hJ zh5-*xVQgdHmwq9~<0E<5)e{ruoF>duwk^*DZ(+~tGF@@hmKjyhp~1`x*Ku6LV|=cJN(6o=zO8NmAx%1m608;jo`>yerCWtt&^r%b(C0|O6bJob%E zoAmXQFZ%jXBJ1^2dGeVoc%K>FmV*lmyD}H zm0KPAm0z{9Hy8q}KsI&Mg|@e^Xd0^_tRe9p3D=(YEAEg~Z@)3=+6kzGKhg<7F0m8- zE{G9sp8C+$rouXt8~}r{Qhg@W4j?4}n~b}hfYFyg1vAs48un-x)wE0_IlxR4k;_EE z+u=&ODxBo_0LX(S{aR?_Zuu*WxK)_aB=C*p5pqe&Wg;Wzf34vy?I&`-@5#MTt7c}q zcQ(wR2i09P@I~JRrI=shF=vY5gye9qD1<@SXgK58Q#F3ATxNBZUAXGW&UKv&Q4%l? zd~1;Ah&d=wRe_Lo+N%>0AJ`#DQik z=W(B*w|*=+f7?B^eW_59;&*7as?-t0(PB%9QR#Nk6$Ae2q7Z{{3NFx_HX{S>qtBZ< z@_83=qGQcA!N7(h z`6vfuR^=Fdr3}9$?zm?FG3ormXr;;M9Up%Cgsim!e6PuTK@5xLy=Tu&;W;elK0_QV zN4FoYbyBe@S1r zA~1y7WT5yY@ru&oR_m27t`-4fbmOCoJKPa@BLO|_Nbfv;5t#K)^mg&gjw}5&b;dL`df+5Z2Qk9EI~`)lci?rAAdxdCX(g2SGW?rS^np7S+3YxQl1 zZ-Moaasn$l(Ic#>}?emG*j{w14~k$z6bcT78epkB6TUEB1ydM?iDYpce; z=H#5$lELqW&A_FK>;2aR^E*>p!}!{@ZDP*|`rF*LM9Qy^22{zoNpSiLKrM}SyU<@5 zDNebpjF93y&bV=HN-bD%JTSX3j4tOv}fjkmW2RleO;}&2@B?&_EIn6 zl1jvI75}8i;H4$UqiDp$mS1%HRsZX!dlo4YMhq-N>FEm8F;GWR&}4-RTYL3g*xS+% zrGllG+WJwliiOIB23h%sKJn9po4w6XUGz0m1Y`U-wNkEv4+@!rNUU|kRaFC;wB632 zL$gH?$f*17rb17)`qXVDt^dMo=n-0v$U`U!~3@pGtND zeX@2gm_0}Vlu-9s)>kTC5YuVkh!=wa^vZD)Z3v6i@ zonzX6K6Qb3cW&@J^Am%s%O!|FHUUOor4vA|YIcfudE`n}@jO)16{+ksrTSGGu!sn< zr%Ann?4eqq&34gN`C7N8C1I%W9kB9`#WANdr`!A&@xwa0+vm-cD6N#hqD!W5Cvr`+ z*+jt2;_-xto!DJ%9CLC@#Db+JU5k^W=4Hw$mK+Bvj6)61;&?5re``U86P78+a%G{d zk$I^j$$uQQDl}`=s}iylM4@#MF3z8K9m^Npt#wel^)LowP1-2?T5sGM_jJyO9!3{oqw)?eHx`hQamJ;-1l|7yz5Om zZeWV_ZZn+9ojMxnn{5sB_^+4d`CTQPoy$&!HVi9ZeJdI!dW^Z)SP^E9VCC)0JG{I$NdS{m*H=^b$-TGNmmSOyBJyCy5$*=J7%F%xXxPAdWxNJwcU~{E3!98NOHA!C_qR# zVvE~K?^MWEx>&hnwa9GYrVp5n=EO(AbtZ1sFL{Ll1?NLbehfOlIrRpcS?ZL~W834n znHYW=P@;|!Uo8ZkG~J8#7EiuPLnktk4JOHgEP=E7bpt==uT2g)PH-bRjrj}5p?125>VEID);MWVLDP!nzfFZ$ z&U`{wei%-^J($p>E$E{>iO{V)^&;ZMni-8CW)%4)_K~2p~-^UgyGNfiDf3By|#yDTy4rysz&s zJMP5>D7B}zywu+iMn%R!PGFS6NT~5X%6VpF@$oq=!E!^G#8{*USMUg{O51g4xLr!c#R=)uS{=M6|WHIBC2cXImJkeZLH3$AodPug@0d|U5hfI(pVie0n5>4=zm7fX#MC%TZudB9v zyF+36H*dC{@-DKk2=4Rc^XaCTle={&GJRv?bIajKOpKnX^)ywV^83%Z*(nRRhLRsA z*HFS8pCXr(7Sn7cH#zLw8)F$eu9s_b+%Kxo)@T8R3bTpNs4{HE-o_`gxkd3DL{{?G zZXOQTn;m6;PTD;!T+aj$r90zp zi7_!dt9cQJQ7HDhw!x|=XFg=dV% zWC-?KF==1a^V6f*%1I&o`>MEFh#=&ztYMQBZsGix=pKIq?kfVCJY3@^o8SIH>P$^` zmGp;%di0!$Z+wz-``=_9oDwKHi*&+8f71@@tD8{rI$4}qN&wKnjn`Ej&R*<`rvNT{ z8Ft5fJH^c}BX3)|egM9<`z!f&$$@qiA4kLBNTg_>h3bIRq$y-J^t$@jgZt?*4$0g} zFb&T_Zcu3B&!-6gm*U4kC#7H5oL8q*waNAq?Hfu(MhZt-is`V`^A-=`icyOUk8q*h^~41y zqL3W+Pi}|5G5^sU-{VS3)*x@Vl}umi!Y0Wne^+xA044+-LOMfW^U{e0(Od%qHRwN$ zl3zlu?o?rUSiwfJqKN!IATFu5rx zx?##C8Oj;h@#Uz#FQbL&ja&t;PfX$Z6jWf^rsa(A;|pki+wFdTrMP@L`0MbtF!#yW zwye;(gMg;$Sib}D^bduMholmRQJ8hfP4gpiR9vLx0$XG1;all(q!#xj1BAG)mNO|R16|IO zgzivUj zx)v!7f>h^gdG@P$?d2?=xfz?Xq70#z~YW^$7|J)c@;W`gfUEVfK z^LH#OnR@V+q3Cy|4;k?%?GL~nxFy{k zqyVA2a7I;*a_ZHGXAa!8H7Y|#eSpqAzclKfWBTb?&+}cth6JmKQx++XG~?*Z)W12M z$n<8tQmO2nIiQrk>y?=gH!aicgbVp?;0}^%KOj+e8yw1NcbLC`Rbgr1i0%e@!0hhn ztk&cgnQ4E1#wy(G?+#iaE2Jsb*vj2PY$uj@bYFvtgMUd<}o{q*gbMkcD zpzDOnWDas8G1hKPkiL7i;AkwWRVOK#6&_h@$gBUuuHnv$yWcW=CA(;aCw@z}n16?iCI`Z?a1)ekyVmxt5p8t5V|Py4&4G z_YxtWK7CwuTS*y746U3D9AEgzz;JV4aDDD*R5tR~_X;7I@64Q94{yp9pqhbCg$F2l ztQ!Z9qew1sH{E@G{>0lE$gFYD6_CrgfvdIvH!{qk;R_elznay5@z3|+c1XM0-Zprn zHZ=4WxqEy3a1S7w-9b`iW`S6UaZ!ZKunk>*=KHc>+=VogW=Ed>Q7mo!u_<+B$AMHh zEAEw_q%hla7oLm=eryxfxIDKYvr=LRCA+AIas8_M22REIR zSrM1-MGj{^v+$bVnos|teBYWo?U(t1pl-+E5C1tKfK!VaY0B`pqGZ&;wz2SWsnuJj1wNTrKF*_`Ym%xoc})nUh|}r z?N!P;!SB`_2rG$L2wEIWnc6v4X$D^O!xO&)_ib2L!+s^R>XhAT7%IJ757LPcxT0^x zMy+Be*ARR^uiTYA*ux-w|3l%udsBF}5CIC^>M*?&>nAR(T`d;b48VN^DpX-AVz5XM_Uxv6{8V zpOf-deqQC6vyoEz@8|Mg=j~-m%q#x)qsxbzJETLl)dytDph7)j$e>5&;g8*qVh8DI z8~-5`ubwhv`z0{Wc5n9@k4zDC&i6~q8>*Q{PEiD7^ai>_{0c+nTniB|V91m_cdU&t zn$aqdS86jY^Rj>9Cct)@{@?$?fr;7`iy%pTk^Ds6rET19YE9PX;t=1^FsNzzUUg{v z+R2ie$+E0Ej&%dYt(8zvvZ3c>v>Shotm7wH*JN^fN$4057>nDLadWwe z2=JCl_5xe@X>;+M(YAeIe<*Y(mQ5+7ryAv3grt-YoEeaBY*6O&VBu${*3J!+S_h3AW( zWYCEV7ZxT|Ka^)3gm~l}UiqgfN?yvb8cu2v?*>*@PI}AFr>F~P)=3}_4`0zWyvIt| z;&1Vpjw>;(N{nH>r6A>8PVtx|vr7Qb zlHWXB95h1|%MlQn#{bg-j0Pi9vSy7GYES@ELcx@up5WqkZ#K&hJl*9@19;3ZkFM2#pDAL}N{jZ1F%ByMkC{J< z40q~N0R>&k@#t|^mQxXJS+m-wLSc!Sz>eNZaHT%+3iypdvksVNvJz04DS~D!=Mqce zWFu4{5byN1JsFRpG=aJFVUMmv{yEE;S1CpaPd#~=E7fBoa6%&wQWjvgu;p03v?+^N zNCzny;WSX(q0-~${TEFyNu5(Lbm6d=0fWjuc&B4iPXn7)$dHqob+94$!W`nM;@qP8 zpII-Dl~At>`nTg-pt`I!Dd)wzT%{?VMAa6b=IlqT1WAk&0B_DO_|5C!=cnX;TTvRG zualk1E(i=s5TJI*$4vvI@Iqud0#s#m7cx;jSCbPAav#rWl~ zw|^S*U`{MA(!%ci=^>};^S3-z>^#Cql1wK70KBxEk)y`jqz^e=*Mbs2i>BEw3ELvA z+UP{?L&@-Ic5njU#po^vAR-Rnt)D5f8S>ZdTh`J4jK8$hQ%lOTiSQC6p2Iwt5Q}KW zTl&0T9MXp4y=y9kIUVyc3Qh$k{YHQGMM6*X=#wOar1r0L_Z0=6YnK7{m|lDcX3rDS zJYrjinD>%XUV8pX+Sp2(+FR#*%8Zfjm+4Xr++{SYaP!Vw@sO_=w(+hF3x;RH3*dN=M~K%_S)|F$w`5^AG_#eO>I}0dY*BDss#An_|j|0 z*c||p<5$f&#$Oh4)XoH|{!$c2mro92QNK`ADBEz++E9#7SMmeF%SWxv9O!hU8upuV za=BVLy7qUx-*~~j&s^Wh)n|F@G!-=|0V9^k?0>J#CS?!dqWWxM#w`Cv1lBmMa(}wX z8(TL}XCV5sC8DA^9=(4SRxD2vpBe(kp00Z3%Eh^BY0_MgDI1`J~hUNu1-BT3NFr(pRrDyCHay z3(Uj;cIp0E-A)}w9*n#f)m<{J^|)Mf4oWKb;;TWd&ZiA+Y$A+{>;HV7J9fAEk8x^_ zs>*BoDueVP&GZ>_FO`ZEt!{BN$uZEq3n-FVu(`;MOtrWc5u5d}P{Gl$kRu}iRj{^Y z2#}ct98>S3&lqLKs~%+91bsW}GjW4#;+9@R-jG6mtR&u-+0kV9HGygiv}f0hmd2Xk zs!3-B-xGAj6G1a-*$(&-B1O{9*k9HW#6OsxYi(xuYe0rWZlL3?m&zX%JK}}|K$CBW zYqzX?B_>NpY_^`YoP^14{6J?|B^MVv8pcTW*;IFI2;LX{6^xr@i`{+3a4OsCvZj@M@z=8C#yx!s zctp@+kOiT24&gfuPKuG%nr_X-PYfw^&q0K-%)=0qLbuqgeP|$1=s3yu)#*-C=7}9C z#r#8bJB;wL_m6Mvb%HOXcwWF@hxM!8ccTS21>s;V-yb zQHyz#k%B7vS^G*E0IrY9(SQ}`t{HIZ&)?%r4MQfE$ixd+MPWZaz??%>JAS_#M_ zJVQP^P(Z3ayamWs2~KGmpcyXoZ!C5~!HYh`C*Ip#`YFr+A=Fr7xNR~BKo-Q(1wWJV zdh5$<7O0@PkpD3F!)z$~y zvhn!-fSt#LWW4Jf6TLX2^}M(&CC~_EC1&O+@ca{Gx>!TyE2*OZbB|n!RIkxxKEkk8 zPF@|q_c^JjywA^;dTiIKo;~n9^+9xV7szodc8NU~-w$1Y{y-qZ^9~A#^?08-ki-I% z=V6BPV|P8h;DrBWcnmyAgA(OT~u;@(J>4yDcu^h~X7J@JwO;+GDBkVpP| z!gbg4fbgoLT-?*$q6LWmr|~xd`v)f?3rZH>nEftGD|ncB#;W}DL%l4+yD+EyA8=n3 zqwm?GDwtRvjYx!i!sLArMeC&2js~D6G*`OIh_!{wP(dJ)zi~6Qj}Lvo29)3!ZKHdZ zmJ5>_rya>NZT<_a{2i6@3)_UJB(0a1J@%LWuyJeYk&-;L7^q&8MREESb9Px(x*je_ z!)lgGk5aRGT?_w2Sfk@dXogOV=Y3UWj%xKEN7^O?Zx0NZ`9=!SP4mXzEv26OZVk54 zV;7$d8%$Yc*D<-Y78RGuwCCenuPgPAkTumN*9<{WlxrO|049AvTQMQh;x~OEa z`}V5>)t;||`-ci#m7JR&(`QGQiog5s-&0Bym@aOV8!pBp2HrKWey#U**GMlC%M*hW zVNqBEbVEl}AzC19MpazNnApR}JH1Mb{`N#gg$C_p)?2PW`0>88e(8sjDYrD4l`5fO zl3Vb74Zc+9cLi;bXqrmYeXs?9Gj~umg6iI$+K$4_edh zfnIiK0Cgt3xU5=)I&xUSYS7~O)JhL&blzE|BzNFK>-Vz<8guQ0AdGlmf4;W8&40Z_ z+?#r_^JWZxd5SmGMSM+|>{-QC*|?4UMEO0$qnClT&Z?g@6^Xt7f9X|%xmIistTJaI zS(V3dJ%{5dhDXaS*Jj34yx-o_XA@PWmiZKeWbI=RPj&uD9%O0;iYL@ul+6jzyqNo0 z1IS$fR56)FX*i~HoD%f#P;`@~L>fyKQwZ(e)TfGSa>$1eY7V@aQkr5;6LPdpa%b@s++s~5>KP0z2Gz@w8n4;m{!v1{od0@7Q=_nZ8> z@lusX1r9)wQtt<|yGi)w%_^+<@GVwaMTlEEN=7S}$1((9N3$de5L4u~LWcp2k&wHY z*o{B5bSK-07c%A2A;O5|-bex1^&o2XL37dW_v4=&eOz%|@3qkluc`n{zUueOLDXSp zaB+AZ22a7lb~LyAE}KvY@D;A{_;P6_cflXJ|9=_kE}1yk(Rr=*1dsw&fa%=cIyQgh z2z`8-dIlSh0cr)MD5#N(y_{%sPPJV4_LPPq(B4H7dv6H&t`^;BLEN3Tw9~0v_W82< z8{Dj!oG`*-O&T zXdA?5m2W7N`pV%I_|I*qXe1ORy7WZlvrp|903U4VG$&Or7x#V0g8=g8mOB%oppdP; ze!Qdi3B7|)k7Oz>i@6;x4!$z=#k0>m&eBLlLv&q$@95U!Sa8qf`_51uAnK`=BQBr+ zo3Hx?nUx$CC@S6BBVj_Z=5bS_Ybs4k)zv2`W-$0AaMAddUSsR=OCB1&db3yP25b+s z1V7C_u)KcDLIRmHAD`v1z6w@Ilr@^7{4536DjP5sfV*1`3>Q?!M6K*c_+t@zw;-Vf z=;*AkN$FV31OhHyhWS$i>m2VjTvG|1q1C}iCDX(m}?KcL07M+%Y8F^j;UsT7rHxt8}iANilACQ^S zDq7frTe2RBNq)})!wD#_TqNj`dteylFymPD>dGC(e!le#D21J`%Kp&d0jVKBKx|W$ z$5qOZQz>qY0$ndHf5!<)fV@Pvt`YI_LJX;?0&^UfN@EEeWU0rJ!%AO?L=ixb#=7>+ z+yQmS@*o5OIa1dMOFQ*I6r)qm#3-O9DqFcQdm|^j)M>U8=AP%Tj0diAF;#88zpF1$ zYvMwtyz19l7p_wAGvm|BZ1?XT%!cbd4=L)E8_Z3MtK7WfFRA|3W=jqt68iUK6=FCI zNAu+zUAF?NQEEKM{x8#iXC_-#)&XAev-|e`+c}Imz!jEM1|&u2Vc)s9<4)uHx-bd; z3)wC{UQ)Gr9<|$v5c|XYVRuRay2djGF`9x<@x)>NpZJS^RrTMM85+!h4_F2+fQl&+ zp=SdLZNmWtae(Shgg#-u@Wq0*@7Pg7yE}bjG&Br3e~XJ)UKf38mZappQdehUc`(BtZOE+ygAtmQ>B-Lm{gbL_B8)B88O zD2&D?^{2x8Xgn?QIMz55y20jP#y!p$i3?5iJ~6m(IjvV1SHXRRsL6}gYFy`+`MI;U z=a6Y|8vmW{e7oHF@s*I%w~&TP-LrsFaelc^c?~@G6F*1Ph;c%(@)`ZG;N4#74i-rO zA&qC%dOOVLW{yNy5<=b1<{WMYJaJ0)Aozr)7dR-PoLXKAr&`Jma8y%e)R8gk^YX(; zO`>{SmZH9KXaO>Pvp~e3Rdu!bN!CQ0Hq~)9*}9zv%10fR>&h>&cc~@Xv?$7m{GWt$ ztZS7N%V6Ur^l9~VinK_Y#KAUwTnS*|b<}5}aQITY*$!4Ik`@mfk%?Bg*&+bDBabafU46ioJjv&pN=+|=Smzxsj?Z~hb6 zLdTE2ox?*>)swsO$`!*-@OMG%1HiBv&~agqaHe<@s+e7V`1{r6%J@(ea>9|Si7sG1 zO)1yy;)T_dxxG&PGf+IIE@jXd@C|?lzFJ*v)o<_;k`^%#1FS;}{-OcQEP4lLm1K+hqEQlM#L;NF z*_3JtV$P-k`Yhk2&>tE80lgV{5DxWchyzNiztYVkMVL^fncJ-aT)j&8*3oU&ooG@c zE;==;g$a;Vh#vF0?+R@S%rm)Dhi^PEng1kgE&*FQW23}PSkD&qq3yAGc5+Fyu*23o zg%=32xMm*|@W|GF>)0nl0_+EXJ;Hqk*vrPX^{nam{LKq6McV&YrH)6YOA-N!30jB) z-!SWFJ_Kvy8(G;Yw6rf|vAa*(y}%dKaC~~#zbVX@t?@@{0W^J(S+%38mIuR|fOqh0 zPZe#B10kwaasfE5J3qw!(I=6D* z>`IW;vm;AfzSAif15jAe`@_$cxv;4IXcWF|b%ZstKl3M?WzYSQQ79eYQ>cjOpV4a= z0%dLVmG1jU9lzqS`j%rt%|l}h38jX-*|Lbu(Xi4y-(y5_GXHC}SUylQ-Zr+cKgTWy ztj+%nGd}LZ`-zr-L12jg-tOwO_B2K@oVi3k@|r)@=UU(HoQcUDy-$eJw=W2atZcG+ zQX}qUdjoc~`KLfb9l34e{^e;+dT{E>bKUYmXH}g*V;ChzRWD;QEp?k>*zDUZM3ZR` z(tPySn{%{A5=Vh;r&{Inyda(xjDeGJ(IDmf!6z$tr&}TxCCCXdT$e2=FW=vD+zN)C{xVGCdeZ&ZNzxJ1%Md`XrzLGu zhW4##e^vee`CORYt}6%?FKi^3_9^0=^U>EzZ@qU9-@`sK#HvXX#TbAcf z4Pf(<&qE%|%XsNUYU>CQ=|l1Qx&68qepnc=Wf^3VEn+3G#ibt<(**Wgje)1HbQS*r5!NZIR3ZCx{phN>vs$IQsTtLdF@FmI|oY^e&C z{_1x@3|{&vHGet%PDyzWTk*VKb90wPaq3C&avx70Jx2mUjL|)~up~ih_7t)crU1$ZD;XQwa`>pK2AFfYab)jtF7@6oKWc{-lP z+ntt)QP3+U+(>Ibd!41^R5-AE*2C6oa23Z*{Fma)ql1u49RO5&1+a{ntz*B6k0ZQq z?$%gLq*YJxLihRGfR9B>wF3TC!4d(Prc_ipH?;9Nzk^nHqD_l(_Ks!!A=f$86L#=8 zo|d;-Em5gw#6>YfY$9*dKuQ6V_AvzoY7c5xO?raaOuQwjFI6oaqzHo0|CtT%DMl+x z*nk|V&E4-i4%Aj7!4tbl|5^Dkm9sG;(XjUXkrwC{{zYx{SMd>Fzk0@*!f~EU19~m$ zaj*H3;dV1K_Ihu99>0^WZs)?Ww?tvyg5ByHKe0|2udHqHdDfbZMg}1rHC;bFYXhme zkFm}EQ&TsPQ}uz@zqoGMqIx$iz6D15jUQQ<>dFszbWzL7QBaGhb*=!mb;2Xe7nz)J z)$$ZMzB_gFwj3)P-&q$qJ!C@U$MQ8AvUAC0EgSVf~UJ zOUv8l#scYQw(#2cD{pZ{AyX$G0DN86CvYgHu%vncs8dS&LHg8wO_^p6$(;ED;9oxQ zj!ixG3SIJVh&4@g_e)&$-tr|J?^eOgyOn8&flT;Te{?+}$^W>Q2-^zK@6=+dIuKKv z$A0a65E+((iTqN%6vf;a)c%Ro=s(=-RBmJY1S(hQ;%V}@#`Cd=ULsIJ6L5yQyBeXV z&7QwR&-*uucTXG1=_QLArCzLEvk=I@{idO@CT=EN+F={N-9|b=5`ISxWAiOuz3i@f z^Y=@4Q6Y-vvC@A+#DL%nT$2GwmDx#BE1~|IY0lP9N8ngx- zW7gq+0-G`Sqj|lPBi|{{o)uQHGut@#S;SKvuJ-x`V5rPjju_m=7x&gIR8yv}5!rXa zSI60z`0>D_Zd&K|R*UarWuZlkd`i`-HWi|yijht6Yiyo;!1k(=-zkje-#NU4(tX!v z2Sx0#%(&^C&{WMVUw<<8rX4NoyFNt@_?^6JW))Q6(sfPl)ab&t)@xvZ=cXArb(XR? zAoEVgD8pzeRo^I8(FpVJ(kL3yW*;1h=c!TerR@;*W0&)%qGtd8QY`<RgSSc0U=<`)K}Mu;1J{ z%xdsy6Bnd<+_mRtTmp6pY^NOG|E@qI`Xm0_5bRmHX!~#X$ecwbm7HN!N$^ed`tY|! z$z4e&_DrhjH-P$g#zs!hc1|cdu-yLwPe_wx6uu+b>xQfkr6a*SaW0e_T#BApR+{#c zSYeQ^oGl$FUk5$47kD{x`x&B_th_M=pHQ7?s*=e&_~}=X^OAjpyUa)oKpO)KgZj+?)@Ctmr$!rp4Xy8U3#v$&o*bx(WO9H6Dr4fjo;QQ`Zxu= z9|Lw>DGyIXT}#!qL_842{K+cbu`Sx_32Z03I&}?Om~D{UT0r2}I6hdeHMeDj_Etst zI9%PXw~2(OpW$LDgZQf^&M6aWJ!9YY*SZxs#5i=#_)u$~sk;;{6yUpphD+?>N6-jK zC#IH4T4WFeB0G4o27cV+S5qqe+9=>{?h)hQ0IFK;PrJ1YW_}B@3uYwyK7$Tb#0uNh z=P5;-0oS@EjB(xnSU$39SpW4s3E|JFyep{^;<7Ylo1kdp{$yM_Zr<9ACnuSmwp#gd z;5xtNEV*u-Jg9Qbh=yWU*D$+D^KNN?P~^-^V_+GGZ? zOB;N6`tH^=b3t4BqN!ry>?49@?zYL5%7>C(vH6qsohIGEUDbZrEw(Kwn7tBhFh3aZ z;dA({X13mm+h>i5y`lDx&f|XMO=YsAYGG}@md=*O<1GB&v%9@NiZAD3%@Yj&2X70j zCIGw#DKQ&O)&2(0^8SzTl-{ZuZ`i*S8IT#4*OFX))uIvy>0vAljD<|@UxKo+^4{IX zD@+1m5{FD?Kea}xTC$3rUVtsGDmvZwZdM_B)Yy4-5;DzAam`pLSt^0_UG?|Q@J^cn z?GjS~Dxu9?(6+7Y_teO<8Nfi=cjJUS6DL_W?zj`BNpP+H8?&VV!V(hhxOKoAo3*gt zv~Xldml3O5J28D2b$+!WUp+N$jae{t>4TGz+soKr^rYqdQ)wm=*&!9WErTPmPVC5h zgBOV-GEyzUdax^m=}Vej+(v-2{{0bx2?{jUYFY~*wWzf+<}RYzpa@5}kgWJ-t74H= zDz-#?@!jB=d6f@TnaQ1f?SHKudM`&=3C3C%PM*6jS7HpnVcS#3MxqaBdk=vY?cHUQ zK(nIrj+oO>RL|d5@zCcjnJo~@i?_^?1u>~?W>scK$gEiI2q6q$=PyPtGh;~~{D{jZ0 zbcN{SEJik*;q!Yb41X6$+9Hw*^TO1TW}Y1RB5H*f%;z#>G&hXXGqwx;`3-Y=pKMa5 z5qAAMsFHjHQ`tsg`1FdeGnM28iKXG;v{QqW_1g$n4%^pyA=rU;FWIpWn4!rL9NH z{D#XF4^82C-X0rn99Gbbk#wM^XzcL+UL&=y^Y-UVgeQ~?j8&IgES`nPb7*y8vkDE| zB{ma|UL#S_H(n<0#s8SiF6IPShFAT|E&#PE8M1h#bJ_L34E}%Fd^*LV%{d$@iL>AJ zP!7e{Fi%V-YBgQ10i6}G{>S83kAFj8x};_ICk^_GrO`^=Pr{y^nn8>T#9FrbodUS3 z@`9Gb$$GmkmCvm(M0<5-EPltVf(XQD21c~)l9X>oIsL)UIbrac3L!sq#(7-zViI6T`h-T#sOVhTwULL9cJBFH&}~55D*wU%?NR z;H?WaXEQGw-ZU5XvR;O#qe>wC+G{C;SP@u2e@-PDU#K3DVFZVkvn-?!3!n!{Su^)eU;P3<#v{CTiwNucflS{Z$^jV(!Z~B4z~TD z7631T<~Z_(#&m&*r4>VJ4~yWS1a8BdSB1SemiYnN|BtE#81|iEi?MS1at#Mz96%;; ziP3A#b-?CqEhg)w0Lf6Vb=mrxCJE;Z_*;nL{o1H!3=c=IL=Q;EajHT?E76>!Ue6Px zp?XKuQ37+FWN8ni4|4(gF0?YvLu;D*F%XN)Pd1c_&7o(Le!u0BtJHYTZaC{SB6(Cp znKH?t8MI3LIz)ck?>8&Soyx2m&LlIz=s$|~sFeFOvx5n8($(Yg0Eu!)E-6sDXx#3E z^gaI*rr&}!Q_k8>t7YSpPij-eD$H1}o0KQth4la-CS*lw=sXawq_DZ;)%0SuWA~$YhG0d(2Tp6ua@gA3 z(~*m09M^UO8uQ0b#MwY__OIFOD5M1;wM;|hoLxn2%1u$`gVL6IG1z1-ZNM-$edTn7l)iDP0 z{LzO4?=Lpef1EsoKafx0Oh2Za!s{W;7;3`-Ve7xaEDnJbP??FnZuv)hj*=m{=sFcy z99jWO0@AWTM7|Qht@Q6(Q^aF6at%EK0Z#*G%MoD~h_edn)!Df@#{Md=5rJ8XTFVsi zxg42a&m6DQ9&j&11NZ*5M&%nYb;r(6mwvrovJfqG?NWJW>`p+i`a@*k1_^N+Ta-z} zjwak_el2Ngq(RF|2MF^3Okl+B{m$bR&$TZW$L0y(@r}iu79P^FKC@tA<~u{Qf!T&E zK7+hg=9OO*)sBA&*nvgQ46Fj?oFPTAXCd@Y*B0KGi^Liid23*SO&U7&}9oCXl&%iM;~$IF3RtL{aPSmp(5W-yw`kk^WkhO3=3GM9)| zSfxht*%*y?TGFk@pBQc{%FtD-fi2U$=Z^o?qRfm#hp2Vo#r=<^qRG{*(GPOosI*gd za24w1LZY)Of0J>Z$2eLHZW<@@up?BLD;tvu@I)n%&j7MA#%{@1J0{a&6g`Q zu964y*PBvRTICMVys4qE%T-ERb3;%)0j~J^L*3g&mvbWD%zc-PS9mmbXuE2WaJ|jV z(p!1`P~E>P#>(C1g9G@V%bz|;UTZW+`S*!C789P5yF?lQHc-vWqpn+7G+%iIF;5(F=r zOhx{1T1|CffRr$L^Nte7O|y-?l|Mk)r=V$s<>INd$A4;{iX=p*J7m`&MpO!pqLqc= z3VG%UH(aDS%ecY1JK%jTpPmX&{l^X%%C%jv^?$H;bK{s!<%;kL?HAP4_Bs`h>geb(O|B^EJp{n~+z)GynXC}%qA>#@GweEC$uZ)0~0(FIJc5DA{oasPgh ztb*q;|B%D|nsv+CePvCmxTlQOs*e?ViQ&hpSRSGA17`~`<{kQO?@>qDRUPzBIek!5 z+5A22zUn*6PHqLN=KL!$uFv?Sb5bZvpnm-gj`TDrIUwFFp%Rhy%N;2DQOY6TuEpIH z)A#4|I0^=a2F%$wy0(w%4AfW+2m|AGMRz5qcXQ!i9r-cu9yaIj`qY{?ABbE}Bqx1- zd1;{CyPeWcush8$-bT1L4~LY0I@R(izeNj{R7Z;%&nHKAOkZNL=Y4H=d3nZslN|`} zG80TS6r*|c>fQHZRA;TqrDKDRT6BHR0b8fb$Md!zM(^9xk@GpdM)Q8Mh2=n6Z`58+ z{mm{Dt!0#e|dYSy&v?y%#TRga1QDA{BT<3D4(n}#E%GS7hi~n&ycM(?P z%_^mU^FccgwHWG>7q|hlQcs)jzcvc|2hXN<(YFuQ3@aa6 zo>!($TW9697jUR@BTMg7BF7C8_5&E%e)iN>cO#axG-~#Uu(eA)=RMD%R-Q+if=3D4 zoH(l*7nY{+y8*egU8*yK(n>0^zbNGT_vForb_I7qNb=M22d!~@kzn7 zma9dX)OKgGcUG2pvtLylkLdnN>8ma*g~wt`DYCD04?@kio8O8Y7+!6GJl*bT<~*<7DK!1TazG|eKmKWiy$k89oWWB_6W+*Pu&%H)y`Ln zR~B$gHtF8db0ZMLSJx|9~l|Aq}#;%6tTxiu)XtK}>q=Uer` zxZeUuQC@!ED;lZm}XApOuYkRYjr2!$%$5cADp+dl;Hu%&LOT}_HA)as6B=%hO# z&S#pvy2{u6Mnpq+!y!yw$&F^<>Kliib_kz?;JMlU= ztL?A01M%`cWZfAzK#dkv=;Y5x z{~wy(JD%)CC=n5>)KWlf`kw)>>i8uWm92>U5=~AQu zaFhWm%*t4(#C2Y-DDi)Zp&GZwe6eNN;oX}C#o!`SB1AutvmZgzDTaVLhYYub9-H(= zZXry@DuM=7=-(w~tNSkKtp3D$pCZ41-n0H#)VL-7IbGRx{9;Q|-|L8;`uVrWE4)XE z-w0Okb;wS>!F{!GWm8Gy|n8yi2HEl#aaaZ~aO|OGCF9BC1N6lSn`#kK){#O) zu-#Q1nVsg`z60C6H=uHO)3Wa3)%Cvb#Ak9LJgW;~xje}UEvF?!#tq?ikPJdAtVQ%N zx8$fY`%#P9=ZGEAp3LIB2_ITiWDrAdd^E}wwK6Q8XeWO_I7b1u3wMhK^f4SgzJ31z zh!q`y-Gyh1|9#xgYKkp4N<)Gw^prk>j7+i8Xa7-!s64Xsa?Mquat1>)$a7XCO}wja zb)SfYNB;Y;SBtlMBZ|Ct8h9C>fH75}q1$AbfrJ0XgwdVGz)Jd<#M*JeGWZHNghF$R za5??SvAsUb5VRiFm7w- zTghF@5r5uHJnYQTS^-DtwYY!=gKbB@=f7NtQZv+irj{J_uC`LBx12f|fj{`65wn zcsK!=O0@mb+TtnFE|XQBR)_e#7!Dt3zJ&0275s<0jT+i{fzO;_M`GqA1v%4f59({& z42V7%$$kZg0h22xc?S$U?!^{vNnk@XggHT1U?;-tV9!(6@yZh%D zAACwyo~;p$p$HZAdRreBAczQO##^CIhmD*`z)uGyL50o9Vw<`3+(H z*wpJi06E-w``^&fs`s?>jN)nQ>#nznOHJNRtEHoUisYWV^dcfltvMJAxs4dC4%&h?)1)9_A5yP7PoC53Kecxfzo#`afv+uTT8t1 znQ{7)gxvY;iuh|Bg(#pjecgP#bS%@09P!JQr{wiE%uQ@$D(CJ|C%p(nkgG9-KzeU5 z$*L2msT0FAKc}-Dy@poCcV0MkuC~&c^YsG^!L?96v6w7c=)3`gp53`3|`=} zOaJ>KAsRBCy?5ckdyPXXkAC>}r7eYq&mkg5LHS5&iNwR#wN%iG@4=zN=%!|y&*7j3 zc*fQ#*&|cuOEJ$v5>GA9^Hmi%54A5dQ^0%>&j-jTt>J(lIz=ZlJ-JJI@#Ul5ibnX8 zz``44*m-MZuS%Wdp~5LKs^qTB-Xc7?qa~jjGn)KsY%^bJBMdV>_X3VMo=)~wZ(a&j za^G2oZN16nq-9sEW`S;@Ua?#9NUecjjpZz(ULeT8ZC=mi2T&6UHDWgVrGS^hsdZPl zOt!df2IZcDQUB$F@!Es9u*jgj-GKeYxv0_FmKjT`KXe0(=hyIt5q>v=~{3=5|U`PG_`d}>%ntJ%b~dR5|(X^&FT8(;32J9 z-lbVP6U_~kKbU~ui*wh~JwUOy@gikRo8>-BBsHP2ZUuU}}L@^=kLM=-v69 zDJZR%rS-|B#Uh)3MPucUdrv}nYyxbby|?4}5wg)MGGn#-qwf-!mJs%1y40sY=>SqMia4}=xQMk5a>MBC zl#1jz6-7uRcyJf1y6^$Xij?;wL_DYHyS_A4G#a<#&127+06B-hP_y;IJBqshY)D;WW7&xK#JUj_-wCa+ZCGM1M2XJLKFhS7MO)u%lr;AZi?|+p2J6ZX z#lFgnzkMKTnG=2JD75d2x+LOUz&B5&YG9_n5+goA(&JuFL_3t5wAVA_M80 z=~hjGH{OJsvqQk@fBe>gis)Xrw@r|Epp7m-Uc=9+h6Gg-4r z&gWdlyw6iPI|ff z#A#Y^=!y&HZ`7~?-TB{Y(6-N!l6z3Yv=lF4wW1%h z*eHd$+1Xi9L1sjt-=>v%+sU-VpQ2a(TY{%!F202XJ8P0N#gZaEjK@1mlcKJPMWc3< z*PjGf){Q}Mpci994V+PT;7UrtO~nx(uJiH+OfSs?kLyTW3c*Kxnt$*jRrBp6Joz|E zJ3ZS&c51atGvQ^DepY>w+}smWNyY0yb{lUrUv~bM>4~`%|Eox0%7|-D_Pgx=wXVJG zfJAnY4^IZD3T0?{HXJI895wLr2n*mVBIR^)c$)aRJ)*_RhYuxWngJImisnnuz`6(J zBmJ$X`tlI*qLaU31JA$F6Cr<0xM{Irt>dCAfAHr~bMZr{{NdnW zdZ~y=#1q2zfF2!BK8*^M@2&qYM3#w~njy%opwK}_^{o?_HgDxxN2dZ0_g%+ZpGT*} zI;JPuf2wrv7>yws`J)uRe(wy3LQ?M<6MbyMPE33LUqHk$G_}!!&43gCLwTqCskS52 z2r|+~YqdGlahVZcp5<3*r8e#?l%I}D()S}K?me{KQR`3*4s0qO9c^hanu)ad-zzwt z-9K8wpz3cqzG@!rumNS@=K4jXI-GfxDKb|0byhD*Y5<~?X=awkPplX z9Go2ud**pO+Y#-FZee5E0@0gk&(D8d-Es6gmt_Z8J07$Uadkicg%iW##4lp?hKhwv zNDx8cyo-7UGA4tOO*j8Ae`lM*S5B(H+uSJODcj8LRlPg#3<&Se@9plBriO;!b42Su zMhyM-a^||+hrVr|+84Gg$+d+_ifN3BL{ae`S>4G4bobHiAfBr6haYWL0qd-KJ|XI} z_q_fFrCFq@lU@1@5G|c6O*z1vC-HNin_8vGOipOrg#Us6gc1~~$RR_usvAF(W5NIv zxz$ETNXQ#dZ#4mIWxjDa=#p%?b6I_t6*7n&Audf+jW)iar1af84@GTk+`BxzPwVau zdwaCeN>65E!)P*xVVCI-cIM|yZX8;p`3L>91q24B%lW{u@y$i=M&8#*+gSKQ0S-h$ zBJ!q6*SXE(;hsd1L$@aOVb8p_JrSGHJ0i@+FQHOn^yEWaIP39rRGkxt`iCD@o@Jio z0+1ZvW1W-yf@cfB`c2ApjH|y8K9B@ExV_CI^LlXEs*wb3wkpqtd3Dhj_>vAnlrTiT zFqst_DcTz#rW2dnvnRGTk~0zJvcq5TInFW1ntEyu3wvoT(SdR{*QSXiqGq5~85%3dwxHB0^+nKV7f8+H7$UR_ZGVfOC#uFS-RzqrmW zh`O8`WrgM!x0eM}9uX&iX(gaLJojL2I!q_7FYKq?hoDT`@v!H<>Y5?hoNu^zM)~2N zWV2<2h?}82*9q1BUIrY4wb>%LNzRtNT*i9$xA2mG3Y9Jlh{7Gm#ytRXimuCp%DC(+ zEvjEkY!kKZCo!@_9i;`Xf2=0m)#RlHM9^+4 zBbN@Wi8Ce}n$}w<4ls3OkPD;lv)RE29hu#bS3m{22j6Au8_vO!jxDXY-G30l zxNZk&$vSm$7UW}CbZ07@{S7A{v@XF|kVoe1TqPK#HgjwUUy&J)_~6O>?0S1;I;6+5s1DbZE#qtEXi)MlIG zqZ7&qSD)BD=W!C?BK7FxB`c~)pJ&aMN4tM1MtXH&cG2-L~ytdMAhnqwdSZN*$3vjc}#1j)N)#}@eC@RMU@pMB?hM04AuIEK-xV* z*^}L(&sJLSaZ8^F2!>-IoGu9G$DBPl-MDnHmT6^2VCH5Rw8(epEOcyqb?c2hXWs0}sXFf*(R zO@4J=`~TMh=y`_hGtjm}Iz)&yZ+mhQw|z>k@!SyI3qU^=gE9mjUIv}}7Q$*Bx8vjp z-Y)&{9Q0iFlM&%>sxJpESi}(W&QKvb_-;rr#K#<4BE-&B#9;OGTxJ;|@4 zO1K=PamzF7gV~%bo1p=hIvqm_VlM>{aEO<_rYhzYy>jg!C+R*u1rO<^GyS~Aj|08N zP(zFrEbks6(*^WC+1ZO%U7A)x2&;dNB<}|*cek?YdRJLjt;7;kE$kyoF?0~?{D;z7hjia z9{xcby70?WnU@Y_7-xQcuw!-E$zl*fPoj2z%!J`PzJ<2>m4k9#-%3?96&$*SEPqY} zS#F!3jb8VGr6t?L{$TANUpZ$d<{XGAGC*gg*hB8?W>@CEYSUWdqPdu(6>*Kgvew*S zrKolrKXR~G-2qqv#W=ho6n{jPAzk=x@h`?L9i=)yARLrZ3$gnz1uFOd`Rn6h;#>E5 z&C0=Y%iI=z&fW>|ZPBkg*?ux5td2@G;Y#Bb7GnV7w7iv|GAIvf){RpDj?du5BNGr8 z{xT96a&GqKHT-{Bv8Z=1U!9M2D9W4qd>Ms;xt1!L{ z245F%zk_wqk<<5j?H0+CM)bOJ34dD0RW7IOmgz$-jS;N_uLR{& zS~!n#!*)^-xmKxU=T?qKP}A`(4LW}T)QP+~iFWTWx zO(vg$%i;v06_UFq5)|;EdMn(EvChUHu?T?mT0uaM%xEuM*JbUf?eC@S(l*#JvjlKBG#`F?7ja4M(V?6vRN*nIbVY**| zs8EyI&j~7YJ#f)THI7$!BuYp*fEJ8xuOuzH-;qIK9_c$5ND{cxNreh3rsv}rDi&?5 zVzi16ZEE@X*b@F=C$(L3IJOrhqN)1QY^k#LhXKcvp@g>lczLkpoY=s1hX4^kr}fj- z16?z!*0>mo=3vOF4V!yk$9vTeyZR_xuWOiO8PG8}m&SILEe&<{T>jC=MFWX@?n#nE zYzv1Eii_nN0Ne9lx$D`82Zi}@VOoUJzy0L$kz=zJZ~ zQc^^UEiz#14yck>2Wae{NI)4k zMzcFiqT}Xfvz92;k3@&m0X8}Vgy2(wXDm8BP30NX{*WP50}1EC3im~ONY`5Vl#u40 znRGG_6MzzZk#`I7jDhJ#GCY&1$oV{h=U&<;ALpED#To0A#vA+sLE*fTyl`X7=SZh) zA=0AmYF^ctS#nUltFD8(WFV5xr{AI@*751L0FNEM12Aw>yxn|W?{#B}ElnVfdBnMC zSHUp*PNZv!7N1(%Q*bSiS85iI3+Y(l7xb`~hXfHkY|#E!vHOnU&$kC zh+}Il!0&H}t=g}4dXd^5JWQr+Xe z?Z0{#AE~E9siQ_2a9E9h3GU0)G6t^}<9;z7v~GJnu1JEl)k36JR$41)S~8ETsoPCX z9_!K0DbrdzgDn&b={qi{6Um)};hq-)mou_qIEog}1-#7@z#r1LQvCkw8F?*j;##(? zve=s2LwSaW;cNGq7zRQlvF@D|mmSC(*nRl;S;ZgQ=@8tS;h&Rke@Fk`sgaGxW#56u z40~`8A%jX_b(eG2^&Ro?8)nuoAci@i+5ia;<;Rb)(hc~hZ2s}|j0z;P{yVZR65Y)gFEtM@34;+Hf? zqHb1rHurR1%5{5qGgo@F<1pN^?#(R=W+@_OLN?vdr_D5XMCDxtMv&h1TqkPvjC>Iw z9RPy3=@D98Sr@hVphs1Ym^~G?D8$~zm(Tycr&A9pAHMrz=(irtGL}|;e-V`O=L;*I zhfrE}2+JSBEeYH|XtU}N`ST{Vc%y&0&}NWNCUqjE?5hW{0Skaf$j&M{pX?KGKNjAd z_SbOvJyzOy=O#Ud+vRLWt@IxMkB5n+u0cSVw2YnHGoN~Y=bDrLvN^lZw*D1YlByqi z!x@%Y7MPCAV0mHHY2pLr8he$mm44D zGZ((Wm&v7{Wg>9<#&Sv}^LRGCG?RN9%V%*mnU!Cw=AOPlT&dgWydb-Eyy>e~M>#Yp zEJW%?YDTt9n|t$k{!03+A9UHekrbKC%i?ma^c>i@?nRc3m{Fod;hc)nlPLX4#<62q zZG`Av@#e6R-(YW;{!rqngJZ|gn1(iTbBfDq4zMV*opPhPiA4YQsCw1w#D}MQZ)Ysz1%8jmeV9#dI&Q#R9zIur*xB3eth;Q2y!Dl1zYo3?%{K@M-K|DcFK)GN5l&77jH12fn?a#?!d#=yY!Y`8Qbhkxn$M5oXL~8slL;a@LPk(;(v0d4yn$@ zO7_^q37J+b7SgZosMzkS^Ui^bLodq+5b1Qy;m_)(a(T-!4|WGRv!=uR`#yX8A$)$P zR$c??f8bZ?jq&aOX>SC;J#D-U|6GbSp2+8R7!#CL(Py?7*ZBf_d} z8N3Y}^mnN>uXH{%`B3Gv&UC!+?H~KUzWr0l?GcfVvFJ7NsjM4=ZGb+D|66S{p&ek> zytTP8_}X9Ogtc_`e0};_qY3<|j=w zLYB8*FW1@x3zZ+Z)|=_jPf2qH=@UllEx=Q*9;Dhjo(*=NZl%%Df9c%=_A~aR9QV)w zmeT5WU)O_&>+BgU4(F}Lf#ptz5KDj|DJ1{FXnFCmOx`n{8@gd#&i3Zl&FG9$6Fd)qUv+pWj(`nhc8K$0c0YCfI9G9t)c=WVvFk9=xq~r-TcX4h`LNW3SMFL#%5LK;|_dXvj7FDFZ{8Of72CW*ZN?p)6>1` z=laJ}cmh$TZ?BcODEqLLU0H3(l{o3Wk3?sLb850)x?IlacgCP_h^>C1tZRJCdE|XoKr|x7KUS3FX$Hh&-v6M zWksXQ*Q8EenGp>Kl+w|QMV$NIM($COU~{*z?boGW4m9jN-_p6uXMxY8`PQDNEQEa}%T5Env!sNSKT zC6cWv(Qn6EBkAnEnVYDMQd}2uO3d5mf!VXe$w<_Hy(L>SM7CGLBdZlWRvYhk@nwl{ zPE&H_v&X+YschzV-SKAn^Kp2#hBv!fEI@y&Z{zo2S@SY=@m0maJLVw(Vxd|KG@TNwyH8`wUt$?uv|Pmq~QfN z9C9wOy3owBb|`vQBAb)pqb*07un)+X!1=U52NFXok6^hArJN*_;$NiNRxMXHE}bE_ z!kGo^^kh*CS(|Yc6EiyU(RZw;cNhU|SBrwrIT`jWs@LIdZgHSV0kMlKR*T2xzmhQg zgWGA&p*Mx*TdDno&`7)Hf8FxbpRkki{hAL;Ka1AqWNkJ^Byv2iqKLelk>~PSb^61V zA~%533H#ymIcmgbUs-&xde)Le&9J(n$E~Hv*&5VV{PL#%t2troPbEt~T!HUUEoAMm z?L~On;1KKy`xk7fpjE@JK8&mIiC?L3Q!}f64D!)7N zE5Y7qRvTQzwAL6NT@D!s$=BH#IvF(DdIAc15y0KH>0@vC96)5EF?GBi$=kfPAcl$7 zS#9go#i~AI{$5W0G}-F*oPI14K@{`%C|z`V_*m{PQXB`VoL4=?FfWY^}#2F4prb;(_iVo0QII)USeu8@w>2 z+xNUmdOdD7rilML_-X-4w!Ug@ZyPSv(==Nj$n%R6{)_Pu{jyA_LF{sl$$v`W7UwpL zC7m%F?c!CS!~h?(rj`Q;|HDu`Y{@g|Pkc#cVhXhE!=io45l^kWbRrXXCvG^MWOD`P zDLE+g{JLCGHq6F2n!5Bbae->~gKnnFTr1 zAeCEk0kyP@scQ?{h1wm`D6RU3eL zS~LGK9UiF&xskjhN((UG*+A$wvsXa{@)_nk%RiPd4LTKk^k4By1*e#$ciRM#{ryr{ zy4i}m&3ZC*9(HM969I|x!D%f|=_#4Bg>}#o47qIEApcC4Wh5JCBexOXnG5CuoR>Jd zgp52u-|^ow&YR3F)Trotna}+YA=mE$-`5+boo`fU)KQ=Ij^P$X0f(?x{+H~H2wP%_ zec@17_!g=0i>sV#nZwRN?_nEv{a6k0W<}jpM-7u*a<1FmbZ29U5PEvUa{=tw_(7)e z%ycSMy&OK%bi8jiSdfV%cY4FpEFHlzIvn`wS<002Tm0{j^?EK{OTkdtx`fRHnAdwa zNwE8^%{gge+&iqipfm)_WItRjGt1%)lEFHcIeT2)i0@of4|i^*e%`V=G+=)@t451{ zFojJCGUSUqXVGus`<6bA1|Mo<6sZ5}39xyrXw{R--?B$S<*erx=EAB;eQs)C;^3IQ8(`cC}!{x`joS5l66 zSchtjh)H4QRt735SD!)U#4J!SISly1Y{H;Fw!!B<&3-{mZ!>M{q=Uqv1Ry$zNd zr6s*G{X$9~ep+u2tf!dE#rl5BJ#!}_IjK0K(qWI&=JmVo`&(EaisTKmDeFv~c_d0f zpQ9*-qTR!ddJvfS4JBoOA~3Fj>$R5h*+T;5bhCsnZ95i|NKI{gc_|d>da;b-?TV;E zjY4BNE>HF{ROx0WKMSsoPuP5P)@Nv79qCoUk!(zO+8I7!TBC776@n`|`CEdqr*gAV zV>hR>=N6KbTZ9IT7foxklHxoUZXL=DOKWI#b@f+d1xcrbr=fm-R8B$6Tf3K;Ev+u4r5;d<{JM*b9Q@ZjtO|Y)b3+-GVM&R%SF^&t59DAN5BOS zEK?Gj5QWkA%q6)+L}42qR&(+uAW~|B9bOEp;2pc&JuC@qbm+izNNJBWb%dWx@ShBQ z^jov7{Kdj+m~6=wi*rQE5P{`&r@-~%?-qV3B4~HgEQmpUUXcI_bGL4kDYy|2i!|?i zu<76m`DnmVVLVeQ$Mu}$LpXY&zSG&CrSDd*o%j3x|Hbl^+IJWKzDN`p@Na}=Q;?r! zXVq^bE)5U-k!_@g1nxvQd+Kgp^D-@kSWUx@&?o}`b%vCqLl8$9wf|lB?YuWqkbsx*~@ET^a2n z#uc?tVUTEU;Gk<6U=V@ooPT6r#pjPRck(J3nwOcv3Uo*6pB(~C+>fhFoRvbrgoseb z${_-ywHWJzdsG341w_xnS+O0ScFjND_nlXz;oz7rqHk{3N!L;=Gc9k?^OyJ7jaQEa zh{RjT$=%#fyGa1{L`tmBYteH6$VE5jqT0;y297P8H z>by1kT2i*?U4`PEf%o2ZjmC7>1**4q_yc3tertO~iJ09%TjJrV1Ox;Z%Q8q`7>L8% zwdmDVK@}GM)a!BiZa^IneqP~U838QktP8%?dej85-&nYV01po|tGbdpN(KUlVeK4WybY_k!I>UvgKZYi9gW_LCuD9gWnAI%}}Jig30g2jhDc6Qm3pCx{} z&OF-AS%!zQuBpK6oU-`4_Z0Hhd4D=E)|dzQ#U5IO(%sQ}jMmBJ%=W_vAH#oQ4LATO zucl>cd#Buuh{`{zN&{tkZs-M~BPRstXJtLNx>H;CuWsE!7+bH9NymNj|?)-6}V%_;k02H@b>VbnaRV;{&WTA zx64ck!3qYF0oAU?SGMHS+S{!B2Tl)v&Agfs%H|Ory-*{7Y{RFr<5AGkJb8H5)>j??!v>y&Q9v7V3o7=X2`W;*5W6V>h%3& zLPY>i3%6g1D_s!nPs*%sO(F+K2CJMe5ldM}Z9F9Ew!QSgvQdEUcTDz3W=`qi{Mw;g%p@rLQ1p;98y|MH zWn5h#BP@O)Z5T2f&p1HOfGb5w7So*{uL0%u!!5PwtNb7buFmipo-oN~+CQ&m6hDh?x+y-Vcphs9BMF16` zr6=_T8f)PSlGN>aReYYzM=ssfXXHJrCE6>Q|E(TiK9AuWs?&E(!hZYi(Ds7KZ{68t zR8zN7xBu)tfY)MgzEZgGy37Nx5U*AjyKuX=YzoT2W7>I(qplDLx}`%3vT+tb00aWC$+SZn67@MHyz9!@uagwqr8iYl#4H%Do+8sR2_M~po_<}6na0^#vHO{*LY~I5<0yOKY zggX)n7P^~*2QX&5e~ zv)<7zX~p5t8XJx%wexVRI=6-V&i|Y5%Gxfw`Fd7aK`CB<%Ut%~8-A1OSTjIvDrI3@ zKd7X7Sv+pdS_Q8>nD7nod~E&q?p)|rR%4$sc735Lz6{e(X}0hAugj>fUB9KDnjJ2y zo4b2UCMI(h@#KgV1T0cC2Az#*nWt^9kqUn@K3)=5asDUAnhW3tPt*-$5 zBuI%x;*L*?E>47tIBx|{3F1jAIzO4LAh2>QBI9ydX;ySwmn<5Taj1TmV)M&QUd2?4 zBarH~Ftm6dW7E|_np60aal~^qppy&rbzAV8E;O`4|Mg;sA{gm6_Lh%KbTk+1f*alx z3O@09oaX+rRo(hf=dED7)wov1Ov>|zY5jUW_3MrkyCQMLOB++v&D){nLLYq=Ea%*y zmp)@=Dw0nBZ;Y%3;m73txqSI1dsIe3M> z9G1~zzW6te8kTXW<(GVmY0ASp>EqCyjcGUGIl=$Ed^*p~B~90mU+=F0r@NXvhAb^1CdnmuoWQW9)wgB}e8QT!Ncff7rdC|1C#} zVIsJtiGNYRx45b6t)Hbbzra95pavOWh3ZYis^Se$@iC?87Q`sCZL#Yqv3ggqp2ru1 zTVHSCAUba0H<_lWsX;_TUnE-#MDm)++(p}qFZ;qX6^78phMYIDL@(S`?crm)WB4Rw zA@UY7`KvFsaIfKx=5K1k>jC@tk-G3)3m?5iu?joj<43iUic4TL7}(hkjuqH+gOZfZ z0Z1K?Vs@M+P%)n2;X^E)r6W=Pj%Wa$?svLvN(K``oyl57|Ewg&I%Bpk62pg{av`cM z{jrgphsHHKh+qNqgVDjPg+~&agCNKoW$dPSYINCsNZKJgz=saqi1HcUn$bmMB*)i_ zufTsnBo&5|$W|)?I?w(d|Cl_s=gAc9zi_6$Bm`*`j=edhI&MkM$-}~yJS&mJqx2Nigpd1fL9<0C znw@7LI$05nt24(&lU9D1LcN7glv3gEUs7yF#bkRk`E}H1B*`j_$TcI3^VZ#pEO!v3 za=kCx08{raE~l2ZLPEi2u>#z*5!MCKX8zt(0I$-Og&E*0;_jYAXs+Zb4%X5n{<=E{ zqD?#Q8P!Wrla;@l%z)vOJo@7F( zGaTc%R?(0V@xDiJl?+Bm-TlJau`O~a>C>;%!w(G3!K3QZ{%PjU*8E#~zvR-4&dCix zyQ4Z)s_YyFksg;0m_o`xnO3@&Cuygr{v@D9ZH#WKnBxKY;g0BBDZ-)56^^^#F{YCv za6uYk{98TFSXU3!OfWv%nGKhCM9Fn6>fdq^xFBaGCf2)SG_FgQKXTbLPD}^Tw@I!& zoAc>QR-8<#1SOLpk`x-Lz{FDjCaDb8pFau)(ONwp66!_hr-_xkxmb6f9 zh9$MUdy7xu#N8PmaSeEsNLDe6x)Rc@F_H@8H+E0D*Y|pzqy@<3i5&uy z1yC1-O@7R&(OsJdudD1FX&w%PAZW@zC^B_gyA~S#ea#3rn2D%bu-o)-dZtAWzO|ZV z!%89_AO+T(**)j ztPUzuLUFYEr)OLWf?OE>dPzk*$dJ=j zF%2}>lH?ow-J)gio6TRuqC{`k%xitSOH8w3(1@Vl>KFd{7l_!LmPk0MiKm#;WHG8e z>~&-Qw=jqIt|Yg1>&|(ck(&lTs>D&qD=o|Tld23KA$ zK}7}|tvD@uiZ&v2hN@Fm%X9)fID8VF)j9(C5_`*s{84T%t(H8!rJkLK;MGk=fyHo) zhlXdC6mjFM{*dGizh~Oy#Lu3RXpX-#HJ%K=J!Vc7t4i$l$G{tB3?8 zC4@CnEXM+_Hxk|SmW(2thu*AHUg7F&lT*q}{8L!ocQ@2fZq`1*z0qk0$C-D13+s9s zZD-w6AekEexxc-t*VTsJQoo?t_Wf*F4W~p?d}vB+k94(?Rx_kjgv%mR%mW@hqmXT0 za6|7L<%-O}?Nrs6_LsibV`GZXgM_Y&$Ba+Jm&1mPllrH$gl{nJZA;5p(E`+0a+wF< z>ApjyqS#3bKWhz;ImF>fj2` zT%Asj!l=U*Blw(YkM6mQKf?T&XuIbwX)7f=Tc)u($#94au8t@A3|Z?yy9e6S^RN?c z7`n=-9c9nS^{PxN(KFgtH^|P=-9~CY^0%+<7pP$(KWmvWV_+kn8-5@;Cnlpv+$v%~ zd$Gf}XGyiCt{4E2oHz_au79zW`}}S5GmpDlYklALVp#30H&zTr>hBu0#ysVWjXRT0#x zt)W)z`MaIZ_m{`Rzpgu1?)$p#>-~Pco-g};xKTMjkcWC!^Gb>99?yfgS?sdl)|Z(2 zl+>U_3kA~oORnx4kgWrm#;{6#3FAs!&Z`$-Qj3(P<&YT*ds%>C@p%)IN;Lbsiz|;2 z;lKN00%G5qcO#!w*+=o&72&YW#*rkvj|X6#$;Du+z`*Zm61GYd09HM0+_k-vi;Bt6 zmbG|1Fo?639p~Iti&=~UYs|i<*SkRJ@BQ4^L6Lv!fe#G z->A_V<{H`zrmy|@;2+o>803~QU@-V0K&zz-f$}HgdbP)xI)~gQjHZF&%73?B-Vn?M z!+bGfq_c+GJ2}0ZxAKES&&6<92~Y^#w7Kd{WiRmY*zme=zC@>NSc>dyZl7H~2XVEZ zJ)AYS1#3p+e}6QeA2HA^p(WU&(|s1oGr-{IzM$iXEZ_CG4bl6Haq39%r3T}()4tu|AKC`EeD4e;gZme_aGyx#@0n!_JhYXOUK7d2Ov z2|MQGN`)JEgIU(k*wZIelW}aTLT>uqoMq zXg=0?Xy~ED)6w~q0 zk}Y^!r~d&;mZ1H(|3N`LckRHfO2h~#bbf#rb;&6?9dLomTwc9Uh{YN~J``K+`V%C) zAr1o@Y(Pt}nUSY-6Xh8W+#!8 zFPZx&z{X)TV-RayljeDCSFi`&C-Qcd;?-9B?}p5s5@20Je>H}`LWB&d4Z5-9HB)`p#ghXId2qYqW^YvE9sR*sw++wRoT;Y~0+?Bv)BAFta~iJ$VkO?UR*MZDvvLt<6+$bh8%mr8-Re5?PaI!j zl%%V5pIn>?y629R3vgJD1v4SRO?WjSu)?Y2_L9w+J`U^>6vQ?dl*rM|;TUC>c=GA#Thht>@@ z%rP5r;N?-gI*;I)4&!^TgNs_JVUO(TvDXy}_knzUh6XB3XJ+ry`zm-je58$29Fg5S zeIzJ0@ZI0`Jt^1}qo*Nf^h>tQD;kFqKZS!LQw4P|gTS4?LV${=stL=x#mU5sd6l<~ z@4}D)J3=fgbq4bmp0YgA<|^^~qVC#2r)vB&{n~CCJG=YSD*Hzo?{KP>;rXw_WO&)( zUn>f$MT`QuQoU2w_Aff>uN;2lEsm@O9>YE(bU; zOJRY;1NmUc;KvlWE3Y8X8MFj`Nbp$Ini0f?s<0WhX2qXCdKA!K4{$*aJ zNOmTApj5R-`R~&kL3kkxG|*e;8UchnO38dY9ikvN-Hz?Kw|hc1`e(#iPEaKHIdzEST0%uilX2hbq=(FZWh^?btVS^u1u zVZ#SYX=nwoQ$Rk!P;5J_fV)SBn!C&X*3U5lpu7Ge)YC1hYUb58ykb;}U(tGj%i*(P zqU7cOs9D&w$JgaV5%|Zgtg%w0)bOTnN(R#Jnz$BJWB#40&s-mQ{d4q=S0!%(g~SkT zI!)iMuknj!p&q+O@J)fFPyk+#S#&0$Ku%ae_0m;Y>ZwtE7z_5M;k;!=QUJQn z@ZWv*4oS>yeT~PbXSG(%tUY4yQ39<4cMYv2$qN$jr6W8k4-3rANh;CP=^6%+cc2Ez zDGxi{F_1BQD%pHV0p0b^?GD~zWBCmY#Q27K&W5kGQ6J^u_CDPc@Px%d{nWZ-?f-q?s#U|Fz1w>xx=-Hi`7`4^uZ)9?8i*XC=EjbN&i|W^KDo=gk4?@$iUm ztx+_IR&{SK>~Hs398%gsu~%ZE>*iG9ISX|hdK6LBS6`S;+wzzvmH;uNx}blS+1Pm1-M1+WTt+!6aC)3X_hhLZAXNgP-RD8EM10I^H?=VY~cw z81heRb)5JkIBcb3+e>$}C3ebxOQ$+UpyE$a8P6y?e@T!aT29si!m9z0hT;i8JP@~& zV#af?!oK=A3F!7W=5}HqP~hEXYO9`@wOPe5$=?|o#nVosmpQ6M%snUbm}89~*KLBa z*K(*Gx1na!%@*WTwuUv6PjA8-aM8_w1)nc`f@Nn7?ycy?au@vfjItK6Z4>r{pc9eZ zfeFDnIt&PrTCbP|66Vvf=e}&&LeHtT8Fy|#T)H~qToyl$!Qow`Yfuv zHdatsHB%X1uJmM8%f@Mo9=5W zZ8Oeyhr1N972Shx)BA{=&F;S|2S<(i)BCl?6XLh421iq0m6&KoVngcIvdw9&aGEp0?qD+O%^Ma0PFAKWVI3z46U3i z##~OeWZtfy9bOGG<(&8!7N zmuQ|g;A4xgH=H|%ib(398rz{zCO~jRM6MydE#y6Qu-GR4$9NYn<8gpZB-L0rW zH)@^=TXLFj*Utx+9gG(>Wk!uh!8_zV9hv)#ma$dz{kI)GNT;8hzGc2uJ#1jrw#xp&@qD_Fx^n58`s6D z*}<^9Zl*+!lACdXWBV8&!<|O zRt}R6JvmHLZxk)M9b}3{*i^sZ;rGY)nGvma#&Rkb+1tcb3k>h{7zCnxO?+|?q|L~HZ~?wF3Z&_2A3-K9STRoVdYu(JU*8t-X{AA4?qoLh~Z60^sbF{Yn) z;3g7B3RHtEOVQqcplmIwwma*st50qrEja=sBHcof0Waz9Z_p&5dwqbK%(2o6K!VY^ z5413fd%W>}pHlkk2UCUe_AnWn0mfjL-_c6^_1D7^viX zd@l z81nyvPo|q*#EKcK2P)^>c?lJ`u8b1D=|v2@n<+^`0P>vd5&K`cpfpOxR2|0uvkp-6 zyYB1yRqZ$BjZe<6aG!OLyAHC8_N~P(eOpq`a;s8VXBW%LfDv@oSpU#)ZWH@YG z|Ngp_?s9cED%EfD>6wfRaCrQ^<6jZ?l~;bW zYSzfa;=!sdJ8-Y97>$(VxyMb6|Fp@Ht8@nmS+RU#XD^YHo^cfZjrS&0u&v?I=CUEt z*4JOd(7j9}jMiEjnf9=GF11#f+XY$vNgI2`#rea?rpT1{|Ea634}$I`p>4i@fWGnT z`$!w%Y7ctWYB*=XbZn^C3~XQ_*Dzt-wmd@*hQ89Gp# zeHP*(A-;+$PpDht!Rf}G3UQ#q+vUKi8nloGnb*0xZ?iuCa5HdCrsvg5TA*cE6G_mE zC>_9|_H=MWls=5151&TbRINX_9E}Bh^YhQ1#6|^cSvo%VuaSmE-4N!J46^|}&9I?Z zv&1CWf#edQtr7^(O(}kyZe)}h`$C)&pyMU}bExrb{E7PH1<`3)YQpjWFOljiI3n&V zr+#!#HQ8D8+X&@{BAlJm4pvyD1EHd;$6q7;w1PfRfkK>Rel}pJ2&vJ&@(;7q_M5iG zLhs62kqBPl&S2VC=UFK*?xvx}XCk0{aRtz|ya|l-E8NR9b`F10%lX+rI`ME8?5HoN z-v8GEaOlQx5vJsBJdJpG5kPW6%wpr1;nx|~TN!&aOvcqR(n3D5-*fqy??KzG{=Dma z>@iui@fdBkAI5b<{J~PxROwbKnbYO1R%j65UXt?m_ho_cgPf$la2~3n>lk$7c8(&onHYH6i#|FGi%b*AU z2wP995pq43Rk4$8YcknGB8ltCs~(*?^mmLKTfb`!iO|O@-954o=lizND<>rqSdD^i znZI&2p5`d=PH`NYva%7M^uyNW4zn)~w)@Iu0RX69PP zHgi;c6S;PwEp5o{-IY3FJEy`NId!N!t6nMwXCQkku!)bD#$Q%C030WfM@3?1jX3h* ze`1k56`$uQ_M%6Heyn|@2qWda!~KJxj>X`}hl`h0hL?7jx^(>8);OszItStu3N@ z;{7vuom75Voiup09_S{U;A7$NYj!0!pXu5?{qX6+yDKhR-%+2XR>Wub!Nnc`a{^sZ zHaO}W1~^nzZL=DJ2IPygy4%-Q?C({*U2}qq_YS8@^TYD04Mr~#+g9ULIzDD)ns&{^ zMN9IKk&M^BXJ?BDBU9r5yV|alHg=J_C07H6?vU>cZtygGt$XQ&0(sCHbHZy(=`bHh zK*H`j2Fa1x!{@gHgE3;Lf@yvMcQcKdg1c)`c<^#&PlS-K;x!aNGAJbUFA{5Z8rx4- zd}tSbo~0IAfmqj?Hcb?` zAdNelyeS>gbxCwWy$BsKe|&c2w~F3h_5TV|L zT%`WB+WMAo_0S1~pS!rL!0RmwFt6JZ4}|8aD`?~>g;LY~{k3OooSDS){u-;Wb{*dW z@0Z1WaxFzrEmrZ6dvD=ivPq%SGOemqCfT+r-w2G*xV%3%706*X9ki^yQn0w09&fuN}m8A^?tYL5DE z{J>as>1T?)lp-cyN?n~;Tc~|LVcOaX6{tlk-p_%%NTU*`?KgCoOs`{G4K$2-s>>0e zr%f^PX0Bj?$E9yAq(fffh0yu7PDdOxFiI-MCOL=+dA|i|0d$F$BIdXCet$j{2r6A8 zoa@u?sj(Wg9@l8dTH%=JHJ@J(se3t(8mOz1sR;B*K6~BL)| zoKe#cFo4-iTJ2lay|a=(!6V7dJrSgtQ6Zr*$se58Tu!gg2r5OON%zvZbVvESfI4CY zuK@dqvp#?j;Ysw7u5iwpMWqXsZq`IP_lH3~iTvtDZP5dLq>r4LB|^DJ@;{BsBm}+T z{^s87`amw27!i321_6}l33mTVyZ|_svJvzjw)bZQht^D9tdtzLj@S+s*sSdHwXFsO z2Rfa351$%0qiG=RspJF~`)=J!`dP8&PguLAGABhWAXfoK3{+kqEJcytG50-nFi0Xc z_2*9N=G}FiZj4~9v{ot)5EdN&OJ3=%U-L!(xJ*?Wx3Kny2SiBIl>%9|mBX${{!?pa z0s3*>XmcC)XNlN{SByud&9*J~fRi5Mc@6gih&#X>?%}3xnn6fsT4Lo`P)vDT(|1X) zyi!LWBt#GJ>tge)d_834@$|h{`=~>nWkwaN zZ>_i?BexezH#nV_-%M2yJ6|E}=qv{+(MCOvEV;ivKGgjTkQJi0FEIYS>kv8{!0QQx zAnGLG_t{-z!#fnk!j!Sj^1(@S(6ZjH>bOA#ZvEJ^6K=j>zt;htYZEGVf1|R;(8e!V zGFP-0Ap?rnECN~^ce;CY*=_?HVb-Cc^m}+WOOm;K@qM0XpPu%A?`#Z(?@1Jx?W_Rh znl|TXISIU~mZanyNB}zjvpBoCdaGUIvJ=#Lh%(`^?0Q3+=Y6V2~ z_|fNt;aZV(U0F%LlzQD#$MN5Fvcw%yz&gY7X!sc~5AgA}QiMa6v(MD8PBB!W$4Vqb zF9*WDJDapHa5c2}^<*zy;d8u3-dd5p4=cbMsd*9qli8m%tI8@xem?@beZ}-?*6+5_ zJaJk;-S~mFOqSR2@VozDd~DljPrGJkg0WgaxTm22T9}4^ww?Af$qCBDeL63thSbR9 zkW}}I20c_T;1#fm6R9y~hLAAeejK@4hU!obTLlT4~F_52#`HGJNlv%aKhDh z`!qY8J+S4KW?W-4F|2B0dw%n6*%j>^bSLt{D1$Fz$Tu~*Ozdw?b~JfE8vkVV0q+ZGKY4-t(Go_6WBWpZ5AKk z<&+bNRF$?j249+TvvQa;PxyuZeF$guEU#N^i;~zbddTV@{+@r%lku{<0qc`rSC_XO zF1Ve(^CA{=G8w>!>C#eYv%s1y7;$7u_1KsFyb}8Ai?VN%#)QxD!RY*~y|I<(362u@ zh+M3^NI-sD>LXBTpka>V0ciAWJ<4A4#h(51wjCYF%2X}uY$am0crC)uUh8;Yz<+gq z{wOW^e*>Xk`0HY9dex(K=mTPB^R}7R-}3vu{|)b`d{!px?Ji9Y?#$+oQu{2({Ry`s z4<+M@B7B%VlP-$b1UQP)BtEXcY0k0JU^uHOu zP`V$w#$q7jivr3N@~XMI%xJ=goqk^3Ozp0Ju59xD7OmqOWITtmL|wSwn(GLFx5gLW z3s!ERI{}fZQ~OPyM@w?Y_1>JT3g7ci|k{jo};3@4*-i!gboz*dmaR7JQr;;_*1k&rvI-x@6pWF(IxDpM@J06aF*Jfez`ZhD!LhJ>9xvvL*NMG^zAN8pj=9_=a;xRSnlq|aii|W` zdWQ68x%9OEr)KA`I}?iPvF@bmnLhrmVEf6?34!T4MlpJ&nK&#fb9809uC`q(!V?e~ zjT$qv>VK5#Q<5{&_;{5`MAuIc8?G~iGyg9+NjlwrWRuMzRRQp>y5(^={O6I!4}ZCGF+C*fhD>!ifCVT*mDB_Y!e_JNS}iBviV zO^!%`cMvT4G;tk2;B+wD$>%B2AKbKv3jKSz!#XT*d>;T#Gk~WO z%TQm9n6A+kGx2nk@(TU)%2qzJ!~E)3DM)x5UxE31Kbl(8o`|I3}c!-DQgSlvP_Y)l*LFQo2_U@(Ty+k9K^ zbyn%4o>iuuWR%p z^48qvS7gp?e~pG~x_A3Ex&oJ*DtKSkBW?Aj_(vexWZuUX~s_yB|N_Vp8xe`ZAFb_O{#8Dfnn_& zk4=b8yBRA==YW_K)y3hi$^;E)L7cWHbic2TYs4JQw|MGoN0DlbMtswyKU@I)djz44 z2Ynf?eVeY9Y@SDVkL^KW0AI-Z6Ct8H_!$c4e;YinTVc@n$Db|3zgry}P(TP?ytit( zHy(o6-bY)H^N9yBIl-%Rk2pPDrMyF_Og+~|E>Mbv;}P&E!)HA=doJj^x0c;A?%DK8 zR1H4@nz=#Ap5`y#@?VFk$v-_maHWmy%j`ahE)hRKj+@`+3LTz5690)!i1Vx=EX!q? zz}o?G|B5?te8UbkqT}1fXke(@s(wyOcAO%&mdJn9tJC~(&$~0d@6=tu=7hC>6vC;~ zCq8Z3Ct6GzN$4s0RTfpwS&QcaAvM6!}sC z9p9Di5I*AgcTuBbk@S%y9q}?dFc8eF+>4f?NSd)`13L z4G@LUbxOOaNGRLHvS$oZz{`jxyyJxj26#U>#B@;=>M!^SgJ=Dk?jDN!3QsVr$A!+O zDg+`nW0pDcL*JEJFy3$YYS26yO8e@EA-w+L50gior(9-f@=WAP&0=kD?kxNFedRM; z@-WeumW`m6vRPhVdElgE7^JNaH5EiJmCcB%p5&#(k5M9Hfmi)Xy_u%+{#4W08#s@b z*$)mc{NYeaLftA+#{Q;Gk9 zz+WbU)Pc9iigyh_U`p6);iAcC0>9!HwaB0vwq@o^u_!C&=WSL~p~n@J%<@6!xgop8 zVR$>8XIT7RpkI$*weODrH0d&V?b9M5Pp@?dwwd<3g5##i|CUZ&>2$!o`8mnzu?=x3 zP1?Ui^>G=OMfZwKdc%K-GX|?B5S>BuW*uNNRP2pX$K94f~BA!Zd(%y`G5|s*p)O zD@jfc=E%-1p^h3p&Y2!isSDBm^M}uP{&!skZN##OJ%_8$gH)qi0X%BVMU0d2^vqk_ zBM|pC13V!Gt#0>=?Gfd51@4l8 z>~;eIt1~c+WFF!vYjvrSem>Xn)n)6Z+}jO^EfqiEX#o8ibCSLkoS0mZjkrhclJ{gC z{ATCM3jsR*h%qO+$a+kwJ$sv!ffSm5@ih=7L>U^eO0sb{yyDds56?xf+g5*xepo>T59u)ZJ8_jnp-r zT^l|28bH0$ei(m}ttV2$=dR>bbeCK0(icCwa4!hRV{cQS>!y_~!UxiMxAaQq-MV&V zWDi_Y!MCRU8~l93=j*(f4TGum0JkZ@lrCKj!+6H54PH%Au+t9m1+*&hOnZYxR1k^UpWT zG(t-T?as|IUcK&n=1p6vm{pMjYifE<#h1+XL_yssjNlyF8>9midU7PFRO`%|PM7|9>9F*FY5|d}K zTEy)uesqhRVcp=}Q|$fvp!mZA;?NiMa)5YrlqikgltX$CMBS`$mBG4z5TCjTWDn)|4)nR@%p z6{!HBPFP8}l=_Cs!}}nZNtuLD|466lAWyTZ$^7?+0xczQZoRpq!ju$5UzR%pIC=n` z)=92-hSUM?@)hoMzNUD{m%F&lhL&pWuT%wr$GR+E$c%l&;ngjA{*R6SW-?g~z!jZj zGZsuaY^iID!ZVatx)2#HZkK;zCka;CnL1tGJyNP-LW1xARD$pt7K^h*bO=$3Klwi{ z8Mj+nMt8X5o4J^YnysK|RmWd_4k~V!n-?;L1{k47am=sUn{ud~%X#0Qj-OGd{b|Me zKVU1ZFDov1?Hxnv*lj1+{s9HF@fzrz!4AZJU(FKsDgB?&X}ZMFldI0U1~DDe4)D7o z08+Q2fOc$w?S|}6c#WrJNRtcZf(XiAbzuhq){0a$1!t|}_S6;Z&+`xJJ;jQBX^xwP{Vb8N@a;`fx!(LGU{FslcjWq>(+ z57{;S|Kr44_A%zx0iBupOuT$K7;0=TeE7U-=KDk87HxZOy%_+eK??|oAAz|)wlQfT zL4$C~CjH;cO*rJ^_5(>C)I+0|7Dw-7v)glUHXx9x?>vrbEB~;Prag$CyA`1B*%$zX zvF=pxaClH0rM`^gYb<*eg|3&)e6SGuq9aO&d_t8rj;e9Dt^IEYVgP%dqLNgA2}=d9 z@hVZZD_1^d@pq+>;{G-Z`E3AEBj;C~N=e2I+UdP7acv1dd(I48T%(&UO8dCQZjy{W zlNvKs#OEP!O-O;zaWn`=Ze*yU7q~#&{|zZgub^mhY`-Ys&_(w;XAkJO_5lc%{*yF5 zjr%$s*lu&~WJ4?cY~7Ls)QSqsL%R6Y3!MyD?>~zJ-L(%irL)!--TwhSNGjDK1E6|z zy(+JDwb(afU@k=%G^@l?wVJT;M#On}QXv@tufq zxKzUv=8tY@bc2W5#ReimdMm#E@kvd6svH2jdKMdDQMuH*IDw9K$FrL8=bq=ZmPCPLM-vd=!*H^AKR`e(K%`q!-h^Ngh3sE4H>M4vIu!C9Nc z^>dg%rGZu1tGj^;NS(uG16;Y_y(mQBiYTD#=p03$a#WcpmU%;;krH*>huyp*t|oEj zL&YkXtnmHM=}Dt_D_Ph)K0sDP>G%o-yK5_L$od_J077Pq#2uteLGrO#QX&FnUr-gu zo8Q8OQ%T#;vxyNX_WX8R z5h^>F+oVR?!gdE#^YuO=Dti+J97~nF#(5bf4mE?nc1;czanJqr-{fo5p92U5<{9mc zAVi4?R(lws4F=fjv&&{pUP}#RSez$fS)#P`E+Z-;$+gHB<2So+a|kX3ygzx-6~){= z+Uqbk%Rp2ecd!Y|6-z*dr$9G-;VYPul8|KF_DKBpw8ht;`zDZMayw8F8*-C=46)>p zPaAPu|Ma6=j}212G-Fhfk`t~6^XnTT9Fdj?9_oIEn=$g3sjN|a@}&RHf(qNX%tQu$ zzju8UO2usDA%W+G$AsdBa5Y;mP9Ko}+yqko5wnnD-qfWcvN&udOInwnI6Y_S9Kb44 z4|@hcCw>5@JynCp(g5xyiANbd-JvsJ;e~VMTytZQRn9$PAuPa$?k;|WTk*kBh*llQ zcW`b_ao5ger(iUUct202GZ@;{89S5txd-E?eU!+nu6HBUM5baTj@&gz<S~DJ)r+5ftR9tWYgR7 z&rnTX{|Rf>_CLk%(eo)sT^c4~J*j`0BdI*n@PFodPR0ZSV?G{GcFm=iOsF~uS~`p& z|45CE1zM_h*5i5m5uDi0oW0_DjE4)Y=J`ON#$qNL;8&6KDvYRbYWrMW@XoaZhxEnV zBz>G|s`E=ENpTx0{@i-D5hocYupSG%apw)3j+g&!x7+Zf5H_Eb@EM;jD3-vi44+D8 zM`t{>T!eW^5%r6Q1Mu+zxX;QYDt3y-f8Ar&f({F{G?@>ctr3fv4(=tch&+89e;G3|9 zF#!~R#qoy1btT_nUZyi9(<-W;QA^0Nq@TK9#+_;vW4Wb3s{XZWcz*DRDP-8_2_B|f z46bf?GBvc*d2y=PZ03#c4RQNi{CAa{`RDtRBu*3ftUsM|^#41Q?ys@b%$Wv@4opaz zj9sSeIe&AXz4hPB7Pv2(!91mRlR$g~YcjARRw**_+*zS3LY(c&IT0QQ9YCJ@j6=k1 zw%o&efG<81_=3TF1P-8PGseX+fe}*w0|d2MY*i}Z1;jr60-_8P+OS73oOev^ULeoz znChmW%YfmAF)ibB32&Kl8MR`@l%^FYZmFIN1#+YmKD+wQU*y&(-7mjCbcN0_leTd+ zgG00|whgB6@t<#o8g643* zs8P977%}Vbs?fw>pLk)?)9F!Iyj-;s7lWP?3E=)y<0csg=h~hdoa5T=yG$`;NmMSr zzXe}CNDjrEc4C7xt2%lqcHz7`qm%9Lxn=%!lddT4Osn@uWrnV-sCaHtmv8rJWG;?T z{EvWXrA?pM2wT=@8S6dRquU5ClkMLZC+Y;hTmf7@^MAa5eztGq^T7c-UxQFy60Q*a zvs4~c+I1DN`ip58p zeT0$T801%Wz1etA=E~2*Yj2eZc;HR3Gg4rWOfT^8gtu$&{vXF$^`HX3{o57$1wnx# z0pl7IRV5mn>I@kjTUc-`Jp9*{Wk5)S6gGaZUI*XAn4WQZB|r&^t}m2Tdn!v>br z>WqL)g<(g9S~STLi|%mrxFT}da6Pc!it>iYVSoV<5jy_Tg_hL%IZac zu?6Zp5_dzyu08#OhZ{T(tahps@<@hn4~GanuZ<^o{quTNgwc61I^U`0Ax1n|^>4M{ zdrCmgPzOySr6?ZMH*(h!65-5Z@*HsYR{oFe65_zxcBb3=IjrX_YE3z0TmSge%0aBg z?}nV8y$b}?z7YB6{uAq#8BT+c(D}b&nK$n`eO25|oo#aW`u0LmT;f)*3rD7)m^Qnb zIx<7yZ*_u31+>|CmRAsPi5^)Xv z0S1E(GecQ!L=jrQ!+NLMr@0;mdLclEHagWSx^w7}t-N$QQYGN|3v$hhm39*RrN#b* zRF9~$$0eHwZw?)^{odu2b)-9d7;TRxUlHTmJW|$G^UX3dL;7au;+Kf~W`2c+vz+@; zP+6yF3Z1P`OkZd@+B?=zMnOpad~}AT*`jZ@t^amYfW~X2Lyw2v`Knv4z#rx7k#h2zUGOLUZ%j(*T60v#`A2?OcB@AjL4CnHC}knkKC{F zlL4BLM|z+cvXOBqS4!e6(y#bl}GYY+!- ztUQp@vB1A}2h1S|MWjTxb|i$i*N=&vRS#O#KjM|tG&8jD+s_K4`8p796##~QIRg7c zi}8R?_x(l&hELo+s{(oSkG%ffx?zS;+d->QW&hE#w-~OsgCPf6L6ThxN&gTgUT+7M zwgBh4_XK9P;BUSe0$RGKV4$1f0#Z+wsCP_8gK^m$X={Cmo^dO`EN&TIQHfN)l9 zxK@cO67y6%f#;0aX|%kkoL8mQb1_7#=VUAjx_d;t)O-0910CaTiq8M$1K1dDcMoIR z*1C^lnB7~B2gj==I^3u4^jLTI051fdL z(}Iv|&s@MlLq^L3Lf;33UyDAcwKNwOncY{kDbfpU9V_Tlc~sUR--`t+5z?TqB?JS% zwf*v+BhJg?{pW%fBuEDst)7x%N;Q6-Yc7C)In!%=u)dRsnL=d<-(^^%*WHG8eTE|V zSa%FdJ5OuuU6brH35-Iy;DYQ#8=cFR8!2%OYt|((gOH5Z$Z0Co}!Uyq0hNU3xn2scytP z=tKCXoy^^>(_?%8eqQL~m(<`TR*kDJp1ROyMzRQY@aW!SR}-r#P8kHry>w&!om#S{ zqPY%6$4*(qr9?u>a|$ZSK@O#zjSpKKfkPG_;}*Agpq}ZUjNy@_TD)uB`XOV>iK>Y< zf0ib@Q10=5WWtu^tBKGLK6z5)-+fvkU-@ivF&RGBTBv1mP8+qSwwD9uhZG-`c&zu>It_nI%F5_uk0T zc@~<|mb;p5TdmQZ29g9n!$GJb6yg{Ew{3vm&-y$5HgvydN@1}=>CeqBofo2{JpOb6 zuaJ*vZ94oHbF2Ad6T7Bz5)2|V)lF|M%L`Z1;{(!aOp|u6w@i1=XASzEm!-Hudx=UC zIvnaJ`_X9X{UbrXrN~AdVLsOWTdc$Z;mdegp@@Qkl^FO&dPS{JK&zs0OR@ECy=Y@D zIS*CoJvZaTnKGAuCzvf0+LEy?cQS!8u35j@Cc1C%8a~9a${t2Q6GwM0GUIZXDuDE9n%FqHsMgP#@^yGp01Q4 zb+ml#y9}0n(IPpUgfZFm*AHsBgwKo%JIG3lMoL|1+*p!t!FKetSMC+^GUzwgEh?8? z6R`!)m7oFbyYWukDRb53JM!}v*+(Vf=0}=8;ll-HK#US<-GAMM~qe>t(yOJ|sje{NzU5EZ@zTz=vhD}H`+E6N2Nzw@A_`H1;&5m2bzYWS2`7LapeuU*!X;vHM#r$?vo zKW+pEwog@Xdr7f~CQmlcOf*YQ*#tT(1odVsGx$&k-I-DiMe&Kp={TE##m0-3x9oGyj7X3dIZ$FcS^y_ zh?Ta}Es3=IHiMcmN8f`=wS;vvq2dC1m^CDCGx}!IrsH}3dk7HE^xjI@jC)^{p3vx+ z_EKQPtA2##tK{)u!xJJkL`ir6Su!?XJU(t?DE~z8y2g)gFM%cO_w4rae^r}TQ3O%V za*Qk!ZHA^UzBw3YZnM;OJ^?*rp1zd&`N-NUYmhIPRdJ`1SCJxsZM;6OlTn#^J@WNr zG)iv%8oQOtA3m&M*SnfVauwr_Qsj<3< zDJPVuo0+cxe_haLqNXEG`XTVG63GjvhIMSVBghIo1e(=n;5-=+YhLywd5|5Qt_b09|&8IV4Gp=nT+|vqmPFb z`ucL>oV1RAw4o}R^y-kg_7xb z8?TU7RrEtIm|UZ@P?Uz|1mT~2RWsNu-jmEVuWxDkka2sACXP2 z>AWl_)r(4d^2$49$r-PLEB%!pPzD-3b%5cXNV{B~GzVei>CH1DyGy*@?9M&f3+yr@ zE4Pl^N|W+>0!T_eP&kk@qduM0tj4~+r5!Bta@gvn%*R8;UO$I zcq@fmP_}><@eD}5cb#>H#1HAV0QXE>FNmNh!=&qh<0;S|Lql%%2PF79;Sg5e`l$>G+0Y~*sAGcu=uSDc-H4#r+EYZR;2V@EKwp; z%gKedPz?o=Re6An5PKYnbrR~z>w^pMcM(y^BBUuR2)UfxsODBoWxHovcsB36w20-= z3?u*2^2e;({N(a_fK9WJBtrr{RxH*G^&d2GQ>>PD-L*%RaIAZ5T4E9W&WHI4FknIh z7R)&Ui?Vez)FWi5_&Q9Rb2912G(F2)NJ_?{OOf<{UyrX5aZM8FWU1+YNqpZ7e*x>Y zRgJW|V%!pgp!G&g{I-zvH}`@`C*-vpnN5`?H=YXcykRxx@I--~id6DYc=_%YaH~9m zKaqWqmET874WgXQ;yJ(O=nMucG%C761Iuf>N4t4?u%z^i+qb6zUzwvP!%zcgxmQ)4xo(0Esf@p4mmR^4_?CCBrGM%b zl06fe(BrtCtAbI>3PAgTY_FR(djPEC`C=QlC)J~P;fFSzeXB2rYN(o)|7^}By#OUB zN=63DY&y-bX;S3rf8wzi7guv{e7H4rO5t1Hy;vmq5=7g@FpkF2V@WRC%VSgyNjwiv zk*!4FSc`Rl-z*}#Cu!A8AetwS))}BQKihCGZVa9{7X$I9uG4Rb$QDgvgF8aaMb7P? z6O}FYdG|q8*ppAS6)O*g;H_FGBqL;A<5d@Pr2`3RzlX^?gM`j~Y})(X+yHkyuMkq@ zx4??l4TOJ=kRkB9oy_EhMO(|evcjH)luXxyFbJI*F zdHtlcJ2pXEbyUlQ1plk!>7H-SzdM;k0=I!%-2Cf=-vc_kYsl?eKf}`Aw^dn1Z!Mj? zKlF=CrS&ciw)E3q(b+R=po_Azfg4|EN=SAa4PN6v_6MqL1}mq6D^QAbMOC9R-s?wN z5!+A7b?e_Mp9%btvz9XV_M+5OPwF#EAE5wuOh?V!5R)85+y2dbC;1W4c2gtJwOaK$ zNuUYk0_Qi|G@%oMDwh!-^>BTwmH)4*E00TR+y3|7ZliiDy*Y~&rlpoSpqZ$Zh?$w! zN>OttaYl3ODxh+$G$%wWO(ik4vIHR{E9YA{4+JpgRw;q~?05Me!s$$zQ%>5=4zl+lZE=^syQp`y)MoF)WZV5?A9eG7 zKkAwPw2@xMOs|W&$kBPZy6P^iuMm=~NvDnKmy{CwUPVdZ$|gD`YiSgCG!X6v0kge$ z5}XM<(hGR!pmN7%+DBh>#cV~8^XO@mYY}t`F{NZ~V0u+A^i+n}ap6fE!6T-R{ws0^ zvd~VXVt@A7BbV3e@O0E+WKI(GpJbPRPJc+oJcl*GeH@(Xz#{jr$G>JvOylSn3@b^( z;O9Fb)7&MJ1cqzP1^h?IW3nvw9)6;#f}KvgTCfLq2<7quYGmWaxTk4Y z=~@!tmBrOkR?G@))Nd&lgdanP7B4KaW_*x=F>Yk*dx65Knj+`319H;i*fxtN0-z*P zB=4mf-NbfT}C;%8+nuB2eGf)`n*I%lScgJx<}tgiNI2z4g$(wFI${Biwbnn_t`AVdRn-A^q_|s%Jb3J zA96w@_OLt-Tl&ne{RM}VVS4Ha3A`6PWW+&>GU_eLs*ZtymQ|}tA!v{mc93x0^4^^Y zUEWb7CD(_SFj=c`0Wg%E(dk)I$*FT%@gIS|+&%Vc`J5>&_YSxR=j}hTFL|zmC_ns! z=fB$81FU}Krt^(f1z5#TF=~d*$)8w)kKKb{K}DoA?6f81ICN`h@AI5e79th|03e~4 zXK#7jKdhSc@@{nhFGd~kL$?zxXkI$FlICl8e3J&!{z?Qn!Fo3F6`^+KbeyZFbD?hB zb8=bpBd?|#?H(_-_Eo|@4sDxp~zPi1Q;68)oepV*i8BB?}xDRjuh{wC|~xltbx4Abi*;V0>)@w>q1*i{S(VxljzYKR=6NocOd8xB4&jc zLm6mM^HjJlC;oDi6_L3#1uOAXOj8%}&aA-4D*%%{wt8l`W(_&y32vIim`|0J>Fz3d z8+`lF6^Oc$P=SX+sj3YsYb2$6fgVPl_`^e^0>vlK@)&#{q<$<3ARou~*SQ*f%>J8* z^DOrHt}^Ph3n~Lv#LaR<9;H96#eN7_=%k}buO*vwy_LPXitjbuIu+||0oAiGlxGrW zXq1zw9pup0^wzFe8@T-yS=>3lc_2hM=E|VszqYM-ueq34@N=rOKty&=(1P2THe2oW zd4y|}t~ooDJF>M|rj>EZP5IGuuHw33gg;!q+s%;dclTt9t=Pr1>lf=!%iYdk=qZ4? zg5tre%qW@|kg**OrJY8}Z^U zf(u^2-tATp^}Z<8v3Da)0rlQ$l{Xte>$1Klb^7c6rZP6P0}>%;3{Xow*LhLo&$Er) zr*X1+&X>=|PpB0XN&wuETUo3d5D$iOaOg|*4q_^!ePi0%7urR-19AGIEwm(RS}_DX z)B>(&f1GhK#p&8&@P!i-R15c4+vz$5l0$$RZjVDf&IHWZ;p9V_vmATvB`7jHB<5(=hADm%iQgv)DN!K2EB8`vNSji^xQ=p0e2 z1xMzy9~UiyGirYrU1Vc#qk&_2A<##l=sH}J^8_>bG#o@B#*j#i9`aDM0O8SgZxE*^ zn2Nx}Ih{@MjzKU$oSqS-P-7}&qbDXv^Ic8~#U~3RZxtb1m)Ow<1t?Y48XbJP&opE| zYpv7MkfRP5VlvzFzB}u}wEK>*PwciAoJN6Bu%242eb69&FuV#3;ToB^(-y7 z#p_qT$^JuHPJZCQw~D>)kZ*+|*t=BLD}Ql}f611lTsr=fEq#6xL53gt>vK&*^^$BV z^9iOY?-OT{sz|#?!}-~moZd#~#(nIpFYu_iMHFGnlLM%~%t-Zw@dNkQ?{GrjcdCU}S&0xH{=*^%9ULfZl*C^WjtUuO*|Q1(J#awO%@ z94Y8q+TQ%;YZY%QW8Q=t=UHz&yb`QL&3Z5n>JC#>o_8F=BsM*oDrUCdnpBj!t%pl? znQ-fW3Zd{Isy3j^+^Eg=-sXk-+2n?+l)O4fe@5AaM@uiaW0)=^6Lj2#+7xVQ=wpm~ zoj7FO6q+b?4xKrpD|e;Mq0T`{hWvt&@ zt2n2SVakKdbbjDa9Z!ZVT-K{{XdR`?RNwP-uYoP2t_Hb|Ua9sLSf%BEeIH*6HXwfwXd)TX9&pa)@rzlh@Ne|tY! zNf@$S3OLVjnFHphrYXLS#zH@3N93Az-R+&>DZ!C8fnZ9)!Cq8MI2Z?%kgY2<1VO3V zuSRYha{me_k2vWhBK5%%JuA>35aJrHP}}_J5z2dU+x9V~gv^mMf-YdmK0da3d$)8}HtEgO&JalEVb2)*I-BxKb=Ua|!JKJ@rv) zDuXu>Xkkt0${~=`;(KpQHma=0PUuK{Ah8a!6AdkKQlb=T8E0`tRs?MA@1sb&%U59! zAB_w%A?hqYU3ZdskdZ9;F}=M2StMnOQ>;Gizvl8SS<4Q-OzrC)*FZ_ZA|R%;fdFtv zK@<6|ju(D68P(uiPj*64M*N#N)upCo^`*`ti`Tu2o)PL}2S|A1{7kh1O2***YmxIk zdx^(1zxuxYt3S+7xskr3bu=kFM0O!rE~~e%pATeS^41M4*QCo;*-Xc+CSzldnaXI5 z#$4Z#qE=u>z{Aiv2HiqPhS>;_MG3Fmf=$0i;l32A>0lOSSL-o+eyqj&7I>Z-_Clua zFaC=5zc~iP`ne16&St#qK6aRfR808;VHOkbR5JK$=Cr@5y}6yV2j313P}wJo?e8_i z_4$ZSuRna6L_d=O&KwBX%r`r0nFcyDjn--LSYSDkBU!U9<%ji zYae>*m>7XxN&WsVr4&pvj4saI`961w(@m>)n3Qtq=voI@{Uk@xZAbL_NI9}VwAH~@ zx6ER`xv;S-Ikv>bMDGB$--b2-HT91t#s*|zQ}diZu_E4@?y@vX;vZ(lOac*9;}dTQ z=>x4+4|~JF89;_>un}S6vv;%&_fhpU8=6$ZEj9DN=y4VkRw)>+P;Xcrv+4RQJ9Q~O z!F)3huh6r%?AXh%_$JeDSk83jshhf<5`6xENW=O@E-;6xum7!frMX$RkV;Kh0jUz3 zC6qGIG4=>^-IYX@-vD)5anWiU@#6i_4GPf>TZZn7^>etEVZlVlur-F~-!GNa#|rwa ziPt$-(fW>~<70Skrs=E!ni&9=RKBWhZ@K0c;rB>m8+(h{lO?s_6`%B#{5My*S+^RThBfv!WGuZDePI7q2 zMniBG`dMXb8Xd34pH)k%=Sm13$HxdlKAh{ljb5T=9mGxN)7l1{5`sMg7GESXhPvOl z+w?(q(+Iwg=LF%M;~~exn%C0)0LVmlkCcYOBl2fUm)jAPCZxIn=kXABv+%9 zM|ztVE?J%s|4fSpThBEt_S-ZhA&R{8P6*<-Tbth)+aW$i3%kbYpK{+^lJ9@Dm0mNX zu%pMMqAg(~ATrw+1UBm2yrlBGGWorN9sJ81#fhUPhHtC#{%kZgvm7;2hCv&+D$0D( zgy%FHpVF{BIwMhGYfDR0Y^Y@O=G5OH@b4@OjR4Cz87#~`TV&*RKcLe2RCU$Ifz0le z7b?4TZr>!e&UZ;8udFDwk2H4ILj5JBx@P5Rc6qoKUsQL?y5WJr(kk2QJr}brwwc%# zrKDrF0&UwDI|u%1Vth%^jc0txz70-Ov z-ByyPbYJr9A^>=0+5H;E%Id7espyh$FTQ^rRePhZMz$Waz2)#;>|ob(pepd@`N_t| zbpdRY{#M8F8URZLi?+$}-_r24_N>u_D_g?F#q$c0Vqb8?a z@XDUZ#l(hjWkt|Y3LVJ!9x18}e-oXVZ9&MDO#Q65Q4dna5{nzS%74agX&u9i#RA91 z3Ui-}zsdeO=Sf*9qMaIW-Z|S4g`)SA`pF#++F{jW7j^j>14wz@G-+UB_J@aHSFuLf58=Gi&(p1q_@P4lnW`NOXH@5|QXIT*GX%v1nv`^O}}H&T!|eziPO83~TBH z#~JOR?JhM_5~l`SFu!VIP+0l#2-BKv_2H25=?w3okvz_ZQr4PnL~D#QMbaZVb&G&7 z0xxe?r7Tkuma9{!DUt~b^)ph&LcdG=(d_vf_vJwgToO_(PYN`H#>N;G0-gT<-2Bnd ze`Yw~6>TgNmz658>4lh_N)yBX?IGaJ5|y#E6$pgCOQ8lz{03ueNT@6NuYiB|aer#6 zuv!&cL+uR!@?<% zKrF6%|NjOauqjn&a;hFPrxm+i5x2f^2Yn^JXll>51~-Ghhlhu8b(vG&;?VV$n9aw{ zE!zOgW4J;*;C~bkFj}~Wvn;H}h<%4K>(%0|7WLuxq1^aj_gc($eTe$D4K-vK^D|6C V!gjYOnD~?AvbDYfAz!$8_g@fxMehIr literal 0 HcmV?d00001 diff --git a/PaletteEditView/Tests/PaletteEditViewTests/Models/AlphaPaletteStateTests.swift b/PaletteEditView/Tests/PaletteEditViewTests/Models/AlphaPaletteStateTests.swift new file mode 100644 index 00000000..eb602cdd --- /dev/null +++ b/PaletteEditView/Tests/PaletteEditViewTests/Models/AlphaPaletteStateTests.swift @@ -0,0 +1,65 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import Testing +@testable import PaletteEditView + +@MainActor +struct AlphaPaletteStateTests { + + private typealias Subject = AlphaPaletteState + + @Test + func `Default state is full alpha`() { + let subject = Subject() + + #expect(subject.alpha == 255) + } + + @Test + func `setAlpha sets alpha without notifying onChanged`() { + var callbackCount = 0 + let subject = Subject(onChanged: { _ in + callbackCount += 1 + }) + + subject.setAlpha(128) + + #expect(subject.alpha == 128) + #expect(callbackCount == 0) + } + + @Test + func `updateAlpha updates alpha and notifies onChanged`() { + var outputAlpha = 0 + let subject = Subject(onChanged: { newAlpha in + outputAlpha = newAlpha + }) + + subject.setAlpha(64) + subject.updateAlpha(200) + + #expect(subject.alpha == 200) + #expect(outputAlpha == 200) + } + + @Test( + arguments: [ + (-1, 0), + (0, 0), + (128, 128), + (255, 255), + (300, 255) + ] + ) + func `updateAlpha clamps values to 0 through 255`(value: Int, expected: Int) { + let subject = Subject() + + subject.updateAlpha(value) + + #expect(subject.alpha == expected) + } +} diff --git a/PaletteEditView/Tests/PaletteEditViewTests/Models/ColorPaletteStateTests.swift b/PaletteEditView/Tests/PaletteEditViewTests/Models/ColorPaletteStateTests.swift new file mode 100644 index 00000000..18eed65c --- /dev/null +++ b/PaletteEditView/Tests/PaletteEditViewTests/Models/ColorPaletteStateTests.swift @@ -0,0 +1,100 @@ +// +// PaletteEditView +// +// Created by Eisuke Kusachi on 2026/07/02. +// + +import Testing +import UIKit +@testable import PaletteEditView + +@MainActor +struct ColorPaletteStateTests { + + private typealias Subject = ColorPaletteState + + @Test + func `Default state is black with full alpha and grid segment`() { + let subject = Subject() + + #expect(subject.selectedSegment == .grid) + #expect(subject.alpha == 255) + #expect(subject.rgbColor == .black) + #expect(subject.color == .black) + } + + @Test + func `setColor separates RGB and alpha from the source color`() { + let subject = Subject() + + let sourceColor = UIColor(paletteRed: 10, green: 20, blue: 30, alpha: 128) + + subject.setColor(sourceColor) + + #expect(subject.alpha == 128) + #expect(subject.rgbColor == UIColor(paletteRed: 10, green: 20, blue: 30, alpha: 255)) + #expect(subject.color == sourceColor) + } + + @Test + func `setColor does not notify onChanged`() { + var callbackCount = 0 + let subject = Subject(onChanged: { _ in + callbackCount += 1 + }) + + subject.setColor(.red) + + #expect(callbackCount == 0) + } + + @Test + func `updateColor notifies onChanged with the current color`() { + var outputColor: UIColor? + let subject = Subject(onChanged: { newColor in + outputColor = newColor + }) + + let sourceColor = UIColor(paletteRed: 64, green: 128, blue: 192, alpha: 200) + subject.setColor(sourceColor) + subject.updateColor() + + #expect(subject.color == sourceColor) + #expect(outputColor == subject.color) + } + + @Test + func `updateAlpha updates alpha and notifies onChanged`() { + var outputColor: UIColor? + let subject = Subject(onChanged: { newColor in + outputColor = newColor + }) + + subject.setColor(UIColor(paletteRed: 0, green: 0, blue: 0, alpha: 128)) + subject.updateAlpha(255) + + #expect(subject.alpha == 255) + #expect(subject.color == .black) + #expect(outputColor == .black) + } + + @Test( + arguments: [ + (-1, 0), + (0, 0), + (128, 128), + (255, 255), + (300, 255) + ] + ) + func `updateAlpha clamps values to 0 through 255`(value: Int, expected: Int) { + let subject = Subject() + + subject.setColor(.black) + + subject.updateAlpha(value) + + #expect(subject.alpha == expected) + #expect(subject.color == UIColor(paletteRed: 0, green: 0, blue: 0, alpha: expected)) + } +} From e201b6bcf9f52c5c8925151d44b458f4a8787521 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Sat, 25 Jul 2026 17:11:02 +0900 Subject: [PATCH 02/13] Update popup infrastructure for flexible placement and stacking --- .../HandDrawingPopupOverlayContentView.swift | 9 +- .../HandDrawingViewController.swift | 5 +- .../Components/PassthroughHostingView.swift | 65 ++++++++-- .../Components/PopupView/PopupView.swift | 100 +++++++++++---- .../Components/PopupView/PopupViewModel.swift | 67 +++++++--- .../PopupView/PopupViewModelTests.swift | 120 ++++++++++-------- 6 files changed, 252 insertions(+), 114 deletions(-) diff --git a/HandDrawingSwiftMetal/HandDrawingPopupOverlayContentView.swift b/HandDrawingSwiftMetal/HandDrawingPopupOverlayContentView.swift index 0065ac63..3b8dcf7b 100644 --- a/HandDrawingSwiftMetal/HandDrawingPopupOverlayContentView.swift +++ b/HandDrawingSwiftMetal/HandDrawingPopupOverlayContentView.swift @@ -14,9 +14,12 @@ struct HandDrawingPopupOverlayContentView: View { var body: some View { ZStack { ForEach(bindings) { binding in - PopupView(binding.viewModel) { - binding.content - } + PopupView( + binding.viewModel, + placement: binding.placement, + content: { binding.content }, + onClose: binding.onClose + ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/HandDrawingSwiftMetal/HandDrawingViewController.swift b/HandDrawingSwiftMetal/HandDrawingViewController.swift index 710e34f1..8089762e 100644 --- a/HandDrawingSwiftMetal/HandDrawingViewController.swift +++ b/HandDrawingSwiftMetal/HandDrawingViewController.swift @@ -25,8 +25,8 @@ class HandDrawingViewController: UIViewController { private let dialogPresenter = DialogPresenter() private var textureLayerViewModel = PopupViewModel( - size: .init(width: 320, height: 300), - placement: .top + width: 320, + height: 300 ) private weak var popupPassthroughView: PassthroughHostingView? @@ -375,6 +375,7 @@ private extension HandDrawingViewController { .init( target: targetView.layerButton, viewModel: textureLayerViewModel, + placement: .top, content: { textureLayerView } ) ] diff --git a/HandDrawingSwiftMetal/Views/Components/PassthroughHostingView.swift b/HandDrawingSwiftMetal/Views/Components/PassthroughHostingView.swift index 29bc0773..cba3de5b 100644 --- a/HandDrawingSwiftMetal/Views/Components/PassthroughHostingView.swift +++ b/HandDrawingSwiftMetal/Views/Components/PassthroughHostingView.swift @@ -16,17 +16,26 @@ struct PopupAnchorBinding: Identifiable { } let viewModel: PopupViewModel + let placement: PopupPlacement let target: UIView + let targetFrameAdjustment: UIEdgeInsets let content: AnyView + let onClose: (() -> Void)? init( target: UIView, viewModel: PopupViewModel, - @ViewBuilder content: () -> Content + placement: PopupPlacement, + targetFrameAdjustment: UIEdgeInsets = .zero, + @ViewBuilder content: () -> Content, + onClose: (() -> Void)? = nil ) { self.target = target self.viewModel = viewModel + self.placement = placement + self.targetFrameAdjustment = targetFrameAdjustment self.content = AnyView(content()) + self.onClose = onClose } } @@ -67,6 +76,9 @@ final class PassthroughHostingView: UIView { guard popupViewModels.allSatisfy({ $0.isHidden || $0.isUserInteractionEnabled }) else { return false } + if shouldPassThroughToAnchor(at: point) { + return false + } return popupHitTestRects.contains { $0.contains(point) } } @@ -74,7 +86,10 @@ final class PassthroughHostingView: UIView { guard popupViewModels.allSatisfy({ $0.isHidden || $0.isUserInteractionEnabled }) else { return nil } - guard popupHitTestRects.contains(where: { $0.contains(point) }) else { + if shouldPassThroughToAnchor(at: point) { + return nil + } + guard topmostVisiblePopup(at: point) != nil else { return nil } let hit = super.hitTest(point, with: event) @@ -85,7 +100,11 @@ final class PassthroughHostingView: UIView { cancellables.removeAll() for viewModel in popupViewModels { viewModel.$isHidden - .sink { [weak self] _ in + .removeDuplicates() + .sink { [weak self] isHidden in + if !isHidden { + viewModel.bringToFront() + } self?.setNeedsLayout() } .store(in: &cancellables) @@ -94,8 +113,18 @@ final class PassthroughHostingView: UIView { private func syncTargetFrames() { for binding in anchorBindings { - let newFrame = binding.target.convert(binding.target.bounds, to: self) - binding.viewModel.targetFrame = newFrame + binding.viewModel.targetFrame = anchorFrame(for: binding) + } + } + + private func anchorFrame(for binding: PopupAnchorBinding) -> CGRect { + let frame = binding.target.convert(binding.target.bounds, to: self) + return frame.inset(by: binding.targetFrameAdjustment) + } + + private func shouldPassThroughToAnchor(at point: CGPoint) -> Bool { + anchorBindings.contains { binding in + !binding.viewModel.isHidden && anchorFrame(for: binding).contains(point) } } @@ -108,15 +137,31 @@ final class PassthroughHostingView: UIView { } private func syncHitTestRect() { - popupHitTestRects = popupViewModels - .filter { !$0.isHidden } - .map { viewModel in - viewModel.popupRect( - containerWidth: bounds.width + popupHitTestRects = anchorBindings + .filter { !$0.viewModel.isHidden } + .sorted { $0.viewModel.stackingOrder < $1.viewModel.stackingOrder } + .map { binding in + binding.viewModel.popupRect( + containerWidth: bounds.width, + containerHeight: bounds.height, + placement: binding.placement ) } } + private func topmostVisiblePopup(at point: CGPoint) -> PopupAnchorBinding? { + anchorBindings + .filter { !$0.viewModel.isHidden } + .sorted { $0.viewModel.stackingOrder > $1.viewModel.stackingOrder } + .first { binding in + binding.viewModel.popupRect( + containerWidth: bounds.width, + containerHeight: bounds.height, + placement: binding.placement + ).contains(point) + } + } + private func syncHostingVisibility() { let hasVisiblePopup = popupViewModels.contains { !$0.isHidden } hostingView?.isHidden = !hasVisiblePopup diff --git a/HandDrawingSwiftMetal/Views/Components/PopupView/PopupView.swift b/HandDrawingSwiftMetal/Views/Components/PopupView/PopupView.swift index f8234c1e..454f46e4 100644 --- a/HandDrawingSwiftMetal/Views/Components/PopupView/PopupView.swift +++ b/HandDrawingSwiftMetal/Views/Components/PopupView/PopupView.swift @@ -11,6 +11,8 @@ struct PopupView: View { @ObservedObject private var viewModel: PopupViewModel + private let placement: PopupPlacement + private let onClose: (() -> Void)? private let content: () -> Content private let cornerRadius: CGFloat @@ -21,6 +23,7 @@ struct PopupView: View { init( _ viewModel: PopupViewModel, + placement: PopupPlacement, cornerRadius: CGFloat = 26, contentPadding: EdgeInsets = .init( top: 16, @@ -31,14 +34,17 @@ struct PopupView: View { backgroundColor: Color = .init(uiColor: .viewBackground), borderColor: Color = .primary.opacity(0.12), borderWidth: CGFloat = 1, - @ViewBuilder content: @escaping () -> Content + @ViewBuilder content: @escaping () -> Content, + onClose: (() -> Void)? = nil ) { self.viewModel = viewModel + self.placement = placement self.cornerRadius = cornerRadius self.contentPadding = contentPadding self.backgroundColor = backgroundColor self.borderColor = borderColor self.borderWidth = borderWidth + self.onClose = onClose self.content = content } @@ -46,61 +52,99 @@ struct PopupView: View { if !viewModel.isHidden { GeometryReader { proxy in let popupRect = viewModel.popupRect( - containerWidth: proxy.size.width + containerWidth: proxy.size.width, + containerHeight: proxy.size.height, + placement: placement ) - content() - .padding(contentPadding) - .frame( - width: popupRect.width, - height: popupRect.height, - alignment: .topLeading - ) - .background(backgroundColor, in: cardShape) - .overlay { - cardShape.strokeBorder( - borderColor, - lineWidth: borderWidth - ) + Color.clear + .allowsHitTesting(false) + .overlay(alignment: .topLeading) { + popupCard(width: popupRect.width) + .offset( + x: popupRect.minX, + y: popupRect.minY + ) + .allowsHitTesting(viewModel.isUserInteractionEnabled) } - .clipShape(cardShape) - .offset( - x: popupRect.minX, - y: popupRect.minY - ) - .allowsHitTesting(viewModel.isUserInteractionEnabled) } + .zIndex(Double(viewModel.stackingOrder)) } } private var cardShape: RoundedRectangle { .init(cornerRadius: cornerRadius, style: .continuous) } + + @ViewBuilder + private func popupCard(width: CGFloat) -> some View { + popupContent + .padding(contentPadding) + .frame( + width: width, + height: viewModel.height, + alignment: .topLeading + ) + .background(backgroundColor, in: cardShape) + .overlay { + cardShape.strokeBorder( + borderColor, + lineWidth: borderWidth + ) + } + .clipShape(cardShape) + } + + @ViewBuilder + private var popupContent: some View { + if let onClose { + VStack(spacing: 8) { + HStack { + Spacer() + Button(action: onClose) { + Image(systemName: "xmark.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color(uiColor: .systemGray)) + } + .buttonStyle(.plain) + } + .padding(4) + + content() + } + } else { + content() + } + } } #Preview("Top Leading") { - PopupPreview(alignment: .topLeading) + PopupPreview(alignment: .topLeading, placement: .top) } #Preview("Top Trailing") { - PopupPreview(alignment: .topTrailing) + PopupPreview(alignment: .topTrailing, placement: .top) } #Preview("Bottom Leading") { - PopupPreview(alignment: .bottomLeading) + PopupPreview(alignment: .bottomLeading, placement: .bottom) } #Preview("Bottom Trailing") { - PopupPreview(alignment: .bottomTrailing) + PopupPreview(alignment: .bottomTrailing, placement: .bottom) } private struct PopupPreview: View { let alignment: Alignment + let placement: PopupPlacement + @StateObject private var viewModel = PopupViewModel( - size: .init(width: 300, height: 200), - placement: .top, + width: 300, + height: 200, isHidden: false ) @@ -134,7 +178,7 @@ private struct PopupPreview: View { GeometryReader { proxy in let buttonRect = anchor.map { proxy[$0] } ?? .zero - PopupView(viewModel) { + PopupView(viewModel, placement: placement) { VStack(spacing: 0) { Text(String("Popup Content")) .font(.headline) diff --git a/HandDrawingSwiftMetal/Views/Components/PopupView/PopupViewModel.swift b/HandDrawingSwiftMetal/Views/Components/PopupView/PopupViewModel.swift index 90b7aef5..90ea754f 100644 --- a/HandDrawingSwiftMetal/Views/Components/PopupView/PopupViewModel.swift +++ b/HandDrawingSwiftMetal/Views/Components/PopupView/PopupViewModel.swift @@ -9,9 +9,9 @@ import Combine import SwiftUI enum PopupPlacement { - /// Popup opens downward + /// Places the popup directly below the anchor. case top - /// Popup opens upward + /// Places the popup directly above the anchor. case bottom } @@ -25,26 +25,30 @@ final class PopupViewModel: ObservableObject { /// Bounds of the anchor target @Published var targetFrame: CGRect = .zero - /// Vertical gap between `targetFrame` and the popup edge - private let targetSpacing: CGFloat + @Published private(set) var stackingOrder: Int = 0 - private let horizontalPadding: CGFloat + /// Configured popup width. + let width: CGFloat - private let placement: PopupPlacement + /// Configured popup height. + @Published var height: CGFloat + + /// Vertical gap between `targetFrame` and the popup edge + let targetSpacing: CGFloat - private let popupSize: CGSize + private let horizontalPadding: CGFloat init( - size: CGSize, + width: CGFloat, + height: CGFloat = 0, targetSpacing: CGFloat = 8, horizontalPadding: CGFloat = 16, - placement: PopupPlacement, isHidden: Bool = true ) { - self.popupSize = size + self.width = width + self.height = height self.targetSpacing = targetSpacing self.horizontalPadding = horizontalPadding - self.placement = placement self.isHidden = isHidden } @@ -60,8 +64,19 @@ final class PopupViewModel: ObservableObject { isUserInteractionEnabled = isEnabled } + func bringToFront() { + Self.nextStackingOrder += 1 + stackingOrder = Self.nextStackingOrder + } + + func setHeight(_ height: CGFloat) { + self.height = height + } + func popupRect( - containerWidth: CGFloat + containerWidth: CGFloat, + containerHeight: CGFloat? = nil, + placement: PopupPlacement ) -> CGRect { var rect = alignPopupRectHorizontally(containerWidth: containerWidth) switch placement { @@ -70,20 +85,32 @@ final class PopupViewModel: ObservableObject { case .bottom: rect.origin.y = targetFrame.minY - rect.height - targetSpacing } + + guard let containerHeight else { return rect } + + if placement == .bottom { + let anchorClearanceY = targetFrame.minY - targetSpacing - rect.height + rect.origin.y = min(rect.origin.y, anchorClearanceY) + } + + rect.origin.y = max(rect.origin.y, 0) + rect.origin.y = min(rect.origin.y, max(0, containerHeight - rect.height)) return rect } } private extension PopupViewModel { + static var nextStackingOrder = 0 + /// Horizontally aligns the popup with `targetFrame`, clamped to container margins - private func alignPopupRectHorizontally( + func alignPopupRectHorizontally( containerWidth: CGFloat ) -> CGRect { - let halfPopupSize = popupSize.width / 2 + let halfWidth = width / 2 let centeredCenterX = targetFrame.midX - let minCenterX = halfPopupSize + horizontalPadding - let maxCenterX = containerWidth - (halfPopupSize + horizontalPadding) + let minCenterX = halfWidth + horizontalPadding + let maxCenterX = containerWidth - (halfWidth + horizontalPadding) let centerX: CGFloat if minCenterX <= maxCenterX { centerX = min(max(centeredCenterX, minCenterX), maxCenterX) @@ -93,14 +120,14 @@ private extension PopupViewModel { centerX = containerWidth / 2 } - let idealX = centerX - halfPopupSize - let clampedX = min(max(idealX, 0), max(containerWidth - popupSize.width, 0)) + let idealX = centerX - halfWidth + let clampedX = min(max(idealX, 0), max(containerWidth - width, 0)) return CGRect( x: clampedX, y: 0, - width: popupSize.width, - height: popupSize.height + width: width, + height: height ) } } diff --git a/HandDrawingSwiftMetalTests/Views/Components/PopupView/PopupViewModelTests.swift b/HandDrawingSwiftMetalTests/Views/Components/PopupView/PopupViewModelTests.swift index 7ab4e233..ecffd965 100644 --- a/HandDrawingSwiftMetalTests/Views/Components/PopupView/PopupViewModelTests.swift +++ b/HandDrawingSwiftMetalTests/Views/Components/PopupView/PopupViewModelTests.swift @@ -19,7 +19,8 @@ struct PopupViewModelTests { struct `Popup Rect Top Placement` { @Test func `Places popup below anchor centered when it fits in container`() { - let popupSize: CGSize = .init(width: 200, height: 100) + let popupWidth: CGFloat = 200 + let popupHeight: CGFloat = 100 let buttonRect: CGRect = .init( origin: .init(x: 150, y: 0), size: .init(width: 100, height: 20) @@ -28,15 +29,16 @@ struct PopupViewModelTests { let containerWidth: CGFloat = 400 let subject: Subject = .init( - size: popupSize, + width: popupWidth, + height: popupHeight, targetSpacing: targetSpacing, - horizontalPadding: 10, - placement: .top + horizontalPadding: 10 ) subject.targetFrame = buttonRect let result = subject.popupRect( - containerWidth: containerWidth + containerWidth: containerWidth, + placement: .top ) // The popup stays horizontally centered on the anchor @@ -44,8 +46,8 @@ struct PopupViewModelTests { // With .top placement, the popup sits below the target #expect(result.minY == buttonRect.minY + buttonRect.height + targetSpacing) // Horizontal position stays aligned to the anchor center - #expect(result.minX == buttonRect.midX - popupSize.width / 2) - #expect(result.maxX == buttonRect.midX + popupSize.width / 2) + #expect(result.minX == buttonRect.midX - popupWidth / 2) + #expect(result.maxX == buttonRect.midX + popupWidth / 2) // The popup stays within the container horizontal insets #expect(result.minX >= 0) @@ -54,7 +56,8 @@ struct PopupViewModelTests { @Test func `Places popup below anchor and corrects leading overflow at top leading`() { - let popupSize: CGSize = CGSize(width: 200, height: 100) + let popupWidth: CGFloat = 200 + let popupHeight: CGFloat = 100 let buttonRect: CGRect = .init( origin: .init(x: 20, y: 0), size: .init(width: 100, height: 20) @@ -64,19 +67,20 @@ struct PopupViewModelTests { let containerWidth: CGFloat = 400 let subject: Subject = .init( - size: popupSize, + width: popupWidth, + height: popupHeight, targetSpacing: targetSpacing, - horizontalPadding: horizontalPadding, - placement: .top + horizontalPadding: horizontalPadding ) subject.targetFrame = buttonRect let result = subject.popupRect( - containerWidth: containerWidth + containerWidth: containerWidth, + placement: .top ) // Centering on the anchor would cross the leading inset - #expect(buttonRect.midX - popupSize.width / 2 == -30) + #expect(buttonRect.midX - popupWidth / 2 == -30) // The popup is shifted so its leading edge meets the leading inset #expect(result.minX == horizontalPadding) @@ -90,7 +94,8 @@ struct PopupViewModelTests { @Test func `Places popup below anchor and corrects trailing overflow at top trailing`() { - let popupSize: CGSize = .init(width: 200, height: 100) + let popupWidth: CGFloat = 200 + let popupHeight: CGFloat = 100 let buttonRect: CGRect = .init( origin: .init(x: 360, y: 0), size: .init(width: 100, height: 20) @@ -100,19 +105,20 @@ struct PopupViewModelTests { let containerWidth: CGFloat = 400 let subject: Subject = .init( - size: popupSize, + width: popupWidth, + height: popupHeight, targetSpacing: targetSpacing, - horizontalPadding: horizontalPadding, - placement: .top + horizontalPadding: horizontalPadding ) subject.targetFrame = buttonRect let result = subject.popupRect( - containerWidth: containerWidth + containerWidth: containerWidth, + placement: .top ) // Centering on the anchor would cross the trailing edge of the container - #expect(buttonRect.midX + popupSize.width / 2 == 510) + #expect(buttonRect.midX + popupWidth / 2 == 510) // The popup is shifted so its trailing edge meets the trailing inset #expect(result.maxX == containerWidth - horizontalPadding) @@ -126,7 +132,8 @@ struct PopupViewModelTests { @Test func `Keeps popup within container when container is narrower than popup and padding`() { - let popupSize: CGSize = .init(width: 200, height: 100) + let popupWidth: CGFloat = 200 + let popupHeight: CGFloat = 100 let buttonRect: CGRect = .init( origin: .init(x: 10, y: 0), size: .init(width: 20, height: 20) @@ -134,15 +141,16 @@ struct PopupViewModelTests { let containerWidth: CGFloat = 120 let subject: Subject = .init( - size: popupSize, + width: popupWidth, + height: popupHeight, targetSpacing: 8, - horizontalPadding: 16, - placement: .top + horizontalPadding: 16 ) subject.targetFrame = buttonRect let result = subject.popupRect( - containerWidth: containerWidth + containerWidth: containerWidth, + placement: .top ) // When the popup can't fit, keep the leading edge within the container to avoid a negative x. @@ -155,7 +163,8 @@ struct PopupViewModelTests { struct `Popup Rect Bottom Placement` { @Test func `Places popup above anchor centered when it fits in container`() { - let popupSize: CGSize = .init(width: 200, height: 100) + let popupWidth: CGFloat = 200 + let popupHeight: CGFloat = 100 let buttonRect: CGRect = .init( origin: .init(x: 150, y: 200), size: .init(width: 100, height: 20) @@ -164,15 +173,16 @@ struct PopupViewModelTests { let containerWidth: CGFloat = 400 let subject: Subject = .init( - size: popupSize, + width: popupWidth, + height: popupHeight, targetSpacing: targetSpacing, - horizontalPadding: 10, - placement: .bottom + horizontalPadding: 10 ) subject.targetFrame = buttonRect let result = subject.popupRect( - containerWidth: containerWidth + containerWidth: containerWidth, + placement: .bottom ) // The popup stays horizontally centered on the anchor @@ -180,8 +190,8 @@ struct PopupViewModelTests { // With .bottom placement, the popup sits above the target #expect(result.maxY == buttonRect.minY - targetSpacing) // Horizontal position stays aligned to the anchor center - #expect(result.minX == buttonRect.midX - popupSize.width / 2) - #expect(result.maxX == buttonRect.midX + popupSize.width / 2) + #expect(result.minX == buttonRect.midX - popupWidth / 2) + #expect(result.maxX == buttonRect.midX + popupWidth / 2) // The popup stays within the container horizontal insets #expect(result.minX >= 0) @@ -190,7 +200,8 @@ struct PopupViewModelTests { @Test func `Places popup above anchor and corrects leading overflow at bottom leading`() { - let popupSize: CGSize = .init(width: 200, height: 100) + let popupWidth: CGFloat = 200 + let popupHeight: CGFloat = 100 let buttonRect: CGRect = .init( origin: .init(x: 20, y: 200), size: .init(width: 100, height: 20) @@ -200,19 +211,20 @@ struct PopupViewModelTests { let containerWidth: CGFloat = 400 let subject: Subject = .init( - size: popupSize, + width: popupWidth, + height: popupHeight, targetSpacing: targetSpacing, - horizontalPadding: horizontalPadding, - placement: .bottom + horizontalPadding: horizontalPadding ) subject.targetFrame = buttonRect let result = subject.popupRect( - containerWidth: containerWidth + containerWidth: containerWidth, + placement: .bottom ) // Centering on the anchor would cross the leading inset - #expect(buttonRect.midX - popupSize.width / 2 == -30) + #expect(buttonRect.midX - popupWidth / 2 == -30) // The popup is shifted so its leading edge meets the leading inset. #expect(result.minX == horizontalPadding) @@ -226,7 +238,8 @@ struct PopupViewModelTests { @Test func `Places popup above anchor and corrects trailing overflow at bottom trailing`() { - let popupSize: CGSize = .init(width: 200, height: 100) + let popupWidth: CGFloat = 200 + let popupHeight: CGFloat = 100 let buttonRect: CGRect = .init( origin: .init(x: 360, y: 200), size: .init(width: 100, height: 20) @@ -236,19 +249,20 @@ struct PopupViewModelTests { let containerWidth: CGFloat = 400 let subject: Subject = .init( - size: popupSize, + width: popupWidth, + height: popupHeight, targetSpacing: targetSpacing, - horizontalPadding: horizontalPadding, - placement: .bottom + horizontalPadding: horizontalPadding ) subject.targetFrame = buttonRect let result = subject.popupRect( - containerWidth: containerWidth + containerWidth: containerWidth, + placement: .bottom ) // Centering on the anchor would cross the trailing edge of the container - #expect(buttonRect.midX + popupSize.width / 2 == 510) + #expect(buttonRect.midX + popupWidth / 2 == 510) // The popup is shifted so its trailing edge meets the trailing inset. #expect(result.maxX == containerWidth - horizontalPadding) @@ -266,9 +280,10 @@ struct PopupViewModelTests { struct `Hide Popup` { @Test func `Default initializer hides the popup`() { + let size = TestHelpers.randomSize() let subject: Subject = .init( - size: TestHelpers.randomSize(), - placement: .top + width: size.width, + height: size.height ) #expect(subject.isHidden == true) @@ -278,9 +293,10 @@ struct PopupViewModelTests { arguments: [true, false] ) func `toggleView flips isHidden`(initialState: Bool) { + let size = TestHelpers.randomSize() let subject: Subject = .init( - size: TestHelpers.randomSize(), - placement: .top, + width: size.width, + height: size.height, isHidden: initialState ) @@ -293,9 +309,10 @@ struct PopupViewModelTests { @Test func `hide sets isHidden to true`() { + let size = TestHelpers.randomSize() let subject: Subject = .init( - size: TestHelpers.randomSize(), - placement: .top, + width: size.width, + height: size.height, isHidden: false ) @@ -310,9 +327,10 @@ struct PopupViewModelTests { struct `Enable Component Interaction` { @Test func `enableComponentInteraction updates isUserInteractionEnabled`() { + let size = TestHelpers.randomSize() let subject: Subject = .init( - size: TestHelpers.randomSize(), - placement: .top + width: size.width, + height: size.height ) subject.enableComponentInteraction(false) From 5871562a635bd5eb93f2bfd49ef02a54200441f8 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Sat, 25 Jul 2026 23:01:42 +0900 Subject: [PATCH 03/13] Show palette edit popup when reselecting the current color --- .../Extensions/UIColorExtensions.swift | 4 +- .../HandDrawingViewController.swift | 188 +++++++++++++++++- .../HandDrawingViewModel.swift | 29 +++ .../CoreDataBrushPaletteStorage.swift | 8 +- .../CoreDataEraserPaletteStorage.swift | 8 +- .../Models/DrawingTools/BrushPalette.swift | 49 +++-- .../BrushPaletteArchiveModel.swift | 2 +- .../Models/DrawingTools/EraserPalette.swift | 48 +++-- .../EraserPaletteArchiveModel.swift | 2 +- .../Drawingtools/BrushPaletteView.swift | 13 +- .../Drawingtools/EraserPaletteView.swift | 13 +- .../Components/PassthroughHostingView.swift | 1 + .../Models/BrushPaletteTests.swift | 33 ++- .../Models/EraserPaletteTests.swift | 33 ++- 14 files changed, 354 insertions(+), 77 deletions(-) diff --git a/HandDrawingSwiftMetal/Extensions/UIColorExtensions.swift b/HandDrawingSwiftMetal/Extensions/UIColorExtensions.swift index c63ee4b2..42c943e4 100644 --- a/HandDrawingSwiftMetal/Extensions/UIColorExtensions.swift +++ b/HandDrawingSwiftMetal/Extensions/UIColorExtensions.swift @@ -10,9 +10,7 @@ import UIKit extension UIColor { static var viewBackground: UIColor { - UIColor.systemGray6 - .resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)) - .withAlphaComponent(0.88) + UIColor.systemGray6.withAlphaComponent(0.88) } convenience init?(hex: String) { diff --git a/HandDrawingSwiftMetal/HandDrawingViewController.swift b/HandDrawingSwiftMetal/HandDrawingViewController.swift index 8089762e..aa84b2fb 100644 --- a/HandDrawingSwiftMetal/HandDrawingViewController.swift +++ b/HandDrawingSwiftMetal/HandDrawingViewController.swift @@ -7,6 +7,7 @@ import CanvasView import Combine +import PaletteEditView import SwiftUI import TextureLayerCanvasView import TextureLayerView @@ -29,6 +30,16 @@ class HandDrawingViewController: UIViewController { height: 300 ) + private var colorPaletteEditPopupViewModel = PopupViewModel( + width: 350, + height: colorPaletteEditViewHeight + ) + + private var alphaPaletteEditPopupViewModel = PopupViewModel( + width: 350, + height: alphaPaletteEditViewHeight + ) + private weak var popupPassthroughView: PassthroughHostingView? private var cancellables = Set() @@ -245,17 +256,25 @@ private extension HandDrawingViewController { } .store(in: &cancellables) - viewModel.brushPalette.$index + viewModel.onBrushColorEdited = { [weak self] color in + (self?.drawingRenderers[.brush] as? BrushDrawingRenderer)?.setColor(color) + } + + viewModel.onEraserAlphaEdited = { [weak self] alpha in + (self?.drawingRenderers[.eraser] as? EraserDrawingRenderer)?.setAlpha(alpha) + } + + viewModel.brushPalette.$selectedIndex .sink { [weak self] index in - guard let `self`, index < viewModel.brushPalette.colors.count else { return } + guard let self, index < viewModel.brushPalette.colors.count else { return } let newColor = viewModel.brushPalette.colors[index] (self.drawingRenderers[.brush] as? BrushDrawingRenderer)?.setColor(newColor) } .store(in: &cancellables) - viewModel.eraserPalette.$index + viewModel.eraserPalette.$selectedIndex .sink { [weak self] index in - guard let `self`, index < viewModel.eraserPalette.alphas.count else { return } + guard let self, index < viewModel.eraserPalette.alphas.count else { return } let newAlpha = viewModel.eraserPalette.alphas[index] (self.drawingRenderers[.eraser] as? EraserDrawingRenderer)?.setAlpha(newAlpha) } @@ -307,9 +326,15 @@ private extension HandDrawingViewController { self?.saveImage() } contentView.tapDrawingToolButton = { [weak self] in - guard let `self` else { return } + guard let self else { return } + + let wasPaletteEditPopupVisible = + !self.colorPaletteEditPopupViewModel.isHidden || + !self.alphaPaletteEditPopupViewModel.isHidden + self.viewModel.toggleDrawingTool() self.contentView.updateDrawingComponents(self.viewModel.drawingTool.type) + self.updatePalettePopupOnToolSwitch(wasVisible: wasPaletteEditPopupVisible) guard let renderer = self.drawingRenderers[self.viewModel.drawingTool.type] else { return } self.canvasView.setDrawingRenderer(renderer) @@ -352,7 +377,13 @@ private extension HandDrawingViewController { let hostingController = UIHostingController( rootView: BrushPaletteView( palette: viewModel.brushPalette, - paletteHeight: paletteHeight + paletteHeight: paletteHeight, + onTapColor: { [weak self] index, didReselect in + self?.handleBrushPaletteColorTap( + index: index, + didReselect: didReselect + ) + } ) ) embedHostingController(hostingController, in: contentView.brushPaletteView) @@ -362,7 +393,13 @@ private extension HandDrawingViewController { let hostingController = UIHostingController( rootView: EraserPaletteView( palette: viewModel.eraserPalette, - paletteHeight: paletteHeight + paletteHeight: paletteHeight, + onTapAlpha: { [weak self] index, didReselect in + self?.handleEraserPaletteAlphaTap( + index: index, + didReselect: didReselect + ) + } ) ) embedHostingController(hostingController, in: contentView.eraserPaletteView) @@ -377,6 +414,46 @@ private extension HandDrawingViewController { viewModel: textureLayerViewModel, placement: .top, content: { textureLayerView } + ), + .init( + target: targetView.brushPaletteView, + viewModel: colorPaletteEditPopupViewModel, + placement: .bottom, + content: { + ColorPaletteEditView( + colorSource: self.viewModel.brushPalette, + paletteState: self.viewModel.colorPaletteState, + onRemove: { [weak self] in + self?.removeBrushPaletteColor() + }, + onDuplicate: { [weak self] in + self?.addBrushPaletteColor() + } + ) + }, + onClose: { [weak self] in + self?.colorPaletteEditPopupViewModel.hide() + } + ), + .init( + target: targetView.eraserPaletteView, + viewModel: alphaPaletteEditPopupViewModel, + placement: .bottom, + content: { + AlphaPaletteEditView( + alphaSource: self.viewModel.eraserPalette, + paletteState: self.viewModel.alphaPaletteState, + onRemove: { [weak self] in + self?.removeEraserPaletteAlpha() + }, + onDuplicate: { [weak self] in + self?.addEraserPaletteAlpha() + } + ) + }, + onClose: { [weak self] in + self?.alphaPaletteEditPopupViewModel.hide() + } ) ] @@ -529,6 +606,103 @@ private extension HandDrawingViewController { func enableComponentsInteraction(_ isUserInteractionEnabled: Bool) { contentView.enableComponentsInteraction(isUserInteractionEnabled) textureLayerViewModel.enableComponentInteraction(isUserInteractionEnabled) + colorPaletteEditPopupViewModel.enableComponentInteraction(isUserInteractionEnabled) + alphaPaletteEditPopupViewModel.enableComponentInteraction(isUserInteractionEnabled) + } +} + +private extension HandDrawingViewController { + + func updatePalettePopupOnToolSwitch(wasVisible: Bool) { + guard wasVisible else { return } + + colorPaletteEditPopupViewModel.hide() + alphaPaletteEditPopupViewModel.hide() + + switch viewModel.drawingTool.type { + case .brush: + showColorPalettePopup(selectedIndex: viewModel.brushPalette.selectedIndex) + case .eraser: + showAlphaPalettePopup(selectedIndex: viewModel.eraserPalette.selectedIndex) + } + } + + func handleBrushPaletteColorTap(index: Int, didReselect: Bool) { + if colorPaletteEditPopupViewModel.isHidden { + guard didReselect else { return } + showColorPalettePopup(selectedIndex: index) + return + } + + if didReselect { + colorPaletteEditPopupViewModel.hide() + } + } + + func handleEraserPaletteAlphaTap(index: Int, didReselect: Bool) { + if alphaPaletteEditPopupViewModel.isHidden { + guard didReselect else { return } + showAlphaPalettePopup(selectedIndex: index) + return + } + + if didReselect { + alphaPaletteEditPopupViewModel.hide() + } + } + + func showColorPalettePopup(selectedIndex: Int) { + viewModel.brushPalette.select(selectedIndex) + alphaPaletteEditPopupViewModel.hide() + colorPaletteEditPopupViewModel.bringToFront() + colorPaletteEditPopupViewModel.isHidden = false + popupPassthroughView?.syncPopupLayout() + } + + func showAlphaPalettePopup(selectedIndex: Int) { + viewModel.eraserPalette.select(selectedIndex) + colorPaletteEditPopupViewModel.hide() + alphaPaletteEditPopupViewModel.bringToFront() + alphaPaletteEditPopupViewModel.isHidden = false + popupPassthroughView?.syncPopupLayout() + } + + func addBrushPaletteColor() { + let palette = viewModel.brushPalette + guard + palette.colors.count < 64, + let color = palette.color + else { return } + + palette.insert(color, at: palette.selectedIndex + 1) + } + + func addEraserPaletteAlpha() { + let palette = viewModel.eraserPalette + guard + palette.alphas.count < 64, + let alpha = palette.alpha + else { return } + + palette.insert(alpha, at: palette.selectedIndex + 1) + } + + func removeBrushPaletteColor() { + let palette = viewModel.brushPalette + guard palette.colors.count > 1 else { return } + + let selectedIndex = palette.selectedIndex + palette.remove(at: selectedIndex) + palette.select(min(selectedIndex, max(0, palette.colors.count - 1))) + } + + func removeEraserPaletteAlpha() { + let palette = viewModel.eraserPalette + guard palette.alphas.count > 1 else { return } + + let selectedIndex = palette.selectedIndex + palette.remove(at: selectedIndex) + palette.select(min(selectedIndex, max(0, palette.alphas.count - 1))) } } diff --git a/HandDrawingSwiftMetal/HandDrawingViewModel.swift b/HandDrawingSwiftMetal/HandDrawingViewModel.swift index c1f54196..f71cb826 100644 --- a/HandDrawingSwiftMetal/HandDrawingViewModel.swift +++ b/HandDrawingSwiftMetal/HandDrawingViewModel.swift @@ -7,6 +7,7 @@ import Combine import CanvasView +import PaletteEditView import UIKit import TextureLayerView @@ -22,6 +23,34 @@ final class HandDrawingViewModel: ObservableObject { let brushPalette: BrushPalette let eraserPalette: EraserPalette + lazy var colorPaletteState = ColorPaletteState(onChanged: { [weak self] color in + guard let `self` else { return } + let components = color.paletteRGBAComponents() + let normalizedColor = UIColor( + paletteRed: components.red, + green: components.green, + blue: components.blue, + alpha: components.alpha + ) + self.brushPalette.update( + color: normalizedColor, + at: self.brushPalette.selectedIndex + ) + self.onBrushColorEdited?(normalizedColor) + }) + + lazy var alphaPaletteState = AlphaPaletteState(onChanged: { [weak self] alpha in + guard let `self` else { return } + self.eraserPalette.update( + alpha: alpha, + at: self.eraserPalette.selectedIndex + ) + self.onEraserAlphaEdited?(alpha) + }) + + var onBrushColorEdited: ((UIColor) -> Void)? + var onEraserAlphaEdited: ((Int) -> Void)? + let textureLayersState: TextureLayersState = TextureLayersState() var zipFileURL: URL { diff --git a/HandDrawingSwiftMetal/Models/CoreData/CoreDataBrushPaletteStorage.swift b/HandDrawingSwiftMetal/Models/CoreData/CoreDataBrushPaletteStorage.swift index e77779ad..d71fc008 100644 --- a/HandDrawingSwiftMetal/Models/CoreData/CoreDataBrushPaletteStorage.swift +++ b/HandDrawingSwiftMetal/Models/CoreData/CoreDataBrushPaletteStorage.swift @@ -27,7 +27,7 @@ final class CoreDataBrushPaletteStorage { // Save to Core Data when the properties are updated Publishers.Merge( - palette.$index.map { _ in () }.eraseToAnyPublisher(), + palette.$selectedIndex.map { _ in () }.eraseToAnyPublisher(), palette.$colors.map { _ in () }.eraseToAnyPublisher() ) .debounce(for: .milliseconds(coreDataSaveDebounceMilliseconds), scheduler: RunLoop.main) @@ -57,7 +57,7 @@ extension CoreDataBrushPaletteStorage { } ?? [] let index = max(0, min(Int(entity.index), colors.count - 1)) - palette.update(colors: colors, index: index) + palette.update(colors: colors, selectedIndex: index) } func update(directoryURL: URL) throws { @@ -78,7 +78,7 @@ extension CoreDataBrushPaletteStorage { } palette.update( colors: result.hexColors.map { UIColor(hex: $0) }, - index: result.index + selectedIndex: result.index ) } } @@ -92,7 +92,7 @@ private extension CoreDataBrushPaletteStorage { else { return } let id: UUID = target.id - let index = target.index + let index = target.selectedIndex let hexes = target.colors.map { $0.hex() } await context.perform { [context] in diff --git a/HandDrawingSwiftMetal/Models/CoreData/CoreDataEraserPaletteStorage.swift b/HandDrawingSwiftMetal/Models/CoreData/CoreDataEraserPaletteStorage.swift index e9990c84..e8fcb98d 100644 --- a/HandDrawingSwiftMetal/Models/CoreData/CoreDataEraserPaletteStorage.swift +++ b/HandDrawingSwiftMetal/Models/CoreData/CoreDataEraserPaletteStorage.swift @@ -27,7 +27,7 @@ final class CoreDataEraserPaletteStorage { // Save to Core Data when the properties are updated Publishers.Merge( - palette.$index.map { _ in () }.eraseToAnyPublisher(), + palette.$selectedIndex.map { _ in () }.eraseToAnyPublisher(), palette.$alphas.map { _ in () }.eraseToAnyPublisher() ) .debounce(for: .milliseconds(coreDataSaveDebounceMilliseconds), scheduler: RunLoop.main) @@ -57,7 +57,7 @@ extension CoreDataEraserPaletteStorage { palette.update( alphas: alphas, - index: index + selectedIndex: index ) } @@ -79,7 +79,7 @@ extension CoreDataEraserPaletteStorage { } palette.update( alphas: result.alphas, - index: result.index + selectedIndex: result.index ) } } @@ -92,7 +92,7 @@ private extension CoreDataEraserPaletteStorage { else { return } let id = target.id - let index = target.index + let index = target.selectedIndex let alphas = target.alphas await context.perform { [context] in diff --git a/HandDrawingSwiftMetal/Models/DrawingTools/BrushPalette.swift b/HandDrawingSwiftMetal/Models/DrawingTools/BrushPalette.swift index 0367f7cd..42ee1da5 100644 --- a/HandDrawingSwiftMetal/Models/DrawingTools/BrushPalette.swift +++ b/HandDrawingSwiftMetal/Models/DrawingTools/BrushPalette.swift @@ -5,6 +5,7 @@ // Created by Eisuke Kusachi on 2025/08/23. // +import PaletteEditView import UIKit fileprivate let initializeColors: [UIColor] = [ @@ -17,23 +18,27 @@ fileprivate let initializeColors: [UIColor] = [ .purple.withAlphaComponent(0.8) ] -final class BrushPalette: ObservableObject { +final class BrushPalette: ObservableObject, ColorPaletteColorSource { private(set) var id: UUID + var selectedColor: UIColor { + color ?? .black + } + @Published private(set) var colors: [UIColor] = [] - @Published private(set) var index: Int = 0 + @Published private(set) var selectedIndex: Int = 0 public init( id: UUID = UUID(), colors: [UIColor] = initializeColors, - index: Int = 0 + selectedIndex: Int = 0 ) { self.id = id let newColors = colors.isEmpty ? [.black] : colors self.colors = newColors - self.index = max(0, min(index, newColors.count - 1)) + self.selectedIndex = max(0, min(selectedIndex, newColors.count - 1)) } } @@ -41,7 +46,7 @@ extension BrushPalette { func initializeData() { self.colors = initializeColors - self.index = 0 + self.selectedIndex = 0 } func setId(_ id: UUID) { @@ -49,29 +54,35 @@ extension BrushPalette { } var color: UIColor? { - guard index < colors.count else { return nil } - return colors[index] + guard selectedIndex < colors.count else { return nil } + return colors[selectedIndex] } func color(at index: Int) -> UIColor? { self.colors.indices.contains(index) ? colors[index] : nil } - func select(_ index: Int) { - self.index = index + @discardableResult + func select(_ index: Int) -> Bool { + guard colors.indices.contains(index) else { return false } + let didReselect = selectedIndex == index + selectedIndex = index + return didReselect } func insert(_ color: UIColor, at index: Int) { guard (0 ... colors.count).contains(index) else { return } - colors.insert(color, at: index) + var updated = colors + updated.insert(color, at: index) + colors = updated } func update( colors: [UIColor] = [], - index: Int = 0 + selectedIndex: Int = 0 ) { self.colors = colors.isEmpty ? [.black] : colors - self.index = max(0, min(index, self.colors.count - 1)) + self.selectedIndex = max(0, min(selectedIndex, self.colors.count - 1)) } func update( @@ -79,11 +90,21 @@ extension BrushPalette { at index: Int ) { guard colors.indices.contains(index) else { return } - colors[index] = color + var updated = colors + updated[index] = color + colors = updated } func remove(at index: Int) { guard colors.indices.contains(index) && colors.count > 1 else { return } - colors.remove(at: index) + var updated = colors + updated.remove(at: index) + colors = updated + + if selectedIndex > index { + selectedIndex -= 1 + } else if selectedIndex >= updated.count { + selectedIndex = updated.count - 1 + } } } diff --git a/HandDrawingSwiftMetal/Models/DrawingTools/BrushPaletteArchiveModel.swift b/HandDrawingSwiftMetal/Models/DrawingTools/BrushPaletteArchiveModel.swift index f522ddfa..4fa39457 100644 --- a/HandDrawingSwiftMetal/Models/DrawingTools/BrushPaletteArchiveModel.swift +++ b/HandDrawingSwiftMetal/Models/DrawingTools/BrushPaletteArchiveModel.swift @@ -20,7 +20,7 @@ struct BrushPaletteArchiveModel: Codable, Sendable { extension BrushPaletteArchiveModel { @MainActor init(_ palette: BrushPalette) { - self.index = palette.index + self.index = palette.selectedIndex self.hexColors = palette.colors.map { $0.hex() } } } diff --git a/HandDrawingSwiftMetal/Models/DrawingTools/EraserPalette.swift b/HandDrawingSwiftMetal/Models/DrawingTools/EraserPalette.swift index ab4cef44..771fa582 100644 --- a/HandDrawingSwiftMetal/Models/DrawingTools/EraserPalette.swift +++ b/HandDrawingSwiftMetal/Models/DrawingTools/EraserPalette.swift @@ -5,6 +5,7 @@ // Created by Eisuke Kusachi on 2025/08/23. // +import PaletteEditView import UIKit fileprivate let initializeAlphas: [Int] = [ @@ -18,23 +19,27 @@ fileprivate let initializeAlphas: [Int] = [ 50 ] -final class EraserPalette: ObservableObject { +final class EraserPalette: ObservableObject, AlphaPaletteAlphaSource { private(set) var id: UUID + var selectedAlpha: Int { + alpha ?? 255 + } + @Published private(set) var alphas: [Int] = [] - @Published private(set) var index: Int = 0 + @Published private(set) var selectedIndex: Int = 0 public init( id: UUID = UUID(), alphas: [Int] = initializeAlphas, - index: Int = 0 + selectedIndex: Int = 0 ) { self.id = id let newAlphas = alphas.isEmpty ? [255] : alphas self.alphas = newAlphas - self.index = max(0, min(index, newAlphas.count - 1)) + self.selectedIndex = max(0, min(selectedIndex, newAlphas.count - 1)) } } @@ -42,7 +47,7 @@ extension EraserPalette { func initializeData() { self.alphas = initializeAlphas - self.index = 0 + self.selectedIndex = 0 } func setId(_ id: UUID) { @@ -50,28 +55,35 @@ extension EraserPalette { } var alpha: Int? { - self.alphas.indices.contains(index) ? alphas[index] : nil + guard selectedIndex < alphas.count else { return nil } + return alphas[selectedIndex] } func alpha(at index: Int) -> Int? { self.alphas.indices.contains(index) ? alphas[index] : nil } - func select(_ index: Int) { - self.index = index + @discardableResult + func select(_ index: Int) -> Bool { + guard alphas.indices.contains(index) else { return false } + let didReselect = selectedIndex == index + selectedIndex = index + return didReselect } func insert(_ alpha: Int, at index: Int) { guard (0 ... alphas.count).contains(index) else { return } - alphas.insert(alpha, at: index) + var updated = alphas + updated.insert(alpha, at: index) + alphas = updated } func update( alphas: [Int] = [], - index: Int = 0 + selectedIndex: Int = 0 ) { self.alphas = alphas.isEmpty ? [255] : alphas - self.index = max(0, min(index, self.alphas.count - 1)) + self.selectedIndex = max(0, min(selectedIndex, self.alphas.count - 1)) } func update( @@ -79,11 +91,21 @@ extension EraserPalette { at index: Int ) { guard alphas.indices.contains(index) else { return } - alphas[index] = alpha + var updated = alphas + updated[index] = alpha + alphas = updated } func remove(at index: Int) { guard alphas.indices.contains(index) && alphas.count > 1 else { return } - alphas.remove(at: index) + var updated = alphas + updated.remove(at: index) + alphas = updated + + if selectedIndex > index { + selectedIndex -= 1 + } else if selectedIndex >= updated.count { + selectedIndex = updated.count - 1 + } } } diff --git a/HandDrawingSwiftMetal/Models/DrawingTools/EraserPaletteArchiveModel.swift b/HandDrawingSwiftMetal/Models/DrawingTools/EraserPaletteArchiveModel.swift index 08f9776f..a8de3e6e 100644 --- a/HandDrawingSwiftMetal/Models/DrawingTools/EraserPaletteArchiveModel.swift +++ b/HandDrawingSwiftMetal/Models/DrawingTools/EraserPaletteArchiveModel.swift @@ -15,7 +15,7 @@ struct EraserPaletteArchiveModel: Codable, Sendable { extension EraserPaletteArchiveModel { @MainActor init(_ palette: EraserPalette) { - self.index = palette.index + self.index = palette.selectedIndex self.alphas = palette.alphas } } diff --git a/HandDrawingSwiftMetal/Views/Components/Drawingtools/BrushPaletteView.swift b/HandDrawingSwiftMetal/Views/Components/Drawingtools/BrushPaletteView.swift index 6f1f1a49..4ca26fac 100644 --- a/HandDrawingSwiftMetal/Views/Components/Drawingtools/BrushPaletteView.swift +++ b/HandDrawingSwiftMetal/Views/Components/Drawingtools/BrushPaletteView.swift @@ -19,6 +19,8 @@ struct BrushPaletteView: View { private let backgroundColor: Color + private let onTapColor: ((Int, Bool) -> Void)? + @ObservedObject private var palette: BrushPalette @State private var checkeredImage: UIImage? = nil @@ -28,7 +30,8 @@ struct BrushPaletteView: View { paletteHeight: CGFloat, spacing: CGFloat = 2, paddingVertical: CGFloat = 2, - backgroundColor: UIColor = .lightGray.withAlphaComponent(0.15) + backgroundColor: UIColor = .lightGray.withAlphaComponent(0.15), + onTapColor: ((Int, Bool) -> Void)? = nil ) { self.palette = palette self.paletteHeight = paletteHeight @@ -36,6 +39,7 @@ struct BrushPaletteView: View { self.colorSize = paletteHeight - paddingVertical * 2 self.spacing = spacing self.backgroundColor = Color(backgroundColor) + self.onTapColor = onTapColor } var body: some View { @@ -46,9 +50,10 @@ struct BrushPaletteView: View { color: palette.colors[i], checkeredImage: checkeredImage, size: colorSize, - selected: palette.index == i + selected: palette.selectedIndex == i ) { - palette.select(i) + let didReselect = palette.select(i) + onTapColor?(i, didReselect) } } } @@ -89,7 +94,7 @@ private struct Preview: View { UIColor.blue.withAlphaComponent(0.25), UIColor.green.withAlphaComponent(0.25) ], - index: 5 + selectedIndex: 5 ), paletteHeight: paletteHeight ) diff --git a/HandDrawingSwiftMetal/Views/Components/Drawingtools/EraserPaletteView.swift b/HandDrawingSwiftMetal/Views/Components/Drawingtools/EraserPaletteView.swift index 66885d3a..ecae8b72 100644 --- a/HandDrawingSwiftMetal/Views/Components/Drawingtools/EraserPaletteView.swift +++ b/HandDrawingSwiftMetal/Views/Components/Drawingtools/EraserPaletteView.swift @@ -19,6 +19,8 @@ struct EraserPaletteView: View { private let backgroundColor: Color + private let onTapAlpha: ((Int, Bool) -> Void)? + @ObservedObject private var palette: EraserPalette @State private var checkeredImage: UIImage? = nil @@ -28,7 +30,8 @@ struct EraserPaletteView: View { paletteHeight: CGFloat, spacing: CGFloat = 2, paddingVertical: CGFloat = 2, - backgroundColor: UIColor = .lightGray.withAlphaComponent(0.15) + backgroundColor: UIColor = .lightGray.withAlphaComponent(0.15), + onTapAlpha: ((Int, Bool) -> Void)? = nil ) { self.palette = palette self.paletteHeight = paletteHeight @@ -36,6 +39,7 @@ struct EraserPaletteView: View { self.colorSize = paletteHeight - paddingVertical * 2 self.spacing = spacing self.backgroundColor = Color(backgroundColor) + self.onTapAlpha = onTapAlpha } private func alpha(_ alpha: Int) -> CGFloat { @@ -50,9 +54,10 @@ struct EraserPaletteView: View { color: UIColor.black.withAlphaComponent(alpha(palette.alphas[i])), checkeredImage: checkeredImage, size: colorSize, - selected: palette.index == i + selected: palette.selectedIndex == i ) { - palette.select(i) + let didReselect = palette.select(i) + onTapAlpha?(i, didReselect) } } } @@ -85,7 +90,7 @@ private struct Preview: View { alphas: [ 255, 225, 200, 175, 150, 125, 100, 50, 25 ], - index: 3 + selectedIndex: 3 ), paletteHeight: paletteHeight ) diff --git a/HandDrawingSwiftMetal/Views/Components/PassthroughHostingView.swift b/HandDrawingSwiftMetal/Views/Components/PassthroughHostingView.swift index cba3de5b..e8149331 100644 --- a/HandDrawingSwiftMetal/Views/Components/PassthroughHostingView.swift +++ b/HandDrawingSwiftMetal/Views/Components/PassthroughHostingView.swift @@ -108,6 +108,7 @@ final class PassthroughHostingView: UIView { self?.setNeedsLayout() } .store(in: &cancellables) + } } diff --git a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift index d226f9f0..9943be1e 100644 --- a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift +++ b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift @@ -16,25 +16,25 @@ struct BrushPaletteTests { func testInitWithEmptyColors() async throws { let palette = BrushPalette( colors: [], - index: -1 + selectedIndex: -1 ) #expect(palette.colors == [.black]) - #expect(palette.index == 0) + #expect(palette.selectedIndex == 0) } @Test("Confirms selecting a color changes the current color") func testSelect() async throws { let palette = BrushPalette( colors: [.black, .red], - index: 0 + selectedIndex: 0 ) - #expect(palette.index == 0) + #expect(palette.selectedIndex == 0) #expect(palette.color == .black) palette.select(1) - #expect(palette.index == 1) + #expect(palette.selectedIndex == 1) #expect(palette.color == .red) } @@ -42,7 +42,7 @@ struct BrushPaletteTests { func testInsert() async throws { let palette = BrushPalette( colors: [.black], - index: 0 + selectedIndex: 0 ) palette.insert(.blue, at: 0) @@ -53,13 +53,13 @@ struct BrushPaletteTests { func testUpdateColorsAndIndex() async throws { let palette = BrushPalette( colors: [.black, .lightGray, .gray, .white], - index: 0 + selectedIndex: 0 ) - palette.update(colors: [.red, .green], index: 1) + palette.update(colors: [.red, .green], selectedIndex: 1) #expect(palette.colors == [.red, .green]) - #expect(palette.index == 1) + #expect(palette.selectedIndex == 1) #expect(palette.color == .green) } @@ -67,7 +67,7 @@ struct BrushPaletteTests { func testUpdateColorAtIndex() async throws { let palette = BrushPalette( colors: [.black, .red], - index: 0 + selectedIndex: 0 ) palette.update(color: .blue, at: 1) @@ -78,7 +78,7 @@ struct BrushPaletteTests { func testRemove() async throws { let palette = BrushPalette( colors: [.black, .red], - index: 0 + selectedIndex: 0 ) palette.remove(at: 0) @@ -89,4 +89,15 @@ struct BrushPaletteTests { #expect(palette.colors.count == 1) #expect(palette.colors == [.red]) } + + @Test("Confirms reselecting the same color returns true") + func testReselect() async throws { + let palette = BrushPalette( + colors: [.black, .red], + selectedIndex: 1 + ) + + #expect(palette.select(1) == true) + #expect(palette.select(0) == false) + } } diff --git a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift index 93eef52e..fb7e44b2 100644 --- a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift +++ b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift @@ -16,25 +16,25 @@ struct EraserPaletteTests { func testInitWithEmptyColors() async throws { let palette = EraserPalette( alphas: [], - index: -1 + selectedIndex: -1 ) #expect(palette.alphas == [255]) - #expect(palette.index == 0) + #expect(palette.selectedIndex == 0) } @Test("Confirms selecting an alpha changes the current alpha") func testSelect() async throws { let palette = EraserPalette( alphas: [64, 128], - index: 0 + selectedIndex: 0 ) - #expect(palette.index == 0) + #expect(palette.selectedIndex == 0) #expect(palette.alpha == 64) palette.select(1) - #expect(palette.index == 1) + #expect(palette.selectedIndex == 1) #expect(palette.alpha == 128) } @@ -42,7 +42,7 @@ struct EraserPaletteTests { func testInsert() async throws { let palette = EraserPalette( alphas: [128], - index: 0 + selectedIndex: 0 ) palette.insert(64, at: 0) @@ -53,13 +53,13 @@ struct EraserPaletteTests { func testUpdateAlphasAndIndex() async throws { let palette = EraserPalette( alphas: [255], - index: 0 + selectedIndex: 0 ) - palette.update(alphas: [32, 64, 128], index: 2) + palette.update(alphas: [32, 64, 128], selectedIndex: 2) #expect(palette.alphas == [32, 64, 128]) - #expect(palette.index == 2) + #expect(palette.selectedIndex == 2) #expect(palette.alpha == 128) } @@ -67,7 +67,7 @@ struct EraserPaletteTests { func testUpdateAlphaAtIndex() async throws { let palette = EraserPalette( alphas: [128, 255], - index: 0 + selectedIndex: 0 ) palette.update(alpha: 64, at: 1) @@ -78,7 +78,7 @@ struct EraserPaletteTests { func testRemove() async throws { let palette = EraserPalette( alphas: [64, 128], - index: 0 + selectedIndex: 0 ) palette.remove(at: 0) @@ -89,4 +89,15 @@ struct EraserPaletteTests { #expect(palette.alphas.count == 1) #expect(palette.alphas == [128]) } + + @Test("Confirms reselecting the same alpha returns true") + func testReselect() async throws { + let palette = EraserPalette( + alphas: [64, 128], + selectedIndex: 1 + ) + + #expect(palette.select(1) == true) + #expect(palette.select(0) == false) + } } From cb575160bca8aeb639aac794dc60ff75501f1982 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Sun, 26 Jul 2026 19:36:31 +0900 Subject: [PATCH 04/13] Do not use `objectWillChange` --- .../Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift | 2 +- .../Sources/PaletteEditView/Color/ColorPaletteEditView.swift | 2 +- .../Sources/PaletteEditView/Extensions/UIColorExtensions.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift b/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift index 2ba55acb..bef32c33 100644 --- a/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift +++ b/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift @@ -53,7 +53,7 @@ public struct AlphaPaletteEditView: View { .onChange(of: alphaSource.selectedIndex) { _ in updatePaletteState(from: alphaSource.selectedAlpha) } - .onReceive(alphaSource.objectWillChange) { _ in + .onChange(of: alphaSource.selectedAlpha) { _ in updatePaletteState(from: alphaSource.selectedAlpha) } } diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift index 0aaa4ce0..c8d0d931 100644 --- a/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift +++ b/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift @@ -72,7 +72,7 @@ public struct ColorPaletteEditView: View { .onChange(of: colorSource.selectedIndex) { _ in updatePaletteState(from: colorSource.selectedColor) } - .onReceive(colorSource.objectWillChange) { _ in + .onChange(of: colorSource.selectedColor.paletteRGBAComponents()) { _ in updatePaletteState(from: colorSource.selectedColor) } .onChange(of: paletteState.rgbColor) { _ in diff --git a/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift b/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift index 6cfcf983..f9342eae 100644 --- a/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift +++ b/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift @@ -65,7 +65,7 @@ public extension UIColor { } } -public struct PaletteRGBAComponents { +public struct PaletteRGBAComponents: Equatable { public let red: Int public let green: Int public let blue: Int From bdf748894f281b58981d641a17c5f08a6efc3f9e Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Mon, 27 Jul 2026 22:10:59 +0900 Subject: [PATCH 05/13] Remove `paletteRGBComponents()` --- .../Sources/PaletteEditView/Color/ColorSlidersView.swift | 6 +++--- .../PaletteEditView/Color/ViewItems/PickerImageView.swift | 2 +- .../PaletteEditView/Extensions/UIColorExtensions.swift | 5 ----- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ColorSlidersView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ColorSlidersView.swift index fa02a1a8..07461800 100644 --- a/PaletteEditView/Sources/PaletteEditView/Color/ColorSlidersView.swift +++ b/PaletteEditView/Sources/PaletteEditView/Color/ColorSlidersView.swift @@ -49,7 +49,7 @@ struct ColorSlidersView: View { updateSlidersFromColor() } .onChange(of: rgbColor) { _ in - let components = rgbColor.paletteRGBComponents() + let components = rgbColor.paletteRGBAComponents() if components.red != red || components.green != green || components.blue != blue { updateSlidersFromColor() } @@ -63,7 +63,7 @@ struct ColorSlidersView: View { private extension ColorSlidersView { func updateSlidersFromColor() { - let components = rgbColor.paletteRGBComponents() + let components = rgbColor.paletteRGBAComponents() red = components.red green = components.green blue = components.blue @@ -71,7 +71,7 @@ private extension ColorSlidersView { func updateColorFromSliders() { let updated = UIColor(paletteRed: red, green: green, blue: blue, alpha: 255) - let current = rgbColor.paletteRGBComponents() + let current = rgbColor.paletteRGBAComponents() if current.red != red || current.green != green || current.blue != blue { rgbColor = updated } diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift index b9af0d90..b7c7259b 100644 --- a/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift +++ b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift @@ -52,7 +52,7 @@ struct PickerImageView: View { guard let rgb = image.paletteColor( at: location, in: viewSize - )?.paletteRGBComponents() else { return } + )?.paletteRGBAComponents() else { return } rgbColor = UIColor( paletteRed: rgb.red, diff --git a/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift b/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift index f9342eae..60ae9f7e 100644 --- a/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift +++ b/PaletteEditView/Sources/PaletteEditView/Extensions/UIColorExtensions.swift @@ -30,11 +30,6 @@ public extension UIColor { ) } - func paletteRGBComponents() -> (red: Int, green: Int, blue: Int, alpha: Int) { - let components = paletteRGBAComponents() - return (components.red, components.green, components.blue, components.alpha) - } - func paletteRGBAComponents() -> PaletteRGBAComponents { guard let converted = cgColor.converted( From f06a35c5c55400f862f06668431caef89267c2a3 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Tue, 28 Jul 2026 01:03:37 +0900 Subject: [PATCH 06/13] Update `func paletteColor` --- .../Extensions/UIImage+Palette.swift | 69 +++++++++++++------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift index 50983668..eca54a33 100644 --- a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift +++ b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift @@ -11,35 +11,64 @@ extension UIImage { at location: CGPoint, in viewSize: CGSize ) -> UIColor? { - guard viewSize.width > 0, viewSize.height > 0 else { return nil } + guard + viewSize.width > 0, + viewSize.height > 0, + let cgImage + else { + return nil + } - let imageLocation = CGPoint( - x: location.x * (size.width / viewSize.width), - y: location.y * (size.height / viewSize.height) - ) + let pixelWidth = cgImage.width + let pixelHeight = cgImage.height + guard pixelWidth > 0, pixelHeight > 0 else { return nil } + let pixelX = Int((location.x / viewSize.width) * CGFloat(pixelWidth)) + let pixelY = Int((location.y / viewSize.height) * CGFloat(pixelHeight)) guard - imageLocation.x >= 0, - imageLocation.y >= 0, - imageLocation.x < size.width, - imageLocation.y < size.height, - let cgImage, - let dataProvider = cgImage.dataProvider, - let providerData = dataProvider.data + pixelX >= 0, + pixelY >= 0, + pixelX < pixelWidth, + pixelY < pixelHeight else { return nil } - let data = CFDataGetBytePtr(providerData) - let pixelInfo = (Int(imageLocation.x) + Int(imageLocation.y) * Int(size.width)) * 4 + var pixel: [UInt8] = [0, 0, 0, 0] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = + CGImageAlphaInfo.premultipliedLast.rawValue | + CGBitmapInfo.byteOrder32Big.rawValue - guard let data else { return nil } + guard let context = CGContext( + data: &pixel, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 4, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + return nil + } - let red = CGFloat(data[pixelInfo]) / 255.0 - let green = CGFloat(data[pixelInfo + 1]) / 255.0 - let blue = CGFloat(data[pixelInfo + 2]) / 255.0 - let alpha = CGFloat(data[pixelInfo + 3]) / 255.0 + context.interpolationQuality = .none + context.translateBy(x: -CGFloat(pixelX), y: -CGFloat(pixelY)) + context.draw( + cgImage, + in: CGRect( + x: 0, + y: 0, + width: pixelWidth, + height: pixelHeight + ) + ) - return UIColor(red: red, green: green, blue: blue, alpha: alpha) + return UIColor( + red: CGFloat(pixel[0]) / 255.0, + green: CGFloat(pixel[1]) / 255.0, + blue: CGFloat(pixel[2]) / 255.0, + alpha: CGFloat(pixel[3]) / 255.0 + ) } } From a747bd3a9d8ee9ac9f23c7985c7270bd89ae809e Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Tue, 28 Jul 2026 21:51:45 +0900 Subject: [PATCH 07/13] Update `BottomActionToolbar` --- .../Alpha/AlphaPaletteEditView.swift | 2 +- .../Color/ColorPaletteEditView.swift | 4 +- .../Commons/AlphaEditSection.swift | 68 ----------------- .../Commons/BottomActionToolbar.swift | 73 +++++++++++++------ 4 files changed, 55 insertions(+), 92 deletions(-) delete mode 100644 PaletteEditView/Sources/PaletteEditView/Commons/AlphaEditSection.swift diff --git a/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift b/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift index bef32c33..283b70e6 100644 --- a/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift +++ b/PaletteEditView/Sources/PaletteEditView/Alpha/AlphaPaletteEditView.swift @@ -33,7 +33,7 @@ public struct AlphaPaletteEditView: View { VStack(spacing: 0) { Spacer(minLength: 0) - AlphaEditSection( + BottomActionToolbar( alpha: Binding( get: { paletteState.alpha }, set: { paletteState.updateAlpha($0) } diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift index c8d0d931..0b05bcf2 100644 --- a/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift +++ b/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift @@ -52,7 +52,7 @@ public struct ColorPaletteEditView: View { Spacer(minLength: 0) - AlphaEditSection( + BottomActionToolbar( alpha: Binding( get: { paletteState.alpha }, set: { paletteState.updateAlpha($0) } @@ -115,7 +115,7 @@ private struct ColorPalettePreview: View { colorSource: colorSource, paletteState: paletteState ) - .frame(width: 350, height: 400) + .frame(width: 350, height: 500) .padding() } } diff --git a/PaletteEditView/Sources/PaletteEditView/Commons/AlphaEditSection.swift b/PaletteEditView/Sources/PaletteEditView/Commons/AlphaEditSection.swift deleted file mode 100644 index 3dd5e72a..00000000 --- a/PaletteEditView/Sources/PaletteEditView/Commons/AlphaEditSection.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// PaletteEditView -// -// Created by Eisuke Kusachi on 2026/07/25. -// - -import SwiftUI -import UIKit - -struct AlphaEditSection: View { - @Binding private var alpha: Int - - private let gradientColors: [UIColor] - private let onRemove: (() -> Void)? - private let onDuplicate: (() -> Void)? - - private let spacing: CGFloat = 16 - - init( - alpha: Binding, - gradientColors: [UIColor], - onRemove: (() -> Void)? = nil, - onDuplicate: (() -> Void)? = nil - ) { - self._alpha = alpha - self.gradientColors = gradientColors - self.onRemove = onRemove - self.onDuplicate = onDuplicate - } - - var body: some View { - VStack(spacing: spacing) { - SliderWithStepper( - title: "Alpha", - value: $alpha, - gradientColors: gradientColors - ) - - BottomActionToolbar( - onRemove: { onRemove?() }, - onDuplicate: { onDuplicate?() } - ) - } - } -} - -#if DEBUG -#Preview { - AlphaEditSectionPreview() -} - -private struct AlphaEditSectionPreview: View { - @State private var alpha = 128 - - var body: some View { - AlphaEditSection( - alpha: $alpha, - gradientColors: [ - UIColor.black.withAlphaComponent(0), - UIColor.black.withAlphaComponent(1) - ], - onRemove: {}, - onDuplicate: {} - ) - .padding() - } -} -#endif diff --git a/PaletteEditView/Sources/PaletteEditView/Commons/BottomActionToolbar.swift b/PaletteEditView/Sources/PaletteEditView/Commons/BottomActionToolbar.swift index 5b7dfcac..ed496a9d 100644 --- a/PaletteEditView/Sources/PaletteEditView/Commons/BottomActionToolbar.swift +++ b/PaletteEditView/Sources/PaletteEditView/Commons/BottomActionToolbar.swift @@ -7,38 +7,55 @@ import SwiftUI import UIKit +/// A toolbar with an alpha slider and remove / duplicate action buttons. struct BottomActionToolbar: View { - private let onRemove: () -> Void - private let onDuplicate: () -> Void + @Binding private var alpha: Int + private let gradientColors: [UIColor] + private let onRemove: (() -> Void)? + private let onDuplicate: (() -> Void)? + + private let spacing: CGFloat = 16 private let buttonSize: CGFloat = 22 private let buttonSpacing: CGFloat = 16 - private let height: CGFloat = 38 + private let toolbarHeight: CGFloat = 38 init( - onRemove: @escaping () -> Void, - onDuplicate: @escaping () -> Void + alpha: Binding, + gradientColors: [UIColor], + onRemove: (() -> Void)? = nil, + onDuplicate: (() -> Void)? = nil ) { + self._alpha = alpha + self.gradientColors = gradientColors self.onRemove = onRemove self.onDuplicate = onDuplicate } var body: some View { - HStack(alignment: .center, spacing: buttonSpacing) { - Button(action: onRemove) { - Image(systemName: "trash") - .toolbarIcon(size: buttonSize, color: .systemRed) - } + VStack(spacing: spacing) { + SliderWithStepper( + title: "Alpha", + value: $alpha, + gradientColors: gradientColors + ) - Button(action: onDuplicate) { - Image(systemName: "plus.rectangle.on.rectangle") - .toolbarIcon(size: buttonSize) - } + HStack(alignment: .center, spacing: buttonSpacing) { + Button(action: { onRemove?() }) { + Image(systemName: "trash") + .toolbarIcon(size: buttonSize, color: .systemRed) + } - Spacer() + Button(action: { onDuplicate?() }) { + Image(systemName: "plus.rectangle.on.rectangle") + .toolbarIcon(size: buttonSize) + } + + Spacer() + } + .padding(8) + .frame(height: toolbarHeight) } - .padding(8) - .frame(height: height) } } @@ -53,9 +70,23 @@ private extension Image { #if DEBUG #Preview { - BottomActionToolbar( - onRemove: {}, - onDuplicate: {} - ) + BottomActionToolbarPreview() +} + +private struct BottomActionToolbarPreview: View { + @State private var alpha = 128 + + var body: some View { + BottomActionToolbar( + alpha: $alpha, + gradientColors: [ + UIColor.black.withAlphaComponent(0), + UIColor.black.withAlphaComponent(1) + ], + onRemove: {}, + onDuplicate: {} + ) + .padding() + } } #endif From cf8c4b51e5532dbf8d42ba397b3c3032650b0b33 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Wed, 29 Jul 2026 00:27:14 +0900 Subject: [PATCH 08/13] Fix inverted Y sampling in `paletteColor(at:in:)` --- .../Sources/PaletteEditView/Extensions/UIImage+Palette.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift index eca54a33..6c248284 100644 --- a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift +++ b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift @@ -40,6 +40,7 @@ extension UIImage { CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + // A tiny 1x1 context for sampling one pixel's color. guard let context = CGContext( data: &pixel, width: 1, @@ -53,7 +54,9 @@ extension UIImage { } context.interpolationQuality = .none - context.translateBy(x: -CGFloat(pixelX), y: -CGFloat(pixelY)) + // Shift the drawn image so the target pixel lands in this 1x1 context. + let sampleY = pixelHeight - 1 - pixelY + context.translateBy(x: -CGFloat(pixelX), y: -CGFloat(sampleY)) context.draw( cgImage, in: CGRect( From 98eea698e2bb7fcd5caa57a6f542caa2252f257b Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Wed, 29 Jul 2026 00:55:18 +0900 Subject: [PATCH 09/13] Skip the update if the state color matches the palette selection --- .../Color/ColorPaletteEditView.swift | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift index 0b05bcf2..2cec51cc 100644 --- a/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift +++ b/PaletteEditView/Sources/PaletteEditView/Color/ColorPaletteEditView.swift @@ -17,8 +17,6 @@ public struct ColorPaletteEditView: View { private let onRemove: (() -> Void)? private let onDuplicate: (() -> Void)? - @State private var isApplyingExternalColor = false - public init( colorSource: ColorSource, paletteState: ColorPaletteState, @@ -76,7 +74,10 @@ public struct ColorPaletteEditView: View { updatePaletteState(from: colorSource.selectedColor) } .onChange(of: paletteState.rgbColor) { _ in - guard !isApplyingExternalColor else { return } + // Skip the update if the state color matches the palette selection. + guard + paletteState.color.paletteRGBAComponents() != colorSource.selectedColor.paletteRGBAComponents() + else { return } paletteState.updateColor() } } @@ -86,16 +87,8 @@ private extension ColorPaletteEditView { func updatePaletteState(from color: UIColor) { let incoming = color.paletteRGBAComponents() let current = paletteState.color.paletteRGBAComponents() - guard - incoming.red != current.red - || incoming.green != current.green - || incoming.blue != current.blue - || incoming.alpha != current.alpha - else { return } - - isApplyingExternalColor = true + guard incoming != current else { return } paletteState.setColor(color) - isApplyingExternalColor = false } } From b591423a61ceacf4f4749a4a7e04c8ded20b22a6 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Wed, 29 Jul 2026 08:28:52 +0900 Subject: [PATCH 10/13] Move palette logic into `BrushPalette` and `EraserPalette` --- .../HandDrawingViewController.swift | 45 ++------------ .../Models/DrawingTools/BrushPalette.swift | 27 ++++++++- .../Models/DrawingTools/EraserPalette.swift | 27 ++++++++- .../Models/BrushPaletteTests.swift | 59 +++++++++++++++++++ .../Models/EraserPaletteTests.swift | 59 +++++++++++++++++++ 5 files changed, 172 insertions(+), 45 deletions(-) diff --git a/HandDrawingSwiftMetal/HandDrawingViewController.swift b/HandDrawingSwiftMetal/HandDrawingViewController.swift index aa84b2fb..e247540c 100644 --- a/HandDrawingSwiftMetal/HandDrawingViewController.swift +++ b/HandDrawingSwiftMetal/HandDrawingViewController.swift @@ -424,10 +424,10 @@ private extension HandDrawingViewController { colorSource: self.viewModel.brushPalette, paletteState: self.viewModel.colorPaletteState, onRemove: { [weak self] in - self?.removeBrushPaletteColor() + self?.viewModel.brushPalette.removeSelected() }, onDuplicate: { [weak self] in - self?.addBrushPaletteColor() + self?.viewModel.brushPalette.duplicateSelected() } ) }, @@ -444,10 +444,10 @@ private extension HandDrawingViewController { alphaSource: self.viewModel.eraserPalette, paletteState: self.viewModel.alphaPaletteState, onRemove: { [weak self] in - self?.removeEraserPaletteAlpha() + self?.viewModel.eraserPalette.removeSelected() }, onDuplicate: { [weak self] in - self?.addEraserPaletteAlpha() + self?.viewModel.eraserPalette.duplicateSelected() } ) }, @@ -667,43 +667,6 @@ private extension HandDrawingViewController { popupPassthroughView?.syncPopupLayout() } - func addBrushPaletteColor() { - let palette = viewModel.brushPalette - guard - palette.colors.count < 64, - let color = palette.color - else { return } - - palette.insert(color, at: palette.selectedIndex + 1) - } - - func addEraserPaletteAlpha() { - let palette = viewModel.eraserPalette - guard - palette.alphas.count < 64, - let alpha = palette.alpha - else { return } - - palette.insert(alpha, at: palette.selectedIndex + 1) - } - - func removeBrushPaletteColor() { - let palette = viewModel.brushPalette - guard palette.colors.count > 1 else { return } - - let selectedIndex = palette.selectedIndex - palette.remove(at: selectedIndex) - palette.select(min(selectedIndex, max(0, palette.colors.count - 1))) - } - - func removeEraserPaletteAlpha() { - let palette = viewModel.eraserPalette - guard palette.alphas.count > 1 else { return } - - let selectedIndex = palette.selectedIndex - palette.remove(at: selectedIndex) - palette.select(min(selectedIndex, max(0, palette.alphas.count - 1))) - } } private extension HandDrawingViewController { diff --git a/HandDrawingSwiftMetal/Models/DrawingTools/BrushPalette.swift b/HandDrawingSwiftMetal/Models/DrawingTools/BrushPalette.swift index 42ee1da5..b710c96a 100644 --- a/HandDrawingSwiftMetal/Models/DrawingTools/BrushPalette.swift +++ b/HandDrawingSwiftMetal/Models/DrawingTools/BrushPalette.swift @@ -20,6 +20,9 @@ fileprivate let initializeColors: [UIColor] = [ final class BrushPalette: ObservableObject, ColorPaletteColorSource { + static let minColorCount = 1 + static let maxColorCount = 64 + private(set) var id: UUID var selectedColor: UIColor { @@ -58,6 +61,14 @@ extension BrushPalette { return colors[selectedIndex] } + var canDuplicateSelected: Bool { + colors.count < Self.maxColorCount && color != nil + } + + var canRemoveSelected: Bool { + colors.count > Self.minColorCount + } + func color(at index: Int) -> UIColor? { self.colors.indices.contains(index) ? colors[index] : nil } @@ -71,12 +82,20 @@ extension BrushPalette { } func insert(_ color: UIColor, at index: Int) { - guard (0 ... colors.count).contains(index) else { return } + guard + colors.count < Self.maxColorCount, + (0 ... colors.count).contains(index) + else { return } var updated = colors updated.insert(color, at: index) colors = updated } + func duplicateSelected() { + guard canDuplicateSelected, let color else { return } + insert(color, at: selectedIndex + 1) + } + func update( colors: [UIColor] = [], selectedIndex: Int = 0 @@ -96,7 +115,7 @@ extension BrushPalette { } func remove(at index: Int) { - guard colors.indices.contains(index) && colors.count > 1 else { return } + guard colors.indices.contains(index) && colors.count > Self.minColorCount else { return } var updated = colors updated.remove(at: index) colors = updated @@ -107,4 +126,8 @@ extension BrushPalette { selectedIndex = updated.count - 1 } } + + func removeSelected() { + remove(at: selectedIndex) + } } diff --git a/HandDrawingSwiftMetal/Models/DrawingTools/EraserPalette.swift b/HandDrawingSwiftMetal/Models/DrawingTools/EraserPalette.swift index 771fa582..51bccc98 100644 --- a/HandDrawingSwiftMetal/Models/DrawingTools/EraserPalette.swift +++ b/HandDrawingSwiftMetal/Models/DrawingTools/EraserPalette.swift @@ -21,6 +21,9 @@ fileprivate let initializeAlphas: [Int] = [ final class EraserPalette: ObservableObject, AlphaPaletteAlphaSource { + static let minAlphaCount = 1 + static let maxAlphaCount = 64 + private(set) var id: UUID var selectedAlpha: Int { @@ -59,6 +62,14 @@ extension EraserPalette { return alphas[selectedIndex] } + var canDuplicateSelected: Bool { + alphas.count < Self.maxAlphaCount && alpha != nil + } + + var canRemoveSelected: Bool { + alphas.count > Self.minAlphaCount + } + func alpha(at index: Int) -> Int? { self.alphas.indices.contains(index) ? alphas[index] : nil } @@ -72,12 +83,20 @@ extension EraserPalette { } func insert(_ alpha: Int, at index: Int) { - guard (0 ... alphas.count).contains(index) else { return } + guard + alphas.count < Self.maxAlphaCount, + (0 ... alphas.count).contains(index) + else { return } var updated = alphas updated.insert(alpha, at: index) alphas = updated } + func duplicateSelected() { + guard canDuplicateSelected, let alpha else { return } + insert(alpha, at: selectedIndex + 1) + } + func update( alphas: [Int] = [], selectedIndex: Int = 0 @@ -97,7 +116,7 @@ extension EraserPalette { } func remove(at index: Int) { - guard alphas.indices.contains(index) && alphas.count > 1 else { return } + guard alphas.indices.contains(index) && alphas.count > Self.minAlphaCount else { return } var updated = alphas updated.remove(at: index) alphas = updated @@ -108,4 +127,8 @@ extension EraserPalette { selectedIndex = updated.count - 1 } } + + func removeSelected() { + remove(at: selectedIndex) + } } diff --git a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift index 9943be1e..c2f2383a 100644 --- a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift +++ b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift @@ -90,6 +90,65 @@ struct BrushPaletteTests { #expect(palette.colors == [.red]) } + @Test("Confirms duplicating the selected color inserts a copy after it") + func testDuplicateSelected() async throws { + let palette = BrushPalette( + colors: [.black, .red], + selectedIndex: 0 + ) + + #expect(palette.canDuplicateSelected) + palette.duplicateSelected() + + #expect(palette.colors == [.black, .black, .red]) + #expect(palette.selectedIndex == 0) + } + + @Test("Confirms duplicating is ignored at the max color count") + func testDuplicateSelectedAtMaxCount() async throws { + let colors = Array(repeating: UIColor.black, count: BrushPalette.maxColorCount) + let palette = BrushPalette( + colors: colors, + selectedIndex: 0 + ) + + #expect(!palette.canDuplicateSelected) + palette.duplicateSelected() + #expect(palette.colors.count == BrushPalette.maxColorCount) + + palette.insert(.red, at: 1) + #expect(palette.colors.count == BrushPalette.maxColorCount) + #expect(palette.colors == colors) + } + + @Test("Confirms removing the selected color") + func testRemoveSelected() async throws { + let palette = BrushPalette( + colors: [.black, .red, .blue], + selectedIndex: 1 + ) + + #expect(palette.canRemoveSelected) + palette.removeSelected() + + #expect(palette.colors == [.black, .blue]) + #expect(palette.selectedIndex == 1) + #expect(palette.color == .blue) + } + + @Test("Confirms removing is ignored at the min color count") + func testRemoveSelectedAtMinCount() async throws { + let palette = BrushPalette( + colors: [.red], + selectedIndex: 0 + ) + + #expect(!palette.canRemoveSelected) + palette.removeSelected() + #expect(palette.colors == [.red]) + #expect(palette.selectedIndex == 0) + } + @Test("Confirms reselecting the same color returns true") func testReselect() async throws { let palette = BrushPalette( diff --git a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift index fb7e44b2..d71ed7ed 100644 --- a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift +++ b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift @@ -90,6 +90,65 @@ struct EraserPaletteTests { #expect(palette.alphas == [128]) } + @Test("Confirms duplicating the selected alpha inserts a copy after it") + func testDuplicateSelected() async throws { + let palette = EraserPalette( + alphas: [64, 128], + selectedIndex: 0 + ) + + #expect(palette.canDuplicateSelected) + palette.duplicateSelected() + + #expect(palette.alphas == [64, 64, 128]) + #expect(palette.selectedIndex == 0) + } + + @Test("Confirms duplicating is ignored at the max alpha count") + func testDuplicateSelectedAtMaxCount() async throws { + let alphas = Array(repeating: 128, count: EraserPalette.maxAlphaCount) + let palette = EraserPalette( + alphas: alphas, + selectedIndex: 0 + ) + + #expect(!palette.canDuplicateSelected) + palette.duplicateSelected() + #expect(palette.alphas.count == EraserPalette.maxAlphaCount) + + palette.insert(64, at: 1) + #expect(palette.alphas.count == EraserPalette.maxAlphaCount) + #expect(palette.alphas == alphas) + } + + @Test("Confirms removing the selected alpha") + func testRemoveSelected() async throws { + let palette = EraserPalette( + alphas: [64, 128, 255], + selectedIndex: 1 + ) + + #expect(palette.canRemoveSelected) + palette.removeSelected() + + #expect(palette.alphas == [64, 255]) + #expect(palette.selectedIndex == 1) + #expect(palette.alpha == 255) + } + + @Test("Confirms removing is ignored at the min alpha count") + func testRemoveSelectedAtMinCount() async throws { + let palette = EraserPalette( + alphas: [128], + selectedIndex: 0 + ) + + #expect(!palette.canRemoveSelected) + palette.removeSelected() + #expect(palette.alphas == [128]) + #expect(palette.selectedIndex == 0) + } + @Test("Confirms reselecting the same alpha returns true") func testReselect() async throws { let palette = EraserPalette( From 7620bd36256d48bf636cb128773f3fa3d7f011ff Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Wed, 29 Jul 2026 21:16:45 +0900 Subject: [PATCH 11/13] Update `UIImageExtensions` --- .../PaletteEditView/Color/ViewItems/PickerImageView.swift | 2 +- .../{UIImage+Palette.swift => UIImageExtensions.swift} | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) rename PaletteEditView/Sources/PaletteEditView/Extensions/{UIImage+Palette.swift => UIImageExtensions.swift} (95%) diff --git a/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift index b7c7259b..9b63a630 100644 --- a/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift +++ b/PaletteEditView/Sources/PaletteEditView/Color/ViewItems/PickerImageView.swift @@ -49,7 +49,7 @@ struct PickerImageView: View { } private func pickColor(from location: CGPoint, image: UIImage, viewSize: CGSize) { - guard let rgb = image.paletteColor( + guard let rgb = image.pixelColor( at: location, in: viewSize )?.paletteRGBAComponents() else { return } diff --git a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImageExtensions.swift similarity index 95% rename from PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift rename to PaletteEditView/Sources/PaletteEditView/Extensions/UIImageExtensions.swift index 6c248284..a957f3c4 100644 --- a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImage+Palette.swift +++ b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImageExtensions.swift @@ -7,7 +7,8 @@ import UIKit extension UIImage { - func paletteColor( + /// Returns the color of the image pixel corresponding to a point in `viewSize`. + func pixelColor( at location: CGPoint, in viewSize: CGSize ) -> UIColor? { From afe5616e775acbf5a350139bb89b3ac907247062 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Wed, 29 Jul 2026 21:55:15 +0900 Subject: [PATCH 12/13] Update `BrushPaletteTests`, `EraserPaletteTests` --- .../Models/BrushPaletteTests.swift | 152 +++++++++--------- .../Models/EraserPaletteTests.swift | 152 +++++++++--------- 2 files changed, 154 insertions(+), 150 deletions(-) diff --git a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift index c2f2383a..c3a6115f 100644 --- a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift +++ b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/BrushPaletteTests.swift @@ -12,151 +12,153 @@ import UIKit @MainActor struct BrushPaletteTests { - @Test("Confirms default color is set to .black when initialized with no colors") - func testInitWithEmptyColors() async throws { - let palette = BrushPalette( + private typealias Subject = BrushPalette + + @Test + func `Confirms default color is set to .black when initialized with no colors`() { + let subject = Subject( colors: [], selectedIndex: -1 ) - #expect(palette.colors == [.black]) - #expect(palette.selectedIndex == 0) + #expect(subject.colors == [.black]) + #expect(subject.selectedIndex == 0) } - @Test("Confirms selecting a color changes the current color") - func testSelect() async throws { - let palette = BrushPalette( + @Test + func `Confirms selecting a color changes the current color`() { + let subject = Subject( colors: [.black, .red], selectedIndex: 0 ) - #expect(palette.selectedIndex == 0) - #expect(palette.color == .black) + #expect(subject.selectedIndex == 0) + #expect(subject.color == .black) - palette.select(1) - #expect(palette.selectedIndex == 1) - #expect(palette.color == .red) + subject.select(1) + #expect(subject.selectedIndex == 1) + #expect(subject.color == .red) } - @Test("Confirms inserting a color at the specified index") - func testInsert() async throws { - let palette = BrushPalette( + @Test + func `Confirms inserting a color at the specified index`() { + let subject = Subject( colors: [.black], selectedIndex: 0 ) - palette.insert(.blue, at: 0) - #expect(palette.colors == [.blue, .black]) + subject.insert(.blue, at: 0) + #expect(subject.colors == [.blue, .black]) } - @Test("Confirms it updates colors and currentIndex") - func testUpdateColorsAndIndex() async throws { - let palette = BrushPalette( + @Test + func `Confirms it updates colors and currentIndex`() { + let subject = Subject( colors: [.black, .lightGray, .gray, .white], selectedIndex: 0 ) - palette.update(colors: [.red, .green], selectedIndex: 1) + subject.update(colors: [.red, .green], selectedIndex: 1) - #expect(palette.colors == [.red, .green]) - #expect(palette.selectedIndex == 1) - #expect(palette.color == .green) + #expect(subject.colors == [.red, .green]) + #expect(subject.selectedIndex == 1) + #expect(subject.color == .green) } - @Test("Confirms a color can be updated at the specified index") - func testUpdateColorAtIndex() async throws { - let palette = BrushPalette( + @Test + func `Confirms a color can be updated at the specified index`() { + let subject = Subject( colors: [.black, .red], selectedIndex: 0 ) - palette.update(color: .blue, at: 1) - #expect(palette.colors == [.black, .blue]) + subject.update(color: .blue, at: 1) + #expect(subject.colors == [.black, .blue]) } - @Test("Confirms removing a color at the specified index") - func testRemove() async throws { - let palette = BrushPalette( + @Test + func `Confirms removing a color at the specified index`() { + let subject = Subject( colors: [.black, .red], selectedIndex: 0 ) - palette.remove(at: 0) - #expect(palette.colors == [.red]) + subject.remove(at: 0) + #expect(subject.colors == [.red]) // Cannot remove when the palette has only one color - palette.remove(at: 0) - #expect(palette.colors.count == 1) - #expect(palette.colors == [.red]) + subject.remove(at: 0) + #expect(subject.colors.count == 1) + #expect(subject.colors == [.red]) } - @Test("Confirms duplicating the selected color inserts a copy after it") - func testDuplicateSelected() async throws { - let palette = BrushPalette( + @Test + func `Confirms duplicating the selected color inserts a copy after it`() { + let subject = Subject( colors: [.black, .red], selectedIndex: 0 ) - #expect(palette.canDuplicateSelected) - palette.duplicateSelected() + #expect(subject.canDuplicateSelected) + subject.duplicateSelected() - #expect(palette.colors == [.black, .black, .red]) - #expect(palette.selectedIndex == 0) + #expect(subject.colors == [.black, .black, .red]) + #expect(subject.selectedIndex == 0) } - @Test("Confirms duplicating is ignored at the max color count") - func testDuplicateSelectedAtMaxCount() async throws { - let colors = Array(repeating: UIColor.black, count: BrushPalette.maxColorCount) - let palette = BrushPalette( + @Test + func `Confirms duplicating is ignored at the max color count`() { + let colors = Array(repeating: UIColor.black, count: Subject.maxColorCount) + let subject = Subject( colors: colors, selectedIndex: 0 ) - #expect(!palette.canDuplicateSelected) - palette.duplicateSelected() - #expect(palette.colors.count == BrushPalette.maxColorCount) + #expect(!subject.canDuplicateSelected) + subject.duplicateSelected() + #expect(subject.colors.count == Subject.maxColorCount) - palette.insert(.red, at: 1) - #expect(palette.colors.count == BrushPalette.maxColorCount) - #expect(palette.colors == colors) + subject.insert(.red, at: 1) + #expect(subject.colors.count == Subject.maxColorCount) + #expect(subject.colors == colors) } - @Test("Confirms removing the selected color") - func testRemoveSelected() async throws { - let palette = BrushPalette( + @Test + func `Confirms removing the selected color`() { + let subject = Subject( colors: [.black, .red, .blue], selectedIndex: 1 ) - #expect(palette.canRemoveSelected) - palette.removeSelected() + #expect(subject.canRemoveSelected) + subject.removeSelected() - #expect(palette.colors == [.black, .blue]) - #expect(palette.selectedIndex == 1) - #expect(palette.color == .blue) + #expect(subject.colors == [.black, .blue]) + #expect(subject.selectedIndex == 1) + #expect(subject.color == .blue) } - @Test("Confirms removing is ignored at the min color count") - func testRemoveSelectedAtMinCount() async throws { - let palette = BrushPalette( + @Test + func `Confirms removing is ignored at the min color count`() { + let subject = Subject( colors: [.red], selectedIndex: 0 ) - #expect(!palette.canRemoveSelected) - palette.removeSelected() - #expect(palette.colors == [.red]) - #expect(palette.selectedIndex == 0) + #expect(!subject.canRemoveSelected) + subject.removeSelected() + #expect(subject.colors == [.red]) + #expect(subject.selectedIndex == 0) } - @Test("Confirms reselecting the same color returns true") - func testReselect() async throws { - let palette = BrushPalette( + @Test + func `Confirms reselecting the same color returns true`() { + let subject = Subject( colors: [.black, .red], selectedIndex: 1 ) - #expect(palette.select(1) == true) - #expect(palette.select(0) == false) + #expect(subject.select(1) == true) + #expect(subject.select(0) == false) } } diff --git a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift index d71ed7ed..28cdfcaa 100644 --- a/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift +++ b/HandDrawingSwiftMetalTests/Views/Components/Drawingtool/Models/EraserPaletteTests.swift @@ -12,151 +12,153 @@ import UIKit @MainActor struct EraserPaletteTests { - @Test("Confirms default alpha is set to 255 when initialized with no alphas") - func testInitWithEmptyColors() async throws { - let palette = EraserPalette( + private typealias Subject = EraserPalette + + @Test + func `Confirms default alpha is set to 255 when initialized with no alphas`() { + let subject = Subject( alphas: [], selectedIndex: -1 ) - #expect(palette.alphas == [255]) - #expect(palette.selectedIndex == 0) + #expect(subject.alphas == [255]) + #expect(subject.selectedIndex == 0) } - @Test("Confirms selecting an alpha changes the current alpha") - func testSelect() async throws { - let palette = EraserPalette( + @Test + func `Confirms selecting an alpha changes the current alpha`() { + let subject = Subject( alphas: [64, 128], selectedIndex: 0 ) - #expect(palette.selectedIndex == 0) - #expect(palette.alpha == 64) + #expect(subject.selectedIndex == 0) + #expect(subject.alpha == 64) - palette.select(1) - #expect(palette.selectedIndex == 1) - #expect(palette.alpha == 128) + subject.select(1) + #expect(subject.selectedIndex == 1) + #expect(subject.alpha == 128) } - @Test("Confirms inserting an alpha at the specified index") - func testInsert() async throws { - let palette = EraserPalette( + @Test + func `Confirms inserting an alpha at the specified index`() { + let subject = Subject( alphas: [128], selectedIndex: 0 ) - palette.insert(64, at: 0) - #expect(palette.alphas == [64, 128]) + subject.insert(64, at: 0) + #expect(subject.alphas == [64, 128]) } - @Test("Confirms it updates alphas and currentIndex") - func testUpdateAlphasAndIndex() async throws { - let palette = EraserPalette( + @Test + func `Confirms it updates alphas and currentIndex`() { + let subject = Subject( alphas: [255], selectedIndex: 0 ) - palette.update(alphas: [32, 64, 128], selectedIndex: 2) + subject.update(alphas: [32, 64, 128], selectedIndex: 2) - #expect(palette.alphas == [32, 64, 128]) - #expect(palette.selectedIndex == 2) - #expect(palette.alpha == 128) + #expect(subject.alphas == [32, 64, 128]) + #expect(subject.selectedIndex == 2) + #expect(subject.alpha == 128) } - @Test("Confirms an alpha can be updated at the specified index") - func testUpdateAlphaAtIndex() async throws { - let palette = EraserPalette( + @Test + func `Confirms an alpha can be updated at the specified index`() { + let subject = Subject( alphas: [128, 255], selectedIndex: 0 ) - palette.update(alpha: 64, at: 1) - #expect(palette.alphas == [128, 64]) + subject.update(alpha: 64, at: 1) + #expect(subject.alphas == [128, 64]) } - @Test("Confirms removing an alpha at the specified index") - func testRemove() async throws { - let palette = EraserPalette( + @Test + func `Confirms removing an alpha at the specified index`() { + let subject = Subject( alphas: [64, 128], selectedIndex: 0 ) - palette.remove(at: 0) - #expect(palette.alphas == [128]) + subject.remove(at: 0) + #expect(subject.alphas == [128]) // Cannot remove when the palette has only one alpha - palette.remove(at: 0) - #expect(palette.alphas.count == 1) - #expect(palette.alphas == [128]) + subject.remove(at: 0) + #expect(subject.alphas.count == 1) + #expect(subject.alphas == [128]) } - @Test("Confirms duplicating the selected alpha inserts a copy after it") - func testDuplicateSelected() async throws { - let palette = EraserPalette( + @Test + func `Confirms duplicating the selected alpha inserts a copy after it`() { + let subject = Subject( alphas: [64, 128], selectedIndex: 0 ) - #expect(palette.canDuplicateSelected) - palette.duplicateSelected() + #expect(subject.canDuplicateSelected) + subject.duplicateSelected() - #expect(palette.alphas == [64, 64, 128]) - #expect(palette.selectedIndex == 0) + #expect(subject.alphas == [64, 64, 128]) + #expect(subject.selectedIndex == 0) } - @Test("Confirms duplicating is ignored at the max alpha count") - func testDuplicateSelectedAtMaxCount() async throws { - let alphas = Array(repeating: 128, count: EraserPalette.maxAlphaCount) - let palette = EraserPalette( + @Test + func `Confirms duplicating is ignored at the max alpha count`() { + let alphas = Array(repeating: 128, count: Subject.maxAlphaCount) + let subject = Subject( alphas: alphas, selectedIndex: 0 ) - #expect(!palette.canDuplicateSelected) - palette.duplicateSelected() - #expect(palette.alphas.count == EraserPalette.maxAlphaCount) + #expect(!subject.canDuplicateSelected) + subject.duplicateSelected() + #expect(subject.alphas.count == Subject.maxAlphaCount) - palette.insert(64, at: 1) - #expect(palette.alphas.count == EraserPalette.maxAlphaCount) - #expect(palette.alphas == alphas) + subject.insert(64, at: 1) + #expect(subject.alphas.count == Subject.maxAlphaCount) + #expect(subject.alphas == alphas) } - @Test("Confirms removing the selected alpha") - func testRemoveSelected() async throws { - let palette = EraserPalette( + @Test + func `Confirms removing the selected alpha`() { + let subject = Subject( alphas: [64, 128, 255], selectedIndex: 1 ) - #expect(palette.canRemoveSelected) - palette.removeSelected() + #expect(subject.canRemoveSelected) + subject.removeSelected() - #expect(palette.alphas == [64, 255]) - #expect(palette.selectedIndex == 1) - #expect(palette.alpha == 255) + #expect(subject.alphas == [64, 255]) + #expect(subject.selectedIndex == 1) + #expect(subject.alpha == 255) } - @Test("Confirms removing is ignored at the min alpha count") - func testRemoveSelectedAtMinCount() async throws { - let palette = EraserPalette( + @Test + func `Confirms removing is ignored at the min alpha count`() { + let subject = Subject( alphas: [128], selectedIndex: 0 ) - #expect(!palette.canRemoveSelected) - palette.removeSelected() - #expect(palette.alphas == [128]) - #expect(palette.selectedIndex == 0) + #expect(!subject.canRemoveSelected) + subject.removeSelected() + #expect(subject.alphas == [128]) + #expect(subject.selectedIndex == 0) } - @Test("Confirms reselecting the same alpha returns true") - func testReselect() async throws { - let palette = EraserPalette( + @Test + func `Confirms reselecting the same alpha returns true`() { + let subject = Subject( alphas: [64, 128], selectedIndex: 1 ) - #expect(palette.select(1) == true) - #expect(palette.select(0) == false) + #expect(subject.select(1) == true) + #expect(subject.select(0) == false) } } From bd05611c34c014ebcc70fcd896dc9b376225c4b1 Mon Sep 17 00:00:00 2001 From: Eisuke Kusachi Date: Wed, 29 Jul 2026 22:42:27 +0900 Subject: [PATCH 13/13] Fix CGContext backing store in `pixelColor(at:in:)` --- .../Extensions/UIImageExtensions.swift | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImageExtensions.swift b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImageExtensions.swift index a957f3c4..46c22604 100644 --- a/PaletteEditView/Sources/PaletteEditView/Extensions/UIImageExtensions.swift +++ b/PaletteEditView/Sources/PaletteEditView/Extensions/UIImageExtensions.swift @@ -35,38 +35,43 @@ extension UIImage { return nil } - var pixel: [UInt8] = [0, 0, 0, 0] + var pixel = [UInt8](repeating: 0, count: 4) let colorSpace = CGColorSpaceCreateDeviceRGB() let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue // A tiny 1x1 context for sampling one pixel's color. - guard let context = CGContext( - data: &pixel, - width: 1, - height: 1, - bitsPerComponent: 8, - bytesPerRow: 4, - space: colorSpace, - bitmapInfo: bitmapInfo - ) else { - return nil - } + let sample = pixel.withUnsafeMutableBytes { rawBuffer -> Bool in + guard let baseAddress = rawBuffer.baseAddress else { return false } + guard let context = CGContext( + data: baseAddress, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 4, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + return false + } - context.interpolationQuality = .none - // Shift the drawn image so the target pixel lands in this 1x1 context. - let sampleY = pixelHeight - 1 - pixelY - context.translateBy(x: -CGFloat(pixelX), y: -CGFloat(sampleY)) - context.draw( - cgImage, - in: CGRect( - x: 0, - y: 0, - width: pixelWidth, - height: pixelHeight + context.interpolationQuality = .none + // Shift the drawn image so the target pixel lands in this 1x1 context. + let sampleY = pixelHeight - 1 - pixelY + context.translateBy(x: -CGFloat(pixelX), y: -CGFloat(sampleY)) + context.draw( + cgImage, + in: CGRect( + x: 0, + y: 0, + width: pixelWidth, + height: pixelHeight + ) ) - ) + return true + } + guard sample else { return nil } return UIColor( red: CGFloat(pixel[0]) / 255.0,