mirror of
https://github.com/MrLowPr/overdrive.git
synced 2026-10-17 17:01:14 +02:00
894 lines
37 KiB
C++
894 lines
37 KiB
C++
// Copyright (C) 2026 half144 and PS5 React contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Additional attribution term: see LICENSE-ATTRIBUTION.
|
|
#include "text_shaper.hpp"
|
|
|
|
#include <SheenBidi/SheenBidi.h>
|
|
#include <hb-raster.h>
|
|
#include <hb.h>
|
|
|
|
#include "er_text_shaper.h"
|
|
#include "host_api.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <climits>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <sys/stat.h>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
// Rasterized glyphs kept for reuse, least recently drawn evicted first: about 7,000 CJK glyphs at
|
|
// 24 px. Fonts are loaded whole, on first use, and kept (docs/TEXT.md lists their sizes).
|
|
constexpr std::size_t kGlyphCacheBytes = 4u << 20;
|
|
// Shaped strings kept for reuse (layout measures a node, wraps it, then the compositor draws it):
|
|
// about 1 KB each for a typical label.
|
|
constexpr std::size_t kLayoutCacheEntries = 128;
|
|
|
|
enum class Script : std::uint8_t { kOther, kHan, kKana, kDevanagari, kBengali, kArabic };
|
|
|
|
struct FaceSpec {
|
|
const char* file;
|
|
Script script;
|
|
// Line box over and under the baseline, in thousandths of an em, at most: CJK uses its em box,
|
|
// Arabic its common letters rather than the stacked marks its hhea metrics make room for.
|
|
int ascent, descent;
|
|
// Joined scripts take no letter spacing or faux-bold advance, which would break their joins.
|
|
bool joined;
|
|
};
|
|
constexpr FaceSpec kFaces[] = {
|
|
{"NotoSansJP-Regular.otf", Script::kKana, 880, 120, false},
|
|
{"NotoSansSC-Regular.otf", Script::kHan, 880, 120, false},
|
|
{"NotoSansDevanagari-Regular.ttf", Script::kDevanagari, 1000, 450, true},
|
|
{"NotoSansBengali-Regular.ttf", Script::kBengali, 1000, 450, true},
|
|
{"NotoSansArabic-Regular.ttf", Script::kArabic, 1050, 500, true},
|
|
};
|
|
constexpr int kFaceCount = sizeof kFaces / sizeof kFaces[0];
|
|
constexpr int kJapanese = 0, kChinese = 1;
|
|
|
|
struct Face {
|
|
bool present = false, failed = false;
|
|
hb_face_t* face = nullptr;
|
|
hb_font_t* probe = nullptr;
|
|
std::vector<std::pair<int, hb_font_t*>> sizes;
|
|
};
|
|
|
|
struct State {
|
|
std::string font_dir;
|
|
void (*log)(const char*) = nullptr;
|
|
Face faces[kFaceCount];
|
|
bool any_face = false;
|
|
hb_language_t language = nullptr;
|
|
bool prefer_japanese = false;
|
|
unsigned serial = 0;
|
|
hb_buffer_t* buffer = nullptr;
|
|
hb_raster_draw_t* raster = nullptr;
|
|
};
|
|
State* state = nullptr;
|
|
|
|
void log_line(const char* format, const char* a, double b = 0) {
|
|
if (!state->log) return;
|
|
char line[256];
|
|
std::snprintf(line, sizeof line, format, a, b);
|
|
state->log(line);
|
|
}
|
|
|
|
// ---- Unicode --------------------------------------------------------------------------------
|
|
|
|
std::uint32_t next_codepoint(const char* text, std::size_t length, std::size_t& at) {
|
|
const auto* p = reinterpret_cast<const unsigned char*>(text);
|
|
const unsigned char b = p[at];
|
|
int count = b < 0x80 ? 1 : (b & 0xE0) == 0xC0 ? 2 : (b & 0xF0) == 0xE0 ? 3 : (b & 0xF8) == 0xF0 ? 4 : 0;
|
|
if (!count || at + count > length) {
|
|
at++;
|
|
return 0xFFFD;
|
|
}
|
|
std::uint32_t cp = count == 1 ? b : b & (0x7F >> count);
|
|
for (int i = 1; i < count; i++) {
|
|
if ((p[at + i] & 0xC0) != 0x80) {
|
|
at += i;
|
|
return 0xFFFD;
|
|
}
|
|
cp = (cp << 6) | (p[at + i] & 0x3F);
|
|
}
|
|
at += count;
|
|
return cp;
|
|
}
|
|
|
|
Script script_of(std::uint32_t c) {
|
|
if ((c >= 0x0900 && c <= 0x0963) || (c >= 0x0966 && c <= 0x097F) || (c >= 0xA8E0 && c <= 0xA8FF) ||
|
|
(c >= 0x1CD0 && c <= 0x1CFF))
|
|
return Script::kDevanagari;
|
|
if (c >= 0x0980 && c <= 0x09FF) return Script::kBengali;
|
|
if ((c >= 0x0600 && c <= 0x06FF) || (c >= 0x0750 && c <= 0x077F) || (c >= 0x0870 && c <= 0x08FF) ||
|
|
(c >= 0xFB50 && c <= 0xFDFF) || (c >= 0xFE70 && c <= 0xFEFF))
|
|
return Script::kArabic;
|
|
if ((c >= 0x3040 && c <= 0x30FF) || (c >= 0x31F0 && c <= 0x31FF) || (c >= 0xFF66 && c <= 0xFF9F))
|
|
return Script::kKana;
|
|
if ((c >= 0x2E80 && c <= 0x2FDF) || (c >= 0x3000 && c <= 0x303F) || (c >= 0x3190 && c <= 0x33FF) ||
|
|
(c >= 0x3400 && c <= 0x4DBF) || (c >= 0x4E00 && c <= 0x9FFF) || (c >= 0xF900 && c <= 0xFAFF) ||
|
|
(c >= 0xFE30 && c <= 0xFE4F) || (c >= 0xFF00 && c <= 0xFFEF) || (c >= 0x20000 && c <= 0x3FFFF))
|
|
return Script::kHan;
|
|
return Script::kOther;
|
|
}
|
|
|
|
// Scripts that must be shaped (or reordered) even when a baked font has their characters: Hebrew to
|
|
// Myanmar, Khmer, Mongolian and the Arabic presentation forms.
|
|
bool needs_shaping(std::uint32_t c) {
|
|
return (c >= 0x0590 && c <= 0x109F) || (c >= 0x1780 && c <= 0x18AF) || (c >= 0xFB1D && c <= 0xFDFF) ||
|
|
(c >= 0xFE70 && c <= 0xFEFF);
|
|
}
|
|
|
|
// Strong right-to-left characters (Hebrew, Arabic, Syriac, Thaana, NKo and their extensions): text
|
|
// without any, and without a right-to-left direction, skips the bidi algorithm.
|
|
bool right_to_left(std::uint32_t c) {
|
|
return (c >= 0x0590 && c <= 0x08FF) || (c >= 0xFB1D && c <= 0xFDFF) || (c >= 0xFE70 && c <= 0xFEFF) ||
|
|
(c >= 0x10800 && c <= 0x10FFF) || (c >= 0x1E800 && c <= 0x1EFFF);
|
|
}
|
|
|
|
// Characters drawn as part of the one before them (combining marks, joiners, variation selectors).
|
|
bool extends(std::uint32_t c) {
|
|
if (c == 0x200C || c == 0x200D || (c >= 0xFE00 && c <= 0xFE0F) || (c >= 0xE0100 && c <= 0xE01EF)) return true;
|
|
const hb_unicode_general_category_t category = hb_unicode_general_category(hb_unicode_funcs_get_default(), c);
|
|
return category == HB_UNICODE_GENERAL_CATEGORY_NON_SPACING_MARK ||
|
|
category == HB_UNICODE_GENERAL_CATEGORY_SPACING_MARK ||
|
|
category == HB_UNICODE_GENERAL_CATEGORY_ENCLOSING_MARK;
|
|
}
|
|
|
|
// Invisible formatting characters: never a reason to leave the bitmap path.
|
|
bool ignorable(std::uint32_t c) {
|
|
return (c >= 0x200B && c <= 0x200F) || (c >= 0x2028 && c <= 0x202E) || (c >= 0x2060 && c <= 0x206F) ||
|
|
(c >= 0xFE00 && c <= 0xFE0F) || c == 0xFEFF || c == 0x00AD;
|
|
}
|
|
|
|
bool is_space(std::uint32_t c) { return c == ' ' || c == '\t' || c == 0x3000; }
|
|
|
|
// Ideographic text breaks between any two characters (UAX #14 class ID and its neighbours). Other
|
|
// scripts, Devanagari, Bengali and Arabic among them, break only after spaces.
|
|
bool breaks_around(std::uint32_t c) {
|
|
const Script script = script_of(c);
|
|
return (script == Script::kHan || script == Script::kKana) && c != 0x3000;
|
|
}
|
|
|
|
// A virama or zero-width joiner joins the next consonant into one conjunct: never a line break.
|
|
bool joins_next(std::uint32_t c) {
|
|
return c == 0x200D ||
|
|
hb_unicode_combining_class(hb_unicode_funcs_get_default(), c) == HB_UNICODE_COMBINING_CLASS_VIRAMA;
|
|
}
|
|
|
|
// Kinsoku: characters that may not start a line (closing punctuation, small kana, iteration and
|
|
// prolonged-sound marks) or end one (opening brackets).
|
|
bool no_line_start(std::uint32_t c) {
|
|
static constexpr char32_t kList[] =
|
|
U"!),.:;?]}¢°’”‰′″℃、。〃々〆〉》」』】〕〗〙〟〻ゝゞァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖ"
|
|
U"ーヽヾ・‥…‐゠–〜!%),.:;?]}。」、・ァィゥェォャュョッー゙゚";
|
|
for (char32_t x : kList)
|
|
if (x == c) return true;
|
|
return false;
|
|
}
|
|
bool no_line_end(std::uint32_t c) {
|
|
static constexpr char32_t kList[] = U"([{£¥‘“〈《「『【〔〖〘〝([{「$£¥";
|
|
for (char32_t x : kList)
|
|
if (x == c) return true;
|
|
return false;
|
|
}
|
|
|
|
// ---- Fonts ----------------------------------------------------------------------------------
|
|
|
|
bool load(int index) {
|
|
Face& face = state->faces[index];
|
|
if (face.face) return true;
|
|
if (!face.present || face.failed) return false;
|
|
const std::string path = state->font_dir + "/" + kFaces[index].file;
|
|
FILE* file = std::fopen(path.c_str(), "rb");
|
|
long size = -1;
|
|
if (file && std::fseek(file, 0, SEEK_END) == 0) size = std::ftell(file);
|
|
char* data = size > 0 && std::fseek(file, 0, SEEK_SET) == 0 ? static_cast<char*>(std::malloc(size)) : nullptr;
|
|
const bool ok = data && std::fread(data, 1, size, file) == static_cast<std::size_t>(size);
|
|
if (file) std::fclose(file);
|
|
if (!ok) {
|
|
std::free(data);
|
|
face.failed = true;
|
|
log_line("text: cannot load %s", path.c_str());
|
|
return false;
|
|
}
|
|
hb_blob_t* blob = hb_blob_create(data, static_cast<unsigned>(size), HB_MEMORY_MODE_READONLY, data, std::free);
|
|
face.face = hb_face_create(blob, 0);
|
|
hb_blob_destroy(blob);
|
|
face.probe = hb_font_create(face.face);
|
|
log_line("text: loaded %s (%.1f MB)", kFaces[index].file, size / 1048576.0);
|
|
return true;
|
|
}
|
|
|
|
// The face at a pixel size: positions in 26.6 fixed point.
|
|
hb_font_t* font_at(int index, int px) {
|
|
Face& face = state->faces[index];
|
|
for (auto& [size, font] : face.sizes)
|
|
if (size == px) return font;
|
|
hb_font_t* font = hb_font_create(face.face);
|
|
hb_font_set_scale(font, px * 64, px * 64);
|
|
face.sizes.emplace_back(px, font);
|
|
return font;
|
|
}
|
|
|
|
bool face_has(int index, std::uint32_t cp) {
|
|
hb_codepoint_t glyph;
|
|
return load(index) && hb_font_get_nominal_glyph(state->faces[index].probe, cp, &glyph);
|
|
}
|
|
|
|
// The face that draws `cp`: its script's faces first, then the face of the text around it.
|
|
int face_for(std::uint32_t cp, int previous) {
|
|
int order[kFaceCount + 1];
|
|
int count = 0;
|
|
const Script script = script_of(cp);
|
|
if (script == Script::kHan || script == Script::kKana) {
|
|
const bool japanese = script == Script::kKana || state->prefer_japanese;
|
|
order[count++] = japanese ? kJapanese : kChinese;
|
|
order[count++] = japanese ? kChinese : kJapanese;
|
|
} else if (script != Script::kOther) {
|
|
for (int i = 0; i < kFaceCount; i++)
|
|
if (kFaces[i].script == script) order[count++] = i;
|
|
} else {
|
|
if (previous >= 0) order[count++] = previous;
|
|
for (int i = 0; i < kFaceCount; i++) order[count++] = i;
|
|
}
|
|
for (int i = 0; i < count; i++)
|
|
if (face_has(order[i], cp)) return order[i];
|
|
return -1;
|
|
}
|
|
|
|
bool baked_has(const BitmapFont* font, std::uint32_t cp) {
|
|
if (cp >= font->first && cp <= font->last) return true;
|
|
std::uint32_t lo = 0, hi = font->extras_count;
|
|
while (lo < hi) {
|
|
const std::uint32_t mid = (lo + hi) / 2;
|
|
if (font->extras[mid].codepoint == cp) return true;
|
|
if (font->extras[mid].codepoint < cp) lo = mid + 1;
|
|
else hi = mid;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---- Glyph cache ----------------------------------------------------------------------------
|
|
|
|
struct CachedGlyph {
|
|
std::uint64_t key;
|
|
CachedGlyph *newer, *older;
|
|
int left, top; // bitmap origin from the pen position and the baseline, in pixels (top is up)
|
|
int width, height;
|
|
std::uint8_t pixels[1];
|
|
};
|
|
|
|
struct GlyphCache {
|
|
std::unordered_map<std::uint64_t, CachedGlyph*> map;
|
|
CachedGlyph *newest = nullptr, *oldest = nullptr;
|
|
std::size_t bytes = 0;
|
|
|
|
void unlink(CachedGlyph* g) {
|
|
(g->newer ? g->newer->older : newest) = g->older;
|
|
(g->older ? g->older->newer : oldest) = g->newer;
|
|
}
|
|
void push(CachedGlyph* g) {
|
|
g->newer = nullptr;
|
|
g->older = newest;
|
|
(newest ? newest->newer : oldest) = g;
|
|
newest = g;
|
|
}
|
|
void clear() {
|
|
while (oldest) {
|
|
CachedGlyph* g = oldest;
|
|
unlink(g);
|
|
std::free(g);
|
|
}
|
|
map.clear();
|
|
bytes = 0;
|
|
}
|
|
};
|
|
GlyphCache* cache = nullptr;
|
|
|
|
const CachedGlyph* glyph_image(int face, int px, std::uint32_t glyph) {
|
|
const std::uint64_t key = (static_cast<std::uint64_t>(face) << 40) | (static_cast<std::uint64_t>(px) << 32) | glyph;
|
|
if (auto found = cache->map.find(key); found != cache->map.end()) {
|
|
cache->unlink(found->second);
|
|
cache->push(found->second);
|
|
return found->second;
|
|
}
|
|
hb_raster_draw_glyph(state->raster, font_at(face, px), glyph);
|
|
hb_raster_image_t* image = hb_raster_draw_render(state->raster);
|
|
if (!image) return nullptr;
|
|
hb_raster_extents_t extents;
|
|
hb_raster_image_get_extents(image, &extents);
|
|
const int width = extents.width <= 255 ? static_cast<int>(extents.width) : 0;
|
|
const int height = extents.height <= 255 ? static_cast<int>(extents.height) : 0;
|
|
const std::size_t size = sizeof(CachedGlyph) + static_cast<std::size_t>(width) * height;
|
|
auto* entry = static_cast<CachedGlyph*>(std::malloc(size));
|
|
if (!entry) {
|
|
hb_raster_draw_recycle_image(state->raster, image);
|
|
return nullptr;
|
|
}
|
|
entry->key = key;
|
|
entry->left = extents.x_origin;
|
|
entry->top = extents.y_origin + height;
|
|
entry->width = width;
|
|
entry->height = height;
|
|
// The image's first row is its bottom (y grows upward in glyph space).
|
|
const std::uint8_t* source = hb_raster_image_get_buffer(image);
|
|
for (int row = 0; row < height; row++)
|
|
std::memcpy(entry->pixels + static_cast<std::size_t>(row) * width,
|
|
source + static_cast<std::size_t>(height - 1 - row) * extents.stride, width);
|
|
hb_raster_draw_recycle_image(state->raster, image);
|
|
cache->map.emplace(key, entry);
|
|
cache->push(entry);
|
|
cache->bytes += size;
|
|
while (cache->bytes > kGlyphCacheBytes && cache->oldest != entry) {
|
|
CachedGlyph* old = cache->oldest;
|
|
cache->unlink(old);
|
|
cache->map.erase(old->key);
|
|
cache->bytes -= sizeof(CachedGlyph) + static_cast<std::size_t>(old->width) * old->height;
|
|
std::free(old);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
// ---- Shaping --------------------------------------------------------------------------------
|
|
|
|
struct Glyph {
|
|
std::uint32_t id; // glyph ID in its face, or the codepoint for a baked glyph
|
|
std::int32_t advance, dx, dy; // 26.6
|
|
std::uint32_t cluster; // byte offset of the first character it draws
|
|
std::int8_t face; // -1: baked bitmap glyph
|
|
};
|
|
|
|
struct Run {
|
|
std::uint32_t start, end;
|
|
std::int8_t face;
|
|
std::uint8_t level; // bidi embedding level: odd runs are right to left
|
|
std::uint32_t first_glyph = 0, glyph_count = 0;
|
|
};
|
|
|
|
struct Paragraph {
|
|
std::uint32_t start;
|
|
std::uint8_t level; // base level: 1 for a right-to-left paragraph
|
|
};
|
|
|
|
// kClusterStart: a HarfBuzz cluster (a base and its marks) starts here. kGraphemeStart: a cluster
|
|
// that does not continue a conjunct, the only place a word too wide for its line may be cut.
|
|
enum : std::uint8_t { kClusterStart = 1, kBreakBefore = 2, kSpace = 4, kNewline = 8, kGraphemeStart = 16 };
|
|
|
|
struct Layout {
|
|
std::string text, family;
|
|
int px = 0, letter_spacing = 0, bold = 0, direction = 0;
|
|
unsigned serial = 0;
|
|
const BitmapFont* baked = nullptr;
|
|
std::vector<Glyph> glyphs;
|
|
std::vector<Run> runs;
|
|
std::vector<Paragraph> paragraphs;
|
|
std::vector<std::uint8_t> levels; // per byte
|
|
std::vector<std::int32_t> advance; // per byte: the 26.6 advance of the cluster starting there
|
|
std::vector<std::uint8_t> flags; // per byte
|
|
int ascent = 0, descent = 0, line_height = 0; // pixels
|
|
};
|
|
|
|
struct LayoutCache {
|
|
Layout entries[kLayoutCacheEntries];
|
|
std::size_t next = 0;
|
|
};
|
|
LayoutCache* layouts = nullptr;
|
|
|
|
// Embedding levels per byte (Unicode bidirectional algorithm, one paragraph per line feed), and
|
|
// each paragraph's base level: from `direction`, else from its first strong character.
|
|
void resolve_levels(Layout& layout) {
|
|
const char* text = layout.text.c_str();
|
|
const std::size_t length = layout.text.size();
|
|
const SBLevel base = layout.direction == ER_DIRECTION_RTL ? 1 : layout.direction == ER_DIRECTION_LTR ? 0
|
|
: SBLevelDefaultLTR;
|
|
layout.levels.assign(length + 1, 0);
|
|
bool bidi = layout.direction == ER_DIRECTION_RTL;
|
|
for (std::size_t at = 0; at < length && !bidi;) bidi = right_to_left(next_codepoint(text, length, at));
|
|
if (!bidi) {
|
|
layout.paragraphs.push_back({0, 0});
|
|
return;
|
|
}
|
|
SBCodepointSequence sequence{SBStringEncodingUTF8, const_cast<char*>(text), length};
|
|
SBAlgorithmRef algorithm = SBAlgorithmCreate(&sequence);
|
|
for (SBUInteger offset = 0; algorithm && offset < length;) {
|
|
SBParagraphRef paragraph = SBAlgorithmCreateParagraph(algorithm, offset, length - offset, base);
|
|
if (!paragraph) break;
|
|
const SBUInteger size = SBParagraphGetLength(paragraph);
|
|
std::memcpy(layout.levels.data() + offset, SBParagraphGetLevelsPtr(paragraph), size);
|
|
layout.paragraphs.push_back({static_cast<std::uint32_t>(offset), SBParagraphGetBaseLevel(paragraph)});
|
|
SBParagraphRelease(paragraph);
|
|
offset += size;
|
|
}
|
|
if (algorithm) SBAlgorithmRelease(algorithm);
|
|
if (layout.paragraphs.empty()) layout.paragraphs.push_back({0, 0});
|
|
}
|
|
|
|
void itemize(Layout& layout) {
|
|
const char* text = layout.text.c_str();
|
|
const std::size_t length = layout.text.size();
|
|
std::size_t at = 0;
|
|
int previous_face = -1;
|
|
while (at < length) {
|
|
const std::size_t start = at;
|
|
const std::uint32_t cp = next_codepoint(text, length, at);
|
|
bool baked = cp == '\n' || ignorable(cp) || (baked_has(layout.baked, cp) && !needs_shaping(cp));
|
|
while (at < length) {
|
|
std::size_t peek = at;
|
|
const std::uint32_t mark = next_codepoint(text, length, peek);
|
|
if (!extends(mark)) break;
|
|
baked = baked && (ignorable(mark) || baked_has(layout.baked, mark));
|
|
at = peek;
|
|
}
|
|
const int face = baked ? -1 : face_for(cp, previous_face);
|
|
if (face >= 0) previous_face = face;
|
|
const std::uint8_t level = layout.levels[start];
|
|
if (!layout.runs.empty() && layout.runs.back().face == face && layout.runs.back().level == level)
|
|
layout.runs.back().end = static_cast<std::uint32_t>(at);
|
|
else
|
|
layout.runs.push_back({static_cast<std::uint32_t>(start), static_cast<std::uint32_t>(at),
|
|
static_cast<std::int8_t>(face), level});
|
|
}
|
|
}
|
|
|
|
void shape(Layout& layout) {
|
|
const char* text = layout.text.c_str();
|
|
const std::size_t length = layout.text.size();
|
|
for (Run& run : layout.runs) {
|
|
run.first_glyph = static_cast<std::uint32_t>(layout.glyphs.size());
|
|
if (run.face < 0) {
|
|
std::uint32_t cluster = run.start;
|
|
for (std::size_t at = run.start; at < run.end;) {
|
|
const std::uint32_t start = static_cast<std::uint32_t>(at);
|
|
const std::uint32_t cp = next_codepoint(text, length, at);
|
|
// A baked combining mark belongs to its base's cluster, so no line starts with it.
|
|
if (start == run.start || !extends(cp)) cluster = start;
|
|
if (cp == '\n' || ignorable(cp)) {
|
|
layout.glyphs.push_back({cp, 0, 0, 0, cluster, -1});
|
|
continue;
|
|
}
|
|
const int advance = font_glyph(layout.baked, cp)->advance + layout.letter_spacing + layout.bold;
|
|
layout.glyphs.push_back({cp, advance * 64, 0, 0, cluster, -1});
|
|
}
|
|
} else {
|
|
hb_buffer_t* buffer = state->buffer;
|
|
hb_buffer_clear_contents(buffer);
|
|
hb_buffer_add_utf8(buffer, text, static_cast<int>(length), run.start, static_cast<int>(run.end - run.start));
|
|
hb_buffer_set_direction(buffer, run.level & 1 ? HB_DIRECTION_RTL : HB_DIRECTION_LTR);
|
|
hb_buffer_set_language(buffer, state->language);
|
|
hb_buffer_guess_segment_properties(buffer);
|
|
hb_shape(font_at(run.face, layout.px), buffer, nullptr, 0);
|
|
unsigned count = 0;
|
|
const hb_glyph_info_t* info = hb_buffer_get_glyph_infos(buffer, &count);
|
|
const hb_glyph_position_t* position = hb_buffer_get_glyph_positions(buffer, nullptr);
|
|
const int extra = kFaces[run.face].joined ? 0 : (layout.letter_spacing + layout.bold) * 64;
|
|
for (unsigned i = 0; i < count; i++) {
|
|
const bool first_of_cluster = i == 0 || info[i].cluster != info[i - 1].cluster;
|
|
layout.glyphs.push_back({info[i].codepoint, position[i].x_advance + (first_of_cluster ? extra : 0),
|
|
position[i].x_offset, position[i].y_offset, info[i].cluster, run.face});
|
|
}
|
|
// CJK keeps the Latin line height this way (Noto CJK's hhea metrics would make its lines a
|
|
// quarter taller), and Arabic grows it by about a third rather than doubling it.
|
|
hb_font_extents_t extents{};
|
|
hb_font_get_h_extents(font_at(run.face, layout.px), &extents);
|
|
const int em = layout.px * 64;
|
|
const int ascent = std::min<int>(extents.ascender, em * kFaces[run.face].ascent / 1000);
|
|
const int descent = std::min<int>(-extents.descender, em * kFaces[run.face].descent / 1000);
|
|
layout.ascent = std::max(layout.ascent, (ascent + 63) >> 6);
|
|
layout.descent = std::max(layout.descent, (descent + 63) >> 6);
|
|
}
|
|
run.glyph_count = static_cast<std::uint32_t>(layout.glyphs.size()) - run.first_glyph;
|
|
}
|
|
layout.line_height = layout.ascent + layout.descent;
|
|
|
|
layout.advance.assign(length + 1, 0);
|
|
layout.flags.assign(length + 1, 0);
|
|
for (const Glyph& g : layout.glyphs) {
|
|
layout.advance[g.cluster] += g.advance;
|
|
layout.flags[g.cluster] |= kClusterStart;
|
|
}
|
|
// Break opportunities, before the character at each cluster start: after a space or a zero-width
|
|
// space, and around CJK characters as kinsoku allows.
|
|
std::uint32_t before = 0;
|
|
for (std::size_t at = 0; at < length;) {
|
|
const std::size_t start = at;
|
|
const std::uint32_t cp = next_codepoint(text, length, at);
|
|
if (cp == '\n') layout.flags[start] |= kNewline;
|
|
if (is_space(cp)) layout.flags[start] |= kSpace;
|
|
if ((layout.flags[start] & kClusterStart) && (start == 0 || !joins_next(before)))
|
|
layout.flags[start] |= kGraphemeStart;
|
|
if (start > 0 && (layout.flags[start] & kGraphemeStart) && !is_space(cp) &&
|
|
(is_space(before) || before == 0x200B ||
|
|
((breaks_around(before) || breaks_around(cp)) && !no_line_start(cp) && !no_line_end(before))))
|
|
layout.flags[start] |= kBreakBefore;
|
|
before = cp;
|
|
}
|
|
}
|
|
|
|
const Layout& layout_for(const char* text, const char* family, std::uint8_t font_size, int letter_spacing,
|
|
std::uint8_t font_weight, std::uint8_t direction) {
|
|
const BitmapFont* baked = er_text_font(family, font_size);
|
|
const int bold = font_weight ? 1 : 0;
|
|
const char* family_name = family ? family : "";
|
|
for (const Layout& layout : layouts->entries)
|
|
if (layout.baked == baked && layout.serial == state->serial && layout.px == font_size &&
|
|
layout.letter_spacing == letter_spacing && layout.bold == bold && layout.direction == direction &&
|
|
layout.text == text &&
|
|
layout.family == family_name)
|
|
return layout;
|
|
Layout& layout = layouts->entries[layouts->next];
|
|
layouts->next = (layouts->next + 1) % kLayoutCacheEntries;
|
|
layout = Layout{};
|
|
layout.text = text;
|
|
layout.family = family_name;
|
|
layout.px = font_size;
|
|
layout.letter_spacing = letter_spacing;
|
|
layout.bold = bold;
|
|
layout.direction = direction;
|
|
layout.serial = state->serial;
|
|
layout.baked = baked;
|
|
layout.ascent = baked->baseline;
|
|
layout.descent = baked->line_height - baked->baseline;
|
|
resolve_levels(layout);
|
|
itemize(layout);
|
|
shape(layout);
|
|
return layout;
|
|
}
|
|
|
|
// ---- Line breaking --------------------------------------------------------------------------
|
|
|
|
struct Line {
|
|
std::uint32_t start, end; // bytes, trailing spaces excluded
|
|
std::int32_t width; // 26.6
|
|
};
|
|
|
|
int to_px(std::int32_t fixed) { return (fixed + 63) >> 6; }
|
|
|
|
// Breaks like the bitmap renderer: at newlines, at break opportunities when the next cluster would
|
|
// pass `max_w` (0 = no limit), and inside a word only when nothing else fits, between grapheme
|
|
// clusters (a conjunct stays whole). `truncated` reports text left over after `max_lines`.
|
|
std::vector<Line> break_lines(const Layout& layout, int max_w, int max_lines, bool& truncated) {
|
|
std::vector<Line> lines;
|
|
truncated = false;
|
|
const std::uint32_t length = static_cast<std::uint32_t>(layout.text.size());
|
|
const std::int32_t limit = max_w > 0 ? max_w * 64 : INT32_MAX;
|
|
std::uint32_t at = 0;
|
|
auto skip_spaces = [&](std::uint32_t p) {
|
|
while (p < length && (layout.flags[p] & kSpace)) p++;
|
|
return p;
|
|
};
|
|
auto content_end = [&](std::uint32_t start, std::uint32_t end, std::int32_t& width) {
|
|
// Width without trailing spaces: recount from start, ending at the last non-space cluster.
|
|
width = 0;
|
|
std::int32_t running = 0;
|
|
std::uint32_t last = start;
|
|
for (std::uint32_t p = start; p < end; p++) {
|
|
if (!(layout.flags[p] & kClusterStart)) continue;
|
|
running += layout.advance[p];
|
|
if (!(layout.flags[p] & (kSpace | kNewline))) {
|
|
width = running;
|
|
last = p + 1;
|
|
while (last < end && !(layout.flags[last] & kClusterStart)) last++;
|
|
}
|
|
}
|
|
return last;
|
|
};
|
|
while (at < length) {
|
|
if (max_lines > 0 && static_cast<int>(lines.size()) >= max_lines) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
at = skip_spaces(at);
|
|
if (at >= length) break;
|
|
const std::uint32_t start = at;
|
|
std::uint32_t breakpoint = 0, grapheme = 0;
|
|
std::int32_t width = 0;
|
|
std::uint32_t end = length;
|
|
at = length;
|
|
for (std::uint32_t p = start; p < length; p++) {
|
|
if (layout.flags[p] & kNewline) {
|
|
end = p;
|
|
at = p + 1;
|
|
break;
|
|
}
|
|
if (!(layout.flags[p] & kClusterStart)) continue;
|
|
if (p > start && (layout.flags[p] & kBreakBefore)) breakpoint = p;
|
|
if (p > start && (layout.flags[p] & kGraphemeStart)) grapheme = p;
|
|
// A line holds at least one grapheme, even one wider than `max_w`.
|
|
if (width + layout.advance[p] > limit && p > start && !(layout.flags[p] & kSpace) && (breakpoint || grapheme)) {
|
|
end = breakpoint ? breakpoint : grapheme;
|
|
at = end;
|
|
break;
|
|
}
|
|
width += layout.advance[p];
|
|
}
|
|
Line line{start, 0, 0};
|
|
line.end = content_end(start, end, line.width);
|
|
lines.push_back(line);
|
|
}
|
|
if (lines.empty()) lines.push_back({0, 0, 0});
|
|
return lines;
|
|
}
|
|
|
|
// ---- Drawing --------------------------------------------------------------------------------
|
|
|
|
struct Style {
|
|
std::uint32_t color;
|
|
bool bold, italic;
|
|
std::uint8_t decoration;
|
|
};
|
|
|
|
Style style_at(const ERTextRenderParams* params, const std::uint8_t* span_map, std::uint32_t cluster) {
|
|
Style style{params->color, params->font_weight != 0, params->font_style != 0, params->text_decoration};
|
|
if (!span_map) return style;
|
|
const ERTextSpan& span = params->spans[span_map[cluster]];
|
|
if (span.color) style.color = span.color;
|
|
if (span.font_weight != 0xFF) style.bold = span.font_weight != 0;
|
|
if (span.font_style != 0xFF) style.italic = span.font_style != 0;
|
|
if (span.text_decoration != 0xFF) style.decoration = span.text_decoration;
|
|
return style;
|
|
}
|
|
|
|
void draw_glyph(const Layout& layout, const Glyph& g, int x, int line_top, const ERRect& clip, const Style& style) {
|
|
for (int pass = 0; pass < (style.bold ? 2 : 1); pass++) {
|
|
if (g.face < 0) {
|
|
er_text_draw_glyph(layout.baked, g.id, x + pass, line_top + layout.ascent - layout.baked->baseline, &clip,
|
|
style.color, style.italic);
|
|
continue;
|
|
}
|
|
const CachedGlyph* image = glyph_image(g.face, layout.px, g.id);
|
|
if (!image || !image->width) return;
|
|
const int left = x + pass + image->left + ((g.dx + 32) >> 6);
|
|
const int top = line_top + layout.ascent - image->top - ((g.dy + 32) >> 6);
|
|
er_text_draw_coverage(image->pixels, image->width, image->height, left, top, &clip, style.color, style.italic);
|
|
}
|
|
}
|
|
|
|
std::uint8_t paragraph_level(const Layout& layout, std::uint32_t at) {
|
|
std::uint8_t level = 0;
|
|
for (const Paragraph& paragraph : layout.paragraphs)
|
|
if (paragraph.start <= at) level = paragraph.level;
|
|
return level;
|
|
}
|
|
|
|
// The runs of a line in display order: rule L2 of the bidi algorithm, reversing every sequence of
|
|
// runs at or above each level from the highest down to the lowest odd one.
|
|
std::vector<const Run*> visual_runs(const Layout& layout, const Line& line) {
|
|
std::vector<const Run*> runs;
|
|
std::uint8_t highest = 0, lowest_odd = 255;
|
|
for (const Run& run : layout.runs) {
|
|
if (run.end <= line.start || run.start >= line.end) continue;
|
|
runs.push_back(&run);
|
|
highest = std::max(highest, run.level);
|
|
if (run.level & 1) lowest_odd = std::min(lowest_odd, run.level);
|
|
}
|
|
for (int level = highest; level >= lowest_odd && level > 0; level--) {
|
|
for (std::size_t i = 0; i < runs.size();) {
|
|
if (runs[i]->level < level) {
|
|
i++;
|
|
continue;
|
|
}
|
|
std::size_t j = i;
|
|
while (j < runs.size() && runs[j]->level >= level) j++;
|
|
std::reverse(runs.begin() + i, runs.begin() + j);
|
|
i = j;
|
|
}
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
void render(const ERTextRenderParams* params, const char* text, const std::uint8_t* span_map) {
|
|
const Layout& layout = layout_for(text, params->font_family, er_text_clamp_font_size(params->font_size),
|
|
params->letter_spacing, params->font_weight, params->direction);
|
|
const ERRect& clip = params->clip;
|
|
bool truncated = false;
|
|
std::vector<Line> lines = break_lines(layout, clip.w, params->number_of_lines, truncated);
|
|
const int line_height = params->line_height > 0 ? params->line_height : layout.line_height;
|
|
const bool ellipsis = truncated && params->ellipsize_mode != ER_TEXT_ELLIPSIZE_CLIP;
|
|
const std::uint32_t ellipsis_cp = baked_has(layout.baked, 0x2026) ? 0x2026 : '.';
|
|
const int ellipsis_count = ellipsis_cp == 0x2026 ? 1 : 3;
|
|
const int ellipsis_w = ellipsis_count * (font_glyph(layout.baked, ellipsis_cp)->advance + layout.letter_spacing + layout.bold);
|
|
|
|
for (std::size_t i = 0; i < lines.size(); i++) {
|
|
const int box_top = clip.y + static_cast<int>(i) * line_height;
|
|
if (box_top >= clip.y + clip.h) break;
|
|
// Half the leading above the line, as in CSS and React Native, so a lineHeight centres the text.
|
|
const int line_top = box_top + (line_height - layout.line_height) / 2;
|
|
Line line = lines[i];
|
|
const bool cut = ellipsis && i + 1 == lines.size();
|
|
if (cut) {
|
|
// Whole grapheme clusters that fit before the ellipsis.
|
|
const std::int32_t room = (clip.w - ellipsis_w) * 64;
|
|
const std::uint32_t full = line.end;
|
|
std::int32_t width = 0;
|
|
std::uint32_t p = line.start;
|
|
for (; p < full; p++) {
|
|
if (!(layout.flags[p] & kClusterStart)) continue;
|
|
if (layout.flags[p] & kGraphemeStart) {
|
|
line.end = p;
|
|
line.width = width;
|
|
}
|
|
if (width + layout.advance[p] > room) break;
|
|
width += layout.advance[p];
|
|
}
|
|
if (p == full) {
|
|
line.end = full;
|
|
line.width = width;
|
|
}
|
|
}
|
|
const bool rtl = paragraph_level(layout, line.start) & 1;
|
|
const int width = to_px(line.width) + (cut ? ellipsis_w : 0);
|
|
// A truncated line starts where its paragraph starts; the ellipsis goes at its end.
|
|
const std::uint8_t align = cut ? ER_TEXT_ALIGN_START : er_text_physical_align(params->text_align, rtl);
|
|
int x = clip.x;
|
|
if (align == ER_TEXT_ALIGN_CENTER) x += std::max(0, (clip.w - width) / 2);
|
|
else if (align == ER_TEXT_ALIGN_RIGHT || (align == ER_TEXT_ALIGN_START && rtl)) x += std::max(0, clip.w - width);
|
|
|
|
const Style base = style_at(params, nullptr, 0);
|
|
auto draw_ellipsis = [&](std::int32_t& pen) {
|
|
for (int k = 0; k < ellipsis_count; k++) {
|
|
draw_glyph(layout, Glyph{ellipsis_cp, 0, 0, 0, 0, -1}, (pen + 32) >> 6, line_top, clip, base);
|
|
pen += (font_glyph(layout.baked, ellipsis_cp)->advance + layout.letter_spacing + layout.bold) * 64;
|
|
}
|
|
};
|
|
std::int32_t pen = x * 64;
|
|
if (cut && rtl) draw_ellipsis(pen);
|
|
for (const Run* run : visual_runs(layout, line)) {
|
|
// Shaped right-to-left runs come out of HarfBuzz in display order; baked ones are reversed
|
|
// here, with mirrored brackets.
|
|
const bool reverse = run->face < 0 && (run->level & 1);
|
|
for (std::uint32_t k = 0; k < run->glyph_count; k++) {
|
|
Glyph g = layout.glyphs[run->first_glyph + (reverse ? run->glyph_count - 1 - k : k)];
|
|
if (g.cluster < line.start || g.cluster >= line.end) continue;
|
|
if (reverse) g.id = hb_unicode_mirroring(hb_unicode_funcs_get_default(), g.id);
|
|
const Style style = style_at(params, span_map, g.cluster);
|
|
const int gx = (pen + 32) >> 6;
|
|
draw_glyph(layout, g, gx, line_top, clip, style);
|
|
pen += g.advance;
|
|
if (style.decoration != ER_TEXT_DECORATION_NONE) {
|
|
const int y = line_top + (style.decoration == ER_TEXT_DECORATION_UNDERLINE ? layout.ascent + 1 : layout.ascent * 2 / 3);
|
|
const int x0 = std::max(gx, static_cast<int>(clip.x)), x1 = std::min((pen + 32) >> 6, clip.x + clip.w);
|
|
if (x1 > x0 && y >= clip.y && y < clip.y + clip.h) er_text_fill(style.color, x0, y, x1 - x0, 1);
|
|
}
|
|
}
|
|
}
|
|
if (cut && !rtl) draw_ellipsis(pen);
|
|
}
|
|
}
|
|
|
|
// ---- Engine callbacks -----------------------------------------------------------------------
|
|
|
|
bool claims(const char* text, const char* family, std::uint8_t font_size, std::uint8_t direction) {
|
|
// Right-to-left text needs the bidi algorithm even when the baked font has every glyph.
|
|
if (direction == ER_DIRECTION_RTL) return true;
|
|
if (!state->any_face) return false;
|
|
const BitmapFont* baked = nullptr;
|
|
const std::size_t length = std::strlen(text);
|
|
for (std::size_t at = 0; at < length;) {
|
|
if (static_cast<unsigned char>(text[at]) < 0x80) {
|
|
at++;
|
|
continue;
|
|
}
|
|
const std::uint32_t cp = next_codepoint(text, length, at);
|
|
if (ignorable(cp)) continue;
|
|
if (needs_shaping(cp)) return true;
|
|
if (!baked) baked = er_text_font(family, font_size);
|
|
if (!baked_has(baked, cp)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void measure(const char* text, std::uint8_t font_size, const char* family, std::int16_t letter_spacing,
|
|
std::uint8_t font_weight, int* out_width, int* out_height) {
|
|
const Layout& layout = layout_for(text, family, font_size, letter_spacing, font_weight, ER_DIRECTION_INHERIT);
|
|
std::int32_t width = 0;
|
|
for (std::size_t p = 0; p < layout.text.size(); p++) width += layout.advance[p];
|
|
*out_width = to_px(width);
|
|
*out_height = layout.line_height;
|
|
}
|
|
|
|
int wrap(const char* text, std::uint8_t font_size, const char* family, std::int16_t letter_spacing,
|
|
std::uint8_t font_weight, int max_w, int max_lines, int* out_width) {
|
|
const Layout& layout = layout_for(text, family, font_size, letter_spacing, font_weight, ER_DIRECTION_INHERIT);
|
|
bool truncated = false;
|
|
const std::vector<Line> lines = break_lines(layout, max_w, max_lines, truncated);
|
|
*out_width = 0;
|
|
for (const Line& line : lines) *out_width = std::max(*out_width, to_px(line.width));
|
|
return static_cast<int>(lines.size());
|
|
}
|
|
|
|
// Render workers measure and draw text concurrently. The HarfBuzz buffer and rasterizer, the glyph and
|
|
// layout caches and the lazily loaded faces are shared, and the references they hand out outlive the
|
|
// lookup, so shaped text renders one call at a time. claims() reads only the baked fonts.
|
|
std::mutex shaping;
|
|
|
|
void serial_measure(const char* text, std::uint8_t font_size, const char* family, std::int16_t letter_spacing,
|
|
std::uint8_t font_weight, int* out_width, int* out_height) {
|
|
std::lock_guard lock(shaping);
|
|
measure(text, font_size, family, letter_spacing, font_weight, out_width, out_height);
|
|
}
|
|
|
|
int serial_wrap(const char* text, std::uint8_t font_size, const char* family, std::int16_t letter_spacing,
|
|
std::uint8_t font_weight, int max_w, int max_lines, int* out_width) {
|
|
std::lock_guard lock(shaping);
|
|
return wrap(text, font_size, family, letter_spacing, font_weight, max_w, max_lines, out_width);
|
|
}
|
|
|
|
void serial_render(const ERTextRenderParams* params, const char* text, const std::uint8_t* span_map) {
|
|
std::lock_guard lock(shaping);
|
|
render(params, text, span_map);
|
|
}
|
|
|
|
const ERTextShaper kShaper = {claims, serial_measure, serial_wrap, serial_render};
|
|
|
|
} // namespace
|
|
|
|
namespace text_shaper {
|
|
|
|
void install(const char* font_dir, void (*log)(const char* line)) {
|
|
shutdown();
|
|
state = new State;
|
|
cache = new GlyphCache;
|
|
layouts = new LayoutCache;
|
|
cache->map.reserve(4096);
|
|
state->font_dir = font_dir;
|
|
state->log = log;
|
|
for (int i = 0; i < kFaceCount; i++) {
|
|
struct stat st;
|
|
const std::string path = state->font_dir + "/" + kFaces[i].file;
|
|
state->faces[i].present = stat(path.c_str(), &st) == 0 && S_ISREG(st.st_mode);
|
|
state->any_face = state->any_face || state->faces[i].present;
|
|
}
|
|
host::DeviceInfo device;
|
|
host::device_info(device);
|
|
set_language(*device.language ? device.language : "en");
|
|
state->buffer = hb_buffer_create();
|
|
state->raster = hb_raster_draw_create_or_fail();
|
|
hb_raster_draw_set_scale_factor(state->raster, 64, 64);
|
|
er_text_set_shaper(&kShaper);
|
|
}
|
|
|
|
void set_language(const char* tag) {
|
|
if (!state) return;
|
|
state->language = hb_language_from_string(tag, -1);
|
|
state->prefer_japanese = std::strncmp(tag, "ja", 2) == 0 && (tag[2] == '\0' || tag[2] == '-');
|
|
state->serial++;
|
|
}
|
|
|
|
void shutdown() {
|
|
if (!state) return;
|
|
er_text_set_shaper(nullptr);
|
|
delete layouts;
|
|
cache->clear();
|
|
delete cache;
|
|
for (Face& face : state->faces) {
|
|
for (auto& [size, font] : face.sizes) hb_font_destroy(font);
|
|
hb_font_destroy(face.probe);
|
|
hb_face_destroy(face.face);
|
|
}
|
|
hb_buffer_destroy(state->buffer);
|
|
hb_raster_draw_destroy(state->raster);
|
|
delete state;
|
|
state = nullptr;
|
|
layouts = nullptr;
|
|
cache = nullptr;
|
|
}
|
|
|
|
std::vector<std::pair<int, int>> lines(const char* text, const char* font_family, int font_size, int max_w) {
|
|
std::lock_guard lock(shaping);
|
|
const Layout& layout = layout_for(text, font_family, er_text_clamp_font_size(font_size), 0, 0, ER_DIRECTION_INHERIT);
|
|
bool truncated = false;
|
|
std::vector<std::pair<int, int>> out;
|
|
for (const Line& line : break_lines(layout, max_w, 0, truncated))
|
|
out.emplace_back(static_cast<int>(line.start), static_cast<int>(line.end));
|
|
return out;
|
|
}
|
|
|
|
} // namespace text_shaper
|