Skip to content

SVG Tiger

This example renders the classic colored tiger from prepared SVG paths.

Preview

SVG Tiger

Open the live WebGPU example.

Run And Adapt

Commands below assume a Datoviz source checkout and start at the repository root. Use your configured build environment; Python routes additionally require local bindings.

Route Availability Command or action
C Canonical native source just example-c showcases/svg_tiger (build and run), or rerun ./build/examples/c/showcases/svg_tiger
Python Available python3 -m examples.python.gallery.showcases.svg_tiger
SVG preparation tool Additional integration source; check optional dependencies python3 -m tools.data.prepare_svg_tiger
Browser Live WebGPU route Open live example

Prepared data required

This example intentionally fails when its prepared input is absent; it does not substitute synthetic data. Expected input: .cache/datoviz/examples/svg_tiger/prepared. Prepare it from the repository root with python3 tools/data/prepare_svg_tiger.py --download.

Use this example as capability or integration evidence, not as a minimal copy-paste template. Start from the nearest supported, copy-safe example and add this feature after verifying the linked API reference.

What To Look For

Solid path fills, black outlines, thin whiskers, and document paint order are reproduced with one merged mesh plus one path visual. Cubic curves are flattened by the preparation script; Datoviz performs the final polygon triangulation at runtime.

The artwork comes from Nicolas P. Rougier's Glumpy example gallery. The published Datoviz data bundle contains prepared paths from a pinned Glumpy source revision; regenerate them with:

python3 tools/data/prepare_svg_tiger.py --download

Source

#!/usr/bin/env python3
"""Classic SVG Tiger rendered from the prepared path bundle."""

from __future__ import annotations

import ctypes
import struct
from pathlib import Path

import numpy as np

import datoviz as dvz
from examples.python.gallery import common as ex


PATHS = (Path("data/examples/svg_tiger/prepared/tiger_paths.bin"), Path(".cache/datoviz/examples/svg_tiger/prepared/tiger_paths.bin"))
HEADER = struct.Struct("<8sIIIIdd4d")
RECORD = np.dtype(
    [("offset", "<u4"), ("count", "<u4"), ("closed", "u1"), ("fill", "u1"), ("stroke", "u1"), ("reserved", "u1"), ("fill_rgba", "u1", 4), ("stroke_rgba", "u1", 4), ("width", "<f4"), ("order", "<u4")]
)


def _load():
    path = next((candidate for candidate in PATHS if candidate.exists()), None)
    if path is None:
        raise FileNotFoundError("missing SVG Tiger bundle; run `python3 tools/data/prepare_svg_tiger.py --download`")
    payload = path.read_bytes()
    magic, version, path_count, point_count, record_size, width, height, *bounds = HEADER.unpack_from(payload)
    if magic != b"DVZSVG1\0" or version != 2 or record_size != RECORD.itemsize:
        raise ValueError(f"invalid SVG Tiger bundle: {path}")
    offset = HEADER.size
    records = np.frombuffer(payload, RECORD, path_count, offset).copy()
    offset += path_count * RECORD.itemsize
    points = np.frombuffer(payload, "<f8", 2 * point_count, offset).reshape(-1, 2).copy()
    if offset + points.nbytes != len(payload):
        raise ValueError(f"unexpected SVG Tiger bundle size: {path}")
    return records, points, float(width), float(height), bounds


def _polygon_desc(points: np.ndarray):
    points = np.ascontiguousarray(points, dtype=np.float64)
    desc = dvz.dvz_polygon_desc()
    desc.outer.xy = ctypes.c_void_p(points.ctypes.data)
    desc.outer.count = len(points)
    return desc, points


def _set_view(panel, width: float, height: float) -> None:
    desc = dvz.dvz_panel_view2d_desc()
    desc.mode = dvz.DVZ_PANEL_VIEW2D_CONTAIN
    desc.aspect = dvz.DVZ_PANEL_VIEW2D_ASPECT_EQUAL
    desc.padding = 0.02
    desc.domain_x[:] = (0.0, width)
    desc.domain_y[:] = (0.0, height)
    desc.has_domain_x = desc.has_domain_y = True
    if dvz.dvz_panel_set_view2d(panel, ctypes.byref(desc)) != 0:
        raise RuntimeError("dvz_panel_set_view2d() failed")


def _build_scene():
    records, points, width, height, _bounds = _load()
    scene, figure, panel = ex.scene_panel()
    _set_view(panel, width, height)

    closed = records[(records["closed"] != 0) & (records["count"] >= 3)]
    polygons = dvz.dvz_polygons(scene, 0)
    if not polygons:
        raise RuntimeError("dvz_polygons() failed")
    keepalive = []
    for record in closed:
        ring = points[int(record["offset"]): int(record["offset"] + record["count"])].copy()
        ring[:, 1] = height - ring[:, 1]
        desc, ring = _polygon_desc(ring)
        keepalive.append(ring)
        if dvz.dvz_polygons_add_region(polygons, ctypes.byref(desc)) == 0xFFFFFFFF:
            raise RuntimeError("dvz_polygons_add_region() failed")

    fills = (dvz.DvzColor * len(closed))(*(dvz.DvzColor(*map(int, rgba)) for rgba in closed["fill_rgba"]))
    strokes = (dvz.DvzColor * len(closed))(*(dvz.DvzColor(*map(int, rgba)) for rgba in closed["stroke_rgba"]))
    widths = (ctypes.c_float * len(closed))(*(float(value) for value in closed["width"]))
    dvz.dvz_polygons_set_region_fill_colors(polygons, 0, len(closed), fills)
    dvz.dvz_polygons_set_region_stroke_colors(polygons, 0, len(closed), strokes)
    dvz.dvz_polygons_set_region_stroke_widths_px(polygons, 0, len(closed), widths)
    dvz.dvz_polygons_set_stroke_join(polygons, dvz.DVZ_PATH_JOIN_MITER, 4.0)
    composite = dvz.dvz_polygons_composite(polygons, 0)
    attach = dvz.dvz_visual_attach_desc()
    attach.coord_space = dvz.DVZ_VISUAL_COORD_DATA
    if dvz.dvz_panel_add_composite(panel, composite, ctypes.byref(attach)) != 0:
        raise RuntimeError("dvz_panel_add_composite() failed")

    open_records = records[(records["closed"] == 0) & (records["stroke"] != 0) & (records["count"] >= 2)]
    if len(open_records):
        positions, colors = [], []
        for record in open_records:
            xy = points[int(record["offset"]): int(record["offset"] + record["count"])].copy()
            xyz = np.column_stack((xy[:, 0], height - xy[:, 1], np.full(len(xy), 0.001 * int(record["order"]))))
            positions.append(np.stack((xyz[:-1], xyz[1:]), axis=1).reshape(-1, 3))
            colors.append(np.repeat(record["stroke_rgba"][None, :], 2 * (len(xy) - 1), axis=0))
        whiskers = dvz.dvz_primitive(scene, dvz.DVZ_PRIMITIVE_TOPOLOGY_LINE_LIST, 0)
        dvz.dvz_visual_set_data_many(whiskers, {"position": np.vstack(positions).astype(np.float32), "color": np.vstack(colors).astype(np.uint8)})
        ex.add_visual(panel, whiskers)
    return scene, figure, panel, len(records), len(points)


