mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 15:25:16 +00:00
Merge origin/main
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
//! Arabic marks (harakat) styled apart from their letters.
|
||||
//!
|
||||
//! A designer colours the harakat apart from the letters (or hides them with no fill) without
|
||||
//! breaking the letters' joining or the marks' attachment: runs that differ only in paint
|
||||
//! ([`paint_only`]) are not a shaping boundary at a harakat ([`paint_only_mark_boundary`]), so the
|
||||
//! letter and its marks are still shaped together, glyph for glyph as before. When the character-
|
||||
//! level shaping pass matches the monotone pass ([`own_chars`]), a glyph may take a later
|
||||
//! in-cluster character's run only when it is positively a mark: the face's GDEF GlyphClassDef
|
||||
//! names it class 3 (mark) and that character is a harakat. Anything else—including fonts with no
|
||||
//! GDEF, AAT/`morx` faces where the class is unavailable, ligatures, and multi-component
|
||||
//! glyphs—keeps the shaping cluster's first character. Conservative means a mark may fail to take
|
||||
//! its colour, but a base letter must never take a mark's colour. AAT/`morx` fonts may still leave
|
||||
//! a mark on a ligature (for example Geeza Pro's Allah ligature) painted as its letter. Renderers
|
||||
//! and exporters read the glyph's run as they always did, so they need no change. The text itself
|
||||
//! is never changed.
|
||||
|
||||
use skrifa::raw::TableProvider;
|
||||
use skrifa::raw::tables::layout::ClassDef;
|
||||
use vectorcraft_doc::CharStyle;
|
||||
|
||||
use crate::fontdb::FontFace;
|
||||
|
||||
/// Is `c` an Arabic combining mark: harakat (fatha, damma, kasra, tanwin, shadda, sukun), the
|
||||
/// superscript alef, Quranic annotation marks, and the extended-A marks?
|
||||
pub(crate) fn is_harakat(c: char) -> bool {
|
||||
matches!(
|
||||
c as u32,
|
||||
0x0610..=0x061A
|
||||
| 0x064B..=0x065F
|
||||
| 0x0670
|
||||
| 0x06D6..=0x06DC
|
||||
| 0x06DF..=0x06E4
|
||||
| 0x06E7..=0x06E8
|
||||
| 0x06EA..=0x06ED
|
||||
| 0x08D3..=0x08FF
|
||||
)
|
||||
}
|
||||
|
||||
/// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the
|
||||
/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper
|
||||
/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike`
|
||||
/// (which also ignores baseline shift, tracking, underline, …).
|
||||
pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
// Exhaustive: a new attribute must explicitly keep or relax this boundary rule.
|
||||
let CharStyle {
|
||||
font_family,
|
||||
font_style,
|
||||
font_version,
|
||||
font_axes,
|
||||
size,
|
||||
leading,
|
||||
tracking,
|
||||
kerning,
|
||||
optical_kerning,
|
||||
baseline_shift,
|
||||
h_scale,
|
||||
v_scale,
|
||||
rotation,
|
||||
underline,
|
||||
strikethrough,
|
||||
all_caps,
|
||||
features,
|
||||
position,
|
||||
small_caps,
|
||||
char_align,
|
||||
char_direction,
|
||||
kashidas,
|
||||
proportional_metrics,
|
||||
tate_chu_yoko,
|
||||
tcy_up_down,
|
||||
tcy_left_right,
|
||||
standard_vertical_roman,
|
||||
digits,
|
||||
font_height_reference,
|
||||
no_break,
|
||||
language,
|
||||
glyph_alternate,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
stroke_miter_limit,
|
||||
stroke_dash,
|
||||
overprint_fill: _,
|
||||
overprint_stroke: _,
|
||||
stroke_cap: _,
|
||||
stroke_join: _,
|
||||
stroke_align: _,
|
||||
style_name: _,
|
||||
} = a;
|
||||
// Previously b's paint was replaced with a's before comparing the whole styles. Keep
|
||||
// that PartialEq behavior for non-reflexive paint values (NaN), without cloning paint.
|
||||
let original_paint = (&a.fill, &a.stroke, &a.stroke_width, &a.stroke_miter_limit, &a.stroke_dash);
|
||||
let normalized_paint = (fill, stroke, stroke_width, stroke_miter_limit, stroke_dash);
|
||||
original_paint == normalized_paint
|
||||
&& font_family == &b.font_family
|
||||
&& font_style == &b.font_style
|
||||
&& font_version == &b.font_version
|
||||
&& font_axes == &b.font_axes
|
||||
&& size == &b.size
|
||||
&& leading == &b.leading
|
||||
&& tracking == &b.tracking
|
||||
&& kerning == &b.kerning
|
||||
&& optical_kerning == &b.optical_kerning
|
||||
&& baseline_shift == &b.baseline_shift
|
||||
&& h_scale == &b.h_scale
|
||||
&& v_scale == &b.v_scale
|
||||
&& rotation == &b.rotation
|
||||
&& underline == &b.underline
|
||||
&& strikethrough == &b.strikethrough
|
||||
&& all_caps == &b.all_caps
|
||||
&& features == &b.features
|
||||
&& position == &b.position
|
||||
&& small_caps == &b.small_caps
|
||||
&& char_align == &b.char_align
|
||||
&& char_direction == &b.char_direction
|
||||
&& kashidas == &b.kashidas
|
||||
&& proportional_metrics == &b.proportional_metrics
|
||||
&& tate_chu_yoko == &b.tate_chu_yoko
|
||||
&& tcy_up_down == &b.tcy_up_down
|
||||
&& tcy_left_right == &b.tcy_left_right
|
||||
&& standard_vertical_roman == &b.standard_vertical_roman
|
||||
&& digits == &b.digits
|
||||
&& font_height_reference == &b.font_height_reference
|
||||
&& no_break == &b.no_break
|
||||
&& language == &b.language
|
||||
&& glyph_alternate == &b.glyph_alternate
|
||||
}
|
||||
|
||||
/// Is the run boundary at byte `at` of `text`, between styles `before` and `after`, one that
|
||||
/// stays inside a shaping cluster: they differ only in paint and a harakat is on either side?
|
||||
pub(crate) fn paint_only_mark_boundary(text: &str, at: usize, before: &CharStyle, after: &CharStyle) -> bool {
|
||||
let next = text.get(at..).and_then(|s| s.chars().next());
|
||||
let previous = text.get(..at).and_then(|s| s.chars().next_back());
|
||||
(next.is_some_and(is_harakat) || previous.is_some_and(is_harakat)) && paint_only(before, after)
|
||||
}
|
||||
|
||||
/// The byte ranges of the harakat in `text[range]`, each run of consecutive harakat as one range.
|
||||
/// The range is normalised as [`crate::edit::style_range`] takes one: its ends clamped to the
|
||||
/// text, each moved back to the start of the character it falls in, and put in order.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn harakat_ranges(text: &str, range: std::ops::Range<usize>) -> Vec<std::ops::Range<usize>> {
|
||||
let (mut start, mut end) = (crate::edit::floor_char(text, range.start), crate::edit::floor_char(text, range.end));
|
||||
if start > end {
|
||||
std::mem::swap(&mut start, &mut end);
|
||||
}
|
||||
let Some(part) = text.get(start..end) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut out: Vec<std::ops::Range<usize>> = vec![];
|
||||
for (i, c) in part.char_indices().filter(|(_, c)| is_harakat(*c)) {
|
||||
let (a, b) = (start + i, start + i + c.len_utf8());
|
||||
match out.last_mut() {
|
||||
Some(last) if last.end == a => last.end = b,
|
||||
_ => out.push(a..b),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `face`'s GDEF GlyphClassDef name glyph `gid` as class 3 (mark)? False when the face has
|
||||
/// no GDEF, no class def, or the class is unavailable (typical of AAT/`morx` paths).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_gdef_mark(face: &FontFace, gid: u32) -> bool {
|
||||
mark_classes(face).as_ref().is_some_and(|classes| classes.get(skrifa::GlyphId::new(gid)) == 3)
|
||||
}
|
||||
|
||||
/// Borrow the face's mark classification once for a shaping pass. No GDEF, missing classes
|
||||
/// or malformed tables keep the conservative cluster-start ownership.
|
||||
pub(crate) fn mark_classes(face: &FontFace) -> Option<ClassDef<'_>> {
|
||||
face.skrifa()?.gdef().ok()?.glyph_class_def()?.ok()
|
||||
}
|
||||
|
||||
/// Each glyph's own character, from the shaping `by_char` at character cluster level of the
|
||||
/// glyphs `raw` (glyph id, cluster, x advance, x and y offset; `by_char` with the y advance after
|
||||
/// the x advance), the segment ending at byte `end`, and `text` for character identity.
|
||||
/// `is_gdef_mark(gid)` is true only when the face's GDEF GlyphClassDef names that glyph class 3.
|
||||
/// Empty unless `by_char` sets the same glyphs the same way (glyph id, advance and offsets, glyph
|
||||
/// by glyph): repeated glyph ids alone could hide a changed correspondence. A glyph whose character
|
||||
/// lies outside its own shaping cluster of `raw` (from its cluster value up to the next larger one,
|
||||
/// else `end`) gets its cluster's first character instead. A later in-cluster character is kept
|
||||
/// only when the glyph is a GDEF mark and that character is a harakat; otherwise the cluster start
|
||||
/// is kept (conservative: a mark may miss its colour; a base must never take a mark's). `raw`'s
|
||||
/// clusters are never changed. These are HarfBuzz shaping clusters, which can span more than one
|
||||
/// grapheme (a ligature).
|
||||
pub(crate) fn own_chars(
|
||||
raw: &[(u32, u32, i32, i32, i32)],
|
||||
by_char: &[(u32, u32, i32, i32, i32, i32)],
|
||||
end: usize,
|
||||
text: &str,
|
||||
is_gdef_mark: impl Fn(u32) -> bool,
|
||||
) -> Vec<u32> {
|
||||
let same = by_char.len() == raw.len() && by_char.iter().zip(raw).all(|(c, r)| c.0 == r.0 && c.2 == r.2 && c.4 == r.3 && c.5 == r.4);
|
||||
if !same {
|
||||
return vec![];
|
||||
}
|
||||
let mut starts: Vec<u32> = raw.iter().map(|g| g.1).collect();
|
||||
starts.sort_unstable();
|
||||
starts.dedup();
|
||||
let cluster_end = |cl: u32| starts.get(starts.partition_point(|&c| c <= cl)).map_or(end, |&c| c as usize);
|
||||
by_char
|
||||
.iter()
|
||||
.zip(raw)
|
||||
.map(|(c, r)| {
|
||||
let in_cluster = r.1 <= c.1 && (c.1 as usize) < cluster_end(r.1);
|
||||
if !in_cluster {
|
||||
return r.1;
|
||||
}
|
||||
if c.1 == r.1 {
|
||||
return c.1;
|
||||
}
|
||||
// Later in-cluster character: only a positively identified mark glyph on a harakat.
|
||||
let mark_char = text.get(c.1 as usize..).and_then(|s| s.chars().next()).is_some_and(is_harakat);
|
||||
if is_gdef_mark(r.0) && mark_char { c.1 } else { r.1 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
+180
-1
@@ -503,11 +503,22 @@ impl Ctx<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Source-run index covering byte `b` (the cluster start for a letter with its marks).
|
||||
fn run_index_at(&self, b: usize) -> Option<usize> {
|
||||
let find = |b: usize| {
|
||||
let i = self.runs.partition_point(|(r, _)| r.end <= b);
|
||||
self.runs.get(i).filter(|(r, _)| r.start <= b).map(|_| i)
|
||||
};
|
||||
find(b).or_else(|| b.checked_sub(1).and_then(find))
|
||||
}
|
||||
|
||||
/// Draw a kashida `len` points long from `at` (the pen on the baseline) on line `line`, as
|
||||
/// tatweels in the face, size and style of glyph `g`. Short, Medium and Long kashidas are one
|
||||
/// tatweel stretched along the baseline; a Stylistic one is tatweels side by side. Each tatweel
|
||||
/// is a glyph at `g`'s byte offset that stands for no character (length 0) and does not
|
||||
/// advance: `g`'s advance takes the kashida in.
|
||||
/// advance: `g`'s advance takes the kashida in. Paint comes from the base letter's run at the
|
||||
/// cluster start (`g.byte`), not from a trailing mark that may be the last glyph of the
|
||||
/// cluster when harakat are coloured or hidden apart from their letters.
|
||||
fn emit_kashida(&mut self, g: &SGlyph, kind: Kashida, len: f64, at: Point, line: usize) {
|
||||
let Some((gid, t)) = kashida::tatweel(g) else { return };
|
||||
let mut k = g.clone();
|
||||
@@ -521,6 +532,10 @@ impl Ctx<'_> {
|
||||
k.ch = kashida::TATWEEL;
|
||||
k.inline = None;
|
||||
k.tcy = None;
|
||||
// Base letter's run: `g` may be a mark (last in the shaping cluster) with mark paint.
|
||||
if let Some(run) = self.run_index_at(g.byte) {
|
||||
k.run = run;
|
||||
}
|
||||
if kind == Kashida::Stylistic {
|
||||
let n = (len / t).round().clamp(1.0, kashida::MAX_TATWEELS) as usize;
|
||||
for m in 0..n {
|
||||
@@ -535,3 +550,167 @@ impl Ctx<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod wrapping_tests;
|
||||
|
||||
/// Forced mark-last kashida emitter: the absorbing glyph carries a mark run while the cluster
|
||||
/// start is a letter run; the tatweel must take the letter run. Reverting the base-run assignment
|
||||
/// in [`Ctx::emit_kashida`] fails these.
|
||||
#[cfg(test)]
|
||||
mod emit_kashida_mark_last {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
|
||||
fn arabic_face() -> Option<Arc<crate::FontFace>> {
|
||||
let db = FontDb::global();
|
||||
["Noto Sans Arabic", "Arial", "Tahoma", "Times New Roman"]
|
||||
.into_iter()
|
||||
.filter_map(|name| db.face(name, "Regular"))
|
||||
.find(|face| face.covers('ب') && face.covers('\u{064E}') && face.covers('\u{0640}'))
|
||||
}
|
||||
|
||||
fn force_mark_last(mark_fill: Paint) {
|
||||
let Some(face) = arabic_face() else {
|
||||
eprintln!("SKIPPED: emit_kashida_mark_last; no Arabic face with tatweel");
|
||||
return;
|
||||
};
|
||||
let Some((tat_gid, _)) = kashida::tatweel(&SGlyph {
|
||||
face: Arc::clone(&face),
|
||||
gid: 0,
|
||||
byte: 0,
|
||||
len: 2,
|
||||
run: 0,
|
||||
adv: 10.0,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
kern_shift: 0.0,
|
||||
sx: face.upem.recip() * 48.0,
|
||||
sy: face.upem.recip() * 48.0,
|
||||
bshift: 0.0,
|
||||
rotation: 0.0,
|
||||
ascent: 40.0,
|
||||
descent: 10.0,
|
||||
leading: 0.0,
|
||||
cap: 30.0,
|
||||
xh: 20.0,
|
||||
ch: 'ب',
|
||||
tcy: None,
|
||||
inline: None,
|
||||
word_end: false,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
level: Level::rtl(),
|
||||
no_break: false,
|
||||
vertical_roman: false,
|
||||
cursive: true,
|
||||
hyphen_language: crate::hyphen::Language::English,
|
||||
}) else {
|
||||
eprintln!("SKIPPED: emit_kashida_mark_last; face has no tatweel");
|
||||
return;
|
||||
};
|
||||
let letter = CharStyle { font_family: face.family.clone(), size: 48.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
|
||||
let mut mark = letter.clone();
|
||||
mark.fill = mark_fill.clone();
|
||||
// "بَ": letter run 0 covers 0..2, mark run 1 covers 2..4. Cluster starts at byte 0.
|
||||
let text = "بَ";
|
||||
let opts = LayoutOptions::default();
|
||||
let db = FontDb::global();
|
||||
let mut cx = Ctx {
|
||||
on_path: false,
|
||||
db,
|
||||
text,
|
||||
runs: vec![(0..2, &letter), (2..4, &mark)],
|
||||
set_runs: Vec::new(),
|
||||
inlines: vec![None, None],
|
||||
faces: vec![std::cell::OnceCell::new(), std::cell::OnceCell::new()],
|
||||
default: letter.clone(),
|
||||
opts: &opts,
|
||||
vertical: false,
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
upright_cluster: None,
|
||||
out: TextLayout::default(),
|
||||
};
|
||||
// Last visual glyph of the cluster: a mark that wrongly keeps the mark run (run 1).
|
||||
let mut g = SGlyph {
|
||||
face: Arc::clone(&face),
|
||||
gid: face.glyph_for('\u{064E}'),
|
||||
byte: 0,
|
||||
len: 4,
|
||||
run: 1,
|
||||
adv: 0.0,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
kern_shift: 0.0,
|
||||
sx: face.upem.recip() * 48.0,
|
||||
sy: face.upem.recip() * 48.0,
|
||||
bshift: 0.0,
|
||||
rotation: 0.0,
|
||||
ascent: 40.0,
|
||||
descent: 10.0,
|
||||
leading: 0.0,
|
||||
cap: 30.0,
|
||||
xh: 20.0,
|
||||
ch: '\u{064E}',
|
||||
tcy: None,
|
||||
inline: None,
|
||||
word_end: false,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
level: Level::rtl(),
|
||||
no_break: false,
|
||||
vertical_roman: false,
|
||||
cursive: true,
|
||||
hyphen_language: crate::hyphen::Language::English,
|
||||
};
|
||||
assert_ne!(g.run, 0, "precondition: absorbing glyph has mark run ≠ cluster-start run");
|
||||
assert_eq!(cx.style_at(g.byte).fill, letter.fill, "precondition: cluster start is letter paint");
|
||||
assert_eq!(mark.fill, mark_fill, "precondition: mark paint differs");
|
||||
cx.emit_kashida(&g, Kashida::Long, 20.0, Point::ZERO, 0);
|
||||
let tatweels: Vec<_> = cx.out.glyphs.iter().filter(|pg| pg.gid == tat_gid && pg.len == 0).collect();
|
||||
assert!(!tatweels.is_empty(), "tatweel emitted");
|
||||
for t in tatweels {
|
||||
assert_eq!(t.run, 0, "tatweel must use base letter run, not mark run {}", g.run);
|
||||
let fill = cx.runs.get(t.run).map(|(_, s)| &s.fill).expect("run");
|
||||
assert_eq!(fill, &letter.fill, "tatweel paint is the letter's");
|
||||
assert_ne!(fill, &mark_fill, "tatweel must not inherit mark paint");
|
||||
}
|
||||
// Without the base-run assignment, emit_kashida would keep g.run == 1.
|
||||
g.run = 1;
|
||||
let mut broken = Ctx {
|
||||
on_path: false,
|
||||
db,
|
||||
text,
|
||||
runs: vec![(0..2, &letter), (2..4, &mark)],
|
||||
set_runs: Vec::new(),
|
||||
inlines: vec![None, None],
|
||||
faces: vec![std::cell::OnceCell::new(), std::cell::OnceCell::new()],
|
||||
default: letter.clone(),
|
||||
opts: &opts,
|
||||
vertical: false,
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
upright_cluster: None,
|
||||
out: TextLayout::default(),
|
||||
};
|
||||
// Simulate the pre-fix path: clone run from the mark glyph only.
|
||||
let mut k = g.clone();
|
||||
k.gid = tat_gid;
|
||||
k.len = 0;
|
||||
k.ch = kashida::TATWEEL;
|
||||
// k.run stays 1 — what emit_kashida did before the fix.
|
||||
broken.emit(&k, Affine::IDENTITY, Point::ZERO, 0.0, 0.0, 0);
|
||||
assert_eq!(broken.out.glyphs[0].run, 1, "sanity: unrepaired path keeps mark run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coloured_mark_last_tatweel_uses_letter_run() {
|
||||
force_mark_last(Paint::solid(Color::rgb8(200, 30, 30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_mark_last_tatweel_uses_letter_run() {
|
||||
force_mark_last(Paint::None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod embed;
|
||||
mod features;
|
||||
mod fontconfig;
|
||||
mod fontdb;
|
||||
mod harakat;
|
||||
pub mod hyphen;
|
||||
mod justify;
|
||||
mod kashida;
|
||||
|
||||
+55
-13
@@ -8,7 +8,7 @@ use unicode_bidi::Level;
|
||||
use unicode_script::{Script, UnicodeScript};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use harfrust::{BufferClusterLevel, Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use kurbo::Shape;
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::charmap::Charmap;
|
||||
@@ -1009,6 +1009,10 @@ struct Shaped {
|
||||
raw: Vec<(u32, u32, i32, i32, i32)>,
|
||||
proportional: Vec<bool>,
|
||||
vpal: Vec<Option<VAdjust>>,
|
||||
/// Each glyph's own character (cluster at character level), when harakat are styled apart
|
||||
/// from their letters and the character-level pass matches ([`crate::harakat`]); empty
|
||||
/// otherwise (glyphs keep their shaping-cluster start).
|
||||
chars: Vec<u32>,
|
||||
}
|
||||
|
||||
/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put
|
||||
@@ -1092,6 +1096,14 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
if !manual_ends.is_empty() {
|
||||
return shape_apart(manual_ends, out);
|
||||
}
|
||||
// Harakat styled apart from their letters (paint only): shaped with them, each glyph then
|
||||
// takes its own character's run ([`crate::harakat`]).
|
||||
let mark_boundaries: Vec<usize> = boundaries()
|
||||
.filter(|&b| run_at(b - 1).1 != run_at(b).1 && crate::harakat::paint_only_mark_boundary(text, b, run_at(b - 1).1, run_at(b).1))
|
||||
.collect();
|
||||
// Source run starts are sorted. Reuse their classification when checking cluster splits.
|
||||
let styled_marks = !mark_boundaries.is_empty();
|
||||
let char_level = std::cell::Cell::new(false);
|
||||
|
||||
// Runs that differ only in their OpenType feature lists are shaped as one buffer: the features
|
||||
// of each run apply to its own characters only (a feature with a range of clusters, which here
|
||||
@@ -1119,14 +1131,20 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
.filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'))
|
||||
.and_then(|l| l.parse::<harfrust::Language>().ok());
|
||||
let mut shaped = Shaped::default();
|
||||
let Shaped { raw, proportional, vpal } = &mut shaped;
|
||||
let Shaped { raw, proportional, vpal, chars } = &mut shaped;
|
||||
let hb_shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
// The baseline and character-level pass use exactly the same resolved features.
|
||||
// Alternate positioning passes still resolve their own features, with `more` last.
|
||||
let with_more = |variant: &dyn Fn(&CharStyle) -> Vec<Feature>| {
|
||||
let mut resolved = variant(st);
|
||||
resolved.extend(run_features(variant, st, &resolved));
|
||||
resolved.extend(more.iter().cloned());
|
||||
resolved
|
||||
};
|
||||
let along_features = with_more(&|s| feats.resolve(s));
|
||||
// gid, cluster, x and y advance, x and y offset.
|
||||
let shape = |variant: &dyn Fn(&CharStyle) -> Vec<Feature>, direction: Direction| {
|
||||
let mut feats = variant(st);
|
||||
feats.extend(run_features(variant, st, &feats));
|
||||
feats.extend(more.iter().cloned());
|
||||
let shape = |feats: &[Feature], direction: Direction| {
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
@@ -1141,6 +1159,9 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
}
|
||||
buf.set_direction(direction);
|
||||
buf.guess_segment_properties();
|
||||
if char_level.get() {
|
||||
buf.set_cluster_level(BufferClusterLevel::Characters);
|
||||
}
|
||||
// The characters' language picks the font's forms for it (`locl`: Turkish keeps fi
|
||||
// apart and the dot on its i).
|
||||
if let Some(l) = &language {
|
||||
@@ -1157,8 +1178,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
}
|
||||
// A buffer whose characters name no script reads as `UNKNOWN`: its plan has none.
|
||||
let script = Some(buf.script()).filter(|s| *s != harfrust::script::UNKNOWN);
|
||||
let plan = face.shape_plan(&shaper, buf.direction(), script, language.as_ref(), &feats);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats).plan(Some(&plan)));
|
||||
let plan = face.shape_plan(&shaper, buf.direction(), script, language.as_ref(), feats);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(feats).plan(Some(&plan)));
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
@@ -1166,13 +1187,25 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
|
||||
*raw = shape(&|s| feats.resolve(s), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
|
||||
*raw = shape(&along_features, along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
|
||||
let raw = &*raw;
|
||||
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(raw).all(|(a, b)| a.0 == b.0);
|
||||
// The same shaping at character level names each glyph's own character (kept only when
|
||||
// it sets the same glyphs alike, each character in its glyph's cluster).
|
||||
if let Some(classes) = styled_marks.then(|| crate::harakat::mark_classes(face)).flatten()
|
||||
// Without a mark glyph, no glyph can take a later character's paint. Avoid
|
||||
// reshaping; the original clusters already give the conservative ownership.
|
||||
&& raw.iter().any(|g| classes.get(skrifa::GlyphId::new(g.0)) == 3)
|
||||
{
|
||||
char_level.set(true);
|
||||
let by_char = shape(&along_features, along);
|
||||
char_level.set(false);
|
||||
*chars = crate::harakat::own_chars(raw, &by_char, range.end, text, |gid| classes.get(skrifa::GlyphId::new(gid)) == 3);
|
||||
}
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&|s| feats.resolve_fixed_width(s), along);
|
||||
let full = shape(&with_more(&|s| feats.resolve_fixed_width(s)), along);
|
||||
if same_glyphs(&full) {
|
||||
*proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
@@ -1183,7 +1216,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
// and without either tell how it moves each and changes its advance down the column
|
||||
// (y up, advances negative). Which advances `vpal` itself changed comes from setting
|
||||
// them with it alone.
|
||||
let ttb = |proportional, kerning| shape(&|s| feats.resolve_vertical(s, proportional, kerning), Direction::TopToBottom);
|
||||
let ttb = |proportional, kerning| shape(&with_more(&|s| feats.resolve_vertical(s, proportional, kerning)), Direction::TopToBottom);
|
||||
let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false));
|
||||
if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) {
|
||||
*vpal = with
|
||||
@@ -1223,7 +1256,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
shaped
|
||||
};
|
||||
let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) };
|
||||
let Shaped { raw, proportional, vpal } = if let Some(first) = first_optical.filter(|_| !feats.vertical) {
|
||||
let Shaped { raw, proportional, vpal, chars } = if let Some(first) = first_optical.filter(|_| !feats.vertical) {
|
||||
// The pair entering Optical keeps the left character's font kerning. Subsequent
|
||||
// Optical pairs use outline spacing, so font pair features stop at its second character.
|
||||
let optical_off = if first == range.start {
|
||||
@@ -1283,7 +1316,11 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
cluster_starts.dedup();
|
||||
// A glyph is drawn in one style: where a cluster would take in characters of runs in different
|
||||
// styles, the runs are shaped apart, as runs that do not shape alike are.
|
||||
let inside_clusters: Vec<usize> = boundaries().filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1).collect();
|
||||
// Except at harakat styled apart only in paint ([`crate::harakat`]).
|
||||
let inside_clusters: Vec<usize> = boundaries()
|
||||
.filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1)
|
||||
.filter(|b| mark_boundaries.binary_search(b).is_err())
|
||||
.collect();
|
||||
if !inside_clusters.is_empty() {
|
||||
return shape_apart(inside_clusters, out);
|
||||
}
|
||||
@@ -1343,6 +1380,11 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() {
|
||||
let cl = cl as usize;
|
||||
let (run, gst) = run_at(cl);
|
||||
// A glyph of a harakat styled apart takes its own character's run (paint only).
|
||||
let run = match chars.get(gi).map(|&c| run_at(c as usize)) {
|
||||
Some((own, own_st)) if crate::harakat::paint_only(gst, own_st) => own,
|
||||
_ => run,
|
||||
};
|
||||
let end = cluster_end(cl);
|
||||
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
|
||||
let ch = first_char(cl);
|
||||
|
||||
@@ -15,6 +15,7 @@ mod embed;
|
||||
mod fit;
|
||||
mod glyph_alternates;
|
||||
mod hang;
|
||||
mod harakat;
|
||||
mod inline;
|
||||
mod joiners;
|
||||
mod justify;
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
//! Arabic marks (harakat) styled apart from their letters ([`crate::harakat`]).
|
||||
//! Layout cases need an Arabic face: craft-fonts' Noto Sans Arabic when the build has
|
||||
//! `CRAFT_FONTS_DIR`, else an installed OpenType Arabic face. Without either they skip.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, Point, Rect, Shape};
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::text::Kashida;
|
||||
use vectorcraft_doc::{CharStyle, Justify, TextKind, TextObject};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use crate::harakat::{harakat_ranges, is_gdef_mark, is_harakat, own_chars, paint_only, paint_only_mark_boundary};
|
||||
use crate::{FontDb, FontFace, PositionedGlyph, TextLayout, layout};
|
||||
|
||||
const FAMILY: &str = "Noto Sans Arabic";
|
||||
const VOCALIZED: &str = "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ";
|
||||
/// Short vocalized word used to probe whether a face's shaped mark glyphs are GDEF class 3.
|
||||
const GDEF_PROBE: &str = "بَبَب";
|
||||
|
||||
/// An OpenType Arabic face covering harakat whose shaped fixture glyphs include GDEF mark
|
||||
/// (class 3) glyphs. Strict colour/ownership layout tests need that: without GDEF, production
|
||||
/// correctly keeps the letter's paint and those assertions would fail. Fonts with coverage and
|
||||
/// GSUB but no mark classes are skipped here; synthetic `own_chars` cases and
|
||||
/// `installed_fonts_keep_their_glyphs_with_coloured_harakat` still cover the no-GDEF fallback.
|
||||
fn arabic_face(test: &str) -> Option<Arc<FontFace>> {
|
||||
let opentype = |face: &FontFace| {
|
||||
use skrifa::raw::TableProvider;
|
||||
face.skrifa().is_some_and(|f| f.gsub().is_ok())
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let face = ["Noto Sans Arabic", "Arial", "Geeza Pro", "SF Arabic", "Tahoma", "Times New Roman"]
|
||||
.into_iter()
|
||||
.filter_map(|name| db.face(name, "Regular"))
|
||||
.find(|face| {
|
||||
if !(face.covers('ب') && face.covers('\u{064E}') && face.covers('\u{0640}') && opentype(face)) {
|
||||
return false;
|
||||
}
|
||||
// Shape the probe: every zero-advance authored glyph must be a GDEF mark, and at least
|
||||
// one must appear, so strict colour assertions match production ownership.
|
||||
let shaped = layout(db, &text_with_harakat(GDEF_PROBE, &face.family, None));
|
||||
let marks: Vec<_> = shaped.glyphs.iter().filter(|g| g.len > 0 && g.advance.abs() < 1e-6).collect();
|
||||
!marks.is_empty() && marks.iter().all(|g| is_gdef_mark(face, g.gid))
|
||||
});
|
||||
match &face {
|
||||
Some(face) => eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {} (GDEF marks)", face.family, face.style),
|
||||
None => {
|
||||
eprintln!("SKIPPED: {test}; no OpenType Arabic font with harakat and GDEF mark classes (set CRAFT_FONTS_DIR or install one)")
|
||||
}
|
||||
}
|
||||
face
|
||||
}
|
||||
|
||||
fn red() -> Paint {
|
||||
Paint::solid(Color::rgb8(200, 30, 30))
|
||||
}
|
||||
|
||||
fn style(family: &str) -> CharStyle {
|
||||
CharStyle { font_family: family.into(), size: 48.0, ..CharStyle::default() }
|
||||
}
|
||||
|
||||
/// `text` in `family`, its harakat filled with `fill` (`None`: as the letters).
|
||||
fn text_with_harakat(text: &str, family: &str, fill: Option<Paint>) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, style(family));
|
||||
if let Some(fill) = fill {
|
||||
for r in harakat_ranges(text, 0..text.len()) {
|
||||
crate::edit::style_range(&mut t.runs, r.start, r.end, |st| st.fill = fill.clone());
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// One glyph's geometry, bit for bit: face, glyph id, cluster, pen position, transform, advance.
|
||||
type Geometry = (u32, u32, usize, usize, [u64; 2], [u64; 6], u64);
|
||||
|
||||
fn geometry(l: &TextLayout) -> Vec<Geometry> {
|
||||
l.glyphs
|
||||
.iter()
|
||||
.map(|g| {
|
||||
(g.font_id, g.gid, g.byte, g.len, [g.origin.x.to_bits(), g.origin.y.to_bits()], g.xf.as_coeffs().map(f64::to_bits), g.advance.to_bits())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn painted(t: &TextObject, l: &TextLayout, fill: &Paint) -> usize {
|
||||
l.glyphs.iter().filter(|g| run_of(t, g).style.fill == *fill).count()
|
||||
}
|
||||
|
||||
fn run_of<'a>(t: &'a TextObject, g: &PositionedGlyph) -> &'a vectorcraft_doc::TextRun {
|
||||
t.runs.get(g.run).unwrap_or_else(|| panic!("glyph {} at byte {} names missing run {}", g.gid, g.byte, g.run))
|
||||
}
|
||||
|
||||
type LetterGlyphs = std::collections::HashSet<(u32, u32)>;
|
||||
|
||||
/// Glyphs drawn by the fixture's bare letters, including separately shaped letter dots.
|
||||
/// Include the face id: glyph ids are only meaningful within their own font.
|
||||
fn letter_glyphs(text: &str, family: &str) -> LetterGlyphs {
|
||||
let bare: String = text.chars().filter(|&c| !is_harakat(c)).collect();
|
||||
layout(FontDb::global(), &text_with_harakat(&bare, family, None)).glyphs.iter().map(|g| (g.font_id, g.gid)).collect()
|
||||
}
|
||||
|
||||
fn fixture_mark(g: &PositionedGlyph, letters: &LetterGlyphs) -> bool {
|
||||
// Some fonts draw a letter's dots as zero-advance GDEF marks. They keep the letter's paint.
|
||||
g.advance.abs() < 1e-6 && !letters.contains(&(g.font_id, g.gid))
|
||||
}
|
||||
|
||||
/// Expected fill from the fixture's source runs, independent of the glyph's assigned `run`:
|
||||
/// zero-advance glyphs absent from the bare letters take the first harakat's fill in their
|
||||
/// cluster; letter glyphs (including their dots) take the cluster-start fill.
|
||||
fn expected_fill(t: &TextObject, text: &str, g: &PositionedGlyph, letters: &LetterGlyphs) -> Paint {
|
||||
let end = (g.byte + g.len).min(text.len());
|
||||
let span = text.get(g.byte..end).unwrap_or("");
|
||||
let letter_fill = style_fill_at(t, g.byte);
|
||||
if fixture_mark(g, letters) && span.chars().any(is_harakat) {
|
||||
// Mark glyph: first harakat in the cluster span.
|
||||
let off = span.char_indices().find(|(_, c)| is_harakat(*c)).map(|(i, _)| g.byte + i).unwrap_or(g.byte);
|
||||
return style_fill_at(t, off);
|
||||
}
|
||||
letter_fill
|
||||
}
|
||||
|
||||
fn style_fill_at(t: &TextObject, byte: usize) -> Paint {
|
||||
let mut off = 0;
|
||||
for r in &t.runs {
|
||||
let end = off + r.text.len();
|
||||
if byte < end || (byte == end && byte == off) {
|
||||
return r.style.fill.clone();
|
||||
}
|
||||
off = end;
|
||||
}
|
||||
t.runs.last().map(|r| r.style.fill.clone()).unwrap_or_else(|| Paint::solid(Color::BLACK))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_only_differences_are_told_apart() {
|
||||
let a = style(FAMILY);
|
||||
let mut b = a.clone();
|
||||
b.fill = red();
|
||||
b.stroke = red();
|
||||
b.stroke_width = 2.0;
|
||||
assert!(paint_only(&a, &b) && paint_only(&a, &a));
|
||||
let mut c = a.clone();
|
||||
c.size = 12.0;
|
||||
assert!(!paint_only(&a, &c));
|
||||
let mut d = b.clone();
|
||||
d.baseline_shift = 3.0;
|
||||
assert!(!paint_only(&a, &d), "a raised harakat is not paint only");
|
||||
assert!(paint_only_mark_boundary("بِ", "ب".len(), &a, &b));
|
||||
assert!(!paint_only_mark_boundary("بب", "ب".len(), &a, &b), "between two letters");
|
||||
assert!(!paint_only_mark_boundary("e\u{301}", 1, &a, &b), "a Latin accent keeps the existing rule");
|
||||
assert_eq!(harakat_ranges("بِسْمِ", 0.."بِسْمِ".len()), [2..4, 6..8, 10..12]);
|
||||
assert_eq!(harakat_ranges("اللَّهِ", 0.."اللَّهِ".len()), [6..10, 12..14], "shadda and fatha together");
|
||||
}
|
||||
|
||||
/// The allocation-free comparison must keep the previous comparison's complete contract,
|
||||
/// including attributes that affect layout rather than shaping and non-reflexive file values.
|
||||
#[test]
|
||||
fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() {
|
||||
use vectorcraft_doc::{CharAlign, CharPosition, Dash, Digits, FontHeightReference, GlyphChoice, LineCap, LineJoin, ParaDirection, StrokeAlign};
|
||||
|
||||
fn previous(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
let mut b = b.clone();
|
||||
b.fill = a.fill.clone();
|
||||
b.stroke = a.stroke.clone();
|
||||
b.stroke_width = a.stroke_width;
|
||||
b.overprint_fill = a.overprint_fill;
|
||||
b.overprint_stroke = a.overprint_stroke;
|
||||
b.stroke_cap = a.stroke_cap;
|
||||
b.stroke_join = a.stroke_join;
|
||||
b.stroke_miter_limit = a.stroke_miter_limit;
|
||||
b.stroke_dash = a.stroke_dash.clone();
|
||||
b.stroke_align = a.stroke_align;
|
||||
b.style_name = a.style_name.clone();
|
||||
*a == b
|
||||
}
|
||||
|
||||
fn check(a: &CharStyle, b: &CharStyle, want: bool, name: &str) {
|
||||
for (left, right) in [(a, b), (b, a)] {
|
||||
assert_eq!(paint_only(left, right), previous(left, right), "{name}: old contract");
|
||||
}
|
||||
assert_eq!(paint_only(a, b), want, "{name}");
|
||||
}
|
||||
|
||||
let a = CharStyle {
|
||||
fill: Paint::Pattern { pattern: "named pattern".repeat(8), xf: Affine::IDENTITY },
|
||||
stroke: Paint::Solid { color: Color::BLACK, swatch: Some("global swatch".repeat(8)), tint: 0.5 },
|
||||
stroke_dash: Some(Dash { pattern: vec![2.0, 3.0, 4.0, 5.0], offset: 0.5, align_corners: true }),
|
||||
style_name: Some("named character style".repeat(8)),
|
||||
..style(FAMILY)
|
||||
};
|
||||
macro_rules! field {
|
||||
($field:ident, $value:expr, $want:expr) => {{
|
||||
let mut b = a.clone();
|
||||
b.$field = $value;
|
||||
check(&a, &b, $want, stringify!($field));
|
||||
}};
|
||||
}
|
||||
field!(font_family, "other family".into(), false);
|
||||
field!(font_style, "Bold".into(), false);
|
||||
field!(font_version, Some("other version".into()), false);
|
||||
field!(font_axes, [("wght".into(), 700.0)].into(), false);
|
||||
field!(size, 13.0, false);
|
||||
field!(leading, Some(60.0), false);
|
||||
field!(tracking, 20.0, false);
|
||||
field!(kerning, Some(0.0), false);
|
||||
field!(optical_kerning, true, false);
|
||||
field!(baseline_shift, 1.0, false);
|
||||
field!(h_scale, 90.0, false);
|
||||
field!(v_scale, 90.0, false);
|
||||
field!(rotation, 3.0, false);
|
||||
field!(underline, true, false);
|
||||
field!(strikethrough, true, false);
|
||||
field!(all_caps, true, false);
|
||||
field!(features, vec!["-liga".into()], false);
|
||||
field!(position, CharPosition::Superscript(Default::default()), false);
|
||||
field!(small_caps, Some(80.0), false);
|
||||
field!(char_align, CharAlign::EmBoxTop, false);
|
||||
field!(char_direction, Some(ParaDirection::RightToLeft), false);
|
||||
field!(kashidas, false, false);
|
||||
field!(proportional_metrics, true, false);
|
||||
field!(tate_chu_yoko, true, false);
|
||||
field!(tcy_up_down, 1.0, false);
|
||||
field!(tcy_left_right, 1.0, false);
|
||||
field!(standard_vertical_roman, true, false);
|
||||
field!(digits, Digits::Hindi, false);
|
||||
field!(font_height_reference, FontHeightReference::CapHeight, false);
|
||||
field!(no_break, true, false);
|
||||
field!(language, Some("ar".into()), false);
|
||||
field!(glyph_alternate, Some(GlyphChoice { ch: 'a', gid: 1, font: "other".into(), version: String::new() }), false);
|
||||
field!(fill, Paint::None, true);
|
||||
field!(stroke, Paint::None, true);
|
||||
field!(stroke_width, 2.0, true);
|
||||
field!(overprint_fill, true, true);
|
||||
field!(overprint_stroke, true, true);
|
||||
field!(stroke_cap, LineCap::Round, true);
|
||||
field!(stroke_join, LineJoin::Round, true);
|
||||
field!(stroke_miter_limit, 2.0, true);
|
||||
field!(stroke_dash, None, true);
|
||||
field!(stroke_align, StrokeAlign::Outside, true);
|
||||
field!(style_name, None, true);
|
||||
// Only the first style's ignored fields survive the old normalization. A NaN in the
|
||||
// second style's paint is ignored, but reversing the arguments must still return false.
|
||||
field!(fill, Paint::solid(Color::Gray { k: f32::NAN }), true);
|
||||
field!(stroke, Paint::solid(Color::Gray { k: f32::NAN }), true);
|
||||
field!(stroke_width, f64::NAN, true);
|
||||
field!(stroke_miter_limit, f64::NAN, true);
|
||||
field!(stroke_dash, Some(Dash { pattern: vec![f64::NAN], offset: 0.0, align_corners: false }), true);
|
||||
field!(tracking, f64::NAN, false);
|
||||
}
|
||||
|
||||
/// A face without usable mark classification keeps cluster-start paint, without changing
|
||||
/// its glyphs or positioning when a harakat's paint is edited. All variants are in-memory.
|
||||
#[test]
|
||||
fn synthetic_harakat_keep_geometry_with_and_without_gdef_marks() {
|
||||
use skrifa::raw::TableProvider;
|
||||
use write_fonts::FontBuilder;
|
||||
use write_fonts::tables::gdef::Gdef;
|
||||
use write_fonts::tables::layout::ClassDefFormat1;
|
||||
use write_fonts::types::{GlyphId16, Tag};
|
||||
|
||||
let bytes = crate::test_fonts::lam_alef_font(b"rlig").unwrap();
|
||||
let font = skrifa::FontRef::new(&bytes).unwrap();
|
||||
for variant in ["marks", "no GDEF", "no class definition", "base classes"] {
|
||||
let mut builder = FontBuilder::new();
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if variant == "marks" || tag != Tag::new(b"GDEF") {
|
||||
builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes());
|
||||
}
|
||||
}
|
||||
if variant == "no class definition" {
|
||||
builder.add_table(&Gdef::new(None, None, None, None)).unwrap();
|
||||
} else if variant == "base classes" {
|
||||
let classes = ClassDefFormat1::new(GlyphId16::new(0), vec![1; usize::from(font.maxp().unwrap().num_glyphs())]);
|
||||
builder.add_table(&Gdef::new(Some(classes.into()), None, None, None)).unwrap();
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(builder.build()), 1, "{variant}");
|
||||
let family = crate::test_fonts::lam_alef_family(b"rlig");
|
||||
let plain = text_with_harakat("بَبَبَ", family, None);
|
||||
let coloured = text_with_harakat("بَبَبَ", family, Some(red()));
|
||||
let before = layout(&db, &plain);
|
||||
let after = layout(&db, &coloured);
|
||||
assert!(!before.glyphs.is_empty(), "{variant}");
|
||||
assert_eq!(geometry(&before), geometry(&after), "{variant}");
|
||||
assert_eq!(coloured.plain_text(), plain.plain_text(), "{variant}");
|
||||
if variant == "marks" {
|
||||
assert!(painted(&coloured, &after, &red()) > 0, "{variant}: harakat own their paint");
|
||||
} else {
|
||||
assert!(after.glyphs.iter().all(|g| run_of(&coloured, g).style.fill != red()), "{variant}: cluster-start paint");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A range is normalised as `edit::style_range` takes one: clamped, each end moved back to the
|
||||
/// start of its character, put in order. Never a silent empty result for a usable range.
|
||||
#[test]
|
||||
fn harakat_ranges_normalise_their_range() {
|
||||
let word = "بِسْمِ"; // ب 0..2, kasra 2..4, س 4..6, sukun 6..8, م 8..10, kasra 10..12
|
||||
let all = harakat_ranges(word, 0..word.len());
|
||||
assert_eq!(harakat_ranges(word, word.len()..0), all, "reversed");
|
||||
assert_eq!(harakat_ranges(word, 0..usize::MAX), all, "past the end");
|
||||
assert!(harakat_ranges(word, usize::MAX..usize::MAX).is_empty(), "wholly past the end");
|
||||
assert_eq!(harakat_ranges(word, 1..word.len()), all, "a start inside ب moves back to it");
|
||||
assert_eq!(harakat_ranges(word, 0..7), [std::ops::Range { start: 2, end: 4 }], "an end inside the sukun moves back before it");
|
||||
assert_eq!(harakat_ranges(word, 3..9), [2..4, 6..8], "both ends inside characters");
|
||||
assert_eq!(harakat_ranges(word, std::ops::Range { start: 9, end: 3 }), [2..4, 6..8], "reversed, inside characters");
|
||||
assert!(harakat_ranges(word, 4..4).is_empty(), "empty");
|
||||
}
|
||||
|
||||
/// The character-level pass names each glyph's own character only when it sets the same glyphs
|
||||
/// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone
|
||||
/// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character;
|
||||
/// everything else stays at the cluster start (conservative).
|
||||
#[test]
|
||||
fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() {
|
||||
// بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and
|
||||
// initial beh (cluster 0). Bytes: ب0 ف2 ب4 ف6 ب8. Glyph id 20 is the fatha (GDEF mark).
|
||||
const TEXT: &str = "بَبَب";
|
||||
let marks = |gid: u32| gid == 20;
|
||||
let raw = [(10, 8, 500, 0, 0), (20, 4, 0, -30, 400), (11, 4, 300, 0, 0), (20, 0, 0, -40, 420), (12, 0, 450, 0, 0)];
|
||||
let by_char = |own: [u32; 5]| raw.iter().zip(own).map(|(g, c)| (g.0, c, g.2, 0, g.3, g.4)).collect::<Vec<_>>();
|
||||
let ok = by_char([8, 6, 4, 2, 0]);
|
||||
assert_eq!(own_chars(&raw, &ok, 10, TEXT, marks), [8, 6, 4, 2, 0]);
|
||||
// The fathas' characters swapped (the same glyph ids): each is outside its glyph's cluster,
|
||||
// so both keep their cluster's (their letter's) character, never the other fatha's.
|
||||
assert_eq!(own_chars(&raw, &by_char([8, 2, 4, 6, 0]), 10, TEXT, marks), [8, 4, 4, 0, 0]);
|
||||
// A character before its glyph's cluster, or past the segment's end: the cluster's own.
|
||||
assert_eq!(own_chars(&raw, &by_char([8, 3, 4, 2, 0]), 10, TEXT, marks), [8, 4, 4, 2, 0]);
|
||||
assert_eq!(own_chars(&raw, &by_char([9, 6, 4, 2, 0]), 9, TEXT, marks), [8, 6, 4, 2, 0]);
|
||||
// A base letter named as its mark keeps the letter (not a GDEF mark).
|
||||
assert_eq!(own_chars(&raw, &by_char([8, 6, 6, 2, 0]), 10, TEXT, marks), [8, 6, 4, 2, 0]);
|
||||
// Without GDEF mark classification every later owner falls back to the cluster start.
|
||||
assert_eq!(own_chars(&raw, &ok, 10, TEXT, |_| false), [8, 4, 4, 0, 0]);
|
||||
// Wide mark (advance 500) beside a narrower base (300): mark keeps 2; base keeps 0.
|
||||
// Review counterexample: advance must not decide ownership.
|
||||
let wide = [(20, 0, 500, 0, 0), (11, 0, 300, 0, 0)];
|
||||
let wide_by = [(20, 2, 500, 0, 0, 0), (11, 2, 300, 0, 0, 0)];
|
||||
assert_eq!(own_chars(&wide, &wide_by, 4, "بَ", marks), [2, 0], "wide GDEF mark keeps harakat owner");
|
||||
// Equal advances: same rule.
|
||||
let equal = [(20, 0, 300, 0, 0), (11, 0, 300, 0, 0)];
|
||||
let equal_by = [(20, 2, 300, 0, 0, 0), (11, 2, 300, 0, 0, 0)];
|
||||
assert_eq!(own_chars(&equal, &equal_by, 4, "بَ", marks), [2, 0], "equal advances");
|
||||
// Multi-component glyph (GDEF class 4, not mark) named as a later harakat stays at cluster start.
|
||||
let multi = [(40, 0, 100, 0, 0), (11, 0, 300, 0, 0)];
|
||||
let multi_by = [(40, 2, 100, 0, 0, 0), (11, 0, 300, 0, 0, 0)];
|
||||
assert_eq!(own_chars(&multi, &multi_by, 4, "بَ", marks), [0, 0], "component is not a mark");
|
||||
// Single-glyph ligature (one output for letter+mark) named as the mark stays at cluster start:
|
||||
// one glyph can represent a ligature containing a base, not a lone mark.
|
||||
let liga = [(50, 0, 400, 0, 0)];
|
||||
let liga_by = [(50, 2, 400, 0, 0, 0)];
|
||||
assert_eq!(own_chars(&liga, &liga_by, 4, "بَ", |_| false), [0], "ligature without GDEF mark class");
|
||||
assert_eq!(own_chars(&liga, &liga_by, 4, "بَ", |gid| gid == 50), [2], "only if GDEF names it mark");
|
||||
// Sole advancing glyph without GDEF mark proof stays at the cluster start.
|
||||
let advancing = [(20, 0, 100, 0, 0)];
|
||||
let advancing_by = [(20, 2, 100, 0, 0, 0)];
|
||||
assert_eq!(own_chars(&advancing, &advancing_by, 4, "بَ", |_| false), [0]);
|
||||
assert_eq!(own_chars(&advancing, &advancing_by, 4, "بَ", marks), [2]);
|
||||
// The same ids set otherwise (an advance or an offset changed, another glyph, one short):
|
||||
// nothing is borrowed.
|
||||
let mut moved = ok.clone();
|
||||
moved[1].5 += 1;
|
||||
assert!(own_chars(&raw, &moved, 10, TEXT, marks).is_empty(), "a mark moved");
|
||||
let mut wider = ok.clone();
|
||||
wider[2].2 += 1;
|
||||
assert!(own_chars(&raw, &wider, 10, TEXT, marks).is_empty(), "an advance changed");
|
||||
let mut other = ok.clone();
|
||||
other[0].0 = 13;
|
||||
assert!(own_chars(&raw, &other, 10, TEXT, marks).is_empty(), "another glyph");
|
||||
assert!(own_chars(&raw, &ok[1..], 10, TEXT, marks).is_empty(), "a glyph short");
|
||||
}
|
||||
|
||||
/// A word whose letters and marks repeat the same glyph ids (بَبَب): each fatha takes its own
|
||||
/// colour, on its own letter, and every glyph is set as without colour.
|
||||
#[test]
|
||||
fn repeated_glyphs_take_their_own_harakat_colour() {
|
||||
let Some(face) = arabic_face("repeated_glyphs_take_their_own_harakat_colour") else {
|
||||
return;
|
||||
};
|
||||
const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8
|
||||
let db = FontDb::global();
|
||||
let green = Paint::solid(Color::rgb8(30, 140, 70));
|
||||
let plain = text_with_harakat(WORD, &face.family, None);
|
||||
let mut coloured = plain.clone();
|
||||
crate::edit::style_range(&mut coloured.runs, 2, 4, |st| st.fill = green.clone());
|
||||
crate::edit::style_range(&mut coloured.runs, 6, 8, |st| st.fill = red());
|
||||
let (a, b) = (layout(db, &plain), layout(db, &coloured));
|
||||
assert_eq!(geometry(&a), geometry(&b), "joining and mark positions unchanged");
|
||||
assert_authored_ownership(&coloured, WORD, &b, "repeated");
|
||||
let fill = |g: &PositionedGlyph| run_of(&coloured, g).style.fill.clone();
|
||||
let letters = letter_glyphs(WORD, &face.family);
|
||||
let marks: Vec<&PositionedGlyph> = b.glyphs.iter().filter(|g| fixture_mark(g, &letters) && !g.outline.is_empty()).collect();
|
||||
assert_eq!(marks.len(), 2, "two fathas");
|
||||
assert_eq!(marks[0].gid, marks[1].gid, "the same glyph id twice");
|
||||
for m in &marks {
|
||||
let want = if m.byte == 0 { green.clone() } else { red() };
|
||||
assert!(m.byte == 0 || m.byte == 4, "a fatha in its letter's cluster: {}", m.byte);
|
||||
assert_eq!(fill(m), want, "the fatha of the letter at {} keeps its own colour", m.byte);
|
||||
}
|
||||
let letter_fill = plain.runs[0].style.fill.clone();
|
||||
assert!(b.glyphs.iter().filter(|g| letters.contains(&(g.font_id, g.gid))).all(|g| fill(g) == letter_fill), "letters keep theirs");
|
||||
}
|
||||
|
||||
/// Coloured harakat: the letters keep their joining and the marks their attachment, glyph for
|
||||
/// glyph and bit for bit; every mark glyph (and only the marks) is drawn in the harakat's run.
|
||||
#[test]
|
||||
fn harakat_coloured_apart_keep_every_glyph_and_take_their_own_colour() {
|
||||
let Some(face) = arabic_face("harakat_coloured_apart_keep_every_glyph_and_take_their_own_colour") else {
|
||||
return;
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let family = face.family.as_str();
|
||||
let plain = text_with_harakat(VOCALIZED, family, None);
|
||||
let coloured = text_with_harakat(VOCALIZED, family, Some(red()));
|
||||
assert!(coloured.runs.len() > 1);
|
||||
let (a, b) = (layout(db, &plain), layout(db, &coloured));
|
||||
assert_eq!(geometry(&a), geometry(&b), "joining and mark positions unchanged");
|
||||
let letters = letter_glyphs(VOCALIZED, family);
|
||||
let marks = b.glyphs.iter().filter(|g| fixture_mark(g, &letters) && !g.outline.is_empty()).count();
|
||||
assert!(marks > 0, "vocalized text has mark glyphs");
|
||||
assert_eq!(painted(&coloured, &b, &red()), marks);
|
||||
assert_authored_ownership(&coloured, VOCALIZED, &b, "coloured");
|
||||
let hidden = text_with_harakat(VOCALIZED, family, Some(Paint::None));
|
||||
let h = layout(db, &hidden);
|
||||
assert_eq!(geometry(&a), geometry(&h));
|
||||
assert_eq!(painted(&hidden, &h, &Paint::None), marks);
|
||||
assert_authored_ownership(&hidden, VOCALIZED, &h, "hidden");
|
||||
let mut sized = text_with_harakat(VOCALIZED, family, None);
|
||||
let first = harakat_ranges(VOCALIZED, 0..VOCALIZED.len())[0].clone();
|
||||
crate::edit::style_range(&mut sized.runs, first.start, first.end, |st| st.size = 30.0);
|
||||
assert_ne!(geometry(&layout(db, &sized)), geometry(&a));
|
||||
}
|
||||
|
||||
/// Justified kashida lines with coloured or hidden harakat keep authored glyph ownership, and
|
||||
/// inserted tatweels take the base letter's paint—not a trailing mark's.
|
||||
#[test]
|
||||
fn coloured_harakat_keep_kashida() {
|
||||
let Some(face) = arabic_face("coloured_harakat_keep_kashida") else {
|
||||
return;
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let family = face.family.as_str();
|
||||
let justified = |t: &mut TextObject, width: f64| {
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 2000.0).to_path(0.1)) };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.para.justify = Justify::JustifyAll;
|
||||
t.para.kashida = Kashida::Long;
|
||||
};
|
||||
const PARTLY: &str = "بِسْمِ اللَّهِ الرحمن الرحيم الحمد لله رب العالمين";
|
||||
let tat = face.glyph_for('\u{0640}');
|
||||
let mut tatweels = 0;
|
||||
for width in [200.0, 260.0, 300.0, 420.0, 600.0] {
|
||||
let mut plain = text_with_harakat(PARTLY, family, None);
|
||||
let mut coloured = text_with_harakat(PARTLY, family, Some(red()));
|
||||
let before = layout(db, &coloured);
|
||||
assert_authored_ownership(&coloured, PARTLY, &before, &format!("before kashida {width}"));
|
||||
justified(&mut plain, width);
|
||||
justified(&mut coloured, width);
|
||||
let (a, b) = (layout(db, &plain), layout(db, &coloured));
|
||||
tatweels += a.glyphs.iter().filter(|g| g.gid == tat && g.len == 0).count();
|
||||
assert_eq!(geometry(&a), geometry(&b), "{width}");
|
||||
assert_kashida_ownership(&coloured, &before, &b, &red(), &format!("coloured {width}"), false);
|
||||
}
|
||||
assert!(tatweels > 0, "the text takes kashida");
|
||||
let mut stretched = 0;
|
||||
for width in [200.0, 260.0, 300.0, 420.0, 600.0] {
|
||||
let mut plain = text_with_harakat(FULLY, family, None);
|
||||
let mut coloured = text_with_harakat(FULLY, family, Some(red()));
|
||||
let mut hidden = text_with_harakat(FULLY, family, Some(Paint::None));
|
||||
let before_c = layout(db, &coloured);
|
||||
let before_h = layout(db, &hidden);
|
||||
assert_authored_ownership(&coloured, FULLY, &before_c, &format!("full before {width}"));
|
||||
justified(&mut plain, width);
|
||||
justified(&mut coloured, width);
|
||||
justified(&mut hidden, width);
|
||||
let (a, b, h) = (layout(db, &plain), layout(db, &coloured), layout(db, &hidden));
|
||||
assert_eq!(geometry(&a), geometry(&b), "{width}");
|
||||
assert_eq!(geometry(&a), geometry(&h), "hidden {width}");
|
||||
stretched += stretched_words_with_harakat(FULLY, &b, tat);
|
||||
assert_kashida_ownership(&coloured, &before_c, &b, &red(), &format!("full coloured {width}"), false);
|
||||
assert_kashida_ownership(&hidden, &before_h, &h, &Paint::None, &format!("full hidden {width}"), false);
|
||||
}
|
||||
assert!(stretched > 0, "words with coloured harakat take kashida");
|
||||
}
|
||||
|
||||
/// Harakat on every word.
|
||||
const FULLY: &str = "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ الْحَمْدُ لِلَّهِ رَبِّ الْعَالَمِينَ الرَّحْمَنِ الرَّحِيمِ مَالِكِ يَوْمِ الدِّينِ";
|
||||
|
||||
/// The words of `text` with harakat that `l` stretched with a tatweel (glyph `tat`).
|
||||
fn stretched_words_with_harakat(text: &str, l: &TextLayout, tat: u32) -> usize {
|
||||
let mut words: Vec<usize> = l
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.gid == tat && g.len == 0)
|
||||
.map(|g| text[..g.byte].rfind(' ').map_or(0, |i| i + 1))
|
||||
.filter(|&start| text[start..].split(' ').next().is_some_and(|w| w.chars().any(is_harakat)))
|
||||
.collect();
|
||||
words.dedup();
|
||||
words.len()
|
||||
}
|
||||
|
||||
/// Authored glyphs paint as the independent expected fill from source runs; run indices must exist.
|
||||
fn assert_authored_ownership(t: &TextObject, text: &str, l: &TextLayout, what: &str) {
|
||||
let letters = letter_glyphs(text, &t.runs[0].style.font_family);
|
||||
for g in &l.glyphs {
|
||||
if g.len == 0 {
|
||||
continue;
|
||||
}
|
||||
let actual = &run_of(t, g).style.fill;
|
||||
let want = expected_fill(t, text, g, &letters);
|
||||
assert_eq!(actual, &want, "{what}: glyph {} at {} len {} adv {}", g.gid, g.byte, g.len, g.advance);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare authored glyph run indices before/after Insert Kashidas (matched by byte and gid;
|
||||
/// layout order can differ between point and justified area), and assert every inserted tatweel
|
||||
/// uses the base letter's paint at the cluster start—not `mark_fill`. When `require_mark_last`,
|
||||
/// at least one tatweel must follow a same-cluster glyph whose run is the mark paint (so the
|
||||
/// emitter fix is exercised: reverting `emit_kashida`'s base-run assignment fails).
|
||||
fn assert_kashida_ownership(
|
||||
coloured: &TextObject,
|
||||
before: &TextLayout,
|
||||
with_kashida: &TextLayout,
|
||||
mark_fill: &Paint,
|
||||
what: &str,
|
||||
require_mark_last: bool,
|
||||
) -> usize {
|
||||
let mut before_runs: std::collections::HashMap<(usize, u32), Vec<usize>> = std::collections::HashMap::new();
|
||||
for g in before.glyphs.iter().filter(|g| g.len > 0) {
|
||||
before_runs.entry((g.byte, g.gid)).or_default().push(g.run);
|
||||
}
|
||||
let mut after_n = 0;
|
||||
for g in with_kashida.glyphs.iter().filter(|g| g.len > 0) {
|
||||
after_n += 1;
|
||||
let runs =
|
||||
before_runs.get_mut(&(g.byte, g.gid)).unwrap_or_else(|| panic!("{what}: authored glyph {} at {} missing before kashida", g.gid, g.byte));
|
||||
let want_run = runs.pop().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte));
|
||||
assert_eq!(g.run, want_run, "{what}: authored run at {} gid {}", g.byte, g.gid);
|
||||
let want_fill = coloured.runs.get(want_run).map(|r| &r.style.fill).unwrap_or_else(|| panic!("{what}: missing run {want_run}"));
|
||||
assert_eq!(&run_of(coloured, g).style.fill, want_fill, "{what}: authored fill at {}", g.byte);
|
||||
}
|
||||
assert_eq!(after_n, before.glyphs.iter().filter(|g| g.len > 0).count(), "{what}: authored glyph count");
|
||||
assert!(before_runs.values().all(|v| v.is_empty()), "{what}: unjustified authored glyphs left unmatched");
|
||||
let mut mark_last = 0;
|
||||
for (i, g) in with_kashida.glyphs.iter().enumerate().filter(|(_, g)| g.len == 0) {
|
||||
let letter_fill = style_fill_at(coloured, g.byte);
|
||||
let fill = &run_of(coloured, g).style.fill;
|
||||
assert_eq!(fill, &letter_fill, "{what}: tatweel at {} must use base-letter paint", g.byte);
|
||||
assert_ne!(fill, mark_fill, "{what}: tatweel must not inherit mark paint at {}", g.byte);
|
||||
// The glyph that absorbed the kashida (last of the cluster in visual order) is emitted
|
||||
// immediately before the tatweel and shares its cluster byte.
|
||||
if let Some(prev) = i.checked_sub(1).and_then(|j| with_kashida.glyphs.get(j))
|
||||
&& prev.byte == g.byte
|
||||
&& prev.len > 0
|
||||
&& &run_of(coloured, prev).style.fill == mark_fill
|
||||
&& letter_fill != *mark_fill
|
||||
{
|
||||
mark_last += 1;
|
||||
}
|
||||
}
|
||||
if require_mark_last {
|
||||
assert!(mark_last > 0, "{what}: need a mark-last insertion (last visual glyph has mark paint ≠ letter) to exercise emit_kashida");
|
||||
}
|
||||
mark_last
|
||||
}
|
||||
|
||||
/// Installed OpenType Arabic faces (when present): coloured harakat change no glyph, position or
|
||||
/// advance; letter gids from the bare text must not take harakat paint. Glyphs absent from that
|
||||
/// bare set may take either the harakat colour or the letter colour (AAT/`morx` ligature fallback
|
||||
/// keeps the letter)—this test does not prove every mark owns its character. Selected AAT faces
|
||||
/// below record an explicit letter-fill allowance for non-letter gids; they may also paint no
|
||||
/// glyph in the harakat colour when GDEF is unavailable.
|
||||
#[test]
|
||||
fn installed_fonts_keep_their_glyphs_with_coloured_harakat() {
|
||||
const FONTS: &[&str] = &[
|
||||
"Geeza Pro",
|
||||
"SF Arabic",
|
||||
"Al Nile",
|
||||
"Baghdad",
|
||||
"Damascus",
|
||||
"KufiStandardGK",
|
||||
"DecoType Naskh",
|
||||
"Diwan Thuluth",
|
||||
"Farah",
|
||||
"Noto Nastaliq Urdu",
|
||||
"Arial",
|
||||
"Times New Roman",
|
||||
"Tahoma",
|
||||
"Noto Naskh Arabic",
|
||||
"Noto Kufi Arabic",
|
||||
"Noto Sans Arabic",
|
||||
"Amiri",
|
||||
"Aref Ruqaa",
|
||||
"IBM Plex Sans Arabic",
|
||||
];
|
||||
let db = FontDb::with_font_dirs(crate::system_font_dirs());
|
||||
let harakat = VOCALIZED.chars().filter(|c| is_harakat(*c)).count();
|
||||
let mut rows = vec![];
|
||||
for &family in FONTS {
|
||||
if !db.has_family(family) {
|
||||
continue;
|
||||
}
|
||||
let Some(face) = db.face(family, "Regular") else {
|
||||
continue;
|
||||
};
|
||||
let plain = text_with_harakat(VOCALIZED, &face.family, None);
|
||||
let coloured = text_with_harakat(VOCALIZED, &face.family, Some(red()));
|
||||
let bare: String = VOCALIZED.chars().filter(|c| !is_harakat(*c)).collect();
|
||||
let (a, b) = (layout(&db, &plain), layout(&db, &coloured));
|
||||
assert_eq!(geometry(&a), geometry(&b), "{family}: coloured harakat changed the glyphs");
|
||||
let letter_fill = style_fill_at(&coloured, 0);
|
||||
let letter_gids: std::collections::HashSet<u32> =
|
||||
layout(&db, &text_with_harakat(&bare, &face.family, None)).glyphs.iter().map(|g| g.gid).collect();
|
||||
// AAT faces where a ligature mark may keep the letter's colour (explicit allowance, not a
|
||||
// per-glyph ownership proof).
|
||||
let aat_letter_ok = matches!(family, "Geeza Pro" | "Al Nile" | "Baghdad" | "Damascus" | "KufiStandardGK" | "DecoType Naskh");
|
||||
for g in b.glyphs.iter().filter(|g| g.len > 0) {
|
||||
let actual = &run_of(&coloured, g).style.fill;
|
||||
if letter_gids.contains(&g.gid) {
|
||||
// A glyph the bare letters also draw: must not take harakat paint.
|
||||
assert_ne!(actual, &red(), "{family}: letter gid {} at {} took harakat paint", g.gid, g.byte);
|
||||
} else if aat_letter_ok {
|
||||
assert!(
|
||||
actual == &red() || actual == &letter_fill,
|
||||
"{family}: non-letter gid {} at {} may be harakat or letter paint (AAT), got {actual:?}",
|
||||
g.gid,
|
||||
g.byte
|
||||
);
|
||||
} else {
|
||||
// OpenType with GDEF: prefer harakat paint; letter paint still allowed when the
|
||||
// character-level pass falls back (does not prove every mark owns its character).
|
||||
assert!(
|
||||
actual == &red() || actual == &letter_fill,
|
||||
"{family}: non-letter at {} should be harakat or letter paint, got {actual:?}",
|
||||
g.byte
|
||||
);
|
||||
}
|
||||
}
|
||||
let red_glyphs = painted(&coloured, &b, &red());
|
||||
// Without GDEF (or when the character-level pass falls back), every glyph may keep the
|
||||
// letter's paint—conservative and allowed. This is not every-mark ownership.
|
||||
let all_letter = b.glyphs.iter().filter(|g| g.len > 0).all(|g| run_of(&coloured, g).style.fill == letter_fill);
|
||||
assert!(red_glyphs > 0 || all_letter, "{family}: expected some harakat paint or a full letter-paint fallback (aat={aat_letter_ok})");
|
||||
rows.push(format!("{family}: {} glyphs identical; {red_glyphs} glyphs in the harakat colour ({harakat} harakat)", b.glyphs.len()));
|
||||
}
|
||||
if rows.is_empty() {
|
||||
eprintln!("SKIPPED: installed_fonts_keep_their_glyphs_with_coloured_harakat; none of the candidate families are installed");
|
||||
} else {
|
||||
eprintln!("{rows:#?}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user