diff --git a/CLAUDE.md b/CLAUDE.md index 8e78462c..20af0e03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -267,6 +267,8 @@ Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaCo **Granular cache path:** `IconsExportContextImpl.loadIconsWithGranularCache()` creates its own `IconsLoader` and bypasses `FigmaComponentsSource` entirely. Variable-mode dark generation must be applied explicitly at the end of that method via `applyVariableModeDark(to:source:)`. +**RTL in variable-mode dark:** `buildDarkPack` iterates ALL images in a pack (not just `.first`), preserving `isRTL`, `scale`, `idiom`, and `format` from the light variant. Temp file names include index for uniqueness: `{name}{_rtl}_{index}_dark.{format}`. + ### Module Boundaries ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended in ExFigCLI) are diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift index 61d2d660..3b0d917b 100644 --- a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -202,40 +202,54 @@ struct VariableModeDarkGenerator { colorMap: [String: ColorReplacement], tempDir: URL ) throws -> ImagePack? { - guard let svgImage = pack.images.first else { + guard !pack.images.isEmpty else { logger.warning("Icon '\(pack.name)' has no images, skipping dark generation") return nil } - let svgData: Data - do { - svgData = try Data(contentsOf: svgImage.url) - } catch { - logger.warning("Failed to read SVG for icon '\(pack.name)': \(error.localizedDescription)") - return nil + let safeName = pack.name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + + var darkImages: [Image] = [] + for (index, svgImage) in pack.images.enumerated() { + let svgData: Data + do { + svgData = try Data(contentsOf: svgImage.url) + } catch { + logger.warning("Failed to read SVG for icon '\(pack.name)' [\(index)]: \(error.localizedDescription)") + continue + } + + guard let svgContent = String(data: svgData, encoding: .utf8) else { + logger.warning("Icon '\(pack.name)' [\(index)] SVG is not valid UTF-8, skipping") + continue + } + + let darkSVG = SVGColorReplacer.replaceColors(in: svgContent, colorMap: colorMap) + + let suffix = svgImage.isRTL ? "_rtl" : "" + let tempURL = tempDir.appendingPathComponent("\(safeName)\(suffix)_\(index)_dark.\(svgImage.format)") + try Data(darkSVG.utf8).write(to: tempURL) + + darkImages.append(Image( + name: pack.name, + scale: svgImage.scale, + idiom: svgImage.idiom, + url: tempURL, + format: svgImage.format, + isRTL: svgImage.isRTL + )) } - guard let svgContent = String(data: svgData, encoding: .utf8) else { - logger.warning("Icon '\(pack.name)' SVG is not valid UTF-8, skipping dark generation") + guard !darkImages.isEmpty else { + logger.warning("Icon '\(pack.name)' produced no dark images (all \(pack.images.count) variants failed)") return nil } - let darkSVG = SVGColorReplacer.replaceColors(in: svgContent, colorMap: colorMap) - - let safeName = pack.name - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: " ", with: "_") - let tempURL = tempDir.appendingPathComponent("\(safeName)_dark.svg") - try Data(darkSVG.utf8).write(to: tempURL) - return ImagePack( name: pack.name, - images: [Image( - name: pack.name, - scale: .all, - url: tempURL, - format: "svg" - )], + images: darkImages, platform: pack.platform, nodeId: pack.nodeId, fileId: pack.fileId diff --git a/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift b/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift index c534ed72..d87c03d8 100644 --- a/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift +++ b/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift @@ -539,6 +539,247 @@ final class VariableModeDarkGeneratorColorMapTests: XCTestCase { } } +// MARK: - processLightPacks RTL Tests + +final class VariableModeDarkGeneratorRTLTests: XCTestCase { + private var generator: VariableModeDarkGenerator! + private var tempDir: URL! + + override func setUp() { + super.setUp() + generator = makeGenerator() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("exfig-test-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + super.tearDown() + } + + private func makeRedToBlueDarkCtx() -> (VariableModeDarkGenerator.ResolutionContext, [String: Node]) { + let nodeJson = """ + {"document":{"id":"1:1","name":"icon","fills":[{"type":"SOLID", + "color":{"r":1.0,"g":0.0,"b":0.0,"a":1.0}, + "boundVariables":{"color":{"id":"VariableID:v1","type":"VARIABLE_ALIAS"}}}]}} + """ + // swiftlint:disable:next force_try + let node = try! JSONCodec.decode(Node.self, from: Data(nodeJson.utf8)) + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "red", valuesByMode: [ + "light": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": (r: 0.0, g: 0.0, b: 1.0, a: 1.0), + ]), + ] + ) + let ctx = VariableModeDarkGenerator.ResolutionContext( + variablesMeta: meta, libMeta: nil, libNameIndex: nil, + modes: .init(lightModeId: "light", darkModeId: "dark", primitivesModeId: nil), + darkModeName: "Dark" + ) + return (ctx, ["1:1": node]) + } + + func testProcessLightPacksPreservesRTLFlag() throws { + let svg = "" + let ltrURL = tempDir.appendingPathComponent("ltr.svg") + let rtlURL = tempDir.appendingPathComponent("rtl.svg") + try Data(svg.utf8).write(to: ltrURL) + try Data(svg.utf8).write(to: rtlURL) + + let pack = ImagePack( + name: "arrow", + images: [ + Image(name: "arrow", scale: .all, url: ltrURL, format: "svg", isRTL: false), + Image(name: "arrow", scale: .all, url: rtlURL, format: "svg", isRTL: true), + ], + platform: .ios, nodeId: "1:1", fileId: "file1" + ) + + let (ctx, nodeMap) = makeRedToBlueDarkCtx() + let outDir = tempDir.appendingPathComponent("out") + try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + + let darkPacks = try generator.processLightPacks([pack], nodeMap: nodeMap, ctx: ctx, tempDir: outDir) + + XCTAssertEqual(darkPacks.count, 1) + XCTAssertEqual(darkPacks[0].images.count, 2, "Should generate dark variant for both LTR and RTL") + XCTAssertNotNil(darkPacks[0].images.first(where: { !$0.isRTL }), "Should have LTR dark image") + XCTAssertNotNil(darkPacks[0].images.first(where: { $0.isRTL }), "Should have RTL dark image") + } + + func testDarkRTLImageHasReplacedColors() throws { + let svg = "" + let ltrURL = tempDir.appendingPathComponent("ltr.svg") + let rtlURL = tempDir.appendingPathComponent("rtl.svg") + try Data(svg.utf8).write(to: ltrURL) + try Data(svg.utf8).write(to: rtlURL) + + let pack = ImagePack( + name: "arrow", + images: [ + Image(name: "arrow", scale: .all, url: ltrURL, format: "svg", isRTL: false), + Image(name: "arrow", scale: .all, url: rtlURL, format: "svg", isRTL: true), + ], + platform: .ios, nodeId: "1:1", fileId: "file1" + ) + + let (ctx, nodeMap) = makeRedToBlueDarkCtx() + let outDir = tempDir.appendingPathComponent("out2") + try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + + let darkPacks = try generator.processLightPacks([pack], nodeMap: nodeMap, ctx: ctx, tempDir: outDir) + XCTAssertEqual(darkPacks.count, 1) + for image in darkPacks[0].images { + let content = try String(data: Data(contentsOf: image.url), encoding: .utf8) + XCTAssertTrue(content?.contains("0000ff") == true, "\(image.isRTL ? "RTL" : "LTR") should have dark color") + XCTAssertFalse( + content?.contains("ff0000") == true, + "\(image.isRTL ? "RTL" : "LTR") should not have light color" + ) + } + } + + func testProcessLightPacksPreservesScaleAndIdiom() throws { + let svg = "" + let svgURL = tempDir.appendingPathComponent("icon.svg") + try Data(svg.utf8).write(to: svgURL) + + let pack = ImagePack( + name: "star", + images: [ + Image(name: "star", scale: .all, idiom: "universal", url: svgURL, format: "svg", isRTL: false), + ], + platform: .ios, nodeId: "1:1", fileId: "file1" + ) + + let (ctx, nodeMap) = makeRedToBlueDarkCtx() + let outDir = tempDir.appendingPathComponent("out3") + try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + + let darkPacks = try generator.processLightPacks([pack], nodeMap: nodeMap, ctx: ctx, tempDir: outDir) + XCTAssertEqual(darkPacks.count, 1) + let darkImage = darkPacks[0].images[0] + XCTAssertEqual(darkImage.idiom, "universal") + XCTAssertEqual(darkImage.format, "svg") + XCTAssertFalse(darkImage.isRTL) + } + + func testDarkRTLImageHasRTLSuffixInFileName() throws { + let svg = "" + let ltrURL = tempDir.appendingPathComponent("ltr.svg") + let rtlURL = tempDir.appendingPathComponent("rtl.svg") + try Data(svg.utf8).write(to: ltrURL) + try Data(svg.utf8).write(to: rtlURL) + + let pack = ImagePack( + name: "arrow", + images: [ + Image(name: "arrow", scale: .all, url: ltrURL, format: "svg", isRTL: false), + Image(name: "arrow", scale: .all, url: rtlURL, format: "svg", isRTL: true), + ], + platform: .ios, nodeId: "1:1", fileId: "file1" + ) + + let (ctx, nodeMap) = makeRedToBlueDarkCtx() + let outDir = tempDir.appendingPathComponent("out4") + try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + + let darkPacks = try generator.processLightPacks([pack], nodeMap: nodeMap, ctx: ctx, tempDir: outDir) + XCTAssertEqual(darkPacks.count, 1) + let rtlImage = darkPacks[0].images.first(where: { $0.isRTL }) + XCTAssertNotNil(rtlImage) + XCTAssertTrue( + try XCTUnwrap(rtlImage?.url.lastPathComponent.contains("_rtl")), + "RTL dark image should have _rtl in filename" + ) + } + + func testMultipleNonRTLImagesGetDistinctFiles() throws { + let svg1 = "" + let svg2 = "" + let url1 = tempDir.appendingPathComponent("icon_1x.svg") + let url2 = tempDir.appendingPathComponent("icon_2x.svg") + try Data(svg1.utf8).write(to: url1) + try Data(svg2.utf8).write(to: url2) + + let pack = ImagePack( + name: "star", + images: [ + Image(name: "star", scale: .individual(1.0), url: url1, format: "svg", isRTL: false), + Image(name: "star", scale: .individual(2.0), url: url2, format: "svg", isRTL: false), + ], + platform: .ios, nodeId: "1:1", fileId: "file1" + ) + + let (ctx, nodeMap) = makeRedToBlueDarkCtx() + let outDir = tempDir.appendingPathComponent("out5") + try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + + let darkPacks = try generator.processLightPacks([pack], nodeMap: nodeMap, ctx: ctx, tempDir: outDir) + XCTAssertEqual(darkPacks.count, 1) + XCTAssertEqual(darkPacks[0].images.count, 2) + + let urls = darkPacks[0].images.map(\.url) + XCTAssertNotEqual(urls[0], urls[1], "Different scale images must have distinct temp file URLs") + + let content0 = try String(contentsOf: urls[0], encoding: .utf8) + let content1 = try String(contentsOf: urls[1], encoding: .utf8) + XCTAssertTrue(content0.contains("width=\"10\""), "First image content should be preserved") + XCTAssertTrue(content1.contains("width=\"20\""), "Second image content should be preserved") + } + + func testPartialFailureStillProducesDarkPack() throws { + let svg = "" + let validURL = tempDir.appendingPathComponent("valid.svg") + try Data(svg.utf8).write(to: validURL) + let missingURL = tempDir.appendingPathComponent("nonexistent.svg") + + let pack = ImagePack( + name: "icon", + images: [ + Image(name: "icon", scale: .all, url: validURL, format: "svg", isRTL: false), + Image(name: "icon", scale: .all, url: missingURL, format: "svg", isRTL: true), + ], + platform: .ios, nodeId: "1:1", fileId: "file1" + ) + + let (ctx, nodeMap) = makeRedToBlueDarkCtx() + let outDir = tempDir.appendingPathComponent("out6") + try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + + let darkPacks = try generator.processLightPacks([pack], nodeMap: nodeMap, ctx: ctx, tempDir: outDir) + XCTAssertEqual(darkPacks.count, 1, "Should produce dark pack from surviving image") + XCTAssertEqual(darkPacks[0].images.count, 1, "Should have only the valid image") + XCTAssertFalse(darkPacks[0].images[0].isRTL) + } + + func testAllImagesFailReturnsNil() throws { + let missingURL1 = tempDir.appendingPathComponent("missing1.svg") + let missingURL2 = tempDir.appendingPathComponent("missing2.svg") + + let pack = ImagePack( + name: "broken", + images: [ + Image(name: "broken", scale: .all, url: missingURL1, format: "svg", isRTL: false), + Image(name: "broken", scale: .all, url: missingURL2, format: "svg", isRTL: true), + ], + platform: .ios, nodeId: "1:1", fileId: "file1" + ) + + let (ctx, nodeMap) = makeRedToBlueDarkCtx() + let outDir = tempDir.appendingPathComponent("out7") + try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + + let darkPacks = try generator.processLightPacks([pack], nodeMap: nodeMap, ctx: ctx, tempDir: outDir) + XCTAssertTrue(darkPacks.isEmpty, "Should produce no dark packs when all images fail") + } +} + // MARK: - VariablesCache Tests final class VariablesCacheTests: XCTestCase {