def main() -> None:
    scene, figure, panel, path_count, point_count = _build_scene()
    print(f"svg_tiger: {path_count} paths, {point_count} flattened points")

    def configure(view) -> None:
        desc = dvz.dvz_panzoom_desc()
        desc.controller_flags = dvz.DVZ_PANZOOM_FLAGS_KEEP_ASPECT
        controller = dvz.dvz_panzoom(scene, ctypes.byref(desc))
        if not controller or dvz.dvz_view_bind_controller(view, panel, controller, dvz.DVZ_DIM_MASK_XY) != 0:
            raise RuntimeError("panzoom setup failed")

    ex.run_with_view(scene, figure, "SVG Tiger", configure)


if __name__ == "__main__":
    main()
/*
 * Copyright (c) 2021 Cyrille Rossant and contributors. All rights reserved.
 * Licensed under the MIT license. See LICENSE file in the project root for details.
 * SPDX-License-Identifier: MIT
 */

/* svg_tiger - This example renders the classic colored tiger from prepared SVG paths.
 *
 * What to look for: solid path fills, black outlines, thin whiskers, and document paint order are
 * reproduced with one merged mesh plus one retained path visual. Cubic curves are flattened by the
 * preparation script; Datoviz performs the final polygon triangulation at runtime.
 *
 * The artwork comes from Nicolas P. Rougier's Glumpy example gallery. The published Datoviz data
 * bundle contains prepared paths from a pinned Glumpy source revision; regenerate them with:
 *
 *   python3 tools/data/prepare_svg_tiger.py --download
 *
 * Scenario: showcases_svg_tiger
 * Style: showcase, source artwork colors, shared graphite-cyan background
 *
 * Build:  just example-c showcases/svg_tiger
 * Run:    ./build/examples/c/showcases/svg_tiger --live
 * Smoke:  ./build/examples/c/showcases/svg_tiger --png
 */



/*************************************************************************************************/
/*  Includes                                                                                     */
/*************************************************************************************************/

#include <stdbool.h>
#include <stdint.h>

#include "_assertions.h"
#include "datoviz/controller/panzoom.h"
#include "datoviz/scene.h"
#include "example_common.h"
#include "runner/scenario_runner.h"
#include "svg_tiger_model.h"



/*************************************************************************************************/
/*  Constants                                                                                    */
/*************************************************************************************************/

#define WIDTH  EXAMPLE_WINDOW_WIDTH
#define HEIGHT EXAMPLE_WINDOW_HEIGHT

#define SVG_TIGER_DATA_PATH  "data/examples/svg_tiger/prepared/tiger_paths.bin"
#define SVG_TIGER_CACHE_PATH ".cache/datoviz/examples/svg_tiger/prepared/tiger_paths.bin"



/*************************************************************************************************/
/*  Forward declarations                                                                         */
/*************************************************************************************************/

DvzScenarioSpec dvz_showcase_svg_tiger_scenario(void);



/*************************************************************************************************/
/*  Helpers                                                                                      */
/*************************************************************************************************/

/**
 * Print the exact cache preparation command.
 */
static void _print_prepare_hint(void)
{
    dvz_fprintf(
        stderr, "svg_tiger: missing prepared data at %s or %s\n"
                "Run `python3 tools/data/prepare_svg_tiger.py --download` from the repository "
                "root.\n",
        SVG_TIGER_DATA_PATH, SVG_TIGER_CACHE_PATH);
}



/**
 * Configure the shared equal-aspect showcase viewport and native MSAA.
 *
 * @param panel target panel
 * @param data loaded SVG document metadata
 * @return whether panel configuration succeeded
 */
static bool _configure_panel(DvzPanel* panel, const SvgTigerData* data)
{
    ANN(panel);
    ANN(data);
    if (!example_configure_equal_aspect_panel(
            panel, (DvzDataDomain){.min = 0.0, .max = data->width},
            (DvzDataDomain){.min = 0.0, .max = data->height}, 0.0))
    {
        return false;
    }
#ifndef DVZ_EXAMPLE_NO_APP
    DvzMsaaDesc msaa = dvz_msaa_desc();
    msaa.enabled = true;
    return dvz_panel_set_msaa(panel, &msaa) == 0;
#else
    return true;
#endif
}



/**
 * Add the merged, flat-colored SVG fill mesh.
 *
 * @param scene scene owning the visual
 * @param panel target panel
 * @param data loaded SVG paths
 * @return whether the fill mesh was added
 */
static bool _add_fills(DvzScene* scene, DvzPanel* panel, const SvgTigerData* data)
{
    ANN(scene);
    ANN(panel);
    ANN(data);

    DvzGeometry* geometry = svg_tiger_fill_geometry(data);
    if (geometry == NULL)
        return false;
    DvzVisual* mesh = dvz_mesh(scene, 0);
    if (mesh == NULL)
    {
        dvz_geometry_destroy(geometry);
        return false;
    }

    DvzMaterialDesc material = dvz_phong_material_desc();
    material.phong.ambient = 1.0f;
    material.phong.diffuse = 0.0f;
    material.phong.specular = 0.0f;
    int rc = dvz_visual_set_material(mesh, &material);
    if (rc == 0)
        rc = dvz_mesh_set_geometry(mesh, geometry);
    if (rc == 0)
        rc = dvz_visual_set_depth_test(mesh, true);
    if (rc == 0)
        rc = dvz_panel_add_visual(panel, mesh, NULL);
    dvz_geometry_destroy(geometry);
    return rc == 0;
}



/**
 * Add all SVG outlines and open whisker paths as one retained path visual.
 *
 * @param scene scene owning the visual
 * @param panel target panel
 * @param data loaded SVG paths
 * @return whether the stroke visual was added
 */
