Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 37 additions & 23 deletions Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
241 changes: 241 additions & 0 deletions Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<svg><rect fill=\"#ff0000\"/></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 = "<svg><rect fill=\"#ff0000\"/></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 = "<svg><rect fill=\"#ff0000\"/></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 = "<svg><rect fill=\"#ff0000\"/></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 = "<svg><rect fill=\"#ff0000\" width=\"10\"/></svg>"
let svg2 = "<svg><rect fill=\"#ff0000\" width=\"20\"/></svg>"
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 = "<svg><rect fill=\"#ff0000\"/></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 {
Expand Down
Loading