From 9e77f5d1c891804fd515e5a301772ac8e5a90b70 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Thu, 18 Dec 2025 18:46:23 +0500 Subject: [PATCH 1/6] feat(images): add SVG source format with local rasterization via resvg --- .github/workflows/release.yml | 16 +- CLAUDE.md | 45 ++ Libraries/macos/libresvg.dylib | 3 + Package.swift | 46 ++ Sources/CResvg/include/resvg.h | 516 +++++++++++++++++ Sources/CResvg/module.modulemap | 5 + Sources/CResvg/shim.c | 2 + Sources/ExFig/Input/Params.swift | 27 +- Sources/ExFig/Loaders/ImagesLoader.swift | 71 ++- Sources/ExFig/Output/NativePngEncoder.swift | 193 +++++++ Sources/ExFig/Output/SvgToPngConverter.swift | 96 ++++ Sources/ExFig/Output/SvgToWebpConverter.swift | 113 ++++ Sources/ExFig/Subcommands/ExportImages.swift | 527 ++++++++++++++++++ Sources/Resvg/ResvgError.swift | 80 +++ Sources/Resvg/SvgRasterizer.swift | 138 +++++ Sources/XcodeExport/XcodeImagesExporter.swift | 19 + Tests/ExFigTests/SvgRasterizerTests.swift | 89 +++ mise.toml | 14 +- 18 files changed, 1977 insertions(+), 23 deletions(-) create mode 100755 Libraries/macos/libresvg.dylib create mode 100644 Sources/CResvg/include/resvg.h create mode 100644 Sources/CResvg/module.modulemap create mode 100644 Sources/CResvg/shim.c create mode 100644 Sources/ExFig/Output/NativePngEncoder.swift create mode 100644 Sources/ExFig/Output/SvgToPngConverter.swift create mode 100644 Sources/ExFig/Output/SvgToWebpConverter.swift create mode 100644 Sources/Resvg/ResvgError.swift create mode 100644 Sources/Resvg/SvgRasterizer.swift create mode 100644 Tests/ExFigTests/SvgRasterizerTests.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08a5a897..bde470c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,18 @@ jobs: if: matrix.platform == 'linux-x64' run: | apt-get update - apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev + apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev curl + + - name: Install Rust and build resvg (Linux) + if: matrix.platform == 'linux-x64' + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + git clone --depth 1 --branch v0.45.1 https://github.com/linebender/resvg.git /tmp/resvg + cd /tmp/resvg + cargo build --release -p resvg-capi + mkdir -p $GITHUB_WORKSPACE/Libraries/linux + cp target/release/libresvg.a $GITHUB_WORKSPACE/Libraries/linux/ - name: Set version from tag run: | @@ -63,12 +74,13 @@ jobs: - name: Create release archive (macOS) if: matrix.platform == 'macos' run: | - mkdir -p dist + mkdir -p dist/Libraries cp .build/${{ matrix.build-path }}/exfig dist/ExFig cp -r .build/${{ matrix.build-path }}/exfig_AndroidExport.bundle dist/ cp -r .build/${{ matrix.build-path }}/exfig_XcodeExport.bundle dist/ cp -r .build/${{ matrix.build-path }}/exfig_FlutterExport.bundle dist/ cp -r .build/${{ matrix.build-path }}/exfig_WebExport.bundle dist/ + cp Libraries/macos/libresvg.dylib dist/Libraries/ cp LICENSE dist/ cd dist && zip -r ../${{ matrix.archive-name }}.zip . diff --git a/CLAUDE.md b/CLAUDE.md index 621371ef..37818f5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,51 @@ let loader = ImagesLoader(client: client, params: params, platform: .ios, logger **Frame name resolution:** `entry.figmaFrameName` → `params.common?.images?.figmaFrameName` → `"Illustrations"` +### SVG Source Format + +Images can use SVG as the source format from Figma API, with local rasterization using resvg for higher quality results: + +```yaml +# iOS example - SVG source with PNG output +ios: + images: + - figmaFrameName: "Illustrations" + assetsFolder: "Illustrations" + sourceFormat: svg # Fetch SVG, rasterize locally to PNG + scales: [1, 2, 3] + +# Android example - SVG source with WebP output +android: + images: + - figmaFrameName: "Illustrations" + output: "src/main/res/" + format: webp + sourceFormat: svg # Fetch SVG, rasterize locally to WebP + webpOptions: + encoding: lossless +``` + +**How it works:** + +1. Figma API returns SVG instead of PNG +2. ExFig uses resvg (Rust library) to rasterize SVG locally +3. Output is encoded to PNG (iOS) or WebP (Android) at configured scales + +**Benefits:** + +- Higher quality than Figma's server-side PNG rendering +- Consistent results across all scales +- Smaller file sizes with lossless WebP + +**Key files:** + +| File | Purpose | +| ----------------------------------------------- | -------------------------------- | +| `Sources/Resvg/SvgRasterizer.swift` | Swift wrapper for resvg C API | +| `Sources/ExFig/Output/SvgToWebpConverter.swift` | SVG → WebP conversion | +| `Sources/ExFig/Output/SvgToPngConverter.swift` | SVG → PNG conversion | +| `Libraries/macos/libresvg.dylib` | Pre-built resvg universal binary | + ### TerminalUI Usage ```swift diff --git a/Libraries/macos/libresvg.dylib b/Libraries/macos/libresvg.dylib new file mode 100755 index 00000000..fa0bc3b8 --- /dev/null +++ b/Libraries/macos/libresvg.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b11a965ab150a236b9a0de5e701b5ddcd3d0a2f564abeac31b96b5736e33f66 +size 6290048 diff --git a/Package.swift b/Package.swift index 649a36fe..3ab4c694 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( "FlutterExport", "WebExport", "SVGKit", + "Resvg", .product(name: "XcodeProj", package: "XcodeProj"), .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Yams", package: "Yams"), @@ -49,6 +50,51 @@ let package = Package( ] ), + // resvg C API bindings + .target( + name: "CResvg", + path: "Sources/CResvg", + publicHeadersPath: "include", + linkerSettings: [ + // macOS: dynamic library with rpath + .unsafeFlags( + ["-L", "Libraries/macos", "-lresvg"], + .when(platforms: [.macOS]) + ), + // rpath for debug build: .build/debug/exfig -> ../../Libraries/macos + .unsafeFlags( + ["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../../Libraries/macos"], + .when(platforms: [.macOS]) + ), + // rpath for release build: .build/apple/Products/Release/exfig -> ../../../../Libraries/macos + .unsafeFlags( + ["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../../../../Libraries/macos"], + .when(platforms: [.macOS]) + ), + // rpath for release distribution: ExFig + Libraries/libresvg.dylib + .unsafeFlags( + ["-Xlinker", "-rpath", "-Xlinker", "@executable_path/Libraries"], + .when(platforms: [.macOS]) + ), + // rpath for test bundle: .build/*/debug/*.xctest/Contents/MacOS/* + .unsafeFlags( + ["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../../../../../../Libraries/macos"], + .when(platforms: [.macOS]) + ), + // Linux: static library (no rpath needed) + .unsafeFlags( + ["-L", "Libraries/linux", "-lresvg"], + .when(platforms: [.linux]) + ), + ] + ), + + // Swift wrapper for resvg + .target( + name: "Resvg", + dependencies: ["CResvg"] + ), + // Shared target .target( name: "ExFigCore" diff --git a/Sources/CResvg/include/resvg.h b/Sources/CResvg/include/resvg.h new file mode 100644 index 00000000..126379b0 --- /dev/null +++ b/Sources/CResvg/include/resvg.h @@ -0,0 +1,516 @@ +// Copyright 2021 the Resvg Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +/** + * @file resvg.h + * + * resvg C API + */ + +#ifndef RESVG_H +#define RESVG_H + +#include +#include + +#define RESVG_MAJOR_VERSION 0 +#define RESVG_MINOR_VERSION 45 +#define RESVG_PATCH_VERSION 1 +#define RESVG_VERSION "0.45.1" + +/** + * @brief List of possible errors. + */ +typedef enum { + /** + * Everything is ok. + */ + RESVG_OK = 0, + /** + * Only UTF-8 content are supported. + */ + RESVG_ERROR_NOT_AN_UTF8_STR, + /** + * Failed to open the provided file. + */ + RESVG_ERROR_FILE_OPEN_FAILED, + /** + * Compressed SVG must use the GZip algorithm. + */ + RESVG_ERROR_MALFORMED_GZIP, + /** + * We do not allow SVG with more than 1_000_000 elements for security reasons. + */ + RESVG_ERROR_ELEMENTS_LIMIT_REACHED, + /** + * SVG doesn't have a valid size. + * + * Occurs when width and/or height are <= 0. + * + * Also occurs if width, height and viewBox are not set. + */ + RESVG_ERROR_INVALID_SIZE, + /** + * Failed to parse an SVG data. + */ + RESVG_ERROR_PARSING_FAILED, +} resvg_error; + +/** + * @brief A image rendering method. + */ +typedef enum { + RESVG_IMAGE_RENDERING_OPTIMIZE_QUALITY, + RESVG_IMAGE_RENDERING_OPTIMIZE_SPEED, +} resvg_image_rendering; + +/** + * @brief A shape rendering method. + */ +typedef enum { + RESVG_SHAPE_RENDERING_OPTIMIZE_SPEED, + RESVG_SHAPE_RENDERING_CRISP_EDGES, + RESVG_SHAPE_RENDERING_GEOMETRIC_PRECISION, +} resvg_shape_rendering; + +/** + * @brief A text rendering method. + */ +typedef enum { + RESVG_TEXT_RENDERING_OPTIMIZE_SPEED, + RESVG_TEXT_RENDERING_OPTIMIZE_LEGIBILITY, + RESVG_TEXT_RENDERING_GEOMETRIC_PRECISION, +} resvg_text_rendering; + +/** + * @brief An SVG to #resvg_render_tree conversion options. + * + * Also, contains a fonts database used during text to path conversion. + * The database is empty by default. + */ +typedef struct resvg_options resvg_options; + +/** + * @brief An opaque pointer to the rendering tree. + */ +typedef struct resvg_render_tree resvg_render_tree; + +/** + * @brief A 2D transform representation. + */ +typedef struct { + float a; + float b; + float c; + float d; + float e; + float f; +} resvg_transform; + +/** + * @brief A size representation. + */ +typedef struct { + float width; + float height; +} resvg_size; + +/** + * @brief A rectangle representation. + */ +typedef struct { + float x; + float y; + float width; + float height; +} resvg_rect; + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * @brief Creates an identity transform. + */ +resvg_transform resvg_transform_identity(void); + +/** + * @brief Initializes the library log. + * + * Use it if you want to see any warnings. + * + * Must be called only once. + * + * All warnings will be printed to the `stderr`. + */ +void resvg_init_log(void); + +/** + * @brief Creates a new #resvg_options object. + * + * Should be destroyed via #resvg_options_destroy. + */ +resvg_options *resvg_options_create(void); + +/** + * @brief Sets a directory that will be used during relative paths resolving. + * + * Expected to be the same as the directory that contains the SVG file, + * but can be set to any. + * + * Must be UTF-8. Can be set to NULL. + * + * Default: NULL + */ +void resvg_options_set_resources_dir(resvg_options *opt, const char *path); + +/** + * @brief Sets the target DPI. + * + * Impact units conversion. + * + * Default: 96 + */ +void resvg_options_set_dpi(resvg_options *opt, float dpi); + +/** + * @brief Provides the content of a stylesheet that will be used when resolving CSS attributes. + * + * Must be UTF-8. Can be set to NULL. + * + * Default: NULL + */ +void resvg_options_set_stylesheet(resvg_options *opt, const char *content); + +/** + * @brief Sets the default font family. + * + * Will be used when no `font-family` attribute is set in the SVG. + * + * Must be UTF-8. NULL is not allowed. + * + * Default: Times New Roman + */ +void resvg_options_set_font_family(resvg_options *opt, const char *family); + +/** + * @brief Sets the default font size. + * + * Will be used when no `font-size` attribute is set in the SVG. + * + * Default: 12 + */ +void resvg_options_set_font_size(resvg_options *opt, float size); + +/** + * @brief Sets the `serif` font family. + * + * Must be UTF-8. NULL is not allowed. + * + * Has no effect when the `text` feature is not enabled. + * + * Default: Times New Roman + */ +void resvg_options_set_serif_family(resvg_options *opt, const char *family); + +/** + * @brief Sets the `sans-serif` font family. + * + * Must be UTF-8. NULL is not allowed. + * + * Has no effect when the `text` feature is not enabled. + * + * Default: Arial + */ +void resvg_options_set_sans_serif_family(resvg_options *opt, const char *family); + +/** + * @brief Sets the `cursive` font family. + * + * Must be UTF-8. NULL is not allowed. + * + * Has no effect when the `text` feature is not enabled. + * + * Default: Comic Sans MS + */ +void resvg_options_set_cursive_family(resvg_options *opt, const char *family); + +/** + * @brief Sets the `fantasy` font family. + * + * Must be UTF-8. NULL is not allowed. + * + * Has no effect when the `text` feature is not enabled. + * + * Default: Papyrus on macOS, Impact on other OS'es + */ +void resvg_options_set_fantasy_family(resvg_options *opt, const char *family); + +/** + * @brief Sets the `monospace` font family. + * + * Must be UTF-8. NULL is not allowed. + * + * Has no effect when the `text` feature is not enabled. + * + * Default: Courier New + */ +void resvg_options_set_monospace_family(resvg_options *opt, const char *family); + +/** + * @brief Sets a comma-separated list of languages. + * + * Will be used to resolve a `systemLanguage` conditional attribute. + * + * Example: en,en-US. + * + * Must be UTF-8. Can be NULL. + * + * Default: en + */ +void resvg_options_set_languages(resvg_options *opt, const char *languages); + +/** + * @brief Sets the default shape rendering method. + * + * Will be used when an SVG element's `shape-rendering` property is set to `auto`. + * + * Default: `RESVG_SHAPE_RENDERING_GEOMETRIC_PRECISION` + */ +void resvg_options_set_shape_rendering_mode(resvg_options *opt, resvg_shape_rendering mode); + +/** + * @brief Sets the default text rendering method. + * + * Will be used when an SVG element's `text-rendering` property is set to `auto`. + * + * Default: `RESVG_TEXT_RENDERING_OPTIMIZE_LEGIBILITY` + */ +void resvg_options_set_text_rendering_mode(resvg_options *opt, resvg_text_rendering mode); + +/** + * @brief Sets the default image rendering method. + * + * Will be used when an SVG element's `image-rendering` property is set to `auto`. + * + * Default: `RESVG_IMAGE_RENDERING_OPTIMIZE_QUALITY` + */ +void resvg_options_set_image_rendering_mode(resvg_options *opt, resvg_image_rendering mode); + +/** + * @brief Loads a font data into the internal fonts database. + * + * Prints a warning into the log when the data is not a valid TrueType font. + * + * Has no effect when the `text` feature is not enabled. + */ +void resvg_options_load_font_data(resvg_options *opt, const char *data, uintptr_t len); + +/** + * @brief Loads a font file into the internal fonts database. + * + * Prints a warning into the log when the data is not a valid TrueType font. + * + * Has no effect when the `text` feature is not enabled. + * + * @return #resvg_error with RESVG_OK, RESVG_ERROR_NOT_AN_UTF8_STR or RESVG_ERROR_FILE_OPEN_FAILED + */ +int32_t resvg_options_load_font_file(resvg_options *opt, const char *file_path); + +/** + * @brief Loads system fonts into the internal fonts database. + * + * This method is very IO intensive. + * + * This method should be executed only once per #resvg_options. + * + * The system scanning is not perfect, so some fonts may be omitted. + * Please send a bug report in this case. + * + * Prints warnings into the log. + * + * Has no effect when the `text` feature is not enabled. + */ +void resvg_options_load_system_fonts(resvg_options *opt); + +/** + * @brief Destroys the #resvg_options. + */ +void resvg_options_destroy(resvg_options *opt); + +/** + * @brief Creates #resvg_render_tree from file. + * + * .svg and .svgz files are supported. + * + * See #resvg_is_image_empty for details. + * + * @param file_path UTF-8 file path. + * @param opt Rendering options. Must not be NULL. + * @param tree Parsed render tree. Should be destroyed via #resvg_tree_destroy. + * @return #resvg_error + */ +int32_t resvg_parse_tree_from_file(const char *file_path, + const resvg_options *opt, + resvg_render_tree **tree); + +/** + * @brief Creates #resvg_render_tree from data. + * + * See #resvg_is_image_empty for details. + * + * @param data SVG data. Can contain SVG string or gzip compressed data. Must not be NULL. + * @param len Data length. + * @param opt Rendering options. Must not be NULL. + * @param tree Parsed render tree. Should be destroyed via #resvg_tree_destroy. + * @return #resvg_error + */ +int32_t resvg_parse_tree_from_data(const char *data, + uintptr_t len, + const resvg_options *opt, + resvg_render_tree **tree); + +/** + * @brief Checks that tree has any nodes. + * + * @param tree Render tree. + * @return Returns `true` if tree has no nodes. + */ +bool resvg_is_image_empty(const resvg_render_tree *tree); + +/** + * @brief Returns an image size. + * + * The size of an image that is required to render this SVG. + * + * Note that elements outside the viewbox will be clipped. This is by design. + * If you want to render the whole SVG content, use #resvg_get_image_bbox instead. + * + * @param tree Render tree. + * @return Image size. + */ +resvg_size resvg_get_image_size(const resvg_render_tree *tree); + +/** + * @brief Returns an object bounding box. + * + * This bounding box does not include objects stroke and filter regions. + * This is what SVG calls "absolute object bonding box". + * + * If you're looking for a "complete" bounding box see #resvg_get_image_bbox + * + * @param tree Render tree. + * @param bbox Image's object bounding box. + * @return `false` if an image has no elements. + */ +bool resvg_get_object_bbox(const resvg_render_tree *tree, resvg_rect *bbox); + +/** + * @brief Returns an image bounding box. + * + * This bounding box contains the maximum SVG dimensions. + * It's size can be bigger or smaller than #resvg_get_image_size + * Use it when you want to avoid clipping of elements that are outside the SVG viewbox. + * + * @param tree Render tree. + * @param bbox Image's bounding box. + * @return `false` if an image has no elements. + */ +bool resvg_get_image_bbox(const resvg_render_tree *tree, resvg_rect *bbox); + +/** + * @brief Returns `true` if a renderable node with such an ID exists. + * + * @param tree Render tree. + * @param id Node's ID. UTF-8 string. Must not be NULL. + * @return `true` if a node exists. + * @return `false` if a node doesn't exist or ID isn't a UTF-8 string. + * @return `false` if a node exists, but not renderable. + */ +bool resvg_node_exists(const resvg_render_tree *tree, const char *id); + +/** + * @brief Returns node's transform by ID. + * + * @param tree Render tree. + * @param id Node's ID. UTF-8 string. Must not be NULL. + * @param transform Node's transform. + * @return `true` if a node exists. + * @return `false` if a node doesn't exist or ID isn't a UTF-8 string. + * @return `false` if a node exists, but not renderable. + */ +bool resvg_get_node_transform(const resvg_render_tree *tree, + const char *id, + resvg_transform *transform); + +/** + * @brief Returns node's bounding box in canvas coordinates by ID. + * + * @param tree Render tree. + * @param id Node's ID. Must not be NULL. + * @param bbox Node's bounding box. + * @return `false` if a node with such an ID does not exist + * @return `false` if ID isn't a UTF-8 string. + * @return `false` if ID is an empty string + */ +bool resvg_get_node_bbox(const resvg_render_tree *tree, const char *id, resvg_rect *bbox); + +/** + * @brief Returns node's bounding box, including stroke, in canvas coordinates by ID. + * + * @param tree Render tree. + * @param id Node's ID. Must not be NULL. + * @param bbox Node's bounding box. + * @return `false` if a node with such an ID does not exist + * @return `false` if ID isn't a UTF-8 string. + * @return `false` if ID is an empty string + */ +bool resvg_get_node_stroke_bbox(const resvg_render_tree *tree, const char *id, resvg_rect *bbox); + +/** + * @brief Destroys the #resvg_render_tree. + */ +void resvg_tree_destroy(resvg_render_tree *tree); + +/** + * @brief Renders the #resvg_render_tree onto the pixmap. + * + * @param tree A render tree. + * @param transform A root SVG transform. Can be used to position SVG inside the `pixmap`. + * @param width Pixmap width. + * @param height Pixmap height. + * @param pixmap Pixmap data. Should have width*height*4 size and contain + * premultiplied RGBA8888 pixels. + */ +void resvg_render(const resvg_render_tree *tree, + resvg_transform transform, + uint32_t width, + uint32_t height, + char *pixmap); + +/** + * @brief Renders a Node by ID onto the image. + * + * @param tree A render tree. + * @param id Node's ID. Must not be NULL. + * @param transform A root SVG transform. Can be used to position SVG inside the `pixmap`. + * @param width Pixmap width. + * @param height Pixmap height. + * @param pixmap Pixmap data. Should have width*height*4 size and contain + * premultiplied RGBA8888 pixels. + * @return `false` when `id` is not a non-empty UTF-8 string. + * @return `false` when the selected `id` is not present. + * @return `false` when an element has a zero bbox. + */ +bool resvg_render_node(const resvg_render_tree *tree, + const char *id, + resvg_transform transform, + uint32_t width, + uint32_t height, + char *pixmap); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* RESVG_H */ diff --git a/Sources/CResvg/module.modulemap b/Sources/CResvg/module.modulemap new file mode 100644 index 00000000..1f83aafa --- /dev/null +++ b/Sources/CResvg/module.modulemap @@ -0,0 +1,5 @@ +module CResvg { + header "include/resvg.h" + link "resvg" + export * +} diff --git a/Sources/CResvg/shim.c b/Sources/CResvg/shim.c new file mode 100644 index 00000000..66e3ca62 --- /dev/null +++ b/Sources/CResvg/shim.c @@ -0,0 +1,2 @@ +// Empty shim file to satisfy SPM build system +// The actual resvg implementation is in the pre-built library diff --git a/Sources/ExFig/Input/Params.swift b/Sources/ExFig/Input/Params.swift index 4331e9ae..db0db115 100644 --- a/Sources/ExFig/Input/Params.swift +++ b/Sources/ExFig/Input/Params.swift @@ -83,6 +83,14 @@ struct Params: Decodable { case svg } + /// Source format for fetching images from Figma API. + /// - `png`: Download raster PNG from Figma (default, legacy behavior) + /// - `svg`: Download SVG and rasterize locally with resvg (higher quality) + enum SourceFormat: String, Decodable { + case png + case svg + } + struct iOS: Decodable { /// Single colors configuration (legacy format). /// Uses common.variablesColors for Figma Variables source. @@ -268,6 +276,8 @@ struct Params: Decodable { let scales: [Double]? let imageSwift: URL? let swiftUIImageSwift: URL? + /// Source format for fetching from Figma API. Default: png + let sourceFormat: SourceFormat? } /// Images configuration supporting both single object and array formats. @@ -293,7 +303,8 @@ struct Params: Decodable { nameStyle: images.nameStyle, scales: images.scales, imageSwift: images.imageSwift, - swiftUIImageSwift: images.swiftUIImageSwift + swiftUIImageSwift: images.swiftUIImageSwift, + sourceFormat: nil )] case let .multiple(entries): entries @@ -539,6 +550,8 @@ struct Params: Decodable { let output: String let format: Format let webpOptions: FormatOptions? + /// Source format for fetching from Figma API. Default: png + let sourceFormat: SourceFormat? } /// Images entry with figmaFrameName for multiple images configuration. @@ -549,6 +562,8 @@ struct Params: Decodable { let output: String let format: Images.Format let webpOptions: Images.FormatOptions? + /// Source format for fetching from Figma API. Default: png + let sourceFormat: SourceFormat? } /// Images configuration supporting both single object and array formats. @@ -573,7 +588,8 @@ struct Params: Decodable { scales: images.scales, output: images.output, format: images.format, - webpOptions: images.webpOptions + webpOptions: images.webpOptions, + sourceFormat: images.sourceFormat )] case let .multiple(entries): entries @@ -732,6 +748,8 @@ struct Params: Decodable { let scales: [Double]? let format: ImageFormat? let webpOptions: Android.Images.FormatOptions? + /// Source format for fetching from Figma API. Default: png + let sourceFormat: SourceFormat? } /// Images entry with figmaFrameName for multiple images configuration. @@ -744,6 +762,8 @@ struct Params: Decodable { let scales: [Double]? let format: ImageFormat? let webpOptions: Android.Images.FormatOptions? + /// Source format for fetching from Figma API. Default: png + let sourceFormat: SourceFormat? } /// Images configuration supporting both single object and array formats. @@ -770,7 +790,8 @@ struct Params: Decodable { className: images.className, scales: images.scales, format: images.format, - webpOptions: images.webpOptions + webpOptions: images.webpOptions, + sourceFormat: images.sourceFormat )] case let .multiple(entries): entries diff --git a/Sources/ExFig/Loaders/ImagesLoader.swift b/Sources/ExFig/Loaders/ImagesLoader.swift index cf6b7d86..8d5da0dd 100644 --- a/Sources/ExFig/Loaders/ImagesLoader.swift +++ b/Sources/ExFig/Loaders/ImagesLoader.swift @@ -11,6 +11,12 @@ enum ImagesLoaderFormat: Sendable { case webp } +/// Source format for fetching from Figma API. +enum ImagesSourceFormat: Sendable { + case png // Download PNG from Figma API (default) + case svg // Download SVG and rasterize locally with resvg +} + /// Configuration for loading images from a specific Figma frame. struct ImagesLoaderConfig: Sendable { /// Figma frame name to load images from. @@ -22,12 +28,17 @@ struct ImagesLoaderConfig: Sendable { /// Image format (for Android/Flutter). iOS always uses PNG. let format: ImagesLoaderFormat? + /// Source format for fetching from Figma API. + /// When .svg, downloads SVG and rasterizes locally with resvg. + let sourceFormat: ImagesSourceFormat + /// Creates config for a specific iOS images entry. static func forIOS(entry: Params.iOS.ImagesEntry, params: Params) -> ImagesLoaderConfig { ImagesLoaderConfig( frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", scales: entry.scales, - format: nil // iOS always uses PNG + format: nil, // iOS always uses PNG output + sourceFormat: convertSourceFormat(entry.sourceFormat) ) } @@ -36,7 +47,8 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", scales: entry.scales, - format: convertAndroidFormat(entry.format) + format: convertAndroidFormat(entry.format), + sourceFormat: convertSourceFormat(entry.sourceFormat) ) } @@ -45,7 +57,8 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", scales: entry.scales, - format: entry.format.flatMap { convertFlutterFormat($0) } + format: entry.format.flatMap { convertFlutterFormat($0) }, + sourceFormat: convertSourceFormat(entry.sourceFormat) ) } @@ -54,7 +67,8 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", scales: nil, - format: .svg // Web uses SVG by default + format: .svg, // Web uses SVG by default + sourceFormat: .svg // Web always uses SVG source ) } @@ -63,7 +77,8 @@ struct ImagesLoaderConfig: Sendable { ImagesLoaderConfig( frameName: params.common?.images?.figmaFrameName ?? "Illustrations", scales: nil, - format: nil + format: nil, + sourceFormat: .png ) } @@ -82,6 +97,13 @@ struct ImagesLoaderConfig: Sendable { case .webp: .webp } } + + private static func convertSourceFormat(_ sourceFormat: Params.SourceFormat?) -> ImagesSourceFormat { + switch sourceFormat { + case .svg: .svg + case .png, nil: .png + } + } } /// Output type for images loading operations. @@ -149,6 +171,17 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di } } + /// Whether to use SVG as source format from Figma API. + /// When true, SVG is fetched from Figma and rasterized locally with resvg. + private var useSVGSource: Bool { + config.sourceFormat == .svg + } + + /// The source format to use when fetching from Figma API. + var sourceFormat: ImagesSourceFormat { + config.sourceFormat + } + /// Loads images from Figma, supporting both single-file and separate light/dark file modes. func load( filter: String? = nil, @@ -193,7 +226,8 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di ) async throws -> ImagesLoaderOutput { let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" - if isRasterFormat { + if isRasterFormat, !useSVGSource { + // PNG source: fetch PNG at multiple scales from Figma let scales = getScales(customScales: configScales) let images = try await loadPNGImages( @@ -206,6 +240,8 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let (lightImages, darkImages) = splitByDarkMode(images, darkSuffix: darkSuffix) return (lightImages, darkImages) } else { + // SVG source or vector output: fetch SVG from Figma + // For SVG source with raster output, export code will rasterize locally let pack = try await loadVectorImages( fileId: params.figma.lightFileId, frameName: frameName, @@ -230,13 +266,15 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filesToLoad.append(("dark", darkFileId)) } - if isRasterFormat { + if isRasterFormat, !useSVGSource { + // PNG source: fetch PNG at multiple scales from Figma return try await loadRasterImagesFromMultipleFiles( filesToLoad: filesToLoad, filter: filter, onBatchProgress: onBatchProgress ) } else { + // SVG source or vector output: fetch SVG from Figma return try await loadVectorImagesFromMultipleFiles( filesToLoad: filesToLoad, filter: filter, @@ -328,8 +366,8 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di let fileId = params.figma.lightFileId let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" - if isRasterFormat { - // Raster images (PNG/WebP) with granular cache + if isRasterFormat, !useSVGSource { + // PNG source: Raster images (PNG/WebP) with granular cache let scales = getScales(customScales: configScales) let result = try await loadPNGImagesWithGranularCache( @@ -362,7 +400,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di allNames: lightOnlyNames ) } else { - // Vector images (SVG) with granular cache + // SVG source or vector output: fetch SVG with granular cache let result = try await loadVectorImagesWithGranularCache( fileId: fileId, frameName: frameName, @@ -418,15 +456,16 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di } // Determine format and scales once (same for all files) - let useRasterFormat = isRasterFormat - let scales = useRasterFormat ? getScales(customScales: configScales) : [] + // Use PNG loading only when raster format AND PNG source + let usePNGLoading = isRasterFormat && !useSVGSource + let scales = usePNGLoading ? getScales(customScales: configScales) : [] // Load all files in parallel let results = try await withThrowingTaskGroup(of: FileGranularResult.self) { [self] group in for (key, fileId) in filesToLoad { - group.addTask { [key, fileId, filter, onBatchProgress, useRasterFormat, scales] in - if useRasterFormat { - // Raster images (PNG/WebP) + group.addTask { [key, fileId, filter, onBatchProgress, usePNGLoading, scales] in + if usePNGLoading { + // PNG source: Raster images (PNG/WebP) let result = try await self.loadPNGImagesWithGranularCache( fileId: fileId, frameName: self.frameName, @@ -443,7 +482,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di allNames: result.allNames ) } else { - // Vector images (SVG) + // SVG source or vector output: fetch SVG let result = try await self.loadVectorImagesWithGranularCache( fileId: fileId, frameName: self.frameName, diff --git a/Sources/ExFig/Output/NativePngEncoder.swift b/Sources/ExFig/Output/NativePngEncoder.swift new file mode 100644 index 00000000..86189569 --- /dev/null +++ b/Sources/ExFig/Output/NativePngEncoder.swift @@ -0,0 +1,193 @@ +import Foundation + +#if canImport(CoreGraphics) + import CoreGraphics +#endif + +#if canImport(ImageIO) + import ImageIO + import UniformTypeIdentifiers +#endif + +#if canImport(LibPNG) + import LibPNG +#endif + +/// Errors that can occur during PNG encoding +enum NativePngEncoderError: LocalizedError, Equatable { + case encodingFailed(reason: String) + case invalidDimensions + + var errorDescription: String? { + switch self { + case let .encodingFailed(reason): + "PNG encoding failed: \(reason)" + case .invalidDimensions: + "Invalid image dimensions" + } + } + + var recoverySuggestion: String? { + switch self { + case .encodingFailed: + "Try re-exporting the source image" + case .invalidDimensions: + "Ensure width and height are positive" + } + } +} + +/// Encodes RGBA pixel data to PNG format +/// +/// Uses platform-native APIs for reliable cross-platform PNG encoding: +/// - macOS/iOS: CoreGraphics/ImageIO +/// - Linux: libpng +struct NativePngEncoder: Sendable { + /// Encodes RGBA pixel data to PNG + /// - Parameters: + /// - rgba: RGBA pixel data (4 bytes per pixel) + /// - width: Image width in pixels + /// - height: Image height in pixels + /// - Returns: PNG encoded data + /// - Throws: `NativePngEncoderError` on failure + func encode(rgba: [UInt8], width: Int, height: Int) throws -> Data { + guard width > 0, height > 0 else { + throw NativePngEncoderError.invalidDimensions + } + + guard rgba.count == width * height * 4 else { + throw NativePngEncoderError.encodingFailed( + reason: "RGBA buffer size \(rgba.count) doesn't match dimensions \(width)x\(height)" + ) + } + + #if canImport(CoreGraphics) && canImport(ImageIO) + return try encodeWithCoreGraphics(rgba: rgba, width: width, height: height) + #else + return try encodeWithLibpng(rgba: rgba, width: width, height: height) + #endif + } + + #if canImport(CoreGraphics) && canImport(ImageIO) + /// Encodes PNG using CoreGraphics/ImageIO (Apple platforms) + private func encodeWithCoreGraphics(rgba: [UInt8], width: Int, height: Int) throws -> Data { + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { + throw NativePngEncoderError.encodingFailed(reason: "Failed to create color space") + } + + let bytesPerRow = width * 4 + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + + // Premultiply alpha for CoreGraphics (it expects premultiplied) + var premultiplied = premultiplyAlpha(rgba) + + guard let context = CGContext( + data: &premultiplied, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { + throw NativePngEncoderError.encodingFailed(reason: "Failed to create bitmap context") + } + + guard let cgImage = context.makeImage() else { + throw NativePngEncoderError.encodingFailed(reason: "Failed to create CGImage") + } + + let mutableData = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + mutableData as CFMutableData, + UTType.png.identifier as CFString, + 1, + nil + ) else { + throw NativePngEncoderError.encodingFailed(reason: "Failed to create image destination") + } + + CGImageDestinationAddImage(destination, cgImage, nil) + + guard CGImageDestinationFinalize(destination) else { + throw NativePngEncoderError.encodingFailed(reason: "Failed to finalize PNG") + } + + return mutableData as Data + } + + /// Premultiplies alpha values for CoreGraphics + private func premultiplyAlpha(_ rgba: [UInt8]) -> [UInt8] { + var result = rgba + let pixelCount = rgba.count / 4 + for i in 0 ..< pixelCount { + let offset = i * 4 + let alpha = rgba[offset + 3] + + if alpha > 0, alpha < 255 { + let alphaFloat = Float(alpha) / 255.0 + result[offset] = UInt8(Float(rgba[offset]) * alphaFloat) + result[offset + 1] = UInt8(Float(rgba[offset + 1]) * alphaFloat) + result[offset + 2] = UInt8(Float(rgba[offset + 2]) * alphaFloat) + } else if alpha == 0 { + result[offset] = 0 + result[offset + 1] = 0 + result[offset + 2] = 0 + } + } + return result + } + #else + // libpng format constants + private static let pngFormatRGBA: UInt32 = 6 + + /// Encodes PNG using libpng (Linux) + private func encodeWithLibpng(rgba: [UInt8], width: Int, height: Int) throws -> Data { + var image = png_image() + image.version = UInt32(PNG_IMAGE_VERSION) + image.width = UInt32(width) + image.height = UInt32(height) + image.format = Self.pngFormatRGBA + + // First call to get required buffer size + var bufferSize = 0 + let sizeSuccess = rgba.withUnsafeBytes { rgbaPtr -> Int32 in + png_image_write_to_memory( + &image, + nil, + &bufferSize, + 0, + rgbaPtr.baseAddress, + 0, + nil + ) + } + + guard sizeSuccess != 0, bufferSize > 0 else { + throw NativePngEncoderError.encodingFailed(reason: "Failed to calculate PNG size") + } + + // Allocate buffer and write PNG + var pngBuffer = [UInt8](repeating: 0, count: bufferSize) + let writeSuccess = rgba.withUnsafeBytes { rgbaPtr -> Int32 in + pngBuffer.withUnsafeMutableBytes { bufferPtr -> Int32 in + png_image_write_to_memory( + &image, + bufferPtr.baseAddress, + &bufferSize, + 0, + rgbaPtr.baseAddress, + 0, + nil + ) + } + } + + guard writeSuccess != 0 else { + throw NativePngEncoderError.encodingFailed(reason: "Failed to encode PNG") + } + + return Data(pngBuffer.prefix(bufferSize)) + } + #endif +} diff --git a/Sources/ExFig/Output/SvgToPngConverter.swift b/Sources/ExFig/Output/SvgToPngConverter.swift new file mode 100644 index 00000000..0ca998e2 --- /dev/null +++ b/Sources/ExFig/Output/SvgToPngConverter.swift @@ -0,0 +1,96 @@ +import Foundation +import Resvg + +/// Errors that can occur during SVG to PNG conversion +enum SvgToPngConverterError: LocalizedError, Equatable { + case rasterizationFailed(file: String, reason: String) + case encodingFailed(file: String, reason: String) + + var errorDescription: String? { + switch self { + case let .rasterizationFailed(file, reason): + "SVG rasterization failed: \(file) - \(reason)" + case let .encodingFailed(file, reason): + "PNG encoding failed: \(file) - \(reason)" + } + } + + var recoverySuggestion: String? { + switch self { + case .rasterizationFailed: + "Re-export the SVG from Figma or check for unsupported SVG features" + case .encodingFailed: + "Try re-exporting the source image or use a different format" + } + } +} + +/// SVG to PNG converter using resvg and native PNG encoder +/// +/// Rasterizes SVG images using resvg and encodes to PNG format. +/// Produces higher quality results than Figma's server-side PNG rendering. +struct SvgToPngConverter: Sendable { + private let rasterizer: SvgRasterizer + + /// Creates an SVG to PNG converter + init() { + rasterizer = SvgRasterizer() + } + + /// Converts SVG data to PNG data + /// - Parameters: + /// - svgData: SVG file data + /// - scale: Scale factor for rasterization (1.0 = native size) + /// - fileName: Original file name for error messages + /// - Returns: PNG encoded data + /// - Throws: `SvgToPngConverterError` on failure + func convert(svgData: Data, scale: Double, fileName: String) throws -> Data { + // Rasterize SVG to RGBA + let rasterized: RasterizedSvg + do { + rasterized = try rasterizer.rasterize(data: svgData, scale: scale) + } catch let error as ResvgError { + throw SvgToPngConverterError.rasterizationFailed( + file: fileName, + reason: error.localizedDescription + ) + } catch { + throw SvgToPngConverterError.rasterizationFailed( + file: fileName, + reason: error.localizedDescription + ) + } + + // Create PNG encoder and encode + let encoder = NativePngEncoder() + do { + return try encoder.encode( + rgba: rasterized.rgba, + width: rasterized.width, + height: rasterized.height + ) + } catch let error as NativePngEncoderError { + throw SvgToPngConverterError.encodingFailed( + file: fileName, + reason: error.localizedDescription + ) + } catch { + throw SvgToPngConverterError.encodingFailed( + file: fileName, + reason: error.localizedDescription + ) + } + } + + /// Converts SVG data to PNG and writes to file + /// - Parameters: + /// - svgData: SVG file data + /// - scale: Scale factor for rasterization (1.0 = native size) + /// - outputURL: Output file URL (.png) + /// - fileName: Original file name for error messages + /// - Throws: `SvgToPngConverterError` on failure + func convert(svgData: Data, scale: Double, to outputURL: URL, fileName: String) throws { + let pngData = try convert(svgData: svgData, scale: scale, fileName: fileName) + try pngData.write(to: outputURL) + } +} diff --git a/Sources/ExFig/Output/SvgToWebpConverter.swift b/Sources/ExFig/Output/SvgToWebpConverter.swift new file mode 100644 index 00000000..b764608d --- /dev/null +++ b/Sources/ExFig/Output/SvgToWebpConverter.swift @@ -0,0 +1,113 @@ +import Foundation +import Resvg + +/// Errors that can occur during SVG to WebP conversion +enum SvgToWebpConverterError: LocalizedError, Equatable { + case rasterizationFailed(file: String, reason: String) + case encodingFailed(file: String, reason: String) + + var errorDescription: String? { + switch self { + case let .rasterizationFailed(file, reason): + "SVG rasterization failed: \(file) - \(reason)" + case let .encodingFailed(file, reason): + "WebP encoding failed: \(file) - \(reason)" + } + } + + var recoverySuggestion: String? { + switch self { + case .rasterizationFailed: + "Re-export the SVG from Figma or check for unsupported SVG features" + case .encodingFailed: + "Try re-exporting the source image or use a different format" + } + } +} + +/// SVG to WebP converter using resvg and libwebp +/// +/// Rasterizes SVG images using resvg and encodes to WebP format using libwebp. +/// Produces higher quality results than Figma's server-side PNG rendering. +struct SvgToWebpConverter: Sendable { + /// WebP encoding mode + enum Encoding: Sendable { + case lossy(quality: Int) + case lossless + } + + private let encoding: Encoding + private let rasterizer: SvgRasterizer + + /// Creates an SVG to WebP converter + /// - Parameter encoding: WebP encoding type (lossy or lossless) + init(encoding: Encoding) { + self.encoding = encoding + rasterizer = SvgRasterizer() + } + + /// Converts SVG data to WebP data + /// - Parameters: + /// - svgData: SVG file data + /// - scale: Scale factor for rasterization (1.0 = native size) + /// - fileName: Original file name for error messages + /// - Returns: WebP encoded data + /// - Throws: `SvgToWebpConverterError` on failure + func convert(svgData: Data, scale: Double, fileName: String) throws -> Data { + // Rasterize SVG to RGBA + let rasterized: RasterizedSvg + do { + rasterized = try rasterizer.rasterize(data: svgData, scale: scale) + } catch let error as ResvgError { + throw SvgToWebpConverterError.rasterizationFailed( + file: fileName, + reason: error.localizedDescription + ) + } catch { + throw SvgToWebpConverterError.rasterizationFailed( + file: fileName, + reason: error.localizedDescription + ) + } + + // Create WebP encoder based on encoding mode + let encoder = switch encoding { + case let .lossy(quality): + NativeWebpEncoder(quality: quality, lossless: false) + case .lossless: + NativeWebpEncoder(lossless: true) + } + + // Encode to WebP + do { + let webpBytes = try encoder.encode( + rgba: rasterized.rgba, + width: rasterized.width, + height: rasterized.height + ) + return Data(webpBytes) + } catch let error as NativeWebpEncoderError { + throw SvgToWebpConverterError.encodingFailed( + file: fileName, + reason: error.localizedDescription + ) + } catch { + throw SvgToWebpConverterError.encodingFailed( + file: fileName, + reason: error.localizedDescription + ) + } + } + + /// Converts SVG data to WebP and writes to file + /// - Parameters: + /// - svgData: SVG file data + /// - scale: Scale factor for rasterization (1.0 = native size) + /// - outputURL: Output file URL (.webp) + /// - fileName: Original file name for error messages + /// - Throws: `SvgToWebpConverterError` on failure + func convert(svgData: Data, scale: Double, to outputURL: URL, fileName: String) throws { + let webpData = try convert(svgData: svgData, scale: scale, fileName: fileName) + try webpData.write(to: outputURL) + } +} diff --git a/Sources/ExFig/Subcommands/ExportImages.swift b/Sources/ExFig/Subcommands/ExportImages.swift index 47482fa1..208dbfa4 100644 --- a/Sources/ExFig/Subcommands/ExportImages.swift +++ b/Sources/ExFig/Subcommands/ExportImages.swift @@ -359,6 +359,18 @@ extension ExFigCommand { ui: TerminalUI, granularCacheManager: GranularCacheManager? ) async throws -> PlatformExportResult { + // Branch based on source format + if entry.sourceFormat == .svg { + return try await exportiOSSVGSourceImagesEntry( + entry: entry, + ios: ios, + client: client, + params: params, + ui: ui, + granularCacheManager: granularCacheManager + ) + } + let loaderConfig = ImagesLoaderConfig.forIOS(entry: entry, params: params) let loader = ImagesLoader( client: client, @@ -497,6 +509,331 @@ extension ExFigCommand { ) } + // MARK: - iOS SVG Source Export + + /// Exports iOS images using SVG source format with local rasterization. + /// + /// Downloads SVG from Figma, rasterizes locally with resvg at 1x/2x/3x scales, + /// and saves as PNG to xcassets. + // swiftlint:disable:next function_body_length function_parameter_count + private func exportiOSSVGSourceImagesEntry( + entry: Params.iOS.ImagesEntry, + ios: Params.iOS, + client: Client, + params: Params, + ui: TerminalUI, + granularCacheManager: GranularCacheManager? + ) async throws -> PlatformExportResult { + let loaderConfig = ImagesLoaderConfig.forIOS(entry: entry, params: params) + let loader = ImagesLoader( + client: client, + params: params, + platform: .ios, + logger: logger, + config: loaderConfig + ) + loader.granularCacheManager = granularCacheManager + + let loaderResult = try await ui.withSpinnerProgress("Fetching images from Figma...") { onProgress in + if granularCacheManager != nil { + return try await loader.loadWithGranularCache(filter: filter, onBatchProgress: onProgress) + } else { + let result = try await loader.load(filter: filter, onBatchProgress: onProgress) + return ImagesLoaderResultWithHashes( + light: result.light, + dark: result.dark, + computedHashes: [:], + allSkipped: false, + allNames: [] + ) + } + } + + if loaderResult.allSkipped { + ui.success("All images unchanged (granular cache hit). Skipping iOS export.") + return PlatformExportResult( + count: 0, + hashes: loaderResult.computedHashes, + skippedCount: loaderResult.allNames.count + ) + } + + let imagesTuple = (light: loaderResult.light, dark: loaderResult.dark) + + let processor = ImagesProcessor( + platform: .ios, + nameValidateRegexp: params.common?.images?.nameValidateRegexp, + nameReplaceRegexp: params.common?.images?.nameReplaceRegexp, + nameStyle: entry.nameStyle + ) + + let (images, imagesWarning): ([AssetPair], AssetsValidatorWarning?) = + try await ui.withSpinner("Processing images...") { + let result = processor.process(light: imagesTuple.light, dark: imagesTuple.dark) + return try (result.get(), result.warning) + } + if let imagesWarning { + ui.warning(imagesWarning) + } + + let assetsURL = ios.xcassetsPath.appendingPathComponent(entry.assetsFolder) + + // Collect SVG URLs for download + let svgRemoteFiles = makeSVGRemoteFilesForIOS( + images: images, + assetsURL: assetsURL + ) + + // Download SVG files + let fileDownloader = faultToleranceOptions.createFileDownloader() + let downloadedSVGs: [FileContents] = if !svgRemoteFiles.isEmpty { + try await ui.withProgress("Downloading SVGs from Figma", total: svgRemoteFiles.count) { progress in + try await PipelinedDownloader.download( + files: svgRemoteFiles, + fileDownloader: fileDownloader + ) { current, _ in + progress.update(current: current) + } + } + } else { + [] + } + + // iOS uses 1x, 2x, 3x scales + let scales: [Double] = entry.scales ?? [1.0, 2.0, 3.0] + let converter = SvgToPngConverter() + + // Clear existing assets if not filtering and not using granular cache + if filter == nil, granularCacheManager == nil { + try? FileManager.default.removeItem(atPath: assetsURL.path) + } + + // Rasterize SVGs to PNG at each scale + let pngFiles: [FileContents] = try await ui.withProgress( + "Rasterizing SVGs to PNG", + total: downloadedSVGs.count * scales.count + ) { progress in + var results: [FileContents] = [] + + for fileContents in downloadedSVGs { + guard let svgData = fileContents.data else { continue } + let baseName = fileContents.destination.file.deletingPathExtension().lastPathComponent + let imagesetDir = fileContents.destination.file.deletingLastPathComponent() + + for scale in scales { + let scaleSuffix = scale == 1.0 ? "" : "@\(Int(scale))x" + let pngFileName = "\(baseName)\(scaleSuffix).png" + + do { + let pngData = try converter.convert( + svgData: svgData, + scale: scale, + fileName: baseName + ) + + results.append(FileContents( + destination: Destination( + directory: imagesetDir, + file: URL(fileURLWithPath: pngFileName) + ), + data: pngData + )) + } catch { + logger.error("Failed to rasterize \(baseName) at \(scale)x: \(error)") + throw error + } + + await progress.increment() + } + } + + return results + } + + // Generate Contents.json for each imageset + let contentsJsonFiles = makeImagesetContentsJson( + for: images, + scales: scales, + assetsURL: assetsURL + ) + + // Generate folder Contents.json + let folderContentsFile = FileContents( + destination: Destination( + directory: assetsURL, + file: URL(fileURLWithPath: "Contents.json") + ), + data: Data(#"{"info":{"author":"xcode","version":1}}"#.utf8) + ) + + // Combine all files to write + var allFiles = pngFiles + contentsJsonFiles + allFiles.append(folderContentsFile) + + // Generate Swift extensions + let output = XcodeImagesOutput( + assetsFolderURL: assetsURL, + assetsInMainBundle: ios.xcassetsInMainBundle, + assetsInSwiftPackage: ios.xcassetsInSwiftPackage, + resourceBundleNames: ios.resourceBundleNames, + addObjcAttribute: ios.addObjcAttribute, + uiKitImageExtensionURL: entry.imageSwift, + swiftUIImageExtensionURL: entry.swiftUIImageSwift, + templatesPath: ios.templatesPath + ) + + let exporter = XcodeImagesExporter(output: output) + let allAssetNames = granularCacheManager != nil + ? processor.processNames(loaderResult.allNames) + : nil + let extensionFiles = try exporter.exportSwiftExtensions( + assets: images, + allAssetNames: allAssetNames, + append: filter != nil + ) + allFiles.append(contentsOf: extensionFiles) + + let filesToWrite = allFiles + try await ui.withSpinner("Writing files to Xcode project...") { + try fileWriter.write(files: filesToWrite) + } + + let skippedCount = granularCacheManager != nil + ? loaderResult.allNames.count - images.count + : 0 + + guard params.ios?.xcassetsInSwiftPackage == false else { + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) + } + ui.success("Done! Exported \(images.count) images (SVG source).") + return PlatformExportResult( + count: images.count, + hashes: loaderResult.computedHashes, + skippedCount: skippedCount + ) + } + + do { + let xcodeProject = try XcodeProjectWriter(xcodeProjPath: ios.xcodeprojPath, target: ios.target) + try allFiles.forEach { file in + if file.destination.file.pathExtension == "swift" { + try xcodeProject.addFileReferenceToXcodeProj(file.destination.url) + } + } + try xcodeProject.save() + } catch { + ui.warning(.xcodeProjectUpdateFailed) + } + + if BatchProgressViewStorage.progressView == nil { + await checkForUpdate(logger: logger) + } + + ui.success("Done! Exported \(images.count) images (SVG source).") + return PlatformExportResult( + count: images.count, + hashes: loaderResult.computedHashes, + skippedCount: skippedCount + ) + } + + /// Creates remote file references for SVG downloads (iOS). + private func makeSVGRemoteFilesForIOS( + images: [AssetPair], + assetsURL: URL + ) -> [FileContents] { + var files: [FileContents] = [] + + for pair in images { + // Light variant + if let image = pair.light.images.first { + let imagesetDir = assetsURL.appendingPathComponent("\(pair.light.name).imageset") + files.append(FileContents( + destination: Destination( + directory: imagesetDir, + file: URL(fileURLWithPath: "\(pair.light.name).svg") + ), + sourceURL: image.url + )) + } + + // Dark variant (if exists) + if let dark = pair.dark, let image = dark.images.first { + let imagesetDir = assetsURL.appendingPathComponent("\(dark.name).imageset") + files.append(FileContents( + destination: Destination( + directory: imagesetDir, + file: URL(fileURLWithPath: "\(dark.name)_dark.svg") + ), + sourceURL: image.url + )) + } + } + + return files + } + + /// Creates Contents.json files for each imageset. + private func makeImagesetContentsJson( + for images: [AssetPair], + scales: [Double], + assetsURL: URL + ) -> [FileContents] { + var files: [FileContents] = [] + + for pair in images { + let imagesetDir = assetsURL.appendingPathComponent("\(pair.light.name).imageset") + + var imagesArray: [[String: Any]] = [] + + // Add light variants at each scale + for scale in scales { + let scaleSuffix = scale == 1.0 ? "" : "@\(Int(scale))x" + let scaleString = scale == 1.0 ? "1x" : "\(Int(scale))x" + imagesArray.append([ + "filename": "\(pair.light.name)\(scaleSuffix).png", + "idiom": "universal", + "scale": scaleString, + ]) + } + + // Add dark variants if they exist + if pair.dark != nil { + for scale in scales { + let scaleSuffix = scale == 1.0 ? "" : "@\(Int(scale))x" + let scaleString = scale == 1.0 ? "1x" : "\(Int(scale))x" + imagesArray.append([ + "appearances": [["appearance": "luminosity", "value": "dark"]], + "filename": "\(pair.light.name)_dark\(scaleSuffix).png", + "idiom": "universal", + "scale": scaleString, + ]) + } + } + + let contentsJson: [String: Any] = [ + "images": imagesArray, + "info": ["author": "xcode", "version": 1], + ] + + if let jsonData = try? JSONSerialization.data( + withJSONObject: contentsJson, + options: [.prettyPrinted, .sortedKeys] + ) { + files.append(FileContents( + destination: Destination( + directory: imagesetDir, + file: URL(fileURLWithPath: "Contents.json") + ), + data: jsonData + )) + } + } + + return files + } + private func exportAndroidImages( client: Client, params: Params, @@ -654,6 +991,7 @@ extension ExFigCommand { switch entry.format { case .svg: + // SVG output format try await exportAndroidSVGImagesEntry( images: images, entry: entry, @@ -661,7 +999,18 @@ extension ExFigCommand { granularCacheManager: granularCacheManager, ui: ui ) + case .webp where entry.sourceFormat == .svg: + // WebP output with SVG source - rasterize locally with resvg + try await exportAndroidSVGSourceWebpImagesEntry( + images: images, + entry: entry, + android: android, + params: params, + granularCacheManager: granularCacheManager, + ui: ui + ) case .png, .webp: + // PNG/WebP output with PNG source from Figma try await exportAndroidRasterImagesEntry( images: images, entry: entry, @@ -853,6 +1202,184 @@ extension ExFigCommand { try? FileManager.default.removeItem(at: tempDirectoryURL) } + /// Exports Android images using SVG source with local resvg rasterization to WebP. + /// + /// This method: + /// 1. Downloads SVG files from Figma CDN + /// 2. Rasterizes each SVG at the required scales using resvg + /// 3. Encodes to WebP and writes to drawable directories + // swiftlint:disable:next function_body_length function_parameter_count + private func exportAndroidSVGSourceWebpImagesEntry( + images: [AssetPair], + entry: Params.Android.ImagesEntry, + android: Params.Android, + params: Params, + granularCacheManager: GranularCacheManager?, + ui: TerminalUI + ) async throws { + let tempDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + // Create remote file list for SVG downloads (one SVG per image, no scales) + let remoteFiles = try images.flatMap { asset -> [FileContents] in + let lightFiles = try makeSVGRemoteFiles( + images: asset.light.images, + dark: false, + outputDirectory: tempDirectoryURL + ) + let darkFiles = try asset.dark.flatMap { darkImagePack -> [FileContents] in + try makeSVGRemoteFiles(images: darkImagePack.images, dark: true, outputDirectory: tempDirectoryURL) + } ?? [] + return lightFiles + darkFiles + } + + // Download SVG files + let fileDownloader = faultToleranceOptions.createFileDownloader() + let localSVGFiles: [FileContents] = if !remoteFiles.isEmpty { + try await ui.withProgress("Downloading SVG files", total: remoteFiles.count) { progress in + try await PipelinedDownloader.download( + files: remoteFiles, + fileDownloader: fileDownloader + ) { current, _ in + progress.update(current: current) + } + } + } else { + [] + } + + try fileWriter.write(files: localSVGFiles) + + // Get scales for rasterization + let scales = getScalesForPlatform(entry.scales, platform: .android) + + // Create WebP converter with appropriate encoding + let converter = createSvgToWebpConverter(from: entry.webpOptions) + + // Rasterize SVGs to WebP at each scale + let totalConversions = localSVGFiles.count * scales.count + let webpFiles: [FileContents] = try await ui.withProgress( + "Rasterizing SVGs to WebP", + total: totalConversions + ) { progress in + var results: [FileContents] = [] + var completed = 0 + + for svgFile in localSVGFiles { + let svgData = try Data(contentsOf: svgFile.destination.url) + let baseName = svgFile.destination.file.deletingPathExtension().lastPathComponent + + for scale in scales { + let webpData = try converter.convert( + svgData: svgData, + scale: scale, + fileName: baseName + ) + + // Create output file in temp directory + let webpFileName = URL(string: "\(baseName).webp")! + let scaleDir = tempDirectoryURL + .appendingPathComponent(svgFile.dark ? "dark" : "light") + .appendingPathComponent("webp") + .appendingPathComponent(String(scale)) + try FileManager.default.createDirectory(at: scaleDir, withIntermediateDirectories: true) + + let webpPath = scaleDir.appendingPathComponent(webpFileName.lastPathComponent) + try webpData.write(to: webpPath) + + let fileContents = FileContents( + destination: Destination(directory: scaleDir, file: webpFileName), + dataFile: webpPath, + scale: scale, + dark: svgFile.dark + ) + results.append(fileContents) + + completed += 1 + progress.update(current: completed) + } + } + return results + } + + // Clear output directory if not filtering + if filter == nil, granularCacheManager == nil { + let outputDirectory = URL(fileURLWithPath: android.mainRes.appendingPathComponent(entry.output).path) + try? FileManager.default.removeItem(atPath: outputDirectory.path) + } + + // Map to final output directories + let isSingleScale = scales.count == 1 + let finalFiles = webpFiles.compactMap { fileContents -> FileContents? in + guard let dataFile = fileContents.dataFile else { return nil } + let directoryName = Drawable.scaleToDrawableName( + fileContents.scale, + dark: fileContents.dark, + singleScale: isSingleScale + ) + let directory = URL(fileURLWithPath: android.mainRes.appendingPathComponent(entry.output).path) + .appendingPathComponent(directoryName, isDirectory: true) + return FileContents( + destination: Destination(directory: directory, file: fileContents.destination.file), + dataFile: dataFile + ) + } + + try await ui.withSpinner("Writing files to Android Studio project...") { + try fileWriter.write(files: finalFiles) + } + + try? FileManager.default.removeItem(at: tempDirectoryURL) + } + + /// Creates remote file list for SVG downloads (one per image, no scale). + private func makeSVGRemoteFiles(images: [Image], dark: Bool, outputDirectory: URL) throws -> [FileContents] { + // For SVG source, we only have one image per component (scale: .all) + // Take the first image from each unique name + var seenNames = Set() + return try images.compactMap { image -> FileContents? in + guard !seenNames.contains(image.name) else { return nil } + seenNames.insert(image.name) + + guard let name = image.name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let fileURL = URL(string: "\(name).svg") + else { + throw ExFigError.invalidFileName(image.name) + } + + let dest = Destination( + directory: outputDirectory.appendingPathComponent(dark ? "dark" : "light"), + file: fileURL + ) + return FileContents(destination: dest, sourceURL: image.url, dark: dark) + } + } + + /// Gets valid scales for the given platform. + private func getScalesForPlatform(_ customScales: [Double]?, platform: Platform) -> [Double] { + let validScales: [Double] = platform == .android ? [1, 2, 3, 1.5, 4.0] : [1, 2, 3] + let filtered = customScales?.filter { validScales.contains($0) } ?? [] + return filtered.isEmpty ? validScales : filtered + } + + /// Creates an SVG to WebP converter from format options. + private func createSvgToWebpConverter(from options: Params.Android.Images + .FormatOptions?) -> SvgToWebpConverter + { + guard let options else { + // Default: lossy with quality 80 + return SvgToWebpConverter(encoding: .lossy(quality: 80)) + } + + switch (options.encoding, options.quality) { + case (.lossless, _): + return SvgToWebpConverter(encoding: .lossless) + case let (.lossy, quality?): + return SvgToWebpConverter(encoding: .lossy(quality: quality)) + case (.lossy, .none): + return SvgToWebpConverter(encoding: .lossy(quality: 80)) + } + } + /// Make array of remote FileContents for downloading images /// - Parameters: /// - images: Dictionary of images. Key = scale, value = image info diff --git a/Sources/Resvg/ResvgError.swift b/Sources/Resvg/ResvgError.swift new file mode 100644 index 00000000..d6b44bbe --- /dev/null +++ b/Sources/Resvg/ResvgError.swift @@ -0,0 +1,80 @@ +import CResvg +import Foundation + +/// Errors that can occur during SVG rasterization with resvg +public enum ResvgError: LocalizedError, Equatable, Sendable { + case notUtf8String + case fileOpenFailed(path: String) + case malformedGzip + case elementsLimitReached + case invalidSize + case parsingFailed + case unknownError(code: Int32) + case emptyImage + + /// Creates a ResvgError from a resvg error code + /// - Parameter code: The error code from resvg C API + /// - Returns: The corresponding ResvgError, or nil if RESVG_OK + static func fromCode(_ code: Int32) -> ResvgError? { + switch code { + case Int32(RESVG_OK.rawValue): + nil + case Int32(RESVG_ERROR_NOT_AN_UTF8_STR.rawValue): + .notUtf8String + case Int32(RESVG_ERROR_FILE_OPEN_FAILED.rawValue): + .fileOpenFailed(path: "") + case Int32(RESVG_ERROR_MALFORMED_GZIP.rawValue): + .malformedGzip + case Int32(RESVG_ERROR_ELEMENTS_LIMIT_REACHED.rawValue): + .elementsLimitReached + case Int32(RESVG_ERROR_INVALID_SIZE.rawValue): + .invalidSize + case Int32(RESVG_ERROR_PARSING_FAILED.rawValue): + .parsingFailed + default: + .unknownError(code: code) + } + } + + public var errorDescription: String? { + switch self { + case .notUtf8String: + "SVG data is not a valid UTF-8 string" + case let .fileOpenFailed(path): + "Failed to open SVG file: \(path)" + case .malformedGzip: + "Compressed SVG is not valid GZip format" + case .elementsLimitReached: + "SVG has more than 1,000,000 elements (security limit)" + case .invalidSize: + "SVG has invalid size (width/height <= 0 or missing viewBox)" + case .parsingFailed: + "Failed to parse SVG data" + case let .unknownError(code): + "Unknown resvg error (code: \(code))" + case .emptyImage: + "SVG has no renderable elements" + } + } + + public var recoverySuggestion: String? { + switch self { + case .notUtf8String: + "Ensure the SVG file is encoded as UTF-8" + case .fileOpenFailed: + "Check that the file path exists and is readable" + case .malformedGzip: + "Re-export the SVG from Figma without compression" + case .elementsLimitReached: + "Split the SVG into smaller files" + case .invalidSize: + "Ensure the SVG has valid width, height, or viewBox attributes" + case .parsingFailed: + "Re-export the SVG from Figma or validate the SVG syntax" + case .unknownError: + nil + case .emptyImage: + "Ensure the SVG contains visible elements" + } + } +} diff --git a/Sources/Resvg/SvgRasterizer.swift b/Sources/Resvg/SvgRasterizer.swift new file mode 100644 index 00000000..5ab47faa --- /dev/null +++ b/Sources/Resvg/SvgRasterizer.swift @@ -0,0 +1,138 @@ +import CResvg +import Foundation + +/// Result of SVG rasterization containing RGBA pixel data +public struct RasterizedSvg: Sendable { + public let width: Int + public let height: Int + public let rgba: [UInt8] + + /// Total number of bytes (should equal width * height * 4) + public var byteCount: Int { rgba.count } + + public init(width: Int, height: Int, rgba: [UInt8]) { + self.width = width + self.height = height + self.rgba = rgba + } +} + +/// Rasterizes SVG images to RGBA pixel data using resvg +/// +/// Uses the resvg library (Rust-based, high-quality SVG renderer) via C bindings. +/// Supports all standard SVG features and produces identical results across platforms. +public struct SvgRasterizer: Sendable { + public init() {} + + /// Rasterizes an SVG file to RGBA pixel data + /// - Parameters: + /// - url: Path to SVG file + /// - scale: Scale factor for output resolution (1.0 = native size) + /// - Returns: Rasterized image with width, height, and RGBA bytes + /// - Throws: `ResvgError` on failure + public func rasterize(file url: URL, scale: Double = 1.0) throws -> RasterizedSvg { + let data = try Data(contentsOf: url) + return try rasterize(data: data, scale: scale) + } + + /// Rasterizes SVG data to RGBA pixel data + /// - Parameters: + /// - data: SVG file data (UTF-8 string or gzip compressed) + /// - scale: Scale factor for output resolution (1.0 = native size) + /// - Returns: Rasterized image with width, height, and RGBA bytes + /// - Throws: `ResvgError` on failure + public func rasterize(data: Data, scale: Double = 1.0) throws -> RasterizedSvg { + // Create options + guard let opt = resvg_options_create() else { + throw ResvgError.unknownError(code: -1) + } + defer { resvg_options_destroy(opt) } + + // Parse SVG tree + var tree: OpaquePointer? + let result = data.withUnsafeBytes { ptr -> Int32 in + guard let baseAddress = ptr.baseAddress else { + return Int32(RESVG_ERROR_PARSING_FAILED.rawValue) + } + return resvg_parse_tree_from_data( + baseAddress.assumingMemoryBound(to: CChar.self), + UInt(ptr.count), + opt, + &tree + ) + } + + // Check for parsing errors + if let error = ResvgError.fromCode(result) { + throw error + } + + guard let tree else { + throw ResvgError.parsingFailed + } + defer { resvg_tree_destroy(tree) } + + // Check if image is empty + if resvg_is_image_empty(tree) { + throw ResvgError.emptyImage + } + + // Get original size and compute scaled dimensions + let size = resvg_get_image_size(tree) + let width = Int(Double(size.width) * scale) + let height = Int(Double(size.height) * scale) + + guard width > 0, height > 0 else { + throw ResvgError.invalidSize + } + + // Allocate pixmap buffer + var pixmap = [UInt8](repeating: 0, count: width * height * 4) + + // Create transform for scaling + let transform = resvg_transform( + a: Float(scale), + b: 0, + c: 0, + d: Float(scale), + e: 0, + f: 0 + ) + + // Render SVG to pixmap + pixmap.withUnsafeMutableBytes { ptr in + guard let baseAddress = ptr.baseAddress else { return } + resvg_render( + tree, + transform, + UInt32(width), + UInt32(height), + baseAddress.assumingMemoryBound(to: CChar.self) + ) + } + + // Unpremultiply alpha (resvg outputs premultiplied RGBA) + unpremultiplyAlpha(&pixmap) + + return RasterizedSvg(width: width, height: height, rgba: pixmap) + } + + /// Unpremultiplies alpha values to get correct RGB values + /// + /// resvg outputs premultiplied RGBA where RGB = RGB * alpha. + /// We need straight alpha (RGB independent of alpha) for WebP encoding. + private func unpremultiplyAlpha(_ rgba: inout [UInt8]) { + let pixelCount = rgba.count / 4 + for i in 0 ..< pixelCount { + let offset = i * 4 + let alpha = rgba[offset + 3] + + if alpha > 0, alpha < 255 { + let alphaFloat = Float(alpha) / 255.0 + rgba[offset] = UInt8(min(255, Float(rgba[offset]) / alphaFloat)) + rgba[offset + 1] = UInt8(min(255, Float(rgba[offset + 1]) / alphaFloat)) + rgba[offset + 2] = UInt8(min(255, Float(rgba[offset + 2]) / alphaFloat)) + } + } + } +} diff --git a/Sources/XcodeExport/XcodeImagesExporter.swift b/Sources/XcodeExport/XcodeImagesExporter.swift index c6d6fecc..c77a4b36 100644 --- a/Sources/XcodeExport/XcodeImagesExporter.swift +++ b/Sources/XcodeExport/XcodeImagesExporter.swift @@ -31,4 +31,23 @@ public final class XcodeImagesExporter: XcodeImagesExporterBase { return [contentsFile] + imageAssetsFiles + extensionFiles } + + /// Exports only Swift extensions without asset catalog files. + /// + /// Use this when you're generating asset catalogs manually (e.g., SVG source with local rasterization) + /// but still need the Swift UIImage/Image extensions. + /// + /// - Parameters: + /// - assets: Image asset pairs (used for name extraction if allAssetNames not provided). + /// - allAssetNames: Optional complete list of all asset names. + /// - append: Whether to append to existing extension files. + /// - Returns: Swift extension file contents. + public func exportSwiftExtensions( + assets: [AssetPair], + allAssetNames: [String]? = nil, + append: Bool + ) throws -> [FileContents] { + let imageNames = allAssetNames ?? assets.map { normalizeName($0.light.name) } + return try generateExtensions(names: imageNames, append: append) + } } diff --git a/Tests/ExFigTests/SvgRasterizerTests.swift b/Tests/ExFigTests/SvgRasterizerTests.swift new file mode 100644 index 00000000..926d030d --- /dev/null +++ b/Tests/ExFigTests/SvgRasterizerTests.swift @@ -0,0 +1,89 @@ +@testable import Resvg +import XCTest + +final class SvgRasterizerTests: XCTestCase { + func testRasterizeSimpleSVG() throws { + // Simple red circle SVG + let svgString = """ + + + + """ + + let svgData = Data(svgString.utf8) + let rasterizer = SvgRasterizer() + + let result = try rasterizer.rasterize(data: svgData, scale: 1.0) + + XCTAssertEqual(result.width, 100) + XCTAssertEqual(result.height, 100) + XCTAssertEqual(result.rgba.count, 100 * 100 * 4) + } + + func testRasterizeWithScale() throws { + let svgString = """ + + + + """ + + let svgData = Data(svgString.utf8) + let rasterizer = SvgRasterizer() + + let result = try rasterizer.rasterize(data: svgData, scale: 2.0) + + XCTAssertEqual(result.width, 200) + XCTAssertEqual(result.height, 200) + XCTAssertEqual(result.rgba.count, 200 * 200 * 4) + } + + func testRasterizeZeroSizeImage() throws { + // An SVG with zero dimensions + let svgString = """ + + + """ + + let svgData = Data(svgString.utf8) + let rasterizer = SvgRasterizer() + + XCTAssertThrowsError(try rasterizer.rasterize(data: svgData, scale: 1.0)) { error in + guard let resvgError = error as? ResvgError else { + XCTFail("Expected ResvgError") + return + } + XCTAssertEqual(resvgError, .invalidSize) + } + } + + func testRasterizeInvalidSVG() throws { + let invalidData = Data("not an svg".utf8) + let rasterizer = SvgRasterizer() + + XCTAssertThrowsError(try rasterizer.rasterize(data: invalidData, scale: 1.0)) { error in + guard let resvgError = error as? ResvgError else { + XCTFail("Expected ResvgError") + return + } + XCTAssertEqual(resvgError, .parsingFailed) + } + } + + func testRasterizePreservesTransparency() throws { + // SVG with transparent background + let svgString = """ + + + + """ + + let svgData = Data(svgString.utf8) + let rasterizer = SvgRasterizer() + + let result = try rasterizer.rasterize(data: svgData, scale: 1.0) + + // Check corner pixels are transparent (alpha = 0) + // Corner is at (0,0), index 0 in RGBA + XCTAssertEqual(result.rgba[3], 0, "Corner pixel should be transparent") + } +} diff --git a/mise.toml b/mise.toml index e74c9828..9bb6f8da 100644 --- a/mise.toml +++ b/mise.toml @@ -55,11 +55,21 @@ run = "swift build --configuration release 2>&1 | xcsift -w -f toon --toon-key-f [tasks.test] description = "Run all tests" -run = "swift test 2>&1 | xcsift -w -f toon --toon-key-folding safe" +run = """ +# Copy libresvg.dylib to build directory (workaround for rpath in test bundles) +mkdir -p .build/arm64-apple-macosx/debug +cp -f Libraries/macos/libresvg.dylib .build/arm64-apple-macosx/debug/libresvg.dylib 2>/dev/null || true +swift test 2>&1 | xcsift -w -f toon --toon-key-folding safe +""" [tasks."test:filter"] description = "Run tests with filter (usage: mise run test:filter ExFigTests)" -run = "swift test --filter {{arg(name='filter')}} 2>&1 | xcsift -w -f toon --toon-key-folding safe" +run = """ +# Copy libresvg.dylib to build directory (workaround for rpath in test bundles) +mkdir -p .build/arm64-apple-macosx/debug +cp -f Libraries/macos/libresvg.dylib .build/arm64-apple-macosx/debug/libresvg.dylib 2>/dev/null || true +swift test --filter {{arg(name='filter')}} 2>&1 | xcsift -w -f toon --toon-key-folding safe +""" [tasks.lint] description = "Run SwiftLint" From af83b76a5611e428effcc44ba18eb8aba7376b60 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Thu, 18 Dec 2025 19:01:53 +0500 Subject: [PATCH 2/6] refactor(images): remove redundant doc comments and add swiftlint rule --- Sources/ExFig/Subcommands/ExportImages.swift | 12 +----------- hk.pkl | 7 +++++++ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Sources/ExFig/Subcommands/ExportImages.swift b/Sources/ExFig/Subcommands/ExportImages.swift index 208dbfa4..e8a30fcd 100644 --- a/Sources/ExFig/Subcommands/ExportImages.swift +++ b/Sources/ExFig/Subcommands/ExportImages.swift @@ -511,11 +511,7 @@ extension ExFigCommand { // MARK: - iOS SVG Source Export - /// Exports iOS images using SVG source format with local rasterization. - /// - /// Downloads SVG from Figma, rasterizes locally with resvg at 1x/2x/3x scales, - /// and saves as PNG to xcassets. - // swiftlint:disable:next function_body_length function_parameter_count + // swiftlint:disable:next function_body_length function_parameter_count cyclomatic_complexity private func exportiOSSVGSourceImagesEntry( entry: Params.iOS.ImagesEntry, ios: Params.iOS, @@ -1202,12 +1198,6 @@ extension ExFigCommand { try? FileManager.default.removeItem(at: tempDirectoryURL) } - /// Exports Android images using SVG source with local resvg rasterization to WebP. - /// - /// This method: - /// 1. Downloads SVG files from Figma CDN - /// 2. Rasterizes each SVG at the required scales using resvg - /// 3. Encodes to WebP and writes to drawable directories // swiftlint:disable:next function_body_length function_parameter_count private func exportAndroidSVGSourceWebpImagesEntry( images: [AssetPair], diff --git a/hk.pkl b/hk.pkl index b196c891..c08ec53b 100644 --- a/hk.pkl +++ b/hk.pkl @@ -35,6 +35,13 @@ local swift_linters = new Mapping { batch = true output_summary = "hide" } + // Full project swiftlint check (catches issues in unchanged files affected by changes) + ["swiftlint-all"] { + glob = List("*.swift") + check = "swiftlint lint --strict --quiet" + batch = true + output_summary = "hide" + } } // ============================================================================= From 8ed7f2abf2989b6b539b043a4a19d5141c1687a9 Mon Sep 17 00:00:00 2001 From: Aleksei Kakoulin <36570774+alexey1312@users.noreply.github.com> Date: Fri, 19 Dec 2025 01:02:12 +0500 Subject: [PATCH 3/6] fix(ci): add pre-built libresvg.a for Linux (#14) --- .github/workflows/release.yml | 13 +------------ Libraries/linux/libresvg.a | 3 +++ 2 files changed, 4 insertions(+), 12 deletions(-) create mode 100644 Libraries/linux/libresvg.a diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bde470c2..57f05d41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,18 +45,7 @@ jobs: if: matrix.platform == 'linux-x64' run: | apt-get update - apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev curl - - - name: Install Rust and build resvg (Linux) - if: matrix.platform == 'linux-x64' - run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - . "$HOME/.cargo/env" - git clone --depth 1 --branch v0.45.1 https://github.com/linebender/resvg.git /tmp/resvg - cd /tmp/resvg - cargo build --release -p resvg-capi - mkdir -p $GITHUB_WORKSPACE/Libraries/linux - cp target/release/libresvg.a $GITHUB_WORKSPACE/Libraries/linux/ + apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev - name: Set version from tag run: | diff --git a/Libraries/linux/libresvg.a b/Libraries/linux/libresvg.a new file mode 100644 index 00000000..c2e088c7 --- /dev/null +++ b/Libraries/linux/libresvg.a @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd646001e1126ac82c0b3605eea415187a3fabb57071c6d9ca0153ae77ee33c6 +size 30799742 From c1fcbf8a3fcd231693381762e035f5627d1d5998 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 19 Dec 2025 10:07:49 +0500 Subject: [PATCH 4/6] fix(images): correct dark variant paths in SVG imageset export Dark variants must share the same .imageset directory as their light counterparts. Also fix URL construction using fileURLWithPath instead of URL(string:) to handle filenames properly. --- README.md | 2 +- Sources/ExFig/Subcommands/ExportImages.swift | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2c12b45e..285cf862 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml) [![Release](https://github.com/alexey1312/ExFig/actions/workflows/release.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/release.yml) [![Docs](https://github.com/alexey1312/ExFig/actions/workflows/deploy-docc.yml/badge.svg)](https://alexey1312.github.io/ExFig/documentation/exfig) -![Coverage](https://img.shields.io/badge/coverage-49.43%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-47.72%25-yellow) [![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE) Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and diff --git a/Sources/ExFig/Subcommands/ExportImages.swift b/Sources/ExFig/Subcommands/ExportImages.swift index e8a30fcd..e0c80d6e 100644 --- a/Sources/ExFig/Subcommands/ExportImages.swift +++ b/Sources/ExFig/Subcommands/ExportImages.swift @@ -754,13 +754,13 @@ extension ExFigCommand { )) } - // Dark variant (if exists) + // Dark variant (if exists) - must use same imageset directory as light if let dark = pair.dark, let image = dark.images.first { - let imagesetDir = assetsURL.appendingPathComponent("\(dark.name).imageset") + let imagesetDir = assetsURL.appendingPathComponent("\(pair.light.name).imageset") files.append(FileContents( destination: Destination( directory: imagesetDir, - file: URL(fileURLWithPath: "\(dark.name)_dark.svg") + file: URL(fileURLWithPath: "\(pair.light.name)_dark.svg") ), sourceURL: image.url )) @@ -1045,13 +1045,13 @@ extension ExFigCommand { let tempDirectoryDarkURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let remoteFiles = images.flatMap { asset -> [FileContents] in - let lightFiles = asset.light.images.compactMap { image -> FileContents? in - guard let fileURL = URL(string: "\(image.name).svg") else { return nil } + let lightFiles = asset.light.images.map { image -> FileContents in + let fileURL = URL(fileURLWithPath: "\(image.name).svg") let dest = Destination(directory: tempDirectoryLightURL, file: fileURL) return FileContents(destination: dest, sourceURL: image.url) } - let darkFiles = asset.dark?.images.compactMap { image -> FileContents? in - guard let fileURL = URL(string: "\(image.name).svg") else { return nil } + let darkFiles = asset.dark?.images.map { image -> FileContents in + let fileURL = URL(fileURLWithPath: "\(image.name).svg") let dest = Destination(directory: tempDirectoryDarkURL, file: fileURL) return FileContents(destination: dest, sourceURL: image.url, dark: true) } ?? [] From b16e18841eb227f379b03b144376997dd9941c8a Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 19 Dec 2025 10:41:49 +0500 Subject: [PATCH 5/6] ci: add Git LFS support and update checkout action to v6 --- .github/workflows/ci.yml | 19 ++++++++++++++++--- .github/workflows/release.yml | 14 +++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b82e5937..c609d084 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,9 @@ jobs: name: Lint runs-on: macos-15 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + with: + lfs: true - name: Setup mise environment run: source Scripts/environment.sh @@ -34,7 +36,9 @@ jobs: DEVELOPER_DIR: "/Applications/Xcode_16.3.app/Contents/Developer" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + with: + lfs: true - name: Cache SPM dependencies uses: actions/cache@v4 @@ -69,7 +73,16 @@ jobs: container: image: swift:6.0 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + with: + lfs: true + + - name: Install Git LFS and pull + run: | + apt-get update + apt-get install -y git-lfs + git lfs install + git lfs pull - name: Show Swift version run: swift --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57f05d41..225a5722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,9 @@ jobs: container: ${{ matrix.container }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + with: + lfs: true - name: Select Xcode 16.3 (macOS) if: matrix.platform == 'macos' @@ -45,7 +47,9 @@ jobs: if: matrix.platform == 'linux-x64' run: | apt-get update - apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev + apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev git-lfs + git lfs install + git lfs pull - name: Set version from tag run: | @@ -99,7 +103,7 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 @@ -132,7 +136,7 @@ jobs: needs: [build, update-version] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -181,7 +185,7 @@ jobs: echo "linux=${LINUX_SHA}" >> $GITHUB_OUTPUT - name: Checkout Homebrew tap - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: alexey1312/homebrew-exfig token: ${{ secrets.HOMEBREW_TAP_TOKEN }} From 069fab03bf9768b4104f48536a139e0ff872e82e Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 19 Dec 2025 10:46:34 +0500 Subject: [PATCH 6/6] ci: add Git LFS support and update checkout action to v6 --- .github/workflows/ci.yml | 11 +++++------ .github/workflows/release.yml | 12 ++++++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c609d084..1830b4dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,16 +73,15 @@ jobs: container: image: swift:6.0 steps: - - uses: actions/checkout@v6 - with: - lfs: true - - - name: Install Git LFS and pull + - name: Install Git LFS run: | apt-get update apt-get install -y git-lfs git lfs install - git lfs pull + + - uses: actions/checkout@v6 + with: + lfs: true - name: Show Swift version run: swift --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 225a5722..9ce7ecd9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,13 @@ jobs: container: ${{ matrix.container }} steps: + - name: Install Git LFS (Linux) + if: matrix.platform == 'linux-x64' + run: | + apt-get update + apt-get install -y git-lfs + git lfs install + - uses: actions/checkout@v6 with: lfs: true @@ -46,10 +53,7 @@ jobs: - name: Install system dependencies (Linux) if: matrix.platform == 'linux-x64' run: | - apt-get update - apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev git-lfs - git lfs install - git lfs pull + apt-get install -y libcurl4-openssl-dev libxml2-dev libssl-dev - name: Set version from tag run: |