static bool _add_strokes(DvzScene* scene, DvzPanel* panel, const SvgTigerData* data)
{
    ANN(scene);
    ANN(panel);
    ANN(data);

    SvgTigerStrokeData stroke = {0};
    if (!svg_tiger_stroke_data(data, &stroke))
        return false;
    DvzVisual* path = dvz_path(scene, 0);
    if (path == NULL)
    {
        svg_tiger_stroke_destroy(&stroke);
        return false;
    }

    const DvzVisualDataUpdate updates[3] = {
        {.attr_name = "position", .data = stroke.positions, .item_count = stroke.point_count},
        {.attr_name = "color", .data = stroke.colors, .item_count = stroke.point_count},
        {.attr_name = "stroke_width_px", .data = stroke.widths, .item_count = stroke.point_count},
    };
    int rc = dvz_visual_set_data_many(path, updates, 3);
    if (rc == 0)
        rc = dvz_path_set_subpaths(path, stroke.subpath_count, stroke.lengths);
    if (rc == 0)
        rc = dvz_path_set_caps(path, DVZ_SEGMENT_CAP_BUTT, DVZ_SEGMENT_CAP_BUTT);
    if (rc == 0)
        rc = dvz_path_set_join(path, DVZ_PATH_JOIN_MITER, 4.0f);
    if (rc == 0)
        rc = dvz_visual_set_depth_test(path, true);
    if (rc == 0)
        rc = dvz_panel_add_visual(panel, path, NULL);
    svg_tiger_stroke_destroy(&stroke);
    return rc == 0;
}



/**
 * Initialize the prepared SVG tiger scenario.
 *
 * @param ctx scenario context
 * @param out_user unused user-state output
 * @return whether initialization succeeded
 */
static bool _scenario_init(DvzScenarioContext* ctx, void** out_user)
{
    if (ctx == NULL)
        return false;
    if (out_user != NULL)
        *out_user = NULL;

    SvgTigerData data = {0};
    if (!svg_tiger_load(SVG_TIGER_DATA_PATH, &data) &&
        !svg_tiger_load(SVG_TIGER_CACHE_PATH, &data))
    {
        _print_prepare_hint();
        return false;
    }

    bool ok = false;
    ctx->figure = dvz_figure(ctx->scene, ctx->width, ctx->height, 0);
    EXAMPLE_CHECK(ctx->figure != NULL, "dvz_figure() failed");
    DvzPanel* panel = dvz_panel(ctx->figure, &(DvzPanelDesc){0.0f, 0.0f, 1.0f, 1.0f});
    EXAMPLE_CHECK(panel != NULL, "dvz_panel() failed");
    EXAMPLE_CHECK(_configure_panel(panel, &data), "SVG tiger panel configuration failed");
    EXAMPLE_CHECK(_add_fills(ctx->scene, panel, &data), "SVG tiger fill setup failed");
    EXAMPLE_CHECK(_add_strokes(ctx->scene, panel, &data), "SVG tiger stroke setup failed");

    DvzPanzoomDesc panzoom_desc = dvz_panzoom_desc();
    panzoom_desc.controller_flags = DVZ_PANZOOM_FLAGS_KEEP_ASPECT;
    DvzPanzoom* panzoom = dvz_scenario_panzoom(ctx, panel, &panzoom_desc, DVZ_DIM_MASK_XY);
    EXAMPLE_CHECK(panzoom != NULL, "SVG tiger panzoom setup failed");
    (void)panzoom;

    dvz_fprintf(
        stdout, "svg_tiger: %u paths, %u flattened points\n", data.path_count, data.point_count);
    ok = true;

cleanup:
    svg_tiger_destroy(&data);
    return ok;
}



/*************************************************************************************************/
/*  Functions                                                                                    */
/*************************************************************************************************/

DvzScenarioSpec dvz_showcase_svg_tiger_scenario(void)
{
    return (DvzScenarioSpec){
        .id = "showcases_svg_tiger",
        .title = "SVG Tiger",
        .width = WIDTH,
        .height = HEIGHT,
        .fps = 60.0,
        .requirements = DVZ_SCENARIO_REQ_MESH_VISUAL | DVZ_SCENARIO_REQ_CONTROLLER |
                        DVZ_SCENARIO_REQ_PANZOOM,
        .init = _scenario_init,
    };
}



#ifndef DVZ_EXAMPLE_NO_MAIN
int main(int argc, char** argv)
{
    DvzScenarioSpec spec = dvz_showcase_svg_tiger_scenario();
    return dvz_scenario_run_native_cli(&spec, argc, argv) == 0 ? 0 : 1;
}
#endif
/*
 * Copyright (c) 2021 Cyrille Rossant and contributors. All rights reserved.
 * Licensed under the MIT license. See LICENSE file in the project root for details.
 * SPDX-License-Identifier: MIT
 */

/*************************************************************************************************/
/*  SVG tiger prepared-model helpers                                                             */
/*************************************************************************************************/


/*************************************************************************************************/
/*  Includes                                                                                     */
/*************************************************************************************************/

#include "svg_tiger_model.h"

#include <math.h>
#include <stdio.h>
#include <string.h>

#include "_alloc.h"
#include "_compat.h"
#include "_overflow.h"



/*************************************************************************************************/
/*  Constants                                                                                    */
/*************************************************************************************************/

#define SVG_TIGER_MAGIC       "DVZSVG1"
#define SVG_TIGER_MAGIC_SIZE  8u
#define SVG_TIGER_VERSION     2u
#define SVG_TIGER_RECORD_SIZE 28u
#define SVG_TIGER_MAX_PATHS   10000u
#define SVG_TIGER_MAX_POINTS  10000000u

#define SVG_TIGER_Z_STEP         0.0025f
#define SVG_TIGER_STROKE_Z_BIAS  0.00075f



/*************************************************************************************************/
/*  Structs                                                                                      */
/*************************************************************************************************/

typedef struct SvgTigerFileHeader
{
    char magic[SVG_TIGER_MAGIC_SIZE];
    uint32_t version;
    uint32_t path_count;
    uint32_t point_count;
    uint32_t record_size;
    double width;
    double height;
    double bounds[4];
} SvgTigerFileHeader;


typedef struct SvgTigerFilePath
{
    uint32_t point_offset;
    uint32_t point_count;
    uint8_t closed;
    uint8_t has_fill;
    uint8_t has_stroke;
    uint8_t reserved;
    DvzColor fill;
    DvzColor stroke;
    float stroke_width_px;
    uint32_t paint_order;
} SvgTigerFilePath;


static_assert(sizeof(SvgTigerFileHeader) == 72, "SVG tiger file header layout changed");
static_assert(sizeof(SvgTigerFilePath) == SVG_TIGER_RECORD_SIZE, "SVG tiger path layout changed");



/*************************************************************************************************/
/*  Helpers                                                                                      */
/*************************************************************************************************/

/**
 * Return whether a file header is valid and bounded.
 *
 * @param header file header
 * @return whether the header may be loaded safely
 */
