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 {