Files
ghostty-rs/crates/ghostty-font/src/shaper/web_canvas.rs
T
ddidderr 42f05c926d 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)
2026-10-12 05:21:14 +02:00

255 lines
8.3 KiB
Rust

//! 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)));
}
}