static bool _header_valid(const SvgTigerFileHeader* header)
{
    if (
        header == NULL ||
        memcmp(header->magic, SVG_TIGER_MAGIC, SVG_TIGER_MAGIC_SIZE) != 0 ||
        header->version != SVG_TIGER_VERSION || header->record_size != SVG_TIGER_RECORD_SIZE ||
        header->path_count == 0 || header->path_count > SVG_TIGER_MAX_PATHS ||
        header->point_count == 0 || header->point_count > SVG_TIGER_MAX_POINTS ||
        !(header->width > 0.0) || !(header->height > 0.0) || !isfinite(header->width) ||
        !isfinite(header->height))
    {
        return false;
    }
    for (uint32_t i = 0; i < 4; i++)
    {
        if (!isfinite(header->bounds[i]))
            return false;
    }
    return header->bounds[0] <= header->bounds[2] && header->bounds[1] <= header->bounds[3];
}



/**
 * Return whether one path record refers to valid points and paint state.
 *
 * @param path path record
 * @param point_count total point count
 * @param path_count total path count
 * @return whether the record is valid
 */
static bool _path_valid(
    const SvgTigerFilePath* path, uint32_t point_count, uint32_t path_count)
{
    if (
        path == NULL || path->closed > 1 || path->has_fill > 1 || path->has_stroke > 1 ||
        path->reserved != 0 || !isfinite(path->stroke_width_px) || path->stroke_width_px < 0.0f ||
        path->paint_order >= path_count)
    {
        return false;
    }
    uint64_t end = 0;
    return path->point_count > 0 &&
           !_dvz_add_u64_overflows(path->point_offset, path->point_count, &end) &&
           end <= point_count;
}



/**
 * Return the visual Z coordinate for one authored path.
 *
 * @param path source path
 * @return Z coordinate preserving document paint order
 */
static float _path_z(const SvgTigerPath* path)
{
    return SVG_TIGER_Z_STEP * (float)path->paint_order;
}



/**
 * Destroy an array of owned geometry parts.
 *
 * @param parts geometry pointer array
 * @param count populated geometry count
 */
static void _geometry_parts_destroy(DvzGeometry** parts, uint32_t count)
{
    if (parts == NULL)
        return;
    for (uint32_t i = 0; i < count; i++)
        dvz_geometry_destroy(parts[i]);
    dvz_free(parts);
}



/*************************************************************************************************/
/*  Functions                                                                                    */
/*************************************************************************************************/

/**
 * Load one prepared SVG tiger bundle.
 *
 * @param path prepared binary path
 * @param out output retained path data
 * @return whether the file was loaded and validated
 */
bool svg_tiger_load(const char* path, SvgTigerData* out)
{
    if (path == NULL || out == NULL)
        return false;
    dvz_memset(out, sizeof(*out), 0, sizeof(*out));

    FILE* fp = fopen(path, "rb");
    if (fp == NULL)
        return false;

    bool ok = false;
    SvgTigerFileHeader header = {0};
    SvgTigerFilePath* file_paths = NULL;
    if (fread(&header, sizeof(header), 1, fp) != 1 || !_header_valid(&header))
        goto cleanup;

    out->paths = (SvgTigerPath*)dvz_calloc(header.path_count, sizeof(*out->paths));
    out->points = (dvec2*)dvz_calloc(header.point_count, sizeof(*out->points));
    file_paths = (SvgTigerFilePath*)dvz_calloc(header.path_count, sizeof(*file_paths));
    if (out->paths == NULL || out->points == NULL || file_paths == NULL)
        goto cleanup;
    if (fread(file_paths, sizeof(*file_paths), header.path_count, fp) != header.path_count ||
        fread(out->points, sizeof(*out->points), header.point_count, fp) != header.point_count)
    {
        goto cleanup;
    }
    if (fgetc(fp) != EOF)
        goto cleanup;

    for (uint32_t i = 0; i < header.path_count; i++)
    {
        const SvgTigerFilePath* source = &file_paths[i];
        if (
            !_path_valid(source, header.point_count, header.path_count) ||
            source->paint_order != i)
            goto cleanup;
        out->paths[i] = (SvgTigerPath){
            .point_offset = source->point_offset,
            .point_count = source->point_count,
            .closed = source->closed != 0,
            .has_fill = source->has_fill != 0,
            .has_stroke = source->has_stroke != 0,
            .fill = source->fill,
            .stroke = source->stroke,
            .stroke_width_px = source->stroke_width_px,
            .paint_order = source->paint_order,
        };
    }
    for (uint32_t i = 0; i < header.point_count; i++)
    {
        if (!isfinite(out->points[i][0]) || !isfinite(out->points[i][1]))
            goto cleanup;
    }

    out->path_count = header.path_count;
    out->point_count = header.point_count;
    out->width = header.width;
    out->height = header.height;
    for (uint32_t i = 0; i < 4; i++)
        out->bounds[i] = header.bounds[i];
    ok = true;

cleanup:
    dvz_free(file_paths);
    fclose(fp);
    if (!ok)
        svg_tiger_destroy(out);
    return ok;
}



/**
 * Destroy loaded SVG tiger path data.
 *
 * @param data data to reset
 */
void svg_tiger_destroy(SvgTigerData* data)
{
    if (data == NULL)
        return;
    dvz_free(data->paths);
    dvz_free(data->points);
    dvz_memset(data, sizeof(*data), 0, sizeof(*data));
}



/**
 * Triangulate and merge authored fills while preserving paint order in Z.
 *
 * @param data loaded SVG tiger paths
 * @return owned merged geometry, or NULL on failure
 */
DvzGeometry* svg_tiger_fill_geometry(const SvgTigerData* data)
{
    if (data == NULL || data->paths == NULL || data->points == NULL || data->path_count == 0)
        return NULL;

    DvzGeometry** parts = (DvzGeometry**)dvz_calloc(data->path_count, sizeof(*parts));
    if (parts == NULL)
        return NULL;

    uint32_t part_count = 0;
    for (uint32_t i = 0; i < data->path_count; i++)
    {
        const SvgTigerPath* path = &data->paths[i];
        if (!path->has_fill || path->fill.a == 0 || path->point_count < 3)
            continue;

        dvec2* ring = (dvec2*)dvz_calloc(path->point_count, sizeof(*ring));
        if (ring == NULL)
            goto error;
        for (uint32_t j = 0; j < path->point_count; j++)
        {
            const double* point = data->points[path->point_offset + j];
            ring[j][0] = point[0];
            ring[j][1] = data->height - point[1];
        }

        DvzGeometry* geometry = dvz_triangulate_polygon(
            &(DvzPolygonDesc){
                DVZ_STRUCT_INIT_FIELDS(DvzPolygonDesc),
                .outer = {.xy = (const dvec2*)ring, .count = path->point_count},
            },
            NULL);
        dvz_free(ring);
        if (geometry == NULL)
        {
            dvz_fprintf(stderr, "svg_tiger: triangulation failed for path %u\n", i);
            goto error;
        }

        const float z = _path_z(path);
        for (uint32_t j = 0; j < geometry->vertex_count; j++)
        {
            geometry->positions[j][2] = z;
            geometry->colors[j] = path->fill;
        }
        parts[part_count++] = geometry;
    }
    if (part_count == 0)
        goto error;

    DvzGeometry* merged = dvz_geometry_merge(part_count, (const DvzGeometry* const*)parts);
    _geometry_parts_destroy(parts, part_count);
    return merged;

error:
    _geometry_parts_destroy(parts, part_count);
    return NULL;
}



