Skip to content

Nested frames not handled recursively #13

@EmCmEdT

Description

@EmCmEdT

I noticed that nested frames were not handled recursively (only top frames within frames were handled), so I generated this code to replace parse_file() function:

def parse_file(file, token, download_images=True, out=None):
    output = []
    result = get_file(file, token)
   
    if isinstance(result, tuple):
        return []
    
    try:
        frames = result['document']['children'][0]['children']
        frame_count = 1 if len(frames) > 1 else 0

        def parse_frame(frame, frame_count):

            nonlocal output
            parsed = []
            image_count = 0
            entry_placeholder = False
            text_placeholder = False

            # core.py — inside parse_file, in parse_frame(frame, frame_count)
            def flatten_children(node):
                stack = [node]
                while stack:
                    n = stack.pop()
                    for child in n.get('children', []):
                        # Visit the child and keep diving
                        yield child
                        if child.get('children'):
                            stack.append(child)

            items = ["image", "button", "label", "scale", "listbox", "textbox", "textarea", "rectangle", "spinbox", "circle", "oval", "line"]
            container_types = {"FRAME", "GROUP", "INSTANCE", "COMPONENT", "SECTION", "CANVAS"}

            for i in flatten_children(frame):
                # Skip container nodes unless their name explicitly marks them as a UI item
                name_prefix = i.get('name', '').split(' ', 1)[0].lower()
                figma_type = i.get('type', '')
                if figma_type in container_types and name_prefix not in items:
                    print(f"[parse_file] skip container: {i.get('name')} ({figma_type}) id={i.get('id')}")
                    continue

                # Bounds guard: some invisible text nodes have no absoluteRenderBounds
                if 'absoluteBoundingBox' in i and i['absoluteBoundingBox']:
                    bounds = i['absoluteBoundingBox']
                elif i.get('absoluteRenderBounds'):
                    bounds = i['absoluteRenderBounds']
                else:
                    print(f"[parse_file] skip node with no bounds: {i.get('name')} id={i.get('id')}")
                    continue

                # Relative positions to the root frame
                i['x'] = abs(int(frame['absoluteBoundingBox']['x']) - int(bounds['x']))
                i['y'] = abs(int(frame['absoluteBoundingBox']['y']) - int(bounds['y']))
                i['width'] = int(bounds['width'])
                i['height'] = int(bounds['height'])

                # Classify by name prefix (existing behavior), default to text
                node_type = name_prefix if name_prefix in items else "text"
                i['type'] = node_type

                # Colors
                i['background'] = None
                bg_color = i.get('backgroundColor') or \
                    (i.get('background', [{}])[0].get('color') if i.get('background') else None) or \
                    (i.get('fills', [{}])[0].get('color') if i.get('fills') else None)

                if bg_color:
                    i['background'] = rgb_to_hex(bg_color['r'], bg_color['g'], bg_color['b'])
                    fg = get_foreground_color(bg_color['r'], bg_color['g'], bg_color['b'])
                    i['foreground'] = '#ffffff' if fg == i['background'] and fg == '#000000' else ('#000000' if fg == i['background'] and fg == '#ffffff' else fg)
                else:
                    i['background'] = "#000000"
                    i['foreground'] = "#FFFFFF"

                # Strokes
                if i.get('strokes'):
                    stroke_color = i.get('strokes', [{}])[0].get('color')
                    if stroke_color:
                        i['stroke_color'] = rgb_to_hex(stroke_color['r'], stroke_color['g'], stroke_color['b'])

                # Per-type details (keep existing logic)
                if i['type'] in ['text', 'label']:
                    i['text'] = i.get('characters', '').replace('\n', '\\n')
                    style = i.get('style', {})  # payload text style exists [payload.py](/api/storage/attachment/b9620458-e75e-4abb-82a0-e7c0cda79b78/payload.py)【2-7】
                    i['font'] = style.get('fontFamily', 'Default Font')
                    i['font_size'] = int(style.get('fontSize', 12))

                elif i['type'] == 'image':
                    parts = i.get('name', '').split(' ')
                    name = " ".join(parts[1:]).strip()
                    if not name:
                        image_count += 1
                        name = str(image_count)
                    # download_image knows how to handle frame_count
                    img = download_image(file, i['id'], name, token, out, frame_count) if frame_count > 0 else download_image(file, i['id'], name, token, out)
                    i['image'] = img if img else None

                elif i['type'] == 'scale':
                    scale = i.get('name', '').split(' ')
                    if len(scale) >= 3:
                        i['from'] = int(scale[1])
                        i['to'] = int(scale[2])
                        i['orient'] = scale[3] if len(scale) > 3 else "HORIZONTAL"

                elif i['type'] in ['textbox', 'textarea']:
                    parts = i.get('name', '').split(' ')
                    placeholder = " ".join(parts[1:]).strip()
                    if placeholder:
                        i['placeholder'] = placeholder
                        if i['type'] == 'textbox':
                            entry_placeholder = True
                        else:
                            text_placeholder = True

                elif i['type'] == 'button' and download_images:
                    image_count += 1
                    img = download_image(file, i['id'], str(image_count), token, out, frame_count) if frame_count > 0 else download_image(file, i['id'], str(image_count), token, out)
                    i['image'] = img if img else None

                parsed.append(i)
                print(f"[parse_file] ok: {i.get('name')} -> {i['type']} id={i.get('id')} x={i['x']} y={i['y']} w={i['width']} h={i['height']}")

            
            frame_bg = frame.get('backgroundColor') or \
                            (frame.get('background', [{}])[0].get('color') if frame.get('background') else None) or \
                            (frame.get('fills', [{}])[0].get('color') if frame.get('fills') else None)

            if frame_bg:
                frame_bg = rgb_to_hex(frame_bg['r'], frame_bg['g'], frame_bg['b'])
            else:
                frame_bg = "No background color specified"
        
            output.append([parsed, [
                int(frame['absoluteBoundingBox']['width']),
                int(frame['absoluteBoundingBox']['height']),
                frame_bg,
                result['name'].replace('\n', '\\n'),
                frame_count,
                entry_placeholder,
                text_placeholder
            ]])

        threads = []
        for frame in frames:
            if frame["type"] == "FRAME":
                thread = threading.Thread(target=parse_frame, args=(frame, frame_count,))
                threads.append(thread)
                thread.start()
                frame_count = frame_count + 1;

        for thread in threads:
            thread.join()

    except KeyError as e:
        print(f"KeyError: {str(e)} - likely due to missing keys in JSON response")
    
    return output

Seems to work, so thought I'd share.

Probably don't need the print statements, but maybe leave on if a --verbose flag is triggered or something? Just an idea.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions