feat: vector zoom for SVG in image viewer app

This commit is contained in:
2026-05-18 20:15:22 +02:00
parent 6ac9ae01b0
commit dac186f251
4 changed files with 189 additions and 36 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ LIBS := $(JPEG_LIB)/libjpeg.a $(LIBC_LIB)/liblibc.a
# ---- Source files ---- # ---- Source files ----
SRCS := main.cpp stb_truetype_impl.cpp SRCS := main.cpp svg_doc.cpp stb_truetype_impl.cpp
OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o))
# ---- Target ---- # ---- Target ----
+76 -35
View File
@@ -12,6 +12,8 @@
#include <gui/truetype.hpp> #include <gui/truetype.hpp>
#include <gui/svg.hpp> #include <gui/svg.hpp>
#include "svg_doc.hpp"
extern "C" { extern "C" {
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
@@ -59,12 +61,24 @@ static constexpr float ZOOM_MAX = 16.0f;
static int g_win_w = INIT_W; static int g_win_w = INIT_W;
static int g_win_h = INIT_H; static int g_win_h = INIT_H;
// Image data (original, unscaled) // Image data.
static uint32_t* g_image = nullptr; // g_img_w/h are the *natural* (display-reference) dimensions: layout and
static int g_img_w = 0; // "Actual size" use them. g_buf_w/h are the raster cache's actual dimensions
static int g_img_h = 0; // in pixels; for raster images they match g_img_w/h, for SVG they equal
// natural * g_render_scale and update on every zoom-driven re-render.
static uint32_t* g_image = nullptr;
static int g_img_w = 0;
static int g_img_h = 0;
static int g_buf_w = 0;
static int g_buf_h = 0;
static float g_render_scale = 1.0f;
static bool g_has_alpha = false; static bool g_has_alpha = false;
// Vector mode: SvgDoc owns g_image + its raster, free via svgdoc_free not mfree.
static imageviewer::SvgDoc g_svg_doc{};
static bool g_is_svg = false;
static constexpr int SVG_MAX_RENDER_EDGE = 4096;
// Zoom and pan // Zoom and pan
static float g_zoom = 1.0f; static float g_zoom = 1.0f;
static int g_pan_x = 0; static int g_pan_x = 0;
@@ -118,8 +132,33 @@ static bool path_has_ext_ci(const char* path, const char* ext) {
// Image loading // Image loading
// ============================================================================ // ============================================================================
static void release_image() {
if (g_is_svg) {
imageviewer::svgdoc_free(&g_svg_doc);
} else if (g_image) {
montauk::mfree(g_image);
}
g_image = nullptr;
g_img_w = g_img_h = 0;
g_buf_w = g_buf_h = 0;
g_render_scale = 1.0f;
g_is_svg = false;
}
// Re-rasterize the SVG cache for the current zoom. Updates g_image,
// g_buf_w/h, and g_render_scale to match. Cheap no-op when the cache is
// already close to the requested scale. Safe to call when not in SVG mode.
static void refresh_svg_raster() {
if (!g_is_svg) return;
if (!imageviewer::svgdoc_render(&g_svg_doc, g_zoom, SVG_MAX_RENDER_EDGE)) return;
g_image = g_svg_doc.pixels;
g_buf_w = g_svg_doc.render_w;
g_buf_h = g_svg_doc.render_h;
g_render_scale = g_svg_doc.render_scale;
}
static bool load_image(const char* path) { static bool load_image(const char* path) {
if (g_image) { montauk::mfree(g_image); g_image = nullptr; } release_image();
int fd = montauk::open(path); int fd = montauk::open(path);
if (fd < 0) { if (fd < 0) {
@@ -152,35 +191,26 @@ static bool load_image(const char* path) {
// SVG: vector path through our own rasterizer. stb_image can't decode SVG. // SVG: vector path through our own rasterizer. stb_image can't decode SVG.
if (path_has_ext_ci(path, ".svg")) { if (path_has_ext_ci(path, ".svg")) {
int nat_w = 0, nat_h = 0; // Transfer ownership of filedata to the SvgDoc so it stays alive for
svg_get_natural_size((const char*)filedata, bytes_read, &nat_w, &nat_h); // zoom-driven re-rasterization.
if (!imageviewer::svgdoc_init(&g_svg_doc, filedata, bytes_read)) {
// Cap longest edge to keep memory bounded (4 bytes/pixel). montauk::mfree(filedata);
constexpr int MAX_EDGE = 2048; montauk::strcpy(g_status, "Error: SVG init failed");
int max_edge = nat_w > nat_h ? nat_w : nat_h; return false;
if (max_edge > MAX_EDGE) {
nat_w = (int)((int64_t)nat_w * MAX_EDGE / max_edge);
nat_h = (int)((int64_t)nat_h * MAX_EDGE / max_edge);
} }
if (nat_w < 1) nat_w = 1; g_is_svg = true;
if (nat_h < 1) nat_h = 1; g_img_w = g_svg_doc.natural_w;
g_img_h = g_svg_doc.natural_h;
g_has_alpha = true;
// fill_color is the currentColor substitute. Real SVG files mostly // Initial rasterization at zoom 1.0; later zoom changes call
// specify their own fills, so this only matters for paths that use // refresh_svg_raster() to re-rasterize at the new scale.
// currentColor; default to black for sensible fallback. refresh_svg_raster();
SvgIcon icon = svg_render((const char*)filedata, bytes_read, if (!g_image) {
nat_w, nat_h, Color::from_rgb(0, 0, 0)); release_image();
montauk::mfree(filedata);
if (!icon.pixels) {
montauk::strcpy(g_status, "Error: SVG render failed"); montauk::strcpy(g_status, "Error: SVG render failed");
return false; return false;
} }
g_image = icon.pixels;
g_img_w = icon.width;
g_img_h = icon.height;
g_has_alpha = true;
snprintf(g_status, 256, "%s %dx%d SVG", basename(path), g_img_w, g_img_h); snprintf(g_status, 256, "%s %dx%d SVG", basename(path), g_img_w, g_img_h);
return true; return true;
} }
@@ -216,6 +246,9 @@ static bool load_image(const char* path) {
g_img_w = w; g_img_w = w;
g_img_h = h; g_img_h = h;
g_buf_w = w;
g_buf_h = h;
g_render_scale = 1.0f;
snprintf(g_status, 256, "%s %dx%d %dch", basename(path), w, h, channels); snprintf(g_status, 256, "%s %dx%d %dch", basename(path), w, h, channels);
return true; return true;
@@ -266,6 +299,7 @@ static void zoom_to(float new_zoom, int focus_x, int focus_y) {
g_pan_x = focus_x - (int)((focus_x - g_pan_x) * ratio); g_pan_x = focus_x - (int)((focus_x - g_pan_x) * ratio);
g_pan_y = focus_y - (int)((focus_y - g_pan_y) * ratio); g_pan_y = focus_y - (int)((focus_y - g_pan_y) * ratio);
g_zoom = new_zoom; g_zoom = new_zoom;
refresh_svg_raster();
clamp_pan(); clamp_pan();
} }
@@ -297,6 +331,7 @@ static void zoom_fit() {
float zy = (float)vp_h / g_img_h; float zy = (float)vp_h / g_img_h;
g_zoom = zx < zy ? zx : zy; g_zoom = zx < zy ? zx : zy;
if (g_zoom > ZOOM_MAX) g_zoom = ZOOM_MAX; if (g_zoom > ZOOM_MAX) g_zoom = ZOOM_MAX;
refresh_svg_raster();
center_image(); center_image();
} }
@@ -328,20 +363,26 @@ static void render(Canvas& canvas) {
int draw_x1 = g_pan_x + scaled_w > g_win_w ? g_win_w : g_pan_x + scaled_w; int draw_x1 = g_pan_x + scaled_w > g_win_w ? g_win_w : g_pan_x + scaled_w;
int draw_y1 = g_pan_y + scaled_h > vp_y1 ? vp_y1 : g_pan_y + scaled_h; int draw_y1 = g_pan_y + scaled_h > vp_y1 ? vp_y1 : g_pan_y + scaled_h;
float inv_zoom = 1.0f / g_zoom; // Buffer pixels per screen pixel. For raster images render_scale = 1
// and this reduces to 1/g_zoom (the original behavior). For SVGs the
// raster cache is regenerated to match g_zoom, so this is ~1.0 and the
// viewer draws at native buffer resolution.
float sample_step = g_render_scale / g_zoom;
int buf_w = g_buf_w > 0 ? g_buf_w : g_img_w;
int buf_h = g_buf_h > 0 ? g_buf_h : g_img_h;
for (int dy = draw_y0; dy < draw_y1; dy++) { for (int dy = draw_y0; dy < draw_y1; dy++) {
int src_y = (int)((dy - g_pan_y) * inv_zoom); int src_y = (int)((dy - g_pan_y) * sample_step);
if (src_y < 0) src_y = 0; if (src_y < 0) src_y = 0;
if (src_y >= g_img_h) src_y = g_img_h - 1; if (src_y >= buf_h) src_y = buf_h - 1;
const uint32_t* src_row = &g_image[src_y * g_img_w]; const uint32_t* src_row = &g_image[src_y * buf_w];
uint32_t* dst_row = &canvas.pixels[dy * g_win_w]; uint32_t* dst_row = &canvas.pixels[dy * g_win_w];
for (int dx = draw_x0; dx < draw_x1; dx++) { for (int dx = draw_x0; dx < draw_x1; dx++) {
int src_x = (int)((dx - g_pan_x) * inv_zoom); int src_x = (int)((dx - g_pan_x) * sample_step);
if (src_x < 0) src_x = 0; if (src_x < 0) src_x = 0;
if (src_x >= g_img_w) src_x = g_img_w - 1; if (src_x >= buf_w) src_x = buf_w - 1;
uint32_t spx = src_row[src_x]; uint32_t spx = src_row[src_x];
uint8_t a = (spx >> 24) & 0xFF; uint8_t a = (spx >> 24) & 0xFF;
+72
View File
@@ -0,0 +1,72 @@
/*
* svg_doc.cpp
* SvgDoc: source-backed rasterizable SVG document.
* Copyright (c) 2026 Daniel Hammer
*/
#include "svg_doc.hpp"
#include <montauk/syscall.h>
#include <montauk/heap.h>
#include <gui/gui.hpp>
#include <gui/svg.hpp>
namespace imageviewer {
bool svgdoc_init(SvgDoc* doc, uint8_t* source, int source_len) {
if (!doc) return false;
*doc = {};
if (!source || source_len <= 0) return false;
doc->source = source;
doc->source_len = source_len;
gui::svg_get_natural_size((const char*)source, source_len,
&doc->natural_w, &doc->natural_h);
if (doc->natural_w < 1) doc->natural_w = 1;
if (doc->natural_h < 1) doc->natural_h = 1;
return true;
}
bool svgdoc_render(SvgDoc* doc, float target_scale, int max_edge) {
if (!doc || !doc->source) return false;
if (target_scale <= 0.0f) target_scale = 1.0f;
if (max_edge < 1) max_edge = 1;
// Clamp so the longest edge of the rasterized output stays within max_edge.
int natural_long = doc->natural_w > doc->natural_h ? doc->natural_w : doc->natural_h;
float max_scale = (float)max_edge / (float)natural_long;
if (target_scale > max_scale) target_scale = max_scale;
if (target_scale < 0.01f) target_scale = 0.01f;
// Skip if cache is already close enough (avoids re-render on float jitter).
if (doc->pixels && doc->render_scale > 0.0f) {
float ratio = target_scale / doc->render_scale;
if (ratio > 0.995f && ratio < 1.005f) return true;
}
int new_w = (int)((float)doc->natural_w * target_scale);
int new_h = (int)((float)doc->natural_h * target_scale);
if (new_w < 1) new_w = 1;
if (new_h < 1) new_h = 1;
gui::SvgIcon icon = gui::svg_render(
(const char*)doc->source, doc->source_len,
new_w, new_h, gui::Color::from_rgb(0, 0, 0));
if (!icon.pixels) return false;
if (doc->pixels) montauk::mfree(doc->pixels);
doc->pixels = icon.pixels;
doc->render_w = icon.width;
doc->render_h = icon.height;
doc->render_scale = target_scale;
return true;
}
void svgdoc_free(SvgDoc* doc) {
if (!doc) return;
if (doc->pixels) montauk::mfree(doc->pixels);
if (doc->source) montauk::mfree(doc->source);
*doc = {};
}
} // namespace imageviewer
+40
View File
@@ -0,0 +1,40 @@
/*
* svg_doc.hpp
* Lazily-rasterized SVG document for the Image Viewer.
* Keeps the source bytes around so the rasterizer can be re-run whenever the
* user changes zoom, giving true vector quality at every scale.
* Copyright (c) 2026 Daniel Hammer
*/
#pragma once
#include <cstdint>
namespace imageviewer {
struct SvgDoc {
uint8_t* source; // SVG bytes (owned, freed in svgdoc_free)
int source_len;
int natural_w; // intrinsic SVG dimensions
int natural_h;
uint32_t* pixels; // current rasterization (owned ARGB)
int render_w; // pixel dimensions of `pixels`
int render_h;
float render_scale; // pixels = natural * render_scale (may differ from
// requested when capped by max_edge)
};
// Initialize from owned SVG source bytes. The doc takes ownership of `source`
// and will free it via montauk::mfree on svgdoc_free. Returns false on
// failure; on failure the caller still owns `source`.
bool svgdoc_init(SvgDoc* doc, uint8_t* source, int source_len);
// Ensure the rasterization matches `target_scale`. The longest rendered edge
// is capped at `max_edge` pixels; if `target_scale` would exceed that, the
// scale is clamped (render_scale will reflect the actual scale used).
// Returns false if rasterization fails; the existing cache is left intact.
bool svgdoc_render(SvgDoc* doc, float target_scale, int max_edge);
// Free all owned memory (source bytes + raster cache).
void svgdoc_free(SvgDoc* doc);
} // namespace imageviewer