/**
 * Build flattened stroke arrays and explicit subpath lengths.
 *
 * @param data loaded SVG tiger paths
 * @param out output stroke arrays
 * @return whether stroke arrays were built
 */
bool svg_tiger_stroke_data(const SvgTigerData* data, SvgTigerStrokeData* out)
{
    if (data == NULL || out == NULL || data->paths == NULL || data->points == NULL)
        return false;
    dvz_memset(out, sizeof(*out), 0, sizeof(*out));

    uint64_t point_count = 0;
    uint64_t subpath_count = 0;
    for (uint32_t i = 0; i < data->path_count; i++)
    {
        const SvgTigerPath* path = &data->paths[i];
        if (!path->has_stroke || path->stroke.a == 0 || path->point_count < 2)
            continue;
        uint64_t next = 0;
        if (_dvz_add_u64_overflows(
                point_count, path->point_count + (path->closed ? 1u : 0u), &next))
            return false;
        point_count = next;
        subpath_count++;
    }
    if (point_count == 0 || point_count > UINT32_MAX || subpath_count > UINT32_MAX)
        return false;

    out->positions = (vec3*)dvz_calloc((uint32_t)point_count, sizeof(*out->positions));
    out->colors = (DvzColor*)dvz_calloc((uint32_t)point_count, sizeof(*out->colors));
    out->widths = (float*)dvz_calloc((uint32_t)point_count, sizeof(*out->widths));
    out->lengths = (uint32_t*)dvz_calloc((uint32_t)subpath_count, sizeof(*out->lengths));
    if (
        out->positions == NULL || out->colors == NULL || out->widths == NULL ||
        out->lengths == NULL)
    {
        svg_tiger_stroke_destroy(out);
        return false;
    }

    uint32_t point_offset = 0;
    uint32_t subpath = 0;
    for (uint32_t i = 0; i < data->path_count; i++)
    {
        const SvgTigerPath* path = &data->paths[i];
        if (!path->has_stroke || path->stroke.a == 0 || path->point_count < 2)
            continue;
        const uint32_t length = path->point_count + (path->closed ? 1u : 0u);
        out->lengths[subpath++] = length;
        const float z = _path_z(path) + SVG_TIGER_STROKE_Z_BIAS;
        for (uint32_t j = 0; j < length; j++)
        {
            const uint32_t source_index = j < path->point_count ? j : 0;
            const double* point = data->points[path->point_offset + source_index];
            const uint32_t target = point_offset + j;
            out->positions[target][0] = (float)point[0];
            out->positions[target][1] = (float)(data->height - point[1]);
            out->positions[target][2] = z;
            out->colors[target] = path->stroke;
            out->widths[target] = path->stroke_width_px;
        }
        point_offset += length;
    }
    out->point_count = point_offset;
    out->subpath_count = subpath;
    if (point_offset != point_count || subpath != subpath_count)
    {
        svg_tiger_stroke_destroy(out);
        return false;
    }
    return true;
}



/**
 * Destroy flattened stroke arrays.
 *
 * @param data stroke arrays to reset
 */
void svg_tiger_stroke_destroy(SvgTigerStrokeData* data)
{
    if (data == NULL)
        return;
    dvz_free(data->positions);
    dvz_free(data->colors);
    dvz_free(data->widths);
    dvz_free(data->lengths);
    dvz_memset(data, sizeof(*data), 0, sizeof(*data));
}
/*
 * Copyright (c) 2021 Cyrille Rossant and contributors. All rights reserved.
 * Licensed under the MIT license. See LICENSE file in the project root for details.
 * SPDX-License-Identifier: MIT
 */

/*************************************************************************************************/
/*  SVG tiger prepared-model helpers                                                             */
/*************************************************************************************************/

#pragma once



/*************************************************************************************************/
/*  Includes                                                                                     */
/*************************************************************************************************/

#include <stdbool.h>
#include <stdint.h>

#include "datoviz/geom.h"



/*************************************************************************************************/
/*  Structs                                                                                      */
/*************************************************************************************************/

typedef struct SvgTigerPath
{
    uint32_t point_offset;
    uint32_t point_count;
    bool closed;
    bool has_fill;
    bool has_stroke;
    DvzColor fill;
    DvzColor stroke;
    float stroke_width_px;
    uint32_t paint_order;
} SvgTigerPath;


typedef struct SvgTigerData
{
    SvgTigerPath* paths;
    dvec2* points;
    uint32_t path_count;
    uint32_t point_count;
    double width;
    double height;
    double bounds[4];
} SvgTigerData;


typedef struct SvgTigerStrokeData
{
    vec3* positions;
    DvzColor* colors;
    float* widths;
    uint32_t* lengths;
    uint32_t point_count;
    uint32_t subpath_count;
} SvgTigerStrokeData;



/*************************************************************************************************/
/*  Functions                                                                                    */
/*************************************************************************************************/

bool svg_tiger_load(const char* path, SvgTigerData* out);

void svg_tiger_destroy(SvgTigerData* data);

DvzGeometry* svg_tiger_fill_geometry(const SvgTigerData* data);

bool svg_tiger_stroke_data(const SvgTigerData* data, SvgTigerStrokeData* out);

void svg_tiger_stroke_destroy(SvgTigerStrokeData* data);
#!/usr/bin/env python3
"""Flatten the pinned Glumpy tiger SVG into a compact C-readable cache bundle."""

from __future__ import annotations

import argparse
import hashlib
import json
import math
import re
import struct
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence

try:
    from .common import CACHE_ROOT
except ImportError:
    from common import CACHE_ROOT


EXAMPLE_ID = "svg_tiger"
DEFAULT_OUTPUT = CACHE_ROOT / EXAMPLE_ID
SOURCE_COMMIT = "aedb9212a1e00a68b7c4669405a6a8f754daf283"
SOURCE_URL = (
    "https://raw.githubusercontent.com/glumpy/glumpy/"
    f"{SOURCE_COMMIT}/glumpy/data/tiger.svg"
)
SOURCE_BYTES = 110724
SOURCE_SHA256 = "1bc237707ae0523f0e2115917626bfaceb24bc4a388b5ef659b5604b311ff537"

MAGIC = b"DVZSVG1\0"
VERSION = 2
HEADER = struct.Struct("<8sIIII6d")
PATH_RECORD = struct.Struct("<II4B4B4BfI")
POINT = struct.Struct("<dd")

EXPECTED_PATH_COUNT = 240
EXPECTED_CLOSED_COUNT = 227
EXPECTED_OPEN_COUNT = 13

