Donner SVG 0.8.0-pre
SVG editor and embeddable C⁠+⁠+⁠20 engine.
Loading...
Searching...
No Matches
svg_viewer.cc

Minimal Donner SVG viewer with a live text pane.

Minimal Donner SVG viewer with a live text pane. A small ImGui demo: load an SVG, display it, click to select a shape, and edit the source in a text pane that re-parses on every keystroke. Selection chrome is drawn by injecting an editor-only <rect> and <path> into the document tree - no separate overlay renderer and no editor-side command queue. The only dependency on the editor tree is donner::editor::TextEditor, the syntax-aware text widget.

For the full editor binary (EditorApp + SelectTool + OverlayRenderer

  • command queue + mutation seam) see //donner/editor.

To run:

bazel run //examples:svg_viewer -- donner_splash.svg
/**
* @example svg_viewer.cc Minimal Donner SVG viewer with a live text pane.
*
* A small ImGui demo: load an SVG, display it, click to select a shape,
* and edit the source in a text pane that re-parses on every keystroke.
* Selection chrome is drawn by injecting an `editor-only` `<rect>` and
* `<path>` into the document tree - no separate overlay renderer and no
* editor-side command queue. The **only** dependency on the editor tree
* is `donner::editor::TextEditor`, the syntax-aware text widget.
*
* For the full editor binary (EditorApp + SelectTool + OverlayRenderer
* + command queue + mutation seam) see `//donner/editor`.
*
* To run:
*
* ```sh
* bazel run //examples:svg_viewer -- donner_splash.svg
* ```
*/
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <optional>
#include <sstream>
#include <string>
#include "glad/glad.h"
extern "C" {
#include "GLFW/glfw3.h"
}
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
namespace {
constexpr int kInitialWindowWidth = 1280;
constexpr int kInitialWindowHeight = 800;
constexpr float kSourcePaneWidth = 560.0f;
void GlfwErrorCallback(int error, const char* description) {
std::cerr << "GLFW error " << error << ": " << description << "\n";
}
std::string LoadFile(const std::string& filename) {
auto result =
if (const auto* contents = std::get_if<std::string>(&result)) {
return *contents;
} else {
std::cerr << donner::FileReadErrorMessage(std::get<donner::FileReadError>(result)) << ": "
<< donner::EscapeTerminalText(filename) << "\n";
return {};
}
}
/// Bundles the loaded document with its hit-test controller and the
/// editor-only overlay nodes that render the current selection. Re-parsing
/// throws all of this away and rebuilds it.
struct ViewerState {
donner::svg::SVGDocument document;
std::optional<donner::svg::DonnerController> controller;
std::optional<donner::svg::SVGRectElement> boundsShape;
std::optional<donner::svg::SVGPathElement> selectedPathOutline;
std::optional<donner::svg::SVGElement> selectedElement;
std::optional<donner::ParseDiagnostic> lastParseError;
bool valid = false;
void loadFromString(std::string_view source) {
using namespace donner;
using namespace donner::svg;
using namespace donner::svg::parser;
document = SVGDocument();
valid = false;
controller.reset();
boundsShape.reset();
selectedPathOutline.reset();
selectedElement.reset();
ParseWarningSink disabled = ParseWarningSink::Disabled();
ParseResult<SVGDocument> maybe = SVGParser::ParseSVG(source, disabled);
if (maybe.hasError()) {
lastParseError = std::move(maybe.error());
return;
}
lastParseError.reset();
document = std::move(maybe.result());
controller = DonnerController(document);
// Inject an editor-only container holding the selection chrome as
// regular SVG elements. The renderer draws them alongside the real
// document; toggling display:inline/none shows or hides them.
auto editorOnly = SVGUnknownElement::Create(document, "editor-only");
document.svgElement().appendChild(editorOnly);
boundsShape = SVGRectElement::Create(document);
editorOnly.appendChild(boundsShape.value());
boundsShape->setStyle(
"display: none; fill: none; stroke: deepskyblue; stroke-width: 1px; "
"pointer-events: none");
selectedPathOutline = SVGPathElement::Create(document);
editorOnly.appendChild(selectedPathOutline.value());
selectedPathOutline->setStyle(
"display: none; fill: none; stroke: deepskyblue; stroke-width: 1px; "
"pointer-events: none");
valid = true;
}
void selectNone() {
selectedElement.reset();
if (boundsShape) {
boundsShape->setStyle("display: none");
}
if (selectedPathOutline) {
selectedPathOutline->setStyle("display: none");
}
}
void selectElement(const donner::svg::SVGElement& element) {
using namespace donner::svg;
selectedElement = element;
if (!element.isa<SVGGeometryElement>()) {
return;
}
auto geom = element.cast<SVGGeometryElement>();
if (auto spline = geom.computedSpline()) {
if (selectedPathOutline) {
selectedPathOutline->setStyle("display: inline");
selectedPathOutline->setSpline(*spline);
selectedPathOutline->setTransform(geom.elementFromWorld());
}
if (auto bounds = geom.worldBounds()) {
if (boundsShape) {
boundsShape->setStyle("display: inline");
boundsShape->setX(donner::Lengthd(bounds->topLeft.x));
boundsShape->setY(donner::Lengthd(bounds->topLeft.y));
boundsShape->setWidth(donner::Lengthd(bounds->width()));
boundsShape->setHeight(donner::Lengthd(bounds->height()));
}
}
}
}
/// Click a document-space point. Returns the newly-selected element's
/// source-location range (in the original SVG text) if the click hit a
/// geometry element that carries XML source offsets, so the caller can
/// highlight it in the text pane.
///
/// Selection is **sticky** - clicking empty space is a no-op rather than
/// a deselect. Only a click that lands on an element changes the
/// selection. This matches the behavior of most vector editors and
/// avoids accidental deselection while pan/zoom lands later.
std::optional<donner::SourceRange> handleClick(const donner::Vector2d& documentPoint) {
if (!controller) {
return std::nullopt;
}
auto hit = controller->findIntersecting(documentPoint);
if (!hit.has_value()) {
return std::nullopt;
}
selectElement(*hit);
if (auto xmlNode = donner::xml::XMLNode::TryCast(hit->entityHandle())) {
return xmlNode->getNodeLocation();
}
return std::nullopt;
}
};
/// Build a `TextEditor::ErrorMarkers` map from a parser diagnostic.
/// `TextEditor` keys markers by 1-based line number; diagnostics with no
/// resolved line info land on line 1 so the user always sees something.
donner::editor::TextEditor::ErrorMarkers ParseErrorToMarkers(const donner::ParseDiagnostic& diag) {
donner::editor::TextEditor::ErrorMarkers markers;
const int line =
diag.range.start.lineInfo.has_value() ? static_cast<int>(diag.range.start.lineInfo->line) : 1;
markers.emplace(line, std::string(std::string_view(diag.reason)));
return markers;
}
/// Convert a `FileOffset` from donner's XML source location into a
/// `TextEditor` coordinate. donner's line is 1-based; `TextEditor` is
/// 0-based.
donner::editor::Coordinates FileOffsetToEditorCoordinates(const donner::FileOffset& offset) {
if (!offset.lineInfo.has_value()) {
}
return donner::editor::Coordinates(static_cast<int>(offset.lineInfo->line) - 1,
static_cast<int>(offset.lineInfo->offsetOnLine));
}
} // namespace
int main(int argc, char** argv) {
if (const char* bwd = std::getenv("BUILD_WORKING_DIRECTORY")) {
std::filesystem::current_path(bwd);
}
if (argc != 2) {
std::cerr << "Usage: svg_viewer <filename>\n";
return 1;
}
const std::string svgPath = argv[1];
const std::string initialSource = LoadFile(svgPath);
if (initialSource.empty()) {
return 1;
}
// ---------------------------------------------------------------------------
// GLFW + OpenGL
// ---------------------------------------------------------------------------
glfwSetErrorCallback(GlfwErrorCallback);
if (!glfwInit()) {
std::cerr << "Failed to initialize GLFW\n";
return 1;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
GLFWwindow* window = glfwCreateWindow(kInitialWindowWidth, kInitialWindowHeight,
"Donner SVG Viewer", nullptr, nullptr);
if (!window) {
std::cerr << "Failed to create GLFW window\n";
glfwTerminate();
return 1;
}
glfwMakeContextCurrent(window);
glfwSwapInterval(1);
if (!gladLoadGLLoader(reinterpret_cast<GLADloadproc>(glfwGetProcAddress))) {
std::cerr << "Failed to initialize OpenGL loader\n";
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
// ---------------------------------------------------------------------------
// ImGui
// ---------------------------------------------------------------------------
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr; // no persistence
ImGui::StyleColorsDark();
ImGui_ImplGlfw_InitForOpenGL(window, true);
ImGui_ImplOpenGL3_Init("#version 330");
// ---------------------------------------------------------------------------
// Viewer state
// ---------------------------------------------------------------------------
ViewerState state;
state.loadFromString(initialSource);
textEditor.setLanguageDefinition(donner::editor::TextEditor::LanguageDefinition::SVG());
textEditor.setText(initialSource);
// Track the current source-pane error marker so we only push into
// `TextEditor` when the parse-error state actually changes (avoids
// copying the marker map every frame).
constexpr int kNoErrorLine = -1;
int lastShownErrorLine = kNoErrorLine;
std::string lastShownErrorReason;
if (state.lastParseError.has_value()) {
textEditor.setErrorMarkers(ParseErrorToMarkers(*state.lastParseError));
lastShownErrorLine = state.lastParseError->range.start.lineInfo.has_value()
? static_cast<int>(state.lastParseError->range.start.lineInfo->line)
: 1;
lastShownErrorReason.assign(std::string_view(state.lastParseError->reason));
}
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
int textureWidth = 0;
int textureHeight = 0;
// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
// Re-render every frame - this is a minimal demo and the document is
// small. A real application would track a dirty flag.
if (state.valid) {
renderer.draw(state.document);
const donner::svg::RendererBitmap bitmap = renderer.takeSnapshot();
if (!bitmap.empty()) {
glBindTexture(GL_TEXTURE_2D, texture);
glPixelStorei(GL_UNPACK_ROW_LENGTH, static_cast<GLint>(bitmap.rowBytes / 4u));
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bitmap.dimensions.x, bitmap.dimensions.y, 0,
GL_RGBA, GL_UNSIGNED_BYTE, bitmap.pixels.data());
glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
textureWidth = bitmap.dimensions.x;
textureHeight = bitmap.dimensions.y;
}
}
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
int windowWidth = 0;
int windowHeight = 0;
glfwGetWindowSize(window, &windowWidth, &windowHeight);
// Lock both panes in place. Without this, clicking on an image
// inside an ImGui window falls through to the parent window and
// tries to drag the pane around, since `ImGui::Image` doesn't
// consume the mouse event.
const ImGuiWindowFlags kPaneFlags =
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse;
// Source pane: TextEditor with full re-parse on change.
ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(kSourcePaneWidth, static_cast<float>(windowHeight)),
ImGuiCond_Always);
ImGui::Begin("Source", nullptr, kPaneFlags);
textEditor.render("##source");
if (textEditor.isTextChanged()) {
state.loadFromString(textEditor.getText());
// `isTextChanged` is a sticky flag - the caller is responsible for
// clearing it. Without this reset, every frame would re-parse the
// document and wipe any selection state we just built up via the
// click handler below.
textEditor.resetTextChanged();
}
// Sync the source pane's error markers with the most recent parse
// diagnostic. Diff against the previous frame so we don't push the
// same marker map every frame.
if (state.lastParseError.has_value()) {
const int line = state.lastParseError->range.start.lineInfo.has_value()
? static_cast<int>(state.lastParseError->range.start.lineInfo->line)
: 1;
const std::string_view reasonSv = state.lastParseError->reason;
if (line != lastShownErrorLine || reasonSv != lastShownErrorReason) {
textEditor.setErrorMarkers(ParseErrorToMarkers(*state.lastParseError));
lastShownErrorLine = line;
lastShownErrorReason.assign(reasonSv);
}
} else if (lastShownErrorLine != kNoErrorLine) {
textEditor.setErrorMarkers({});
lastShownErrorLine = kNoErrorLine;
lastShownErrorReason.clear();
}
ImGui::End();
// Render pane: image + click-to-select.
ImGui::SetNextWindowPos(ImVec2(kSourcePaneWidth, 0), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(static_cast<float>(windowWidth) - kSourcePaneWidth,
static_cast<float>(windowHeight)),
ImGuiCond_Always);
ImGui::Begin("Render", nullptr, kPaneFlags);
// Size the document's canvas to the render pane so the SVG scales
// to fit instead of overflowing large or leaving whitespace for
// small documents. The original experimental viewer did the same.
const ImVec2 contentRegion = ImGui::GetContentRegionAvail();
const int desiredW = static_cast<int>(contentRegion.x);
const int desiredH = static_cast<int>(contentRegion.y);
if (state.valid && desiredW > 0 && desiredH > 0) {
const donner::Vector2i currentSize = state.document.canvasSize();
if (currentSize.x != desiredW || currentSize.y != desiredH) {
state.document.setCanvasSize(desiredW, desiredH);
}
}
if (textureWidth > 0 && textureHeight > 0) {
const ImVec2 imageOrigin = ImGui::GetCursorScreenPos();
ImGui::Image(static_cast<ImTextureID>(static_cast<std::uintptr_t>(texture)),
ImVec2(static_cast<float>(textureWidth), static_cast<float>(textureHeight)));
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
const ImVec2 mouse = ImGui::GetMousePos();
// Map screen → canvas → document. `canvasFromDocumentTransform()`
// maps document viewBox coordinates to canvas pixels; we need the
// opposite direction for click math, so invert it.
const donner::Transform2d documentFromCanvas =
const donner::Vector2d canvasPoint(mouse.x - imageOrigin.x, mouse.y - imageOrigin.y);
const auto sourceRange =
state.handleClick(documentFromCanvas.transformPosition(canvasPoint));
if (sourceRange.has_value()) {
textEditor.selectAndFocus(FileOffsetToEditorCoordinates(sourceRange->start),
FileOffsetToEditorCoordinates(sourceRange->end));
}
}
} else {
ImGui::TextUnformatted("(no rendered image)");
}
ImGui::End();
ImGui::Render();
int displayWidth = 0;
int displayHeight = 0;
glfwGetFramebufferSize(window, &displayWidth, &displayHeight);
glViewport(0, 0, displayWidth, displayHeight);
glClearColor(0.10f, 0.10f, 0.10f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
glfwSwapBuffers(window);
}
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
glDeleteTextures(1, &texture);
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
bool hasError() const noexcept
Returns true if this ParseResult contains an error.
Definition ParseResult.h:110
T & result() &
Returns the contained result.
Definition ParseResult.h:51
ParseDiagnostic & error() &
Returns the contained error.
Definition ParseResult.h:81
A text editor widget for Dear ImGui that supports syntax highlighting, undo & redo,...
Definition TextEditor.h:118
void selectAndFocus(const Coordinates &start, const Coordinates &end)
Set a new selection and scroll to it.
Definition TextEditor.h:498
std::string getText() const
Get all text in the editor.
void setText(std::string_view text, bool preserveScroll=false)
Replace the entire buffer with new text.
Backend-agnostic renderer that resolves to the active build backend (Skia or tiny-skia).
Definition Renderer.h:82
RendererBitmap takeSnapshot() const override
Captures a CPU-readable snapshot of the current frame.
void draw(SVGDocument &document) override
Draws the SVG document using the active backend.
Transform2d canvasFromDocumentTransform() const
Returns the transform that maps points from the SVG document's viewBox coordinate space into the canv...
void setCanvasSize(int width, int height)
Set the canvas (output image) size to a fixed width and height, in pixels.
Vector2i canvasSize() const
Get the current canvas size, or the default size (512x512) if the canvas size has not been explicitly...
Derived cast()
Cast this element to its derived type.
Definition SVGElement.h:611
bool isa() const
Return true if this element "is a" instance of type, if it be cast to a specific type with cast.
Definition SVGElement.h:587
static constexpr size_t kDefaultMaximumInputSize
Default maximum number of source or expanded SVG bytes accepted from untrusted input.
Definition SVGParser.h:20
static std::optional< XMLNode > TryCast(EntityHandle entity)
Try to cast to an XMLNode from a raw Entity.
std::string EscapeTerminalText(std::string_view text, bool preserveNewlines=false)
Escape untrusted bytes and terminal format controls while preserving printable UTF-8.
Definition TerminalEscape.h:13
FileReadResult ReadFileBounded(const std::filesystem::path &path, size_t maximumSize)
Open and read a regular file without allocating more than maximumSize bytes.
Vector2< double > Vector2d
Shorthand for Vector2<double>.
Definition Vector2.h:394
Length< double > Lengthd
Shorthand for Length<double>.
Definition Length.h:277
Vector2< int > Vector2i
Shorthand for Vector2<int>.
Definition Vector2.h:397
Transform2< double > Transform2d
Shorthand for Transform2<double>.
Definition Transform.h:333
Error context for a failed parse, such as the error reason, line, and character offset.
Definition FileOffset.h:13
std::optional< LineInfo > lineInfo
Line information for multi-line strings, if known.
Definition FileOffset.h:57
A diagnostic message from a parser, with severity, source range, and human-readable reason.
Definition ParseDiagnostic.h:31
RcString reason
Human-readable description of the problem.
Definition ParseDiagnostic.h:36
SourceRange range
Source range that this diagnostic applies to. For point diagnostics where the end is unknown,...
Definition ParseDiagnostic.h:40
FileOffset start
Start of the range (inclusive).
Definition FileOffset.h:150
Vector2< T > transformPosition(const Vector2< T > &v) const
Transforms a position given as a vector.
Definition Transform.h:270
Transform2< T > inverse() const
Returns the inverse of this transform.
Definition Transform.h:224
T y
The y component of the vector.
Definition Vector2.h:18
T x
The x component of the vector.
Definition Vector2.h:17
Coordinates representing a position in the text buffer, using a grid-based system where tabs are expa...
Definition TextBuffer.h:20
static const LanguageDefinition & SVG()
Get the SVG language definition.
CPU-readable bitmap produced by a renderer snapshot.
Definition RendererInterface.h:65
std::size_t rowBytes
Bytes between rows; allows alignment/padding differences between renderers.
Definition RendererInterface.h:71
bool empty() const
Returns true if this bitmap has no pixel data.
Definition RendererInterface.h:76
std::vector< uint8_t > pixels
Raw RGBA8 pixel data; row starts are separated by rowBytes.
Definition RendererInterface.h:69
Vector2i dimensions
Pixel dimensions of the bitmap in device pixels.
Definition RendererInterface.h:67