feat: vector zoom for SVG in image viewer app
This commit is contained in:
@@ -69,7 +69,7 @@ LIBS := $(JPEG_LIB)/libjpeg.a $(LIBC_LIB)/liblibc.a
|
||||
|
||||
# ---- 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))
|
||||
|
||||
# ---- Target ----
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#include <gui/truetype.hpp>
|
||||
#include <gui/svg.hpp>
|
||||
|
||||
#include "svg_doc.hpp"
|
||||
|
||||
extern "C" {
|
||||
#include <string.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_h = INIT_H;
|
||||
|
||||
// Image data (original, unscaled)
|
||||
static uint32_t* g_image = nullptr;
|
||||
static int g_img_w = 0;
|
||||
static int g_img_h = 0;
|
||||
// Image data.
|
||||
// g_img_w/h are the *natural* (display-reference) dimensions: layout and
|
||||
// "Actual size" use them. g_buf_w/h are the raster cache's actual dimensions
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
static float g_zoom = 1.0f;
|
||||
static int g_pan_x = 0;
|
||||
@@ -118,8 +132,33 @@ static bool path_has_ext_ci(const char* path, const char* ext) {
|
||||
// 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) {
|
||||
if (g_image) { montauk::mfree(g_image); g_image = nullptr; }
|
||||
release_image();
|
||||
|
||||
int fd = montauk::open(path);
|
||||
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.
|
||||
if (path_has_ext_ci(path, ".svg")) {
|
||||
int nat_w = 0, nat_h = 0;
|
||||
svg_get_natural_size((const char*)filedata, bytes_read, &nat_w, &nat_h);
|
||||
|
||||
// Cap longest edge to keep memory bounded (4 bytes/pixel).
|
||||
constexpr int MAX_EDGE = 2048;
|
||||
int max_edge = nat_w > nat_h ? nat_w : nat_h;
|
||||
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);
|
||||
// Transfer ownership of filedata to the SvgDoc so it stays alive for
|
||||
// zoom-driven re-rasterization.
|
||||
if (!imageviewer::svgdoc_init(&g_svg_doc, filedata, bytes_read)) {
|
||||
montauk::mfree(filedata);
|
||||
montauk::strcpy(g_status, "Error: SVG init failed");
|
||||
return false;
|
||||
}
|
||||
if (nat_w < 1) nat_w = 1;
|
||||
if (nat_h < 1) nat_h = 1;
|
||||
g_is_svg = true;
|
||||
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
|
||||
// specify their own fills, so this only matters for paths that use
|
||||
// currentColor; default to black for sensible fallback.
|
||||
SvgIcon icon = svg_render((const char*)filedata, bytes_read,
|
||||
nat_w, nat_h, Color::from_rgb(0, 0, 0));
|
||||
montauk::mfree(filedata);
|
||||
|
||||
if (!icon.pixels) {
|
||||
// Initial rasterization at zoom 1.0; later zoom changes call
|
||||
// refresh_svg_raster() to re-rasterize at the new scale.
|
||||
refresh_svg_raster();
|
||||
if (!g_image) {
|
||||
release_image();
|
||||
montauk::strcpy(g_status, "Error: SVG render failed");
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
@@ -216,6 +246,9 @@ static bool load_image(const char* path) {
|
||||
|
||||
g_img_w = w;
|
||||
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);
|
||||
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_y = focus_y - (int)((focus_y - g_pan_y) * ratio);
|
||||
g_zoom = new_zoom;
|
||||
refresh_svg_raster();
|
||||
clamp_pan();
|
||||
}
|
||||
|
||||
@@ -297,6 +331,7 @@ static void zoom_fit() {
|
||||
float zy = (float)vp_h / g_img_h;
|
||||
g_zoom = zx < zy ? zx : zy;
|
||||
if (g_zoom > ZOOM_MAX) g_zoom = ZOOM_MAX;
|
||||
refresh_svg_raster();
|
||||
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_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++) {
|
||||
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 >= 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];
|
||||
|
||||
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 >= 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];
|
||||
uint8_t a = (spx >> 24) & 0xFF;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user