_NUMBER = r"[-+]?(?:\d+\.?(?:\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
_TOKEN_RE = re.compile(rf"[MmLlCcZz]|{_NUMBER}")
_TRANSFORM_RE = re.compile(r"\s*matrix\s*\(([^)]*)\)\s*")
_SUPPORTED_PATH_COMMANDS = frozenset("MmLlCcZz")
_IGNORED_ELEMENTS = frozenset({"metadata", "defs", "namedview", "title", "desc"})

Matrix = tuple[float, float, float, float, float, float]
Point = tuple[float, float]


class SvgTigerError(ValueError):
    """Raised when the input exceeds the intentionally narrow SVG subset."""


@dataclass(frozen=True)
class Paint:
    """Resolved solid fill and stroke paint for one SVG path."""

    fill: tuple[int, int, int, int] | None
    stroke: tuple[int, int, int, int] | None
    stroke_width: float


@dataclass(frozen=True)
class FlatPath:
    """One flattened SVG path in document coordinates."""

    points: tuple[Point, ...]
    closed: bool
    paint: Paint
    paint_order: int


@dataclass(frozen=True)
class SvgDocument:
    """Flattened path document plus its declared viewport."""

    width: float
    height: float
    paths: tuple[FlatPath, ...]


def _local_name(tag: str) -> str:
    """Return an XML element name without its namespace."""
    return tag.rsplit("}", 1)[-1]


def _sha256(path: Path) -> str:
    """Return the SHA-256 digest of one file."""
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _parse_style(text: str | None) -> dict[str, str]:
    """Parse one CSS-style declaration list into a property mapping."""
    out: dict[str, str] = {}
    if not text:
        return out
    for declaration in text.split(";"):
        if not declaration.strip():
            continue
        if ":" not in declaration:
            raise SvgTigerError(f"invalid style declaration: {declaration!r}")
        key, value = declaration.split(":", 1)
        out[key.strip()] = value.strip()
    return out


def _element_style(element: ET.Element, inherited: dict[str, str]) -> dict[str, str]:
    """Resolve inherited presentation properties for one element."""
    style = dict(inherited)
    style.update(_parse_style(element.get("style")))
    for key in (
        "fill",
        "stroke",
        "stroke-width",
        "fill-opacity",
        "stroke-opacity",
        "opacity",
        "fill-rule",
        "stroke-linecap",
        "stroke-linejoin",
    ):
        value = element.get(key)
        if value is not None:
            style[key] = value
    return style


def _opacity(style: dict[str, str], key: str) -> float:
    """Return a finite clamped opacity property."""
    try:
        value = float(style.get(key, "1"))
    except ValueError as exc:
        raise SvgTigerError(f"invalid {key}: {style.get(key)!r}") from exc
    if not math.isfinite(value):
        raise SvgTigerError(f"non-finite {key}")
    return min(1.0, max(0.0, value))


def _color(value: str, alpha: float) -> tuple[int, int, int, int] | None:
    """Parse a narrow solid SVG color value."""
    value = value.strip().lower()
    if value == "none":
        return None
    named = {"black": "#000000", "white": "#ffffff"}
    value = named.get(value, value)
    if re.fullmatch(r"#[0-9a-f]{3}", value):
        value = "#" + "".join(channel * 2 for channel in value[1:])
    if not re.fullmatch(r"#[0-9a-f]{6}", value):
        raise SvgTigerError(f"unsupported solid color: {value!r}")
    rgb = tuple(int(value[i : i + 2], 16) for i in (1, 3, 5))
    return rgb + (round(255 * alpha),)


def _paint(style: dict[str, str]) -> Paint:
    """Build resolved solid paint from inherited style properties."""
    opacity = _opacity(style, "opacity")
    fill = _color(style.get("fill", "black"), opacity * _opacity(style, "fill-opacity"))
    stroke = _color(style.get("stroke", "none"), opacity * _opacity(style, "stroke-opacity"))
    try:
        stroke_width = float(style.get("stroke-width", "1"))
    except ValueError as exc:
        raise SvgTigerError(f"invalid stroke width: {style.get('stroke-width')!r}") from exc
    if not math.isfinite(stroke_width) or stroke_width < 0:
        raise SvgTigerError("stroke width must be finite and nonnegative")
    if style.get("fill-rule", "nonzero") not in {"nonzero", "evenodd"}:
        raise SvgTigerError(f"unsupported fill rule: {style['fill-rule']!r}")
    return Paint(fill=fill, stroke=stroke, stroke_width=stroke_width)


def _identity() -> Matrix:
    """Return the identity SVG affine transform."""
    return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)


def _multiply(left: Matrix, right: Matrix) -> Matrix:
    """Compose two SVG affine transforms as left times right."""
    a, b, c, d, e, f = left
    g, h, i, j, k, l = right
    return (
        a * g + c * h,
        b * g + d * h,
        a * i + c * j,
        b * i + d * j,
        a * k + c * l + e,
        b * k + d * l + f,
    )


def _parse_transform(text: str | None) -> Matrix:
    """Parse the matrix-only transform subset required by the tiger."""
    if text is None or not text.strip():
        return _identity()
    match = _TRANSFORM_RE.fullmatch(text)
    if match is None:
        raise SvgTigerError(f"unsupported transform: {text!r}")
    values = [float(value) for value in re.findall(_NUMBER, match.group(1))]
    if len(values) != 6 or not all(math.isfinite(value) for value in values):
        raise SvgTigerError(f"invalid affine matrix: {text!r}")
    return tuple(values)  # type: ignore[return-value]


def _transform(point: Point, matrix: Matrix) -> Point:
    """Apply one SVG affine transform to a point."""
    x, y = point
    a, b, c, d, e, f = matrix
    return (a * x + c * y + e, b * x + d * y + f)


def _point_line_distance(point: Point, start: Point, end: Point) -> float:
    """Return the perpendicular distance to an infinite chord or a degenerate endpoint."""
    dx = end[0] - start[0]
    dy = end[1] - start[1]
    length = math.hypot(dx, dy)
    if length == 0:
        return math.hypot(point[0] - start[0], point[1] - start[1])
    return abs(dx * (start[1] - point[1]) - (start[0] - point[0]) * dy) / length


def _midpoint(left: Point, right: Point) -> Point:
    """Return the midpoint of two points."""
    return ((left[0] + right[0]) * 0.5, (left[1] + right[1]) * 0.5)


