mirror of
https://github.com/VioletTides/KnockThru.git
synced 2026-10-27 23:00:18 +01:00
Canvas::text decoded the UTF-8 string inside measure() and again to draw it.
345 lines
15 KiB
C++
345 lines
15 KiB
C++
#include "canvas.hpp"
|
|
|
|
#define STB_TRUETYPE_IMPLEMENTATION
|
|
#define STBTT_STATIC
|
|
#include <stb_truetype.h>
|
|
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
|
#define STB_IMAGE_WRITE_STATIC
|
|
#include <stb_image_write.h>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <stdexcept>
|
|
|
|
namespace knockthru {
|
|
namespace {
|
|
|
|
std::u32string decode_utf8(const std::string& text) {
|
|
std::u32string out;
|
|
for (std::size_t i = 0; i < text.size();) {
|
|
const auto byte = static_cast<unsigned char>(text[i]);
|
|
const int length = byte < 0x80 ? 1 : byte < 0xE0 ? 2 : byte < 0xF0 ? 3 : 4;
|
|
char32_t code = length == 1 ? byte : byte & (0x7F >> length);
|
|
for (int k = 1; k < length && i + k < text.size(); ++k) code = (code << 6) | (text[i + k] & 0x3F);
|
|
out += code;
|
|
i += length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
float clamp01(float value) { return std::min(1.0f, std::max(0.0f, value)); }
|
|
|
|
} // namespace
|
|
|
|
struct Font::Impl {
|
|
std::vector<unsigned char> data;
|
|
stbtt_fontinfo info{};
|
|
};
|
|
|
|
Font::Font(const std::filesystem::path& path) : impl_(std::make_unique<Impl>()) {
|
|
std::ifstream stream(path, std::ios::binary);
|
|
impl_->data.assign(std::istreambuf_iterator<char>(stream), {});
|
|
if (impl_->data.empty() || !stbtt_InitFont(&impl_->info, impl_->data.data(), 0))
|
|
throw std::runtime_error("Cannot load font " + path.string());
|
|
}
|
|
|
|
Font::~Font() = default;
|
|
|
|
const Font::Glyph& Font::glyph(char32_t codepoint, float size) {
|
|
const auto key = std::make_pair(codepoint, static_cast<int>(size * 4));
|
|
if (const auto found = cache_.find(key); found != cache_.end()) return found->second;
|
|
Glyph glyph;
|
|
const float scale = stbtt_ScaleForPixelHeight(&impl_->info, size);
|
|
int advance = 0, bearing = 0;
|
|
stbtt_GetCodepointHMetrics(&impl_->info, static_cast<int>(codepoint), &advance, &bearing);
|
|
glyph.advance = advance * scale;
|
|
unsigned char* bitmap = stbtt_GetCodepointBitmap(&impl_->info, 0, scale, static_cast<int>(codepoint),
|
|
&glyph.width, &glyph.height, &glyph.x_offset, &glyph.y_offset);
|
|
if (bitmap) {
|
|
glyph.coverage.assign(bitmap, bitmap + glyph.width * glyph.height);
|
|
stbtt_FreeBitmap(bitmap, nullptr);
|
|
}
|
|
return cache_.emplace(key, std::move(glyph)).first->second;
|
|
}
|
|
|
|
float Font::kerning(char32_t left, char32_t right, float size) const {
|
|
return stbtt_GetCodepointKernAdvance(&impl_->info, static_cast<int>(left), static_cast<int>(right)) *
|
|
stbtt_ScaleForPixelHeight(&impl_->info, size);
|
|
}
|
|
|
|
float Font::ascent(float size) const {
|
|
int ascent = 0, descent = 0, gap = 0;
|
|
stbtt_GetFontVMetrics(&impl_->info, &ascent, &descent, &gap);
|
|
return ascent * stbtt_ScaleForPixelHeight(&impl_->info, size);
|
|
}
|
|
|
|
Canvas::Canvas(int width, int height)
|
|
: width_(width), height_(height), pixels_(static_cast<std::size_t>(width) * height * 4, 0) {}
|
|
|
|
void Canvas::clear(Color color) {
|
|
for (std::size_t i = 0; i < pixels_.size(); i += 4) {
|
|
pixels_[i] = color.r;
|
|
pixels_[i + 1] = color.g;
|
|
pixels_[i + 2] = color.b;
|
|
pixels_[i + 3] = color.a;
|
|
}
|
|
}
|
|
|
|
void Canvas::blend(int x, int y, Color color, float coverage) {
|
|
if (x < 0 || y < 0 || x >= width_ || y >= height_ || coverage <= 0) return;
|
|
auto* p = &pixels_[(static_cast<std::size_t>(y) * width_ + x) * 4];
|
|
// A fully covered pixel of an opaque color, or one over transparency, is the color itself.
|
|
if (coverage >= 1 && (color.a == 255 || p[3] == 0)) {
|
|
p[0] = color.r;
|
|
p[1] = color.g;
|
|
p[2] = color.b;
|
|
p[3] = color.a;
|
|
return;
|
|
}
|
|
const float a = coverage * color.a / 255.0f;
|
|
const float out_a = a + p[3] / 255.0f * (1 - a);
|
|
if (out_a <= 0) return;
|
|
const auto mix = [&](std::uint8_t src, std::uint8_t dst) {
|
|
return static_cast<std::uint8_t>(std::lround((src * a + dst * (p[3] / 255.0f) * (1 - a)) / out_a));
|
|
};
|
|
p[0] = mix(color.r, p[0]);
|
|
p[1] = mix(color.g, p[1]);
|
|
p[2] = mix(color.b, p[2]);
|
|
p[3] = static_cast<std::uint8_t>(std::lround(out_a * 255));
|
|
}
|
|
|
|
void Canvas::rounded_rect(Rect rect, float radius, Color color) {
|
|
radius = std::min({radius, rect.w / 2, rect.h / 2});
|
|
const int x0 = static_cast<int>(std::floor(rect.x)), x1 = static_cast<int>(std::ceil(rect.x + rect.w));
|
|
const int y0 = static_cast<int>(std::floor(rect.y)), y1 = static_cast<int>(std::ceil(rect.y + rect.h));
|
|
const auto coverage = [&](int x, int y) {
|
|
const float px = x + 0.5f, py = y + 0.5f;
|
|
// Signed distance to the rounded rectangle, negative inside.
|
|
const float dx = std::max(std::abs(px - (rect.x + rect.w / 2)) - (rect.w / 2 - radius), 0.0f);
|
|
const float dy = std::max(std::abs(py - (rect.y + rect.h / 2)) - (rect.h / 2 - radius), 0.0f);
|
|
const float distance = std::sqrt(dx * dx + dy * dy) - radius;
|
|
const float edge_x = std::min(px - rect.x, rect.x + rect.w - px) + 0.5f;
|
|
const float edge_y = std::min(py - rect.y, rect.y + rect.h - py) + 0.5f;
|
|
return clamp01(0.5f - distance) * clamp01(edge_x) * clamp01(edge_y);
|
|
};
|
|
// The shape is convex, so each row's fully covered pixels form one run. Only
|
|
// the pixels before and after it need the distance math.
|
|
for (int y = y0; y < y1; ++y) {
|
|
int left = x0, right = x1 - 1;
|
|
for (; left <= right; ++left) {
|
|
const float c = coverage(left, y);
|
|
if (c >= 1) break;
|
|
blend(left, y, color, c);
|
|
}
|
|
for (; right > left; --right) {
|
|
const float c = coverage(right, y);
|
|
if (c >= 1) break;
|
|
blend(right, y, color, c);
|
|
}
|
|
fill_span(y, left, right + 1, color);
|
|
}
|
|
}
|
|
|
|
void Canvas::fill_span(int y, int from, int to, Color color) {
|
|
if (y < 0 || y >= height_) return;
|
|
from = std::max(from, 0);
|
|
to = std::min(to, width_);
|
|
if (color.a != 255) {
|
|
for (int x = from; x < to; ++x) blend(x, y, color, 1);
|
|
return;
|
|
}
|
|
// An opaque color replaces what is under it.
|
|
auto* p = &pixels_[(static_cast<std::size_t>(y) * width_ + from) * 4];
|
|
for (int x = from; x < to; ++x, p += 4) {
|
|
p[0] = color.r;
|
|
p[1] = color.g;
|
|
p[2] = color.b;
|
|
p[3] = 255;
|
|
}
|
|
}
|
|
|
|
void Canvas::circle(float cx, float cy, float radius, Color color) {
|
|
arc(cx, cy, 0, radius, 0, 7, color);
|
|
}
|
|
|
|
void Canvas::polygon(const std::vector<std::vector<Point>>& rings, Color color) {
|
|
float top = static_cast<float>(height_), bottom = 0, left = static_cast<float>(width_), right = 0;
|
|
for (const auto& ring : rings) {
|
|
for (const auto& p : ring) {
|
|
top = std::min(top, p.y);
|
|
bottom = std::max(bottom, p.y);
|
|
left = std::min(left, p.x);
|
|
right = std::max(right, p.x);
|
|
}
|
|
}
|
|
const int first_x = std::max(0, static_cast<int>(left)), last_x = std::min(width_, static_cast<int>(std::ceil(right)) + 1);
|
|
// Four sample lines per pixel row. Each span adds its exact horizontal coverage.
|
|
constexpr int kSamples = 4;
|
|
std::vector<float> coverage(width_ + 1), crossings;
|
|
for (int y = std::max(0, static_cast<int>(top)); y < std::min(height_, static_cast<int>(std::ceil(bottom))); ++y) {
|
|
std::fill(coverage.begin() + first_x, coverage.begin() + last_x, 0.0f);
|
|
for (int sample = 0; sample < kSamples; ++sample) {
|
|
const float sy = y + (sample + 0.5f) / kSamples;
|
|
crossings.clear();
|
|
for (const auto& ring : rings) {
|
|
for (std::size_t i = 0; i < ring.size(); ++i) {
|
|
const Point a = ring[i], b = ring[(i + 1) % ring.size()];
|
|
if ((a.y <= sy) != (b.y <= sy)) crossings.push_back(a.x + (sy - a.y) * (b.x - a.x) / (b.y - a.y));
|
|
}
|
|
}
|
|
std::sort(crossings.begin(), crossings.end());
|
|
for (std::size_t i = 0; i + 1 < crossings.size(); i += 2) {
|
|
const float x0 = std::clamp(crossings[i], 0.0f, static_cast<float>(width_));
|
|
const float x1 = std::clamp(crossings[i + 1], 0.0f, static_cast<float>(width_));
|
|
const int i0 = static_cast<int>(x0), i1 = static_cast<int>(x1);
|
|
if (i0 == i1) {
|
|
coverage[i0] += (x1 - x0) / kSamples;
|
|
continue;
|
|
}
|
|
coverage[i0] += (i0 + 1 - x0) / kSamples;
|
|
for (int x = i0 + 1; x < i1; ++x) coverage[x] += 1.0f / kSamples;
|
|
coverage[i1] += (x1 - i1) / kSamples;
|
|
}
|
|
}
|
|
for (int x = first_x; x < last_x; ++x) blend(x, y, color, clamp01(coverage[x]));
|
|
}
|
|
}
|
|
|
|
void Canvas::arc(float cx, float cy, float inner, float outer, float from, float to, Color color) {
|
|
const int x0 = static_cast<int>(std::floor(cx - outer - 1)), x1 = static_cast<int>(std::ceil(cx + outer + 1));
|
|
const int y0 = static_cast<int>(std::floor(cy - outer - 1)), y1 = static_cast<int>(std::ceil(cy + outer + 1));
|
|
const bool full = to - from >= 6.283f;
|
|
for (int y = y0; y < y1; ++y) {
|
|
for (int x = x0; x < x1; ++x) {
|
|
const float dx = x + 0.5f - cx, dy = cy - (y + 0.5f);
|
|
const float r = std::sqrt(dx * dx + dy * dy);
|
|
float coverage = clamp01(outer - r + 0.5f) * (inner > 0 ? clamp01(r - inner + 0.5f) : 1.0f);
|
|
if (!full) {
|
|
// Brings the angle into one turn starting at `from`, so arcs that
|
|
// reach past -pi or pi draw whole.
|
|
float angle = std::atan2(dy, dx);
|
|
while (angle < from) angle += 6.2831853f;
|
|
while (angle >= from + 6.2831853f) angle -= 6.2831853f;
|
|
const float outside = std::min(angle - from, to - angle);
|
|
// Round the arc ends by softening a few pixels past each end.
|
|
coverage *= clamp01(outside * r + 0.5f);
|
|
}
|
|
blend(x, y, color, coverage);
|
|
}
|
|
}
|
|
}
|
|
|
|
float Canvas::measure(Font& font, const std::string& utf8, float size) { return measure(font, decode_utf8(utf8), size); }
|
|
|
|
float Canvas::measure(Font& font, const std::u32string& text, float size) {
|
|
float width = 0;
|
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
|
width += font.glyph(text[i], size).advance;
|
|
if (i + 1 < text.size()) width += font.kerning(text[i], text[i + 1], size);
|
|
}
|
|
return width;
|
|
}
|
|
|
|
std::string Canvas::fit(Font& font, const std::string& utf8, float size, float width) {
|
|
if (measure(font, utf8, size) <= width) return utf8;
|
|
auto text = decode_utf8(utf8);
|
|
while (!text.empty()) {
|
|
text.pop_back();
|
|
std::string out;
|
|
for (const char32_t c : text) {
|
|
if (c < 0x80) out += static_cast<char>(c);
|
|
else if (c < 0x800) out += {static_cast<char>(0xC0 | (c >> 6)), static_cast<char>(0x80 | (c & 0x3F))};
|
|
else out += {static_cast<char>(0xE0 | (c >> 12)), static_cast<char>(0x80 | ((c >> 6) & 0x3F)),
|
|
static_cast<char>(0x80 | (c & 0x3F))};
|
|
}
|
|
out += "...";
|
|
if (measure(font, out, size) <= width) return out;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
std::vector<std::string> Canvas::wrap(Font& font, const std::string& utf8, float size, float width, int max_lines) {
|
|
std::vector<std::string> lines;
|
|
std::string line;
|
|
std::size_t start = 0;
|
|
while (start <= utf8.size()) {
|
|
auto end = utf8.find(' ', start);
|
|
if (end == std::string::npos) end = utf8.size();
|
|
const auto word = utf8.substr(start, end - start);
|
|
const auto candidate = line.empty() ? word : line + " " + word;
|
|
if (!line.empty() && measure(font, candidate, size) > width) {
|
|
lines.push_back(line);
|
|
line = word;
|
|
} else {
|
|
line = candidate;
|
|
}
|
|
start = end + 1;
|
|
}
|
|
if (!line.empty()) lines.push_back(line);
|
|
if (static_cast<int>(lines.size()) > max_lines) {
|
|
std::string rest = lines[max_lines - 1];
|
|
for (std::size_t i = max_lines; i < lines.size(); ++i) rest += " " + lines[i];
|
|
lines.resize(max_lines);
|
|
lines.back() = fit(font, rest, size, width);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
void Canvas::text(Font& font, const std::string& utf8, float x, float y, float size, Color color, Align align) {
|
|
const auto text = decode_utf8(utf8);
|
|
const float width = measure(font, text, size);
|
|
if (align == Align::Center) x -= width / 2;
|
|
if (align == Align::Right) x -= width;
|
|
// Centers the cap height on y. Cap height is about 0.7 of the pixel size.
|
|
const float baseline = y + size * 0.36f;
|
|
float pen = x;
|
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
|
const auto& glyph = font.glyph(text[i], size);
|
|
const int left = static_cast<int>(std::lround(pen)) + glyph.x_offset;
|
|
const int top = static_cast<int>(std::lround(baseline)) + glyph.y_offset;
|
|
for (int gy = 0; gy < glyph.height; ++gy)
|
|
for (int gx = 0; gx < glyph.width; ++gx)
|
|
blend(left + gx, top + gy, color, glyph.coverage[gy * glyph.width + gx] / 255.0f);
|
|
pen += glyph.advance;
|
|
if (i + 1 < text.size()) pen += font.kerning(text[i], text[i + 1], size);
|
|
}
|
|
}
|
|
|
|
void Canvas::draw(const Canvas& image, int x, int y, float alpha) {
|
|
const int scale = static_cast<int>(std::lround(clamp01(alpha) * 255));
|
|
for (int iy = 0; iy < image.height_; ++iy) {
|
|
const int dy = y + iy;
|
|
if (dy < 0 || dy >= height_) continue;
|
|
for (int ix = 0; ix < image.width_; ++ix) {
|
|
const int dx = x + ix;
|
|
if (dx < 0 || dx >= width_) continue;
|
|
const auto* s = &image.pixels_[(static_cast<std::size_t>(iy) * image.width_ + ix) * 4];
|
|
auto* d = &pixels_[(static_cast<std::size_t>(dy) * width_ + dx) * 4];
|
|
// An identical pixel blends to itself, give or take a little opacity.
|
|
// During a screen change most of the panel is background, so this skips it.
|
|
if (s[0] == d[0] && s[1] == d[1] && s[2] == d[2] && s[3] == d[3]) continue;
|
|
const int a = s[3] * scale / 255;
|
|
if (a == 0) continue;
|
|
if (a == 255 || d[3] == 0) {
|
|
d[0] = s[0];
|
|
d[1] = s[1];
|
|
d[2] = s[2];
|
|
d[3] = static_cast<std::uint8_t>(a);
|
|
continue;
|
|
}
|
|
// Source over destination, in 0..255 integers.
|
|
const int under = d[3] * (255 - a) / 255, out = a + under;
|
|
for (int c = 0; c < 3; ++c) d[c] = static_cast<std::uint8_t>((s[c] * a + d[c] * under + out / 2) / out);
|
|
d[3] = static_cast<std::uint8_t>(out);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool Canvas::write_png(const std::filesystem::path& path) const {
|
|
return stbi_write_png(path.c_str(), width_, height_, 4, pixels_.data(), width_ * 4) != 0;
|
|
}
|
|
|
|
} // namespace knockthru
|