Design Archive
中EN

Moving 37 artboards into Figma, proven pixel by pixel

After importing Sketch files into Figma the layer tree was intact, but the rendering had quietly changed. I swapped "looks right" for a pixel-by-pixel comparison and kept going until every difference could be explained.

L.09 / 14
CATEGORY
Tool
YEAR
2026
STATUS
Note
TOOLS
python / numpy / pillow / figma

entryWrite-up

The desktop design files of a B2B product had always lived in Sketch, and the team was moving to Figma for collaboration and developer handoff, with design tokens (named values for color, type size, spacing and the like) and a component library to follow. The migration ran in 6 batches, 114 artboards in all. This note covers the last two: the final acceptance of the 35 artboards in batch five, and the full migration of the 2 artboards in batch six. At first I took it for legwork: import, compare by eye, fix what was wrong. Once the work started, comparing by eye turned out not to work.

Figma can import Sketch files directly. After import the layer tree was complete, with names, visibility and hierarchy all matching, and nothing looked wrong in the layers panel, yet the rendering was different. This job turned up three examples. First, the rounded corners on a dialog body were gone: Sketch stores the four corner radii as [0,0,12,12], the extraction script read only the first value, and a box with only its two bottom corners rounded came out with all four corners square. Second, the 1px divider under the top bar and on the right of the sidebar was gone: in the design file that line was not a stroke but an inner shadow with 0 blur and a 1px offset, and the importer treated the inner shadow as an ordinary drop shadow. Third, the blue of the search button looked dull: that area was a screenshot bitmap carrying an ICC color profile (data that defines how colors display on different devices), which Sketch respects on export and Figma ignores on import. Only 6 of the 47 images carried that profile, and they happened to sit on the three artboards with the largest remaining differences; after the fix, the difference for the three images on one of those artboards went from 17,761px to 0.

The same bitmap, with and without an ICC profile, renders in different colors in the two tools.
The same button, all of it inside the difference.

A divider off by 1px, a button one shade off, a corner off by 12px; with dozens of elements on a screen, these are hard to spot by eye on any single artboard. Yet the deviations stay in the file, and every design token and component built later rests on that baseline. So the acceptance standard was set as a pixel-by-pixel comparison of the exported image and the rendered image, with the differences reduced until every one of them could be explained. Judging “about right” by eye drifts: 1% let through on the first artboard becomes 3% on the tenth; with a number, “how much is left” can be reported and scheduled.

Two ways of comparing were tried. The first read the layers on both sides and compared coordinates and sizes; text geometry could match exactly while the visuals were wrong across large areas, because the errors were in fills, corner radii, shadows and blurs, properties that do not live in a bounding box. The second compared pixels: Sketch exports each artboard as a 1× PNG, Figma is asked for a 1:1 render, and the difference between the two images is computed pixel by pixel.

Once the comparison moved to pixels, anti-aliasing noise from the font substitution was mixed in with real defects. On a 1920×1080 artboard, font noise alone could account for 2% to 3%, while real defects might be 0.05%, so the total could not show whether things were converging. The answer was a text mask: pull the absolute bounding boxes of every text layer out of the Sketch file and expand them by 3px; differences inside the mask count as font noise, those outside count as real differences, and acceptance looks only at the latter. Once the two were separated, each round of fixes showed directly how many pixels of real difference it removed, and so which fix should come first. The mask must include the text inside symbol instances (Symbol Instance, that is, component instances); a symbol instance has no child layers of its own, so the master has to be found through its symbol, and the master’s text boxes shifted to the instance’s position. Once that step was added, the real difference in one batch fell from 8,492px to 2,281px; before that, the logo had been misread as a style defect and chased for a long time.

A Sketch file is in fact a zip archive, and the JSON inside it is the complete layer tree. Parsing that JSON locally with Python is an order of magnitude faster than going through Sketch’s scripting interface, and it reaches fields the interface does not expose: corner radii, blur parameters and the inner-shadow flag are all in there. Scripts drive the fixing, capturing and comparing, in a fixed order: fix, capture, compare, look at the few boards with the largest differences, then fix again. The order cannot be swapped; placing an artboard’s content inside a newly created frame (wrapping it in a frame), for example, has to come last, because reading a bounding box after wrapping returns the clipped value. A difference count can only show where something is wrong; crops show why: a color shift, an overall offset and a moved line break look the same in the numbers. After each comparison, look at crops of the three to five boards with the largest differences.

The comparison pipeline as it ended up.

Batch five found every inner shadow missing: the source file had 63 of them, all 1px hairline dividers drawn as inner shadows. With those 63 inner shadows added back, the difference fell from 233,046px to 204,043px, and the job looked finished. Only in batch six did it become clear that Figma had not dropped the inner shadows but imported them as ordinary drop shadows, with color, offset and blur unchanged and only the type flipped from inner to outer. The batch-five fix was half right: the line that belonged on the inside was restored, while the shadow that should never have been on the outside stayed in the file. The correct fix adds the inner shadow back and, at the same time, deletes the drop shadow with identical parameters. Batch six deleted 4, and the one at the bottom of the sidebar alone was worth 208px. When a property has disappeared after import, first check whether it is really gone or has turned into a different property; the two cases call for opposite fixes.

The importer turned inner shadows into drop shadows with the same parameters.
The hairline at the bottom of the sidebar at 5×: Sketch export, Figma render, difference mask.

Sketch frames come in two layouts: a regular frame of fixed size that clips its content, and an auto-layout frame (Stack) that fits its content, does not clip, and often stores a width and height smaller than what it actually renders. I did not tell the two apart and added clipping to every frame, wrapping 79 by mistake; on one artboard the difference rose from 13,952px to 77,716px, with a large part of the content cut off. Telling them apart takes reading one field, groupLayout._class, but before that field was read, the comparison ran three rounds before “worse after the fix” was taken as a signal and looked into. When the difference grows after a round of fixes, roll back first and then look for the cause, rather than stacking another round of fixes on top.