def _flatten_cubic(
    p0: Point,
    p1: Point,
    p2: Point,
    p3: Point,
    tolerance: float,
    out: list[Point],
    depth: int = 0,
) -> None:
    """Append an adaptively flattened cubic endpoint sequence."""
    flatness = max(_point_line_distance(p1, p0, p3), _point_line_distance(p2, p0, p3))
    if flatness <= tolerance or depth >= 24:
        out.append(p3)
        return
    p01 = _midpoint(p0, p1)
    p12 = _midpoint(p1, p2)
    p23 = _midpoint(p2, p3)
    p012 = _midpoint(p01, p12)
    p123 = _midpoint(p12, p23)
    p0123 = _midpoint(p012, p123)
    _flatten_cubic(p0, p01, p012, p0123, tolerance, out, depth + 1)
    _flatten_cubic(p0123, p123, p23, p3, tolerance, out, depth + 1)


def _tokens(path_data: str) -> list[str]:
    """Tokenize path data and reject characters outside the supported grammar."""
    matches = list(_TOKEN_RE.finditer(path_data))
    residue = _TOKEN_RE.sub("", path_data)
    if residue.replace(",", "").strip():
        raise SvgTigerError(f"invalid SVG path syntax near {residue!r}")
    tokens = [match.group(0) for match in matches]
    for token in tokens:
        if token.isalpha() and token not in _SUPPORTED_PATH_COMMANDS:
            raise SvgTigerError(f"unsupported path command: {token}")
    return tokens


def _take_numbers(
    tokens: Sequence[str], index: int, count: int, command: str
) -> tuple[list[float], int]:
    """Read a fixed number of numeric path arguments."""
    if index + count > len(tokens) or any(tokens[i].isalpha() for i in range(index, index + count)):
        raise SvgTigerError(f"path command {command} has incomplete arguments")
    values = [float(tokens[i]) for i in range(index, index + count)]
    if not all(math.isfinite(value) for value in values):
        raise SvgTigerError(f"path command {command} has non-finite arguments")
    return values, index + count


def _deduplicate(points: Iterable[Point]) -> tuple[Point, ...]:
    """Remove consecutive duplicate points from a flattened contour."""
    out: list[Point] = []
    for point in points:
        if not out or point != out[-1]:
            out.append(point)
    if len(out) >= 2 and out[0] == out[-1]:
        out.pop()
    return tuple(out)


def _flatten_path(
    path_data: str, matrix: Matrix, tolerance: float
) -> tuple[tuple[Point, ...], bool]:
    """Parse and flatten one single-subpath SVG path."""
    tokens = _tokens(path_data)
    if not tokens:
        raise SvgTigerError("empty SVG path")

    index = 0
    command: str | None = None
    current = (0.0, 0.0)
    start: Point | None = None
    points: list[Point] = []
    closed = False
    moved = False

    while index < len(tokens):
        token = tokens[index]
        if token.isalpha():
            command = token
            index += 1
        if command is None:
            raise SvgTigerError("path data must begin with a command")

        relative = command.islower()
        upper = command.upper()
        if upper == "Z":
            if start is None:
                raise SvgTigerError("close command without a current subpath")
            closed = True
            current = start
            command = None
            if index != len(tokens):
                raise SvgTigerError("multiple subpaths in one path element are not supported")
            continue

        argument_count = {"M": 2, "L": 2, "C": 6}.get(upper)
        if argument_count is None:
            raise SvgTigerError(f"unsupported path command: {command}")
        values, index = _take_numbers(tokens, index, argument_count, command)

        origin = current if relative else (0.0, 0.0)
        if upper == "M":
            target = (values[0] + origin[0], values[1] + origin[1])
            if moved:
                raise SvgTigerError("multiple subpaths in one path element are not supported")
            current = target
            start = target
            points.append(_transform(target, matrix))
            moved = True
            command = "l" if relative else "L"
        elif upper == "L":
            if start is None:
                raise SvgTigerError("line command before move command")
            target = (values[0] + origin[0], values[1] + origin[1])
            current = target
            points.append(_transform(target, matrix))
        else:
            if start is None:
                raise SvgTigerError("cubic command before move command")
            p0 = _transform(current, matrix)
            p1 = _transform((values[0] + origin[0], values[1] + origin[1]), matrix)
            p2 = _transform((values[2] + origin[0], values[3] + origin[1]), matrix)
            target = (values[4] + origin[0], values[5] + origin[1])
            p3 = _transform(target, matrix)
            _flatten_cubic(p0, p1, p2, p3, tolerance, points)
            current = target

    flattened = _deduplicate(points)
    # Preserve degenerate authored paths in the document statistics and paint ordering. Glumpy does
    # the same and skips them only when building render collections; the tiger contains one such
    # one-point closed path (path242).
    if not flattened:
        raise SvgTigerError("path has no distinct flattened points")
    return flattened, closed


def parse_svg(path: Path, tolerance: float = 0.25) -> SvgDocument:
    """Parse the narrow solid-path SVG subset used by the tiger."""
    if not math.isfinite(tolerance) or tolerance <= 0:
        raise SvgTigerError("flattening tolerance must be positive and finite")
    try:
        root = ET.parse(path).getroot()
    except ET.ParseError as exc:
        raise SvgTigerError(f"invalid XML: {exc}") from exc
    if _local_name(root.tag) != "svg":
        raise SvgTigerError("document root must be <svg>")

    try:
        width = float(root.get("width", "0"))
        height = float(root.get("height", "0"))
    except ValueError as exc:
        raise SvgTigerError("SVG width and height must be numeric") from exc
    if not math.isfinite(width) or not math.isfinite(height) or width <= 0 or height <= 0:
        raise SvgTigerError("SVG width and height must be positive")

    paths: list[FlatPath] = []
    # Match Glumpy's Style defaults rather than the browser SVG default: unspecified paint is
    # disabled. This matters for the tiger's open muzzle strokes, which specify only ``stroke``.
    base_style = {
        "fill": "none",
        "stroke": "none",
        "stroke-width": "1",
        "fill-opacity": "1",
        "stroke-opacity": "1",
        "opacity": "1",
        "fill-rule": "nonzero",
    }

    def visit(element: ET.Element, inherited_style: dict[str, str], parent_matrix: Matrix) -> None:
        name = _local_name(element.tag)
        style = _element_style(element, inherited_style)
        matrix = _multiply(parent_matrix, _parse_transform(element.get("transform")))
        if name == "path":
            data = element.get("d")
            if data is None:
                raise SvgTigerError("path element is missing d data")
            try:
                points, closed = _flatten_path(data, matrix, tolerance)
            except SvgTigerError as exc:
                identifier = element.get("id", f"paint-order-{len(paths)}")
                raise SvgTigerError(f"path {identifier}: {exc}") from exc
            paths.append(FlatPath(points, closed, _paint(style), len(paths)))
            return
        if name not in {"svg", "g"} and name not in _IGNORED_ELEMENTS:
            raise SvgTigerError(f"unsupported SVG element: <{name}>")
        if name in _IGNORED_ELEMENTS:
            return
        for child in element:
            visit(child, style, matrix)

    visit(root, base_style, _identity())
    if not paths:
        raise SvgTigerError("SVG contains no supported paths")
    return SvgDocument(width=width, height=height, paths=tuple(paths))


