mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 20:16:50 -06:00
* Sprint 1: bidi and Arabic typography engine fonts: ISO 15924 script and a:latin/a:ea/a:cs slot classification; shape_range shapes an item with the whole paragraph as HarfRust pre/post context, explicit script/language/direction, glyphs kept in visual order; per-grapheme-cluster fallback (slot face, theme script font, OS cascade families, bundled craft-fonts face); GDEF ligature carets. text: UAX #9 levels for the whole paragraph (a:pPr/@rtl), itemization by level, script, face and shaping style (paint-only changes no longer split words), logical line breaking at cluster boundaries, per-line L1/L2 reordering (L4 mirroring by the shaper); RTL start edge and bullets, justLow kashida; caret Affinity, logical/visual movement, bidi hit testing and selection as the union of visual boxes. model/pptx: theme a:font script fonts and algn="justLow" round-trip. engine: format.rtl (Right-to-Left Text Direction), justifyLow align. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fonts: the bundled script face comes before installed fonts; no Microsoft family names Arabic, Hebrew, Thai and Devanagari clusters try the craft-fonts face first, so every machine draws the same, then openly licensed or OS-native installed families, then any installed face. Installed Microsoft faces are still used through that scan, but not named (AGENTS.md §1.1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Tayeb <tayeb@192.168.11.179> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
125 lines
5.2 KiB
Rust
125 lines
5.2 KiB
Rust
//! OpenType shaping with HarfRust.
|
|
//!
|
|
//! [`shape_range`] shapes one item of a paragraph (one face, one direction, one script) with the
|
|
//! rest of the paragraph as pre/post context, so Arabic letters at an item boundary (a colour
|
|
//! change inside a word, a bold letter) still take their joining forms. Glyphs come back in
|
|
//! visual order, exactly as the shaper produced them: right-to-left items are not reversed.
|
|
|
|
use std::ops::Range;
|
|
|
|
use harfrust::{Direction, Feature, Language, Script, ShapeOptions, Tag, UnicodeBuffer};
|
|
use skrifa::MetadataProvider;
|
|
use skrifa::instance::Size;
|
|
|
|
use crate::FontFace;
|
|
use crate::script::ScriptTag;
|
|
|
|
/// One shaped glyph, in font units.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct ShapedGlyph {
|
|
pub gid: u32,
|
|
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
|
|
pub cluster: usize,
|
|
pub x_advance: i32,
|
|
pub x_offset: i32,
|
|
pub y_offset: i32,
|
|
}
|
|
|
|
/// How to shape an item.
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
pub struct ShapeParams<'a> {
|
|
pub features: &'a [Feature],
|
|
/// Right-to-left (odd bidi level). Mirrored characters (brackets) take their mirrored glyphs.
|
|
pub rtl: bool,
|
|
/// ISO 15924 script (`*b"Arab"`); `None` lets the shaper guess from the text.
|
|
pub script: Option<ScriptTag>,
|
|
/// BCP 47 language (`"ar"`, `"ar-MA"`); `None` = the script's default.
|
|
pub language: Option<&'a str>,
|
|
}
|
|
|
|
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
|
|
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
|
|
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
|
|
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
|
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
|
return Vec::new();
|
|
};
|
|
let mut out = Vec::with_capacity(text.len());
|
|
let shaped = face.hb().map(|hb| {
|
|
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
|
let mut buf = UnicodeBuffer::new();
|
|
buf.set_pre_context(pre);
|
|
buf.set_post_context(post);
|
|
for (i, c) in text.char_indices() {
|
|
buf.add(map(c), (range.start + i) as u32);
|
|
}
|
|
buf.guess_segment_properties();
|
|
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
|
buf.set_script(s);
|
|
}
|
|
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
|
{
|
|
buf.set_language(l);
|
|
}
|
|
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
|
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
|
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
|
out.push(ShapedGlyph {
|
|
gid: info.glyph_id,
|
|
cluster: info.cluster as usize,
|
|
x_advance: pos.x_advance,
|
|
x_offset: pos.x_offset,
|
|
y_offset: pos.y_offset,
|
|
});
|
|
}
|
|
});
|
|
if shaped.is_none()
|
|
&& let Some(f) = face.skrifa()
|
|
{
|
|
// No shaper for this face: one glyph per character from the cmap, in visual order.
|
|
let cmap = f.charmap();
|
|
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
|
for (i, c) in text.char_indices() {
|
|
let g = cmap.map(map(c)).unwrap_or_default();
|
|
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
|
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
|
}
|
|
if params.rtl {
|
|
out.reverse();
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{DEFAULT_FAMILY, FontDb};
|
|
|
|
#[test]
|
|
fn rtl_items_come_in_visual_order_and_mirror() {
|
|
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
|
let text = "(ab)";
|
|
let p = ShapeParams { rtl: true, ..Default::default() };
|
|
let g = shape_range(&face, text, 0..text.len(), &p, |c| c);
|
|
// Visual order: last cluster first.
|
|
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![3, 2, 1, 0]);
|
|
// L4: in a right-to-left run "(" is drawn with the ")" glyph and vice versa.
|
|
if face.covers('(') && face.covers(')') {
|
|
assert_eq!(g[3].gid, face.glyph_for(')'));
|
|
assert_eq!(g[0].gid, face.glyph_for('('));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clusters_point_into_the_whole_paragraph() {
|
|
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
|
let para = "Hello world";
|
|
let g = shape_range(&face, para, 6..11, &ShapeParams::default(), |c| c);
|
|
assert_eq!(g.first().map(|g| g.cluster), Some(6));
|
|
assert!(shape_range(&face, para, 6..99, &ShapeParams::default(), |c| c).is_empty());
|
|
// Not a char boundary.
|
|
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
|
|
}
|
|
}
|