Batch five had a tall 1712×2148 artboard holding only two screenshot bitmaps; with every known issue fixed it was still 80,026px off, all of it on the lower image. Replacing the image, converting it to sRGB, removing the outer group, turning off clipping and switching to a half-resolution version all left the number unchanged. Then the two images swapped places, and the lower one was still the blurry one; capturing the lower node on its own matched the baseline pixel for pixel, a difference of 0. The problem followed the position, not the node or the image: it was a quality loss in Figma’s capture interface when exporting tall artboards, the file itself was correct, and the measurement method was at fault. Once measurement changed to capturing each node separately and stitching the results, the artboard went from 80,026px to 0. At some point in an investigation, both the object under test and the measurement method have to be suspected: those 80,000 pixels very nearly went into the conclusions as unfixable residue.

With the two images swapped, the lower one is still blurry: the problem follows the position.

On import, Figma automatically replaces the Chinese font with another sans-serif, and the early handbook said “weights are correct, leave it”. Measured, that entry was wrong: after switching to the locally installed font from the same family as the source file, the differences on two artboards fell from 49,424px to 28,819px and from 70,565px to 29,147px; compared node by node, the new font had the smaller difference on 52 of the 64 text nodes. The cost was that the new font sets Chinese punctuation at full width where the old one used proportional widths, so long paragraphs began to wrap at different places.

Before the font switch: the line breaks match the design, but the glyphs are off.
After the switch: the glyphs match, but a line break moved by one character.

I chose to switch the whole file to the closest font and handle the few re-wrapped nodes individually, rather than mix two Chinese fonts in one design file for a few dozen pixels, because two font families would cause far more trouble for the tokens later than that pixel difference. Only 2 nodes were affected, one of which was already truncated in the design, and “fixed at three lines with a trailing ellipsis” restored it; the ellipsis still lands one character off, a known side effect, and acceptable. The other node only breaks its lines at a different point, and that cannot be fixed.

Fixed at three lines with a trailing ellipsis; the ellipsis still lands one character off.

The 35 artboards in batch five came down from 271,455px to 85,670px, 0.000% to 0.658% per board; the 2 in batch six went from 2,637px to 1,188px, 0.006% and 0.051% per board. Every batch’s problems went into the same handbook, which now lists 31 known kinds of loss, each with how to detect it, how to fix it and how many pixels it is worth. With the problems from the first five batches already in the handbook, batch six was the cleanest: 0 layer blurs, 0 progressive opacity, 0 bitmap color adjustments, 0 ICC-tagged images, 0 artboard offsets, and only 2 frames that genuinely needed clipping. Every remaining difference was inspected in crops, board by board, and all of it falls into two kinds. One is extreme bitmap downsampling: the sidebar logo is a 511×980 image placed at 14×27, shrunk about 35 times with a different aspect ratio, and since the two tools resample differently, the jagged edges cannot match. The other is anti-aliasing of Chinese glyphs: the characters sit in exactly the same positions and only the edge gray levels differ. Neither can be fixed structurally, short of replacing the bitmaps with versions re-rendered in the target tool, which would no longer count as a migration. The last small artboard has 367px left; zoomed in, the glyph positions and shapes match and what differs is only rendering noise. At that point, I judged the job done.

How the two batches converged.
Chinese text in a 0.5× screenshot bitmap: glyphs coincide, only edge gray levels differ.
The 367px left on the last small board.

processBefore & after

Convergence

Batch five · first comparison
271,455px
Clip host rebound to background rectangle
233,046px
Inner shadows restored
204,043px
Bitmaps converted from ICC to sRGB
165,696px
Measurement fixed for the tall artboard
85,670px · 0.000%–0.658% per board
Batch six · first comparison
2,637px
Chinese body font switched
1,460px
Wrongly imported drop shadows deleted
1,246px
Re-wrapped text truncated by line count
1,188px · 0.006% and 0.051% per board

From the handbook (a few of the 31 known kinds of loss)

Layer blur
None survive import · target radius = source radius × 2 · 422 in one batch
Frame borders
None survive import · 99 in one batch · difference halved on 6 artboards once restored
ICC color profile
Respected by the source on export, ignored by the target · zero after converting the bitmaps
Diagonal linear gradients
The two tools compute the projection in different coordinate spaces · 56 places · 8 artboards from about 12,000 to about 1,400px
Ellipses with edited anchors
Imported as standard ellipses · replaced by vectors built from the anchor data · 30.6% / 36.2% → 0.4% / 1.1%

resultTakeaways

Finding
The 35 artboards in batch five ended at 0.000%–0.658% difference each, and the 2 in batch six at 0.006% and 0.051%. All that remains is bitmap resampling and glyph anti-aliasing.
Parameters
Text bounding boxes expanded by 3px as a font-noise mask · only differences outside the mask count · the handbook lists 31 known kinds of loss
Open
One line-break difference cannot be fixed; the ellipsis lands one character off. Extreme bitmap downsampling and Chinese anti-aliasing are structural and cannot be removed.

logChangelog

2026-10
First entry

Related experiments

note
L.11(TOOL)2026

Laying a flat image down on a 3D map

toolingwebglcss-3d+1
note
L.14(TOOL)2026

From one 3D tilted map to an offline tool

toolingsvggeojson+1
note
L.06(STUDY)2026

One font size, one line height

typefigmavariables
← LabL.09 / 14
1 / 1

100%