def _rgba(value: tuple[int, int, int, int] | None) -> tuple[int, int, int, int]:
    """Return transparent black for a disabled paint."""
    return value if value is not None else (0, 0, 0, 0)


def write_bundle(document: SvgDocument, output: Path, source_path: Path, tolerance: float) -> Path:
    """Write deterministic path records and F64 points into the prepared cache."""
    prepared = output / "prepared"
    prepared.mkdir(parents=True, exist_ok=True)
    bundle_path = prepared / "tiger_paths.bin"

    point_count = sum(len(path.points) for path in document.paths)
    all_points = [point for path in document.paths for point in path.points]
    xs = [point[0] for point in all_points]
    ys = [point[1] for point in all_points]
    bounds = (min(xs), min(ys), max(xs), max(ys))

    with bundle_path.open("wb") as stream:
        stream.write(
            HEADER.pack(
                MAGIC,
                VERSION,
                len(document.paths),
                point_count,
                PATH_RECORD.size,
                document.width,
                document.height,
                *bounds,
            )
        )
        offset = 0
        for path in document.paths:
            fill = _rgba(path.paint.fill)
            stroke = _rgba(path.paint.stroke)
            stream.write(
                PATH_RECORD.pack(
                    offset,
                    len(path.points),
                    int(path.closed),
                    int(path.paint.fill is not None),
                    int(path.paint.stroke is not None),
                    0,
                    *fill,
                    *stroke,
                    path.paint.stroke_width,
                    path.paint_order,
                )
            )
            offset += len(path.points)
        for point in all_points:
            stream.write(POINT.pack(*point))

    metadata = {
        "schema": "datoviz.svg-path-cache.v1",
        "source": {
            "url": SOURCE_URL,
            "commit": SOURCE_COMMIT,
            "bytes": source_path.stat().st_size,
            "sha256": _sha256(source_path),
            "attribution": "Nicolas P. Rougier, Glumpy example gallery, classic SVG Tiger example",
            "gallery": "https://glumpy.github.io/gallery.html",
            "redistribution": "maintainer-approved for Datoviz prepared-data publication",
        },
        "processing": {
            "flatten_tolerance_px": tolerance,
            "paint_defaults": "Glumpy-compatible: unspecified fill and stroke are disabled",
            "stroke_width_policy": "unscaled, matching the Glumpy tiger example",
        },
        "document": {
            "width": document.width,
            "height": document.height,
            "path_count": len(document.paths),
            "closed_path_count": sum(path.closed for path in document.paths),
            "open_path_count": sum(not path.closed for path in document.paths),
            "point_count": point_count,
            "renderable_path_count": sum(
                len(path.points) >= (3 if path.closed else 2) for path in document.paths
            ),
            "bounds": bounds,
        },
        "artifact": {
            "path": bundle_path.name,
            "bytes": bundle_path.stat().st_size,
            "sha256": _sha256(bundle_path),
        },
    }
    (prepared / "metadata.json").write_text(
        json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf8"
    )
    return bundle_path


def _source(args: argparse.Namespace) -> Path:
    """Resolve, optionally download, and verify the pinned source SVG."""
    source = args.input
    if args.download:
        source = args.output / "source" / "tiger.svg"
        source.parent.mkdir(parents=True, exist_ok=True)
        if args.force or not source.exists():
            with urllib.request.urlopen(SOURCE_URL, timeout=120) as response:
                source.write_bytes(response.read())
    if source is None:
        raise SvgTigerError("pass --input PATH or --download")
    if not source.is_file():
        raise SvgTigerError(f"source SVG does not exist: {source}")
    actual_bytes = source.stat().st_size
    actual_sha256 = _sha256(source)
    if actual_bytes != SOURCE_BYTES or actual_sha256 != SOURCE_SHA256:
        raise SvgTigerError(
            "source does not match the pinned Glumpy tiger: "
            f"bytes={actual_bytes}, sha256={actual_sha256}"
        )
    return source


def prepare(args: argparse.Namespace) -> Path:
    """Prepare and validate the cache-local tiger path bundle."""
    source = _source(args)
    document = parse_svg(source, args.tolerance)
    closed_count = sum(path.closed for path in document.paths)
    open_count = len(document.paths) - closed_count
    if (
        len(document.paths) != EXPECTED_PATH_COUNT
        or closed_count != EXPECTED_CLOSED_COUNT
        or open_count != EXPECTED_OPEN_COUNT
    ):
        raise SvgTigerError(
            "unexpected pinned tiger structure: "
            f"paths={len(document.paths)}, closed={closed_count}, open={open_count}"
        )
    return write_bundle(document, args.output, source, args.tolerance)


def _parser() -> argparse.ArgumentParser:
    """Build the command-line parser."""
    parser = argparse.ArgumentParser(description=__doc__)
    source = parser.add_mutually_exclusive_group(required=True)
    source.add_argument("--input", type=Path, help="existing pinned tiger.svg")
    source.add_argument("--download", action="store_true", help="download the pinned Glumpy SVG")
    parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="cache bundle root")
    parser.add_argument("--tolerance", type=float, default=0.25, help="curve error in SVG pixels")
    parser.add_argument("--force", action="store_true", help="redownload the pinned source")
    return parser


def main() -> int:
    """Prepare the tiger bundle from command-line arguments."""
    args = _parser().parse_args()
    try:
        bundle = prepare(args)
    except (OSError, SvgTigerError) as exc:
        raise SystemExit(f"prepare_svg_tiger: {exc}") from exc
    print(bundle)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Example details

Tags

vector-art, svg, bezier, polygon-triangulation, mesh, path, panzoom, prepared-data, capture

Data

Field Value
kind prepared

Dataset

Field Value
name Classic SVG Tiger from Nicolas P. Rougier's Glumpy example gallery
source Nicolas P. Rougier's Glumpy example gallery
source_url https://github.com/glumpy/glumpy/blob/aedb9212a1e00a68b7c4669405a6a8f754daf283/glumpy/data/tiger.svg
license Redistribution of this pinned artwork and its prepared Datoviz derivative was confirmed by the Datoviz maintainer for this release.
citation Nicolas P. Rougier, Glumpy example gallery, classic SVG Tiger example.
preprocessing python3 tools/data/prepare_svg_tiger.py --download
cache_prepared_path .cache/datoviz/examples/svg_tiger/prepared
provenance The artwork comes from Nicolas P. Rougier's Glumpy example gallery. The preparation tool verifies the pinned Glumpy source hash, resolves Glumpy-compatible inherited paint, flattens M/L/C/Z paths with adaptive De Casteljau subdivision, and writes prepared path records. Datoviz triangulates fills with Earcut and renders outlines as retained paths.