Last active
February 6, 2023 22:24
-
-
Save jbarr21/dcfc53136a5ff1511dd458831de7addf to your computer and use it in GitHub Desktop.
Export a color palette from Figma given a file with multiple tables of colors with semantic names on the row labels and theme names on the column names
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright (c) 2019. Uber Technologies | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
#!/usr/bin/env kscript | |
@file:DependsOn("com.github.ajalt:clikt:1.5.0") | |
@file:DependsOn("com.squareup.moshi:moshi-kotlin:1.7.0") | |
@file:DependsOn("com.squareup.okhttp3:okhttp:3.11.0") | |
@file:DependsOn("de.vandermeer:asciitable:0.3.2") | |
import com.github.ajalt.clikt.core.CliktCommand | |
import com.github.ajalt.clikt.parameters.options.flag | |
import com.github.ajalt.clikt.parameters.options.option | |
import com.squareup.moshi.* | |
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory | |
import okhttp3.OkHttpClient | |
import okhttp3.Request | |
import java.net.URLEncoder | |
import java.util.concurrent.TimeUnit | |
import de.vandermeer.asciitable.AsciiTable | |
import java.io.File | |
object Consts { | |
const val figmaAuthToken = "YOUR_FIGMA_AUTH_TOKEN_HERE" | |
const val platformTrackerFileId = "YOUR_FIGMA_FILE_ID_HERE" | |
} | |
interface Node { | |
val id: String | |
val name: String | |
fun colorForCell(colorFrame: FigmaNode): FigmaColor | |
} | |
enum class BgColorNode(override val id: String) : Node { | |
GLOBAL("61:0"); | |
BACKGROUND("70:9"), | |
ICON("94:361"), | |
BORDER("94:773"); | |
override fun colorForCell(colorFrame: FigmaNode): FigmaColor { | |
val rectChild = colorFrame.children.firstOrNull { it.type == FigmaType.RECTANGLE.name } | |
val textChild = colorFrame.children.firstOrNull { it.type == FigmaType.TEXT.name } | |
return when { | |
rectChild?.backgroundColor != null -> rectChild.backgroundColor | |
rectChild?.fills?.first()?.color != null -> rectChild.fills.first().color | |
colorFrame.backgroundColor != null -> colorFrame.backgroundColor | |
textChild?.backgroundColor != null -> textChild.backgroundColor | |
else -> throw java.lang.IllegalStateException("No color for cell") | |
} | |
} | |
} | |
enum class FgColorNode(override val id: String) : Node { | |
TEXT("94:56"); | |
override fun colorForCell(colorFrame: FigmaNode): FigmaColor { | |
val textChild = colorFrame.children.firstOrNull { it.type == FigmaType.TEXT.name } | |
return when { | |
textChild?.fills?.first()?.color != null -> textChild.fills.first().color | |
else -> throw java.lang.IllegalStateException("No color for cell") | |
} | |
} | |
} | |
enum class Theme { | |
RIDER_LIGHT, | |
RIDER_DARK, | |
EATS, | |
SAMPLE | |
} | |
class FigmaExporter : CliktCommand() { | |
private val ascii: Boolean by option("-a", "--ascii", help = "Local path to generated QR code in PNG format").flag(default = false) | |
private val html: Boolean by option("-h", "--html", help = "Local path to generated QR code in PNG format").flag(default = false) | |
private val json: Boolean by option("-j", "--json", help = "Public url of APK").flag(default = false) | |
override fun run() { | |
val nodes: List<Node> = BgColorNode.values().toMutableList() + FgColorNode.values() | |
nodes.map { parseColorSheet(Consts.platformTrackerFileId, it) } | |
.toList() | |
.let { | |
val outputAll = !ascii && !html && !json | |
if (outputAll || ascii) outputAsciiTables(it) | |
if (outputAll || html) outputHtmlTables(it) | |
if (outputAll || json) outputJson(it) | |
} | |
} | |
fun parseColorSheet(fileId: String, nodeId: Node, headers: List<String> = Theme.values().map { it.name }): ColorSheet { | |
val figmaNodeUrl = "https://api.figma.com/v1/files/$fileId/nodes?ids=${URLEncoder.encode(nodeId.id, Charsets.UTF_8.name())}" | |
val inputRows = FigmaApi.makeRequest(figmaNodeUrl).nodes.values.first().document.children | |
val outputRows = mutableListOf<List<ColorCell>>() | |
val outputRowLabels = mutableListOf<String>() | |
inputRows.reversed().filter { it.name != "HEADER" && it.children.isNotEmpty() }.forEach { inputRow -> | |
val outputRow = mutableListOf<ColorCell>() | |
var themeColumnIndex = 0 | |
inputRow.children.forEachIndexed { index, colorFrame -> | |
if (index != 0 && colorFrame.type == FigmaType.FRAME.name) { | |
val textChild = colorFrame.children.firstOrNull { it.type == FigmaType.TEXT.name } | |
val colorName = textChild?.name ?: "unknown" | |
val color = nodeId.colorForCell(colorFrame) | |
outputRow += ColorCell(colorName, color) | |
themeColumnIndex++ | |
} | |
} | |
if (outputRow.isNotEmpty()) { | |
outputRows += outputRow | |
outputRowLabels += inputRow.name | |
} | |
} | |
return ColorSheet(nodeId.name, headers, outputRowLabels, outputRows) | |
} | |
fun outputHtmlTables(colorSheets: List<ColorSheet>) { | |
val css = """ | |
table { border-collapse: collapse; } | |
table, td, th { border: 1px solid white; alignment: center; padding: 20px; } | |
""".trimIndent() | |
val html = "<html><head><title>Figma Styles Export</title><style>{{CSS}}</style></head><body>{{TABLES}}</body></html>" | |
.replace("{{CSS}}", css) | |
.replace("{{TABLES}}", colorSheets.map { buildHtmlTable(it) }.joinToString("<br><br>")) | |
val outputFile = File("color-sheets.html") | |
outputFile.writeText(html) | |
println("\nWrote HTML to ${outputFile.absolutePath}") | |
} | |
private fun buildHtmlTable(colorSheet: ColorSheet): String { | |
val tableHeaders = (mutableListOf(colorSheet.name) + colorSheet.headers) | |
.map { "<th>${it.capitalize()}</th>" } | |
.joinToString("", prefix = "<tr>", postfix = "</tr>") | |
val tableRows = colorSheet.rows | |
.mapIndexed { index, row -> | |
val rowLabelCell = "<td>${colorSheet.rowLabels[index]}</td>" | |
val rowCells = row.map { "<td bgcolor='${it.color.hex()}'>${it.name}</td>" } | |
return@mapIndexed (mutableListOf(rowLabelCell) + rowCells) | |
.joinToString("", prefix = "<tr>", postfix = "</tr>") | |
} | |
.joinToString("") | |
return "<table>{{HEADERS}}{{ROWS}}</table>" | |
.replace("{{HEADERS}}", tableHeaders) | |
.replace("{{ROWS}}", tableRows) | |
} | |
fun outputAsciiTables(colorSheets: List<ColorSheet>) { | |
colorSheets.forEach { colorSheet -> | |
AsciiTable().apply { | |
addRule() | |
addRow(colorSheet.headers.toMutableList().apply { add(0, colorSheet.name) }) | |
addRule() | |
colorSheet.rows.forEachIndexed { index, row -> | |
addRow(mutableListOf(colorSheet.rowLabels[index]) + row.map { "${it.name} (${it.color.rgb()})" }) | |
addRule() | |
} | |
println("\n" + render(200)) | |
} | |
} | |
} | |
} | |
FigmaExporter().main(args) | |
// Figma API | |
object FigmaApi { | |
val moshi = Moshi.Builder() | |
.add(KotlinJsonAdapterFactory()) | |
.build() | |
val okHttpClient = OkHttpClient.Builder() | |
.connectTimeout(30, TimeUnit.SECONDS) | |
.readTimeout(30, TimeUnit.SECONDS) | |
.writeTimeout(30, TimeUnit.SECONDS) | |
.addNetworkInterceptor { it.proceed(it.request().newBuilder().addHeader("X-FIGMA-TOKEN", Consts.figmaAuthToken).build()) } | |
.build() | |
fun makeRequest(url: String): FigmaResponse { | |
return okHttpClient.newCall(Request.Builder().url(url).build()) | |
.execute() | |
.body() | |
?.string() | |
?.let { | |
return@let moshi.adapter<FigmaResponse>(FigmaResponse::class.java).fromJson(it) | |
} ?: throw IllegalStateException("Unable to fetch requested data from Figma") | |
} | |
} | |
data class FigmaResponse(val name: String, val lastModified: String, val version: Long, val nodes: Map<String, FigmaDocument>) | |
data class FigmaDocument(val document: FigmaNode, val components: Map<String, Any>, val styles: Map<String, FigmaStyle>) | |
data class FigmaNode( | |
val id: String, | |
val name: String, | |
val type: String, | |
val blendMode: String, | |
val children: List<FigmaNode> = emptyList(), | |
val backgroundColor: FigmaColor?, | |
val fills: List<FigmaFill> = emptyList() | |
) | |
data class FigmaColor(val r: Double, val g: Double, val b: Double, val a: Double) | |
data class FigmaStyle(val name: String, val styleType: String) | |
data class FigmaFill(val type: String, val blendMode: String, val color: FigmaColor) | |
enum class FigmaType { FRAME, TEXT, RECTANGLE } | |
enum class FigmaBlendMode { SOLID, PASS_THROUGH } | |
// Utils | |
fun FigmaColor.rgb() = listOf(r, g, b).map { it.pctOf255() }.joinToString(", ") | |
fun FigmaColor.hex() = listOf(r, g, b).map { it.toHex() }.joinToString("", prefix = "#") | |
fun Double.pctOf255() = Math.round(this * 255) | |
fun Double.toHex() = pctOf255().toString(16).padStart(2, '0') | |
fun String.capitalize() = toLowerCase().split("[ \\_]".toRegex()).map { it[0].toUpperCase() + it.substring(1) }.joinToString(" ") | |
data class ColorSheet(val name: String, val headers: List<String>, val rowLabels: List<String>, val rows: List<List<ColorCell>>) | |
data class ColorCell(val name: String, val color: FigmaColor) | |
data class ColorMapping(val mapping: List<ColorTheme> = emptyList()) | |
data class ColorTheme(val name: String, val groups: List<ColorGroup> = emptyList()) | |
data class ColorGroup(val name: String, val colors: List<NamedColorCell> = emptyList()) | |
data class NamedColorCell(val name: String, val hex: String, val r: Int, val g: Int, val b: Int) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment