Repository: sketch-hq/fig2sketch

Issue:
Fonts no longer downloading from Google Fonts
Previously `fig2sketch` would download google fonts used in a figma document and embed them into the resulting Sketch document.

However Google have recently changed their download URL format (https://github.com/google/fonts/issues/7481#issuecomment-2025656377) which has broken a number of tools including `fig2sketch`

Discussion:

Solution patch:
diff --git a/pyproject.toml b/pyproject.toml
index 7e89dbe..32da68f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,7 +3,7 @@ name = "fig2sketch"
 readme = "README.md"
 requires-python = ">=3.10"
 dynamic = ["version"]
-dependencies = ["Pillow==9.3.0", "fonttools==4.38.0", "appdirs==1.4.4"]
+dependencies = ["Pillow==10.0.0", "fonttools==4.38.0", "appdirs==1.4.4"]
 
 [project.scripts]
 fig2sketch = "fig2sketch:main"
@@ -17,7 +17,7 @@ enabled = true
 
 [project.optional-dependencies]
 fast = ["fig-kiwi==0.1.0"]
-dev = ["black==24.2.0", "mypy==0.991", "pytest==7.2.0", "fig-kiwi==0.1.0"]
+dev = ["black==24.2.0", "mypy==0.991", "pytest==8.2.0", "fig-kiwi==0.1.0"]
 
 [tool.black]
 line-length = 99
diff --git a/src/converter/context.py b/src/converter/context.py
index 29d7671..a59fa12 100644
--- a/src/converter/context.py
+++ b/src/converter/context.py
@@ -1,4 +1,5 @@
 import logging
+from urllib.error import HTTPError
 from . import component, page, font
 from sketchformat.document import Swatch
 from typing import Sequence, Tuple, Optional, Dict, IO, List
@@ -62,8 +63,14 @@ class Context:
 
         try:
             font_file, font_name = font.get_webfont(*font_descriptor)
-        except:
-            logging.warning(f"Could not download font {font_descriptor}")
+        except Exception as e:
+            if isinstance(e, font.FontNotFoundError) or (
+                isinstance(e, HTTPError) and e.code == 404
+            ):
+                logging.warning(f"Could not find font {font_descriptor} via Google Fonts")
+            else:
+                logging.warning(f"Could not download font {font_descriptor}: {e}")
+
             font_file = None
             if fig_font_name["postscript"]:
                 font_name = fig_font_name["postscript"]
diff --git a/src/converter/font.py b/src/converter/font.py
index 8ed29b7..d6b1bf7 100644
--- a/src/converter/font.py
+++ b/src/converter/font.py
@@ -2,6 +2,8 @@ import appdirs
 import os
 import urllib.request
 import urllib.parse
+import json
+import logging
 from converter import utils
 from fontTools.ttLib import TTFont
 from sketchformat.document import FontReference, JsonFileReference
@@ -12,37 +14,35 @@ fonts_cache_dir = appdirs.user_cache_dir("Fig2Sketch", "Sketch") + "/fonts"
 os.makedirs(fonts_cache_dir, exist_ok=True)
 
 
-class FontError(Exception):
+class FontNotFoundError(Exception):
     pass
 
 
-def retrieve_webfont(family):
-    WEB_FONT_BASE_URL = "http://fonts.google.com/download?family="
-    font_url = WEB_FONT_BASE_URL + urllib.parse.quote(family)
-    font_file = f"{fonts_cache_dir}/{family}.zip"
-
-    if not os.path.exists(font_file):
-        urllib.request.urlretrieve(font_url, font_file)
-
-    return ZipFile(font_file)
+def retrieve_webfont_family(family):
+    WEB_FONT_LIST_URL = "https://fonts.google.com/download/list?family="
+    list_url = WEB_FONT_LIST_URL + urllib.parse.quote(family)
+    list_response = urllib.request.urlopen(list_url)
+    return json.loads(bytearray(list_response.read())[5:])
 
 
 def get_webfont(family, subfamily):
-    font_zip = retrieve_webfont(family)
-    for fi in font_zip.infolist():
-        if not fi.filename.endswith((".ttf", ".otf")):
+    family_list = retrieve_webfont_family(family)
+
+    for fi in family_list["manifest"]["fileRefs"]:
+        filename = fi["filename"].replace("static/", "")
+        if not filename.endswith((".ttf", ".otf")):
             continue
+        if family.lower() in filename.lower() and subfamily.lower() in filename.lower():
+            font_file_path = f"{fonts_cache_dir}/{filename}"
+            if not os.path.exists(font_file_path):
+                urllib.request.urlretrieve(fi["url"], font_file_path)
+
+            font_file = open(font_file_path, "rb")
+            font_names = extract_names(font_file)
 
-        font_file = font_zip.open(fi.filename, "r")
-        font_names = extract_names(font_file)
-        if (
-            font_names["family"].lower() == family.lower()
-            and font_names["subfamily"].lower() == subfamily.lower()
-        ):
-            font_file.seek(0)
             return font_file, font_names["postscript"]
 
-    raise FontError(f"Could not find font {family} {subfamily}")
+    raise FontNotFoundError(f"Could not find font {family} {subfamily}")
 
 
 def convert(
diff --git a/src/converter/instance.py b/src/converter/instance.py
index f3777d6..a6f4fa8 100644
--- a/src/converter/instance.py
+++ b/src/converter/instance.py
@@ -1,4 +1,4 @@
-import copy
+import copy, logging
 from . import base, group
 from .context import context
 from .config import config
@@ -220,7 +220,11 @@ def find_refs(node, ref_id):
     refs = [
         (ref, node.get("overrideKey", node["guid"]))
         for ref in node.get("componentPropRefs", [])
-        if ref["defID"] == ref_id and not ref.get("isDeleted", False)
+        if (
+            ("defID" in ref and ref["defID"] == ref_id)
+            or ("zombieFallbackName" in ref and ref["zombieFallbackName"] == ref_id)
+        )
+        and not ref.get("isDeleted", False)
     ]
 
     for ch in node.get("children", []):


Test patch:
diff --git a/tests/integration/test_structure.py b/tests/integration/test_structure.py
index 645e9f7..3c4c49c 100644
--- a/tests/integration/test_structure.py
+++ b/tests/integration/test_structure.py
@@ -91,11 +91,11 @@ def test_document(sketch_doc):
         assert doc["fontReferences"] == [
             {
                 "_class": "fontReference",
-                "do_objectID": "46BF164E-CB00-4E1A-A5DE-9E6EB74A4F1F",
+                "do_objectID": "4B16DCBA-BB70-4392-9B48-95FE92292C38",
                 "fontData": {
                     "_class": "MSJSONFileReference",
                     "_ref_class": "MSFontData",
-                    "_ref": "fonts/be10cc8996f037af7331dc965bae42ef33478700",
+                    "_ref": "fonts/be1bdec0aa74b4dcb079943e70528096cca985f8",
                 },
                 "fontFamilyName": "Inter",
                 "fontFileName": "Inter-Regular.ttf",
@@ -116,7 +116,7 @@ def test_document(sketch_doc):
     [
         "images/616d10a80971e08c6b43a164746afac1972c7ccc.png",
         "images/92e4d5e0c24ffd632c3db3264e62cc907c2f5e29",
-        "fonts/be10cc8996f037af7331dc965bae42ef33478700",
+        "fonts/be1bdec0aa74b4dcb079943e70528096cca985f8",
     ],
 )
 def test_file_hashes(sketch_doc, img):
@@ -223,7 +223,7 @@ def test_files(sketch_doc):
         "images/92e4d5e0c24ffd632c3db3264e62cc907c2f5e29",
         "pages/8F292FCA-49C0-4E31-957E-93FB2D1A7231.json",
         "pages/A4E5259A-9CE6-49D9-B4A1-A8062C205347.json",
-        "fonts/be10cc8996f037af7331dc965bae42ef33478700",
+        "fonts/be1bdec0aa74b4dcb079943e70528096cca985f8",
         "document.json",
         "user.json",
         "meta.json",