feat(wasm): port Ghostty's wasm32 build
Ports main_wasm.zig as crates/ghostty-wasm (cdylib exporting the Zig names: atlas_*, face_*, deferred_face_*, shaper_*, config_*), the web_canvas font face and shaper (backend selected on wasm32), os/wasm (logging, target, JS interop) and config/Wasm.zig. C-library dependencies (freetype, harfbuzz, fontconfig, onig, gettext, D-Bus, rustix) are target-gated. Like Zig's wasm build, link regex search (StringMap::search_iterator) is unavailable without Oniguruma; Collection/SharedGrid/harfbuzz are compiled out on wasm32. ghostty-os switches from forbid to deny unsafe_code for the JS import blocks. The JS import ABI is currently a documented handle-based interface with a reference host (js/ghostty_host.js); aligning it with zig-js's exact import names follows separately. Test Plan: - cargo build --target wasm32-unknown-unknown -p ghostty-font \ -p ghostty-config -p ghostty-terminal -p ghostty-vt -p ghostty-wasm - cargo test -p ghostty-wasm (node runs the module against a fake canvas)
This commit is contained in:
1 parent
0915f7b935
commit
d89691b0ce
33 files changed
+2866
-30
No files matched your search
@@ -9,15 +9,23 @@ tiny-skia = "0.12.0"
|
||||
png = "0.18.1"
|
||||
thiserror = "2.0.21"
|
||||
log = "0.4.34"
|
||||
freetype-sys = "0.23.0"
|
||||
harfbuzz-sys = "0.8.0"
|
||||
ghostty-unicode.workspace = true
|
||||
bitflags = "2.13.2"
|
||||
yeslogic-fontconfig-sys = "6.0.1"
|
||||
ghostty-datastruct.workspace = true
|
||||
ghostty-config.workspace = true
|
||||
ghostty-terminal.workspace = true
|
||||
|
||||
# The C font libraries are unavailable on wasm32, where the web_canvas
|
||||
# backend (browser Canvas API through JS imports) is used instead
|
||||
# (Zig: `font/backend.zig` `Backend.default`).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
freetype-sys = "0.23.0"
|
||||
harfbuzz-sys = "0.8.0"
|
||||
yeslogic-fontconfig-sys = "6.0.1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
ghostty-os.workspace = true
|
||||
|
||||
# This crate wraps the freetype / harfbuzz / fontconfig C libraries through
|
||||
# their `-sys` crates; unsafe is denied by default and allowed on minimal,
|
||||
# `// SAFETY:`-commented items only.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Font backend selection. Port of `font/backend.zig`.
|
||||
//!
|
||||
//! The Rust rewrite only targets Linux, so [`OPTIONS`] always selects
|
||||
//! On Linux (and other non-wasm targets) [`OPTIONS`] selects
|
||||
//! [`Backend::FontconfigFreetype`] (fontconfig discovery, FreeType rendering,
|
||||
//! HarfBuzz shaping). The other variants are kept for fidelity with the Zig
|
||||
//! enum and its helper predicates.
|
||||
@@ -27,7 +27,11 @@ pub enum Backend {
|
||||
impl Backend {
|
||||
/// Returns the default backend for a build environment.
|
||||
pub fn default_for_target() -> Backend {
|
||||
if cfg!(target_os = "macos") {
|
||||
// wasm32 always uses the browser Canvas API (Zig: `Backend.default`
|
||||
// with `wasm_target == .browser`).
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
Backend::WebCanvas
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Backend::Coretext
|
||||
} else {
|
||||
Backend::FontconfigFreetype
|
||||
@@ -72,14 +76,30 @@ pub struct Options {
|
||||
pub backend: Backend,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const OPTIONS: Options = Options {
|
||||
backend: Backend::FontconfigFreetype,
|
||||
};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub const OPTIONS: Options = Options {
|
||||
backend: Backend::WebCanvas,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn web_canvas_capabilities() {
|
||||
let b = Backend::WebCanvas;
|
||||
assert!(!b.has_freetype());
|
||||
assert!(!b.has_fontconfig());
|
||||
assert!(!b.has_harfbuzz());
|
||||
assert!(!b.has_coretext());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn linux_backend_capabilities() {
|
||||
let b = OPTIONS.backend;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! A deferred face represents a single font face with all the information
|
||||
//! necessary to load it, but defers loading the full face until it is
|
||||
//! needed. Port of `font/DeferredFace.zig` (web_canvas variant, wasm32).
|
||||
|
||||
pub use crate::face::web_canvas::Face;
|
||||
use crate::{
|
||||
Presentation,
|
||||
face::{DesiredSize, web_canvas::Error},
|
||||
};
|
||||
|
||||
/// WebCanvas specific data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebCanvas {
|
||||
/// The CSS "font" attribute, excluding size.
|
||||
pub font_str: String,
|
||||
|
||||
/// The presentation for this font.
|
||||
pub presentation: Presentation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DeferredFace {
|
||||
pub wc: Option<WebCanvas>,
|
||||
}
|
||||
|
||||
impl DeferredFace {
|
||||
pub fn from_web_canvas(wc: WebCanvas) -> DeferredFace {
|
||||
DeferredFace { wc: Some(wc) }
|
||||
}
|
||||
|
||||
/// Returns the family name of the font.
|
||||
pub fn family_name(&self) -> String {
|
||||
self.wc
|
||||
.as_ref()
|
||||
.map(|wc| wc.font_str.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the name of this face.
|
||||
pub fn name(&self) -> String {
|
||||
self.family_name()
|
||||
}
|
||||
|
||||
/// Load the deferred font face.
|
||||
pub fn load(&mut self, size: DesiredSize) -> Result<Face, Error> {
|
||||
let wc = self
|
||||
.wc
|
||||
.as_ref()
|
||||
.expect("deferred face has no web canvas data");
|
||||
Face::init_named(&wc.font_str, size, wc.presentation)
|
||||
}
|
||||
|
||||
/// Returns true if this face can satisfy the given codepoint and
|
||||
/// presentation. Canvas always has the codepoint because we have no way
|
||||
/// of really checking and we let the browser handle it.
|
||||
pub fn has_codepoint(&self, cp: u32, p: Option<Presentation>) -> bool {
|
||||
let Some(wc) = &self.wc else {
|
||||
// Unreachable upstream: the face MUST be loaded.
|
||||
unreachable!("deferred face without web canvas data");
|
||||
};
|
||||
|
||||
// Fast-path if we have a specific presentation and we don't match,
|
||||
// then it is definitely not this face.
|
||||
if p.is_some_and(|desired| wc.presentation != desired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Slow-path: we initialize the font, render it, and check if it
|
||||
// works and the presentation matches.
|
||||
match Face::init_named(&wc.font_str, DesiredSize::new(12.0), wc.presentation) {
|
||||
Ok(face) => face.glyph_index(cp).is_some(),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"failed to init face for codepoint check face={} err={err}",
|
||||
wc.font_str
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,17 @@ use std::sync::Arc;
|
||||
|
||||
use crate::metrics::Metrics;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod freetype;
|
||||
// The pure (non-JS) parts of the web_canvas face compile everywhere so they
|
||||
// can be unit tested natively; the JS-backed `Face` is wasm32-only.
|
||||
pub mod web_canvas;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use freetype::Face;
|
||||
pub use ghostty_config::types_b::FreetypeLoadFlags;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web_canvas::Face;
|
||||
|
||||
/// If a DPI can't be calculated, this DPI is used. This is probably
|
||||
/// wrong on modern devices so it is highly recommended you get the DPI
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
//! The browser Canvas face. Port of `font/face/web_canvas.zig`.
|
||||
//!
|
||||
//! Glyphs are measured and rasterised with a 2D canvas context through the
|
||||
//! JS interop layer (`ghostty_os::wasm::js`). The JS-backed [`Face`] only
|
||||
//! exists on `wasm32`; everything that does not touch JS (metrics math,
|
||||
//! presentation detection, grapheme-cluster glyph indices, bitmap
|
||||
//! conversion) lives at module level so it is unit tested natively.
|
||||
//!
|
||||
//! Deviations from the Zig file (which is stale against the current Atlas):
|
||||
//! the canvas bitmap is RGBA, Zig matches on a non-existent `atlas.format ==
|
||||
//! .rgba`; here a `Bgra` atlas receives the bitmap with R/B swapped (the atlas
|
||||
//! is BGRA, like `Atlas.Wasm.atlas_debug_canvas` assumes) and a `Grayscale`
|
||||
//! atlas receives the alpha channel.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Presentation, atlas, metrics::Metrics};
|
||||
|
||||
/// Graphemes (multi-codepoint clusters) get "glyph indexes" one above the
|
||||
/// maximum unicode codepoint up to the maximum 32-bit unsigned integer.
|
||||
pub const GRAPHEME_START: u32 = 0x10FFFF + 1;
|
||||
pub const GRAPHEME_END: u32 = u32::MAX;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("out of memory")]
|
||||
OutOfMemory,
|
||||
#[error("unknown grapheme cluster")]
|
||||
UnknownGraphemeCluster,
|
||||
#[error("unsupported atlas format")]
|
||||
UnsupportedAtlasFormat,
|
||||
#[error("invalid codepoint")]
|
||||
InvalidCodepoint,
|
||||
#[error(transparent)]
|
||||
Atlas(#[from] atlas::Error),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[error("javascript error: {0}")]
|
||||
Js(#[from] ghostty_os::wasm::js::Error),
|
||||
}
|
||||
|
||||
/// The map that stores multi-codepoint grapheme clusters that are rendered.
|
||||
#[derive(Debug)]
|
||||
pub struct GraphemeTable {
|
||||
to_glyph: HashMap<Vec<u8>, u32>,
|
||||
from_glyph: HashMap<u32, Vec<u8>>,
|
||||
next: u32,
|
||||
}
|
||||
|
||||
impl Default for GraphemeTable {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
to_glyph: HashMap::new(),
|
||||
from_glyph: HashMap::new(),
|
||||
next: GRAPHEME_START,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GraphemeTable {
|
||||
/// Returns the glyph index for the given grapheme cluster (UTF-8). The
|
||||
/// same cluster will always map to the same glyph index. This does not
|
||||
/// render the grapheme, only reserves the index.
|
||||
pub fn glyph_index(&mut self, cluster: &[u8]) -> Result<u32, Error> {
|
||||
// If we already have this stored then return it
|
||||
if let Some(v) = self.to_glyph.get(cluster) {
|
||||
return Ok(*v);
|
||||
}
|
||||
|
||||
// We don't have it stored. The next will be "0" if we're out of
|
||||
// space due to unsigned int wrapping.
|
||||
if self.next == 0 {
|
||||
return Err(Error::OutOfMemory);
|
||||
}
|
||||
|
||||
let index = self.next;
|
||||
self.to_glyph.insert(cluster.to_vec(), index);
|
||||
self.from_glyph.insert(index, cluster.to_vec());
|
||||
self.next = self.next.wrapping_add(1);
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// The UTF-8 bytes of the cluster for a grapheme glyph index.
|
||||
pub fn cluster(&self, glyph_index: u32) -> Option<&[u8]> {
|
||||
self.from_glyph.get(&glyph_index).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn contains(&self, glyph_index: u32) -> bool {
|
||||
self.from_glyph.contains_key(&glyph_index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Measurements from `CanvasRenderingContext2D.measureText`.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct TextMetrics {
|
||||
pub width: f32,
|
||||
pub actual_bounding_box_left: f32,
|
||||
pub actual_bounding_box_right: f32,
|
||||
pub actual_bounding_box_ascent: f32,
|
||||
pub actual_bounding_box_descent: f32,
|
||||
}
|
||||
|
||||
impl TextMetrics {
|
||||
/// We prefer the bounding box since it is tighter but certain text such
|
||||
/// as emoji do not have a bounding box set so we use the full run width.
|
||||
pub fn extent_width(&self) -> f32 {
|
||||
if self.actual_bounding_box_right > 0.0 {
|
||||
self.actual_bounding_box_right
|
||||
} else {
|
||||
self.width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the face metrics from the measurement of "M" (cell width) and
|
||||
/// of "M_" (high and low character: ascent + descent is the cell height).
|
||||
pub fn calc_metrics(m: TextMetrics, m_underscore: TextMetrics) -> Metrics {
|
||||
let cell_width = m.extent_width();
|
||||
let cell_height =
|
||||
m_underscore.actual_bounding_box_ascent + m_underscore.actual_bounding_box_descent;
|
||||
let cell_baseline = m_underscore.actual_bounding_box_descent;
|
||||
|
||||
// There isn't a declared underline position for canvas measurements
|
||||
// so we just go 1 under the cell height to match freetype logic.
|
||||
let underline_position = cell_height - 1.0;
|
||||
let underline_thickness = 1.0f32;
|
||||
|
||||
Metrics {
|
||||
cell_width: cell_width as u32,
|
||||
cell_height: cell_height as u32,
|
||||
cell_baseline: cell_baseline as u32,
|
||||
underline_position: underline_position as u32,
|
||||
underline_thickness: underline_thickness as u32,
|
||||
strikethrough_position: underline_position as u32,
|
||||
strikethrough_thickness: underline_thickness as u32,
|
||||
// The canvas gives no face box; the Zig code leaves these unset (it
|
||||
// predates the required fields), so use the cell size.
|
||||
face_width: f64::from(cell_width),
|
||||
face_height: f64::from(cell_height),
|
||||
..Metrics::init()
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines the presentation of a rendered RGBA bitmap by literally
|
||||
/// inspecting the image data for any color.
|
||||
pub fn bitmap_presentation(bitmap: &[u8]) -> Presentation {
|
||||
let (pixels, _) = bitmap.as_chunks::<4>();
|
||||
if pixels.iter().any(|px| px[0] > 0 || px[1] > 0 || px[2] > 0) {
|
||||
Presentation::Emoji
|
||||
} else {
|
||||
Presentation::Text
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixel size of a rendered glyph, from its measurement. Returns
|
||||
/// `(width, height, broken_bbox)`. Width and height both get "+ 1" so that
|
||||
/// there is a 1px border around the glyph to avoid clipping in the atlas.
|
||||
pub fn glyph_size(m: TextMetrics) -> (u32, u32, bool) {
|
||||
let width = m.extent_width().ceil() as u32 + 1;
|
||||
|
||||
// On Firefox on Linux, the bounding box is broken in some cases for
|
||||
// ideographic glyphs (such as emoji). We detect this and behave
|
||||
// differently.
|
||||
let asc = m.actual_bounding_box_ascent;
|
||||
let desc = m.actual_bounding_box_descent;
|
||||
let broken_bbox = asc + desc < 0.001;
|
||||
let height = if broken_bbox {
|
||||
width
|
||||
} else {
|
||||
(asc + desc).ceil() as u32 + 1
|
||||
};
|
||||
(width, height, broken_bbox)
|
||||
}
|
||||
|
||||
/// Converts an RGBA canvas bitmap to the byte layout of an atlas format.
|
||||
pub fn bitmap_for_atlas(rgba: &[u8], format: atlas::Format) -> Result<Vec<u8>, Error> {
|
||||
debug_assert_eq!(rgba.len() % 4, 0);
|
||||
match format {
|
||||
// Swap R and B (the atlas is BGRA).
|
||||
atlas::Format::Bgra => {
|
||||
let mut out = rgba.to_vec();
|
||||
for px in out.as_chunks_mut::<4>().0 {
|
||||
px.swap(0, 2);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
// Convert down to A8
|
||||
atlas::Format::Grayscale => Ok(rgba.as_chunks::<4>().0.iter().map(|px| px[3]).collect()),
|
||||
atlas::Format::Bgr => Err(Error::UnsupportedAtlasFormat),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserve space in the atlas for a rendered bitmap and write it.
|
||||
pub fn put_in_atlas(
|
||||
atlas: &mut atlas::Atlas,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: &[u8],
|
||||
) -> Result<crate::Glyph, Error> {
|
||||
let formatted = bitmap_for_atlas(rgba, atlas.format)?;
|
||||
let region = atlas.reserve(width, height)?;
|
||||
if region.width > 0 && region.height > 0 {
|
||||
atlas.set(region, &formatted);
|
||||
}
|
||||
Ok(crate::Glyph {
|
||||
width,
|
||||
height,
|
||||
// TODO: this can't be right (upstream comment)
|
||||
offset_x: 0,
|
||||
offset_y: 0,
|
||||
atlas_x: region.x,
|
||||
atlas_y: region.y,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use face_js::Face;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod face_js {
|
||||
use ghostty_os::wasm::js::{self, Arg, Object};
|
||||
|
||||
use super::{
|
||||
Error,
|
||||
GRAPHEME_START,
|
||||
GraphemeTable,
|
||||
TextMetrics,
|
||||
bitmap_presentation,
|
||||
calc_metrics,
|
||||
glyph_size,
|
||||
put_in_atlas,
|
||||
};
|
||||
use crate::{Glyph, Presentation, atlas::Atlas, face::DesiredSize, metrics::Metrics};
|
||||
|
||||
pub struct Face {
|
||||
/// The CSS "font" attribute, excluding size.
|
||||
font_str: String,
|
||||
|
||||
/// The size we currently have set.
|
||||
pub size: DesiredSize,
|
||||
|
||||
/// The presentation for this font.
|
||||
pub presentation: Presentation,
|
||||
|
||||
/// Metrics for this font face. These are useful for renderers.
|
||||
pub metrics: Metrics,
|
||||
|
||||
/// The canvas element that we will reuse to render glyphs
|
||||
canvas: Object,
|
||||
|
||||
graphemes: GraphemeTable,
|
||||
}
|
||||
|
||||
/// An internal (web-canvas-only) format for rendered glyphs since we do
|
||||
/// render passes in multiple different situations.
|
||||
struct RenderedGlyph {
|
||||
width: u32,
|
||||
height: u32,
|
||||
bitmap: Vec<u8>,
|
||||
}
|
||||
|
||||
fn text_metrics(o: &Object) -> Result<TextMetrics, Error> {
|
||||
Ok(TextMetrics {
|
||||
width: o.get_f32("width")?,
|
||||
actual_bounding_box_left: o.get_f32("actualBoundingBoxLeft")?,
|
||||
actual_bounding_box_right: o.get_f32("actualBoundingBoxRight")?,
|
||||
actual_bounding_box_ascent: o.get_f32("actualBoundingBoxAscent")?,
|
||||
actual_bounding_box_descent: o.get_f32("actualBoundingBoxDescent")?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Face {
|
||||
/// Initialize a web canvas font with a "raw" value. The "raw" value
|
||||
/// can be any valid value for a CSS "font" property EXCLUDING the
|
||||
/// size. The size is always added via the `size` parameter.
|
||||
///
|
||||
/// The presentation is given here directly because the browser gives
|
||||
/// us no easy way to determine the presentation we want for this
|
||||
/// font. Callers should just tell us what to expect.
|
||||
pub fn init_named(
|
||||
raw: &str,
|
||||
size: DesiredSize,
|
||||
presentation: Presentation,
|
||||
) -> Result<Face, Error> {
|
||||
// Create our canvas that we're going to continue to reuse.
|
||||
let doc = js::global()?.get_object("document")?;
|
||||
let canvas = doc.call("createElement", &[Arg::Str("canvas")])?;
|
||||
|
||||
let mut result = Face {
|
||||
font_str: raw.to_owned(),
|
||||
size,
|
||||
presentation,
|
||||
// We're going to calculate these right after initialization.
|
||||
metrics: Metrics::init(),
|
||||
canvas,
|
||||
graphemes: GraphemeTable::default(),
|
||||
};
|
||||
result.calc_metrics()?;
|
||||
|
||||
log::debug!("face initialized: {raw}");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Resize the font in-place. If this succeeds, the caller is
|
||||
/// responsible for clearing any glyph caches, font atlas data, etc.
|
||||
pub fn set_size(&mut self, size: DesiredSize) -> Result<(), Error> {
|
||||
let old = self.size;
|
||||
self.size = size;
|
||||
if let Err(e) = self.calc_metrics() {
|
||||
self.size = old;
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the glyph index for the given Unicode code point. For
|
||||
/// canvas, we support every glyph and the ID is just the codepoint
|
||||
/// since we don't have access to the underlying tables anyways. We
|
||||
/// let the browser deal with bad codepoints.
|
||||
pub fn glyph_index(&self, cp: u32) -> Option<u32> {
|
||||
// If this is a multi-codepoint grapheme then we only check if
|
||||
// we actually know about it.
|
||||
if cp >= GRAPHEME_START && !self.graphemes.contains(cp) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Render the glyph to determine if it is colored or not. We have
|
||||
// to do this because the browser will always try to render
|
||||
// whatever we give it and we have no API to determine color.
|
||||
//
|
||||
// We don't want to say yes to the wrong presentation because it
|
||||
// will go into the wrong Atlas.
|
||||
let p = if cp <= 255 {
|
||||
Presentation::Text
|
||||
} else {
|
||||
// If we are unable to render this glyph then we just say we
|
||||
// don't support it.
|
||||
self.glyph_presentation(cp).ok()?
|
||||
};
|
||||
if p != self.presentation {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(cp)
|
||||
}
|
||||
|
||||
/// This determines the presentation of the glyph by literally
|
||||
/// inspecting the image data to look for any color.
|
||||
fn glyph_presentation(&self, cp: u32) -> Result<Presentation, Error> {
|
||||
let render = self.render_glyph_internal(cp)?;
|
||||
Ok(bitmap_presentation(&render.bitmap))
|
||||
}
|
||||
|
||||
/// Returns the glyph index for the given grapheme cluster. The same
|
||||
/// cluster will always map to the same glyph index. This does not
|
||||
/// render the grapheme at this time, only reserves the index.
|
||||
pub fn grapheme_glyph_index(&mut self, cluster: &[u8]) -> Result<u32, Error> {
|
||||
self.graphemes.glyph_index(cluster)
|
||||
}
|
||||
|
||||
/// Render a glyph using the glyph index. The rendered glyph is
|
||||
/// stored in the given texture atlas.
|
||||
pub fn render_glyph(&self, atlas: &mut Atlas, glyph_index: u32) -> Result<Glyph, Error> {
|
||||
let render = self.render_glyph_internal(glyph_index)?;
|
||||
put_in_atlas(atlas, render.width, render.height, &render.bitmap)
|
||||
}
|
||||
|
||||
/// Calculate the metrics associated with a given face.
|
||||
fn calc_metrics(&mut self) -> Result<(), Error> {
|
||||
let ctx = self.context()?;
|
||||
|
||||
// Cell width is the width of our M text
|
||||
let m = text_metrics(&ctx.call("measureText", &[Arg::Str("M")])?)?;
|
||||
|
||||
// To get the cell height we render a high and low character and
|
||||
// get the total of the ascent and descent.
|
||||
let hm = text_metrics(&ctx.call("measureText", &[Arg::Str("M_")])?)?;
|
||||
|
||||
self.metrics = calc_metrics(m, hm);
|
||||
log::debug!("metrics font={} value={:?}", self.font_str, self.metrics);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the 2d context configured for drawing.
|
||||
fn context(&self) -> Result<Object, Error> {
|
||||
// This will return the same context on subsequent calls so it
|
||||
// is important to reset it.
|
||||
let ctx = self.canvas.call("getContext", &[Arg::Str("2d")])?;
|
||||
|
||||
// Clear the canvas
|
||||
{
|
||||
let width = self.canvas.get_f64("width")?;
|
||||
let height = self.canvas.get_f64("height")?;
|
||||
ctx.call(
|
||||
"clearRect",
|
||||
&[
|
||||
Arg::Num(0.0),
|
||||
Arg::Num(0.0),
|
||||
Arg::Num(width),
|
||||
Arg::Num(height),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Set our context font
|
||||
let font_val = format!("{}px {}", self.size.points, self.font_str);
|
||||
ctx.set("font", Arg::Str(&font_val))?;
|
||||
|
||||
// If the font property didn't change, then the font set didn't
|
||||
// work. We do this check because it is very easy to put an
|
||||
// invalid font in and this at least makes it show up in the logs.
|
||||
let check = ctx.get_string("font")?;
|
||||
if font_val != check {
|
||||
log::warn!(
|
||||
"canvas font didn't set, fonts may be broken, expected={font_val} got={check}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
/// Shared logic for rendering a glyph.
|
||||
fn render_glyph_internal(&self, glyph_index: u32) -> Result<RenderedGlyph, Error> {
|
||||
// Encode our glyph to UTF-8 so we can build a JS string out of it.
|
||||
let glyph_str: String = if glyph_index < GRAPHEME_START {
|
||||
char::from_u32(glyph_index)
|
||||
.ok_or(Error::InvalidCodepoint)?
|
||||
.to_string()
|
||||
} else {
|
||||
// We are a multi-codepoint glyph so we have to read the glyph
|
||||
// from the map and it is already utf8 encoded.
|
||||
let slice = self
|
||||
.graphemes
|
||||
.cluster(glyph_index)
|
||||
.ok_or(Error::UnknownGraphemeCluster)?;
|
||||
String::from_utf8_lossy(slice).into_owned()
|
||||
};
|
||||
|
||||
// Get our drawing context and the size of the render
|
||||
let measure_ctx = self.context()?;
|
||||
let m = text_metrics(&measure_ctx.call("measureText", &[Arg::Str(&glyph_str)])?)?;
|
||||
let (width, height, broken_bbox) = glyph_size(m);
|
||||
|
||||
// Resize canvas to match the glyph size exactly
|
||||
{
|
||||
self.canvas.set("width", Arg::from(width))?;
|
||||
self.canvas.set("height", Arg::from(height))?;
|
||||
|
||||
let style = self.canvas.get_object("style")?;
|
||||
style.set("width", Arg::Str(&format!("{width}px")))?;
|
||||
style.set("height", Arg::Str(&format!("{height}px")))?;
|
||||
}
|
||||
|
||||
// Reload our context since we resized the canvas
|
||||
let ctx = self.context()?;
|
||||
|
||||
// For the broken bounding box case we render ideographic
|
||||
// baselines so that we just render the glyph fully in the box
|
||||
// with no offsets.
|
||||
if broken_bbox {
|
||||
ctx.set("textBaseline", Arg::Str("ideographic"))?;
|
||||
}
|
||||
|
||||
// Draw background
|
||||
ctx.set("fillStyle", Arg::Str("transparent"))?;
|
||||
ctx.call(
|
||||
"fillRect",
|
||||
&[
|
||||
Arg::Num(0.0),
|
||||
Arg::Num(0.0),
|
||||
Arg::from(width),
|
||||
Arg::from(height),
|
||||
],
|
||||
)?;
|
||||
|
||||
// Draw glyph
|
||||
ctx.set("fillStyle", Arg::Str("black"))?;
|
||||
ctx.call(
|
||||
"fillText",
|
||||
&[
|
||||
Arg::Str(&glyph_str),
|
||||
Arg::from(m.actual_bounding_box_left + 1.0),
|
||||
if broken_bbox {
|
||||
Arg::from(height)
|
||||
} else {
|
||||
Arg::from(m.actual_bounding_box_ascent + 1.0)
|
||||
},
|
||||
],
|
||||
)?;
|
||||
|
||||
// Read the image data and get it into a Vec<u8> on our side
|
||||
let data = ctx.call(
|
||||
"getImageData",
|
||||
&[
|
||||
Arg::Num(0.0),
|
||||
Arg::Num(0.0),
|
||||
Arg::from(width),
|
||||
Arg::from(height),
|
||||
],
|
||||
)?;
|
||||
let src_array = data.get_object("data")?;
|
||||
let len = src_array.get_f64("length")? as usize;
|
||||
let mut bitmap = vec![0u8; len];
|
||||
src_array.copy_bytes_to(&mut bitmap)?;
|
||||
|
||||
Ok(RenderedGlyph {
|
||||
width,
|
||||
height,
|
||||
bitmap,
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends our canvas to the element with id `face-canvas` (the
|
||||
/// debugging hook behind the `face_debug_canvas` export).
|
||||
pub fn debug_canvas(&self) -> Result<(), Error> {
|
||||
let doc = js::global()?.get_object("document")?;
|
||||
let elem = doc.call("getElementById", &[Arg::Str("face-canvas")])?;
|
||||
if elem.is_nullish() {
|
||||
return Err(js::Error::Nullish.into());
|
||||
}
|
||||
elem.call("append", &[Arg::Object(&self.canvas)])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::atlas::{Atlas, Format};
|
||||
|
||||
#[test]
|
||||
fn grapheme_indexes_are_stable_and_sequential() {
|
||||
let mut t = GraphemeTable::default();
|
||||
let a = t.glyph_index("👍🏽".as_bytes()).unwrap();
|
||||
let b = t.glyph_index("e\u{301}".as_bytes()).unwrap();
|
||||
assert_eq!(a, GRAPHEME_START);
|
||||
assert_eq!(b, GRAPHEME_START + 1);
|
||||
assert_eq!(t.glyph_index("👍🏽".as_bytes()).unwrap(), a);
|
||||
assert_eq!(t.cluster(b), Some("e\u{301}".as_bytes()));
|
||||
assert!(t.contains(a));
|
||||
assert!(!t.contains(GRAPHEME_START + 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_table_exhaustion_is_oom() {
|
||||
let mut t = GraphemeTable {
|
||||
next: 0,
|
||||
..GraphemeTable::default()
|
||||
};
|
||||
assert!(matches!(t.glyph_index(b"ab"), Err(Error::OutOfMemory)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_prefer_bounding_box() {
|
||||
let m = TextMetrics {
|
||||
width: 10.0,
|
||||
actual_bounding_box_right: 8.7,
|
||||
..TextMetrics::default()
|
||||
};
|
||||
let hm = TextMetrics {
|
||||
actual_bounding_box_ascent: 12.4,
|
||||
actual_bounding_box_descent: 3.9,
|
||||
..TextMetrics::default()
|
||||
};
|
||||
let r = calc_metrics(m, hm);
|
||||
assert_eq!(r.cell_width, 8);
|
||||
assert_eq!(r.cell_height, 16);
|
||||
assert_eq!(r.cell_baseline, 3);
|
||||
assert_eq!(r.underline_position, 15);
|
||||
assert_eq!(r.underline_thickness, 1);
|
||||
assert_eq!(r.strikethrough_position, 15);
|
||||
assert_eq!(r.strikethrough_thickness, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_fall_back_to_run_width() {
|
||||
let m = TextMetrics {
|
||||
width: 9.5,
|
||||
..TextMetrics::default()
|
||||
};
|
||||
assert_eq!(calc_metrics(m, TextMetrics::default()).cell_width, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_detects_color() {
|
||||
assert_eq!(
|
||||
bitmap_presentation(&[0, 0, 0, 255, 0, 0, 0, 10]),
|
||||
Presentation::Text
|
||||
);
|
||||
assert_eq!(
|
||||
bitmap_presentation(&[0, 0, 0, 255, 0, 5, 0, 255]),
|
||||
Presentation::Emoji
|
||||
);
|
||||
assert_eq!(bitmap_presentation(&[]), Presentation::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_size_has_one_pixel_border() {
|
||||
let m = TextMetrics {
|
||||
width: 7.0,
|
||||
actual_bounding_box_right: 6.2,
|
||||
actual_bounding_box_ascent: 9.1,
|
||||
actual_bounding_box_descent: 2.0,
|
||||
..TextMetrics::default()
|
||||
};
|
||||
assert_eq!(glyph_size(m), (8, 13, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_size_broken_bbox_is_square() {
|
||||
let m = TextMetrics {
|
||||
width: 14.0,
|
||||
..TextMetrics::default()
|
||||
};
|
||||
assert_eq!(glyph_size(m), (15, 15, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_conversion() {
|
||||
let rgba = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
assert_eq!(
|
||||
bitmap_for_atlas(&rgba, Format::Bgra).unwrap(),
|
||||
[3, 2, 1, 4, 7, 6, 5, 8]
|
||||
);
|
||||
assert_eq!(bitmap_for_atlas(&rgba, Format::Grayscale).unwrap(), [4, 8]);
|
||||
assert!(matches!(
|
||||
bitmap_for_atlas(&rgba, Format::Bgr),
|
||||
Err(Error::UnsupportedAtlasFormat)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_in_atlas_writes_alpha() {
|
||||
let mut atlas = Atlas::new(32, Format::Grayscale);
|
||||
let rgba = [0, 0, 0, 200, 0, 0, 0, 100, 0, 0, 0, 50, 0, 0, 0, 25];
|
||||
let g = put_in_atlas(&mut atlas, 2, 2, &rgba).unwrap();
|
||||
assert_eq!((g.width, g.height), (2, 2));
|
||||
let row = |y: u32| {
|
||||
let o = ((g.atlas_y + y) * atlas.size + g.atlas_x) as usize;
|
||||
atlas.data[o..o + 2].to_vec()
|
||||
};
|
||||
assert_eq!(row(0), [200, 100]);
|
||||
assert_eq!(row(1), [50, 25]);
|
||||
}
|
||||
}
|
||||
@@ -8,37 +8,55 @@
|
||||
pub mod atlas;
|
||||
pub mod backend;
|
||||
pub mod codepoint_map;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod codepoint_resolver;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod collection;
|
||||
pub mod config_types;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod deferred_face;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "deferred_face_web_canvas.rs"]
|
||||
pub mod deferred_face;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod discovery;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod embedded;
|
||||
pub mod face;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod fontconfig;
|
||||
pub mod glyph;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod library;
|
||||
pub mod metrics;
|
||||
pub mod nerd_font_attributes;
|
||||
pub mod opentype;
|
||||
pub mod shape;
|
||||
pub mod shaper;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod shared_grid;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod shared_grid_set;
|
||||
pub mod sprite;
|
||||
|
||||
pub use atlas::Atlas;
|
||||
pub use backend::{Backend, OPTIONS as options};
|
||||
pub use codepoint_map::CodepointMap;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use codepoint_resolver::CodepointResolver;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use collection::Collection;
|
||||
pub use deferred_face::DeferredFace;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use discovery::{Descriptor, Discover};
|
||||
pub use face::Face;
|
||||
pub use glyph::Glyph;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use library::Library;
|
||||
pub use metrics::Metrics;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use shared_grid::SharedGrid;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use shared_grid_set::SharedGridSet;
|
||||
pub use sprite::{Face as SpriteFace, Sprite};
|
||||
|
||||
@@ -72,11 +90,13 @@ pub enum Presentation {
|
||||
}
|
||||
|
||||
/// A FontIndex that can be used to use the sprite font directly.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const SPRITE_INDEX: collection::Index =
|
||||
collection::Index::init_special(collection::Special::Sprite);
|
||||
|
||||
/// The default font size adjustment we use when loading fallback fonts.
|
||||
///
|
||||
/// TODO: Add user configuration for this instead of hard-coding it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const DEFAULT_FALLBACK_ADJUSTMENT: collection::SizeAdjustment =
|
||||
collection::SizeAdjustment::IcWidth;
|
||||
@@ -241,9 +241,8 @@ fn zround(v: f64) -> f64 {
|
||||
|
||||
impl Metrics {
|
||||
/// All-zero metrics, except `cursor_thickness` which defaults to 1.
|
||||
/// (Zig's private `init()`; used by tests.)
|
||||
#[cfg(test)]
|
||||
fn init() -> Metrics {
|
||||
/// (Zig's private `init()`; used by tests and the web_canvas face.)
|
||||
pub(crate) fn init() -> Metrics {
|
||||
Metrics {
|
||||
cell_width: 0,
|
||||
cell_height: 0,
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
//! The shaper implementation for the Linux backend is HarfBuzz
|
||||
//! ([`crate::shaper::harfbuzz::Shaper`]).
|
||||
|
||||
pub use crate::shaper::feature::{DEFAULT_FEATURES, Feature, FeatureList};
|
||||
/// Shaper implementation for our compile options (backend
|
||||
/// `fontconfig_freetype` => HarfBuzz).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use crate::shaper::harfbuzz::Shaper;
|
||||
/// Shaper implementation for the `web_canvas` backend (wasm32).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use crate::shaper::web_canvas::Shaper;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use crate::shaper::{
|
||||
cache::Cache,
|
||||
feature::{DEFAULT_FEATURES, Feature, FeatureList},
|
||||
harfbuzz,
|
||||
noop,
|
||||
run::{RunIterator, RunIteratorHook, RunOptions, TextRun},
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
//! Text shapers. Port of `font/shaper/`.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod cache;
|
||||
pub mod feature;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod harfbuzz;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod hb;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod noop;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod run;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod web_canvas;
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) mod test_util;
|
||||
@@ -0,0 +1,254 @@
|
||||
//! The web canvas shaper. Port of `font/shaper/web_canvas.zig`.
|
||||
//!
|
||||
//! Canvas cannot shape, so this shaper only performs grapheme clustering:
|
||||
//! it can render things like skin tone emoji but not ligatures such as "=>".
|
||||
//! `font_features` are not supported.
|
||||
//!
|
||||
//! The Zig shaper plugs into `RunIterator` through hooks; the Rust
|
||||
//! `RunIterator` is tied to the FreeType `Collection`, which does not exist
|
||||
//! on wasm32, so the hook methods ([`Shaper::prepare`], [`Shaper::add_codepoint`],
|
||||
//! [`Shaper::finalize`]) are exposed directly. The face that reserves
|
||||
//! grapheme glyph indexes is passed to [`Shaper::shape`] as a closure
|
||||
//! (`face.graphemeGlyphIndex` in Zig, `run.group.faceFromIndex(run.font_index)`).
|
||||
|
||||
use ghostty_unicode::grapheme::{BreakState, grapheme_break};
|
||||
|
||||
use crate::shape::Cell;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid codepoint")]
|
||||
InvalidCodepoint,
|
||||
#[error("cell buffer too small")]
|
||||
CellBufferTooSmall,
|
||||
#[error(transparent)]
|
||||
Face(#[from] crate::face::web_canvas::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RunItem {
|
||||
/// The codepoint for this cell. This must be used in conjunction with
|
||||
/// cluster to find the total set of codepoints for a given cell.
|
||||
codepoint: u32,
|
||||
|
||||
/// Cluster is set to the X value of the cell that this codepoint is part
|
||||
/// of. Note that a cell can have multiple codepoints with zero-width
|
||||
/// joiners (ZWJ) and such. Terminals do NOT handle full extended
|
||||
/// grapheme clustering well so it is possible a single grapheme extends
|
||||
/// multiple clusters.
|
||||
cluster: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Shaper {
|
||||
/// The shared memory used for shaping results.
|
||||
cell_buf: Vec<Cell>,
|
||||
|
||||
/// The shared memory used for storing information about a run.
|
||||
run_buf: Vec<RunItem>,
|
||||
}
|
||||
|
||||
impl Shaper {
|
||||
/// `cap` is the size of the buffer used for storing shaped results.
|
||||
/// This should be at least the number of columns in the terminal.
|
||||
pub fn new(cap: usize) -> Shaper {
|
||||
Shaper {
|
||||
cell_buf: vec![Cell::default(); cap],
|
||||
run_buf: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cell_buf_len(&self) -> usize {
|
||||
self.cell_buf.len()
|
||||
}
|
||||
|
||||
pub fn end_frame(&self) {}
|
||||
|
||||
/// Hook: reset the buffer for our current run.
|
||||
pub fn prepare(&mut self) {
|
||||
self.run_buf.clear();
|
||||
}
|
||||
|
||||
/// Hook: add a codepoint of the current run.
|
||||
pub fn add_codepoint(&mut self, cp: u32, cluster: u32) {
|
||||
self.run_buf.push(RunItem {
|
||||
codepoint: cp,
|
||||
cluster,
|
||||
});
|
||||
}
|
||||
|
||||
/// Hook: finalize the run (nothing to do).
|
||||
pub fn finalize(&mut self) {}
|
||||
|
||||
/// Shape the current text run. The return value is only valid until the
|
||||
/// next shape call. `grapheme_index` maps a UTF-8 grapheme cluster to a
|
||||
/// glyph index (see `Face::grapheme_glyph_index`).
|
||||
pub fn shape<E>(
|
||||
&mut self,
|
||||
mut grapheme_index: impl FnMut(&[u8]) -> Result<u32, E>,
|
||||
) -> Result<&[Cell], Error>
|
||||
where
|
||||
Error: From<E>,
|
||||
{
|
||||
let n = self.run_buf.len();
|
||||
if n > self.cell_buf.len() {
|
||||
return Err(Error::CellBufferTooSmall);
|
||||
}
|
||||
|
||||
match n {
|
||||
// Special cases: if we have no codepoints (is this possible?)
|
||||
// then our result is also an empty cell run.
|
||||
0 => return Ok(&self.cell_buf[..0]),
|
||||
|
||||
// If we have only 1 codepoint, then we assume that it is a
|
||||
// single grapheme and just let it through. At this point, we
|
||||
// can't have any more information to do anything else.
|
||||
1 => {
|
||||
self.cell_buf[0] = Cell {
|
||||
x: self.run_buf[0].cluster as u16,
|
||||
glyph_index: self.run_buf[0].codepoint,
|
||||
..Cell::default()
|
||||
};
|
||||
return Ok(&self.cell_buf[..1]);
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// We know we have at least two codepoints, so we now go through each
|
||||
// and perform grapheme clustering.
|
||||
let mut break_state = BreakState::Default;
|
||||
let mut cp1 = self.run_buf[0].codepoint;
|
||||
|
||||
let mut start = 0usize;
|
||||
let mut cur = 0usize;
|
||||
let mut i = 1usize;
|
||||
while i <= n {
|
||||
// We loop to n so that we can handle the end case. In the end
|
||||
// case, we always assume it is a grapheme break. This isn't
|
||||
// strictly true but its how terminals work today.
|
||||
let is_break = i == n || {
|
||||
let cp2 = self.run_buf[i].codepoint;
|
||||
let r = grapheme_break(cp1, cp2, &mut break_state);
|
||||
cp1 = cp2;
|
||||
r
|
||||
};
|
||||
|
||||
// If this is NOT a grapheme break, cp2 is part of a single
|
||||
// grapheme cluster and we expect there could be more.
|
||||
if !is_break {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// This IS a grapheme break, meaning that cp2 is NOT part of cp1.
|
||||
// So we need to render the prior grapheme.
|
||||
let len = i - start;
|
||||
debug_assert!(len > 0);
|
||||
let glyph_index = if len == 1 {
|
||||
// If we have only a single codepoint then just render it as-is.
|
||||
self.run_buf[start].codepoint
|
||||
} else {
|
||||
// We must have multiple codepoints. In this case we UTF-8
|
||||
// encode the codepoints and send them to the face to reserve
|
||||
// a private glyph index.
|
||||
let mut cluster = String::new();
|
||||
for item in &self.run_buf[start..i] {
|
||||
cluster.push(char::from_u32(item.codepoint).ok_or(Error::InvalidCodepoint)?);
|
||||
}
|
||||
grapheme_index(cluster.as_bytes()).map_err(Error::from)?
|
||||
};
|
||||
|
||||
self.cell_buf[cur] = Cell {
|
||||
x: self.run_buf[start].cluster as u16,
|
||||
glyph_index,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
start = i;
|
||||
cur += 1;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Ok(&self.cell_buf[..cur])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::face::web_canvas::{Error as FaceError, GraphemeTable};
|
||||
|
||||
fn run(codepoints: &[(u32, u32)]) -> (Vec<Cell>, GraphemeTable) {
|
||||
let mut shaper = Shaper::new(16);
|
||||
let mut table = GraphemeTable::default();
|
||||
shaper.prepare();
|
||||
for (cp, cl) in codepoints {
|
||||
shaper.add_codepoint(*cp, *cl);
|
||||
}
|
||||
shaper.finalize();
|
||||
let cells = shaper
|
||||
.shape(|c| table.glyph_index(c).map_err(Error::from))
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
(cells, table)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_run() {
|
||||
assert_eq!(run(&[]).0.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_codepoint_passes_through() {
|
||||
let (cells, _) = run(&[(0x41, 3)]);
|
||||
assert_eq!(cells.len(), 1);
|
||||
assert_eq!((cells[0].x, cells[0].glyph_index), (3, 0x41));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_is_one_cell_per_codepoint() {
|
||||
let (cells, table) = run(&[(0x61, 0), (0x62, 1), (0x63, 2)]);
|
||||
assert_eq!(
|
||||
cells
|
||||
.iter()
|
||||
.map(|c| (c.x, c.glyph_index))
|
||||
.collect::<Vec<_>>(),
|
||||
[(0, 0x61), (1, 0x62), (2, 0x63)]
|
||||
);
|
||||
assert!(!table.contains(crate::face::web_canvas::GRAPHEME_START));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_mark_reserves_grapheme_glyph() {
|
||||
// e + COMBINING ACUTE ACCENT, then x
|
||||
let (cells, table) = run(&[(0x65, 0), (0x301, 0), (0x78, 1)]);
|
||||
assert_eq!(cells.len(), 2);
|
||||
assert_eq!(cells[0].x, 0);
|
||||
assert_eq!(
|
||||
cells[0].glyph_index,
|
||||
crate::face::web_canvas::GRAPHEME_START
|
||||
);
|
||||
assert_eq!(
|
||||
table.cluster(cells[0].glyph_index),
|
||||
Some("e\u{301}".as_bytes())
|
||||
);
|
||||
assert_eq!((cells[1].x, cells[1].glyph_index), (1, 0x78));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skin_tone_emoji_is_one_cluster() {
|
||||
let (cells, table) = run(&[(0x1F44D, 0), (0x1F3FD, 0)]);
|
||||
assert_eq!(cells.len(), 1);
|
||||
assert_eq!(table.cluster(cells[0].glyph_index), Some("👍🏽".as_bytes()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_too_small() {
|
||||
let mut shaper = Shaper::new(1);
|
||||
shaper.add_codepoint(0x61, 0);
|
||||
shaper.add_codepoint(0x62, 1);
|
||||
let r = shaper.shape(|_| Ok::<u32, FaceError>(0));
|
||||
assert!(matches!(r, Err(Error::CellBufferTooSmall)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user