mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:09:53 -06:00
* Arabic interface: mirrored right-to-left window and Arabic UI fonts Adds the Arabic (`ar`) interface language's code: its catalogs are craft-i18n's `locales/ar`, so this changes no catalog. - `ar` is registered in `i18n::LANGUAGES` and `Lang::rtl()` says it is written right to left; its translations are used as written (egui's text layout orders each line's bidi text itself). A ribbon location reads from the right with a leftwards mark. - `rtl.rs`: the window is mirrored. Containers open in `rtl::root`; columns, grids, checkboxes, radio buttons, menus, rows, panels, the dialog tab strip and the ribbon's scrolling row follow the language's direction. Title bar, status bar, ribbon tabs and groups, File pages (including Print, with the settings on the right and a solid scrollbar gutter), dialogs, menus and the task panes (their docking edge swaps) are laid out from the right. egui's `Grid` panics in a right-aligned layout, so grids go through `rtl::grid`. - Arabic UI font fallback: the craft-fonts Arabic face, else an installed Arabic font, loaded when the interface is Arabic. - The "Auto (200%)" size label keeps its percent sign inside the isolated number. - Tests: Arabic locale, saved preference and lookup order on fixture catalogs; an interface audit (skipped without the craft-i18n Arabic catalog) that lays the app out right to left at several widths, checks tab, group, pane, Print and Go To positions and that nothing panics. The Arabic work builds on Abdelhaq El Amraoui's mirrored-interface PR (#737). Co-Authored-By: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic interface: pin craft-i18n at a commit with the Arabic catalog; review fixes - craft-i18n.rev and the Flatpak manifest pin 19358491 (craft-i18n main), the first commit line that has locales/ar/wordcraft.tsv. The old pin had no Arabic catalog, so `ar` was registered but not embedded and two i18n tests (the_language_setting_is_a_ui_command_and_persists, the_languages_offered_are_the_embedded_ones_of_the_list) failed in CI. - The Arabic font fallback is requested for any right-to-left interface (`Lang::rtl()`), not only after the language picker sets the flag, so an automatic or saved Arabic gets the installed Arabic face. - The file browser, the update window and Reading Mode's toolbar run their bodies in `rtl::root`, so their far-side buttons and rows are mirrored. - A group in a right-to-left ribbon row is measured from the right edge: the cursor's left edge is negative infinity there, so its width was NaN and the ribbon planner dropped every group, never compacting a narrow Arabic window. A narrow window now folds groups into drop-downs. - The mirrored checkbox and radio marks are labelled by their text, so screen readers name them. - Tests: group width in a right-to-left row; the Arabic interface audit checks Reading Mode's toolbar. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic interface: ask for the installed Arabic font separately from the CJK one A previous commit made every right-to-left interface request the installed CJK fallback font (`system_cjk`) so that the installed Arabic font would load. That also made every Arabic launch read a large system CJK font file when no craft-fonts input is embedded (the Flatpak build has none). `install_ui_fonts` now passes a separate `system_arabic` flag (`Lang::rtl()`): the installed Arabic font is read for it, and `system_cjk` is again set only by a CJK interface or by CJK text on screen. `WordApp::fonts_system_arabic` remembers what was installed, so a language change reinstalls the fonts. Tests: the_installed_arabic_font_is_asked_for_by_arabic_alone (the Arabic flag adds the installed font only when none is embedded, and the CJK request alone adds no Arabic font). Checked a headless screenshot of the Arabic interface without craft-fonts: the installed Arabic font draws the labels. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Fix the AutoSave switch knob: on is at the right, mirrored in Arabic The title bar's AutoSave knob was placed with `lead_x(rtl == on, ..)`, which reverses main's mapping in both directions: left to right it drew the knob at the left when the switch was on, and right to left at the right. main draws it at the right when on and at the left when off, so the mirrored interface must draw it at the left when on: `rtl != on`. The position is now `autosave_knob_x(rtl, on, track)`, pinned by a test for all four cases. A headless screenshot of the off switch shows the knob at the left in English and at the right in Arabic. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic interface: file names and paths in right-to-left lines; kerning row order Tester feedback on the Arabic interface: - File > Open's Recent list, the built-in file browser and the title bar drew file names and paths raw, from the left edge. In an Arabic line the marks of a name ("(نهائي) 2.docx") and the folders of a path with Arabic parts were scrambled. Rows now start at the side reading starts from (`rtl::lead_x`), names go through `i18n::isolate` (a first-strong isolate, with a left-to-right mark before the extension so `2.docx` stays `2.docx`) and paths through `i18n::isolate_path` (a left-to-right isolate with a mark after every separator, so neighbouring Arabic folders do not swap). Other languages get the text untouched; saved and spoken text never carries the marks. - Font dialog, Kerning row: the dialog's grid is drawn left to right inside a right-to-left window, so "Points and above" came after its number; in an Arabic interface it now comes first, so the line reads "14 نقطة فأكثر" from the right. (The grid's columns themselves still keep their left-to-right order.) Tests: names_and_paths_are_isolated_in_a_right_to_left_interface. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic interface: mirror through craft_ui::direction and Style::right_to_left The suite's craft-ui now has the direction helpers (`craft_ui::direction`) and its egui fork a `Style::right_to_left` that mirrors egui's own chrome. Use them instead of our parallel wrappers: - `rtl::sync(ctx)` applies the interface direction every frame the language's changes (`direction::apply`): it sets the shared direction state and `Style::right_to_left` on every theme, which draws check boxes and radio buttons with the mark at the reading start, mirrors submenus, scroll bars, title bars and the combo boxes' arrows (the Print page's choice lists, the font list) and opens combo menus from the right. - `rtl::checkbox`, `radio_value`, `radio` and `indeterminate_checkbox` are gone: about 200 call sites use plain `ui.checkbox`, `ui.radio_value`, ... and egui does the mirroring (and names the control by its text, as a plain widget). - `lead_x`, `lead_center`, `lead_top`, `start_panel` and `end_panel` delegate to `craft_ui::direction`. `root`, `flows`, `island`, `grid`, `menu`, `vertical`, `far_side` and the other layout helpers stay: the shared crate has no equivalent that fits them. Visual changes: the combo boxes' drop-down arrows and lists are mirrored in Arabic; nothing changes in left-to-right languages (checked headless screenshots of the English main window, Font dialog and Print page). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Recent files and the file browser: full path on hover, long names keep their start Review feedback: - Each row of File > Open's Recent list shows the document's full path as its tooltip (the path line under the name can be longer than the row). - A long name in the built-in file browser is laid out in one row, with an ellipsis at its end, from the side reading starts from, instead of being clipped at the row's edge: in a right-to-left interface the old text anchored at the right edge cut the end of an Arabic name and the start of a Latin one. The whole name is the row's tooltip when it was shortened. Checked headless screenshots of the file browser with a long English and a long Arabic name. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Font combos in a right-to-left row: the arrow joins its field on the left The ribbon's font name and size boxes are drawn from a field and a separate arrow box that share one outline: the field is square and the arrow's box is rounded on the side where the arrow goes, painted a border width under the field. That assumed the arrow is right of the field. In a right-to-left row (the Arabic ribbon) the arrow comes after the field on its left, so the field was square on the wrong side and the arrow box rounded on the side facing the field: two boxes with a gap-like seam, the arrow looking outside the frame. `arrow_box_shape(rtl, ..)` mirrors the radii and the underlap, and the field's square corners are chosen by the row's direction (the nw/sw pair in right to left). Left-to-right rows are drawn exactly as before (English before/after crops are identical). Test: the_arrow_box_is_rounded_away_from_its_field. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Pin craft-i18n 4612850 (merged Arabic WordCraft rows, craft-i18n#76) The pin and the Flatpak manifest commit move from 1935849 to 4612850, which is on craft-i18n main and adds the 70 Arabic rows this branch relies on. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Pin craft-i18n cac2cf0 (Arabic number agreement and status-bar rows) Moves the pin and the Flatpak manifest commit from 4612850 to the current craft-i18n main, which includes craft-i18n#95 (48bed03) and #81 (ef58ff3). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic: isolate paths shown beside labels; mirror the Print page zoom File > Info shows Location as its own left-to-right path label (cut short with the full path on hover, not wrapped), and the open-failure, Exported and dictionary-file messages isolate their paths. The Print preview zoom puts minus at the slider's left end and plus at its right in Arabic, as the status bar does. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Arabic: mirror the label/field rows of the Font, Info, Paragraph, Page Setup, Insert Table and Options forms rtl::Table lays a form out as an egui Grid in a left-to-right interface (same id, columns and spacing, so the output is unchanged) and, where the interface is mirrored, with each row's cells running from the right, every column as wide as its widest cell. File > Info puts Properties at the right and Statistics after it, and the Font dialog's kerning row reads 14 points and above. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: record Arabic mirrored interface landing (#1195) Update the language counts, Arabic parity row, gap #11, and UI/ROADMAP status so they match the craft-i18n catalog and mirrored shell already on this branch. * Fix Organizer Copy arrow in RTL and refresh roadmap status. Point the Copy glyph at the Document column when the shell is mirrored, and bump docs/roadmap.md status/revision history for Arabic UI (#1195). * fix(ui): keep pane dock left/right physical in RTL ui.paneDock and view.stylesPaneDock now store and report physical window sides; only unset defaults still open on the reading-start edge. Also wrap the language-packs grid for RTL, document the contract, and load a system Arabic face for Arabic names in LTR UIs. * docs: clarify mirrored default pane edges in RTL Start-default panes open on the physical right in Arabic; end-default panes open on the physical left — not every never-docked pane on the right. * refactor(ui): one PaneState for a saved pane place The four saved-dock arms built the same PaneState; only the place and whether it was set explicitly differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(ui): rustfmt the panes re-exports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: The Legend Of MTS Dev <tlo.mts.tsh@gmail.com> Co-authored-by: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: email <email@email.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
611 lines
28 KiB
Rust
611 lines
28 KiB
Rust
//! WordCraft fonts: the font database (bundled OFL families + user/system fonts), vertical
|
|
//! metrics, glyph outlines and OpenType shaping.
|
|
//!
|
|
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
|
|
//! and advances in font units. `wordcraft-compose` applies sizes, tracking, scaling and
|
|
//! justification on top.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
#![forbid(unsafe_code)]
|
|
|
|
pub mod color;
|
|
mod fontdb;
|
|
pub mod math;
|
|
mod script;
|
|
mod ui_fallback;
|
|
pub mod word;
|
|
|
|
pub use fontdb::{
|
|
FALLBACK_FAMILY, FONT_FILE_EXTS, FaceRef, FontDb, FontFace, FontFormat, Installed, LocalName, MAX_FONT_FILE_BYTES, UserFont, UserFontError,
|
|
base_style, bundled, decode_font_file, is_font_file_name, name_language_matches, sniff, system_font_dirs,
|
|
};
|
|
pub use harfrust::Feature;
|
|
use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer};
|
|
pub use kurbo::BezPath;
|
|
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
|
use skrifa::MetadataProvider;
|
|
use skrifa::instance::Size;
|
|
pub use ui_fallback::{
|
|
SystemUiFont, is_cjk, system_arabic_ui_font, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk,
|
|
ui_uncovered_cjk,
|
|
};
|
|
|
|
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
|
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
|
pub struct CraftFont {
|
|
pub family: &'static str,
|
|
pub style: &'static str,
|
|
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
|
|
pub scripts: &'static [&'static str],
|
|
pub bytes: &'static [u8],
|
|
}
|
|
|
|
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
|
|
|
/// The craft-fonts faces for Japanese (`"Jpan"`), in manifest order. Empty without craft-fonts.
|
|
pub fn japanese_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
|
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan"))
|
|
}
|
|
|
|
/// Japanese faces in document-fallback order: Mincho (serif) families first, matching the
|
|
/// serif default text font, then the others; Regular before other styles.
|
|
pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
|
|
let mut v: Vec<_> = japanese_fonts().collect();
|
|
v.sort_by_key(|f| (!f.family.contains("Mincho"), f.style != "Regular"));
|
|
v
|
|
}
|
|
|
|
/// Japanese faces in UI order: BIZ UDPGothic first (the UI face), then the others; `bold` puts
|
|
/// bold styles before regular ones.
|
|
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
|
let mut v: Vec<_> = japanese_fonts().collect();
|
|
v.sort_by_key(|f| (f.family != "BIZ UDPGothic", (f.style == "Bold") != bold));
|
|
v
|
|
}
|
|
|
|
/// The craft-fonts faces for Simplified Chinese (`"Hans"`), in manifest order. Empty without
|
|
/// craft-fonts.
|
|
pub fn chinese_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
|
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Hans"))
|
|
}
|
|
|
|
/// The craft-fonts faces for Korean (`"Kore"`), in manifest order. Empty without craft-fonts.
|
|
pub fn korean_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
|
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Kore"))
|
|
}
|
|
|
|
/// The craft-fonts Georgian (`"Geor"`) Regular face for the interface. Empty without craft-fonts.
|
|
pub fn georgian_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
|
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Geor") && f.style == "Regular")
|
|
}
|
|
|
|
/// The CJK interface faces, Regular styles only (the UI fakes no bold), with the Japanese UI,
|
|
/// Chinese, and Korean faces ordered for the selected interface language. Mixing the faces within
|
|
/// a line would mix glyph styles, so the first face should cover the interface language. Empty
|
|
/// without craft-fonts.
|
|
pub fn ui_cjk_fonts(prefer_hans: bool) -> Vec<&'static CraftFont> {
|
|
ui_cjk_fonts_for(prefer_hans, false)
|
|
}
|
|
|
|
/// The CJK interface faces in the preferred writing system's order.
|
|
pub fn ui_cjk_fonts_for(prefer_hans: bool, prefer_korean: bool) -> Vec<&'static CraftFont> {
|
|
let regular = |f: &&CraftFont| f.style == "Regular";
|
|
let ja = japanese_ui_fonts(false).into_iter().filter(regular).take(1);
|
|
let zh = chinese_fonts().filter(regular).take(1);
|
|
let ko = korean_fonts().filter(regular).take(1);
|
|
if prefer_korean {
|
|
ko.chain(ja).chain(zh).collect()
|
|
} else if prefer_hans {
|
|
zh.chain(ja).chain(ko).collect()
|
|
} else {
|
|
ja.chain(zh).chain(ko).collect()
|
|
}
|
|
}
|
|
|
|
/// The craft-fonts faces for Arabic script (`"Arab"`), in manifest order: the document fallback
|
|
/// for Arabic text. Empty without craft-fonts.
|
|
pub fn arabic_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
|
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab"))
|
|
}
|
|
|
|
/// The craft-fonts faces for Arabic script (`"Arab"`), Regular styles only: the interface's
|
|
/// fallback for an Arabic interface and for Arabic file names. Empty without craft-fonts.
|
|
pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> {
|
|
arabic_fonts().filter(|f| f.style == "Regular").collect()
|
|
}
|
|
|
|
/// The bundled serif used when a document asks for nothing better.
|
|
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
|
|
pub fn feature(tag: &str) -> Option<Feature> {
|
|
let (on, t) = match tag.strip_prefix('-') {
|
|
Some(r) => (false, r),
|
|
None => (true, tag.strip_prefix('+').unwrap_or(tag)),
|
|
};
|
|
let b = t.as_bytes();
|
|
if b.len() != 4 {
|
|
return None;
|
|
}
|
|
Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..))
|
|
}
|
|
|
|
/// An OpenType feature with a value (`ssty` = 2 for second-level script glyphs).
|
|
pub fn feature_value(tag: &[u8; 4], value: u32) -> Feature {
|
|
Feature::new(Tag::new(tag), value, ..)
|
|
}
|
|
|
|
/// A strong right-to-left character (Hebrew, Arabic, …)?
|
|
pub fn is_rtl(c: char) -> bool {
|
|
use unicode_bidi::BidiClass::{AL, R};
|
|
// Nothing before the Hebrew block is right to left: Latin, Greek and Cyrillic text skips the
|
|
// table search.
|
|
c >= '\u{590}' && matches!(unicode_bidi::bidi_class(c), R | AL)
|
|
}
|
|
|
|
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
|
|
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
|
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
|
|
let mut cur: Option<bool> = None;
|
|
let mut start = 0;
|
|
for (i, c) in text.char_indices() {
|
|
let strong = if is_rtl(c) {
|
|
Some(true)
|
|
} else if unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::L {
|
|
Some(false)
|
|
} else {
|
|
None
|
|
};
|
|
match (cur, strong) {
|
|
(None, Some(d)) => cur = Some(d),
|
|
(Some(a), Some(d)) if a != d => {
|
|
runs.push((start..i, a));
|
|
start = i;
|
|
cur = Some(d);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
runs.push((start..text.len(), cur.unwrap_or(false)));
|
|
runs
|
|
}
|
|
|
|
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
|
|
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
|
|
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
|
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
|
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
|
if !text.chars().any(is_rtl) {
|
|
return shape_dir(face, text, features, &map, false);
|
|
}
|
|
let mut out = Vec::with_capacity(text.len());
|
|
for (r, rtl) in direction_runs(text) {
|
|
let Some(sub) = text.get(r.clone()) else { continue };
|
|
let mut g = shape_run(face, sub, features, &map, rtl);
|
|
for x in &mut g {
|
|
x.cluster += r.start;
|
|
}
|
|
out.extend(g);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Shape `text` as one run in a known direction (the caller ran the bidi algorithm: `rtl` is an
|
|
/// odd embedding level). Right-to-left runs get Arabic joining, mirrored brackets and right-to-left
|
|
/// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order,
|
|
/// each cluster's glyphs in the shaper's (visual) order.
|
|
pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
|
let g = shape_dir(face, text, features, &map, rtl);
|
|
if !rtl {
|
|
return g;
|
|
}
|
|
// Visual (clusters descending) → logical, cluster by cluster.
|
|
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
|
|
for x in g {
|
|
match groups.last_mut() {
|
|
Some(last) if last.first().is_some_and(|f| f.cluster == x.cluster) => last.push(x),
|
|
_ => groups.push(vec![x]),
|
|
}
|
|
}
|
|
groups.sort_by_key(|grp| grp.first().map_or(0, |f| f.cluster));
|
|
groups.into_iter().flatten().collect()
|
|
}
|
|
|
|
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
|
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();
|
|
// One script per shaping call: the shaper picks its script engine (Myanmar, Khmer,
|
|
// Indic, USE reordering…) from the run's script, so "Hi မြန်မာ" in one face must not be
|
|
// shaped as Latin. Runs come in text order; right-to-left callers regroup by cluster.
|
|
for r in script::script_runs(text) {
|
|
let Some(sub) = text.get(r.clone()) else { continue };
|
|
let mut buf = UnicodeBuffer::new();
|
|
for (i, c) in sub.char_indices() {
|
|
buf.add(map(c), (r.start + i) as u32);
|
|
}
|
|
buf.guess_segment_properties();
|
|
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
|
let plan = shape_plan(face, &shaper, &buf, features);
|
|
let gb = shaper.shape(buf, ShapeOptions::new().plan(plan.as_deref()).features(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()
|
|
{
|
|
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: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// How many shape plans each thread keeps: the oldest is dropped to make room, so a thread never
|
|
/// holds more than this many, whatever fonts, scripts and feature sets it shapes.
|
|
const MAX_SHAPE_PLANS: usize = 64;
|
|
|
|
/// The face a kept shape plan was made for. Face ids come from one process-wide counter
|
|
/// (`NEXT_ID`, taken once when a face is built), so no two faces share one; the font bytes'
|
|
/// address and length and the face index are kept too, so a plan is only ever reused for the
|
|
/// font data it was made from, even if the counter were to wrap after 2^32 faces.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
struct PlanFace {
|
|
id: u32,
|
|
data: usize,
|
|
len: usize,
|
|
index: u32,
|
|
}
|
|
|
|
impl PlanFace {
|
|
fn of(face: &FontFace) -> PlanFace {
|
|
let data = face.data();
|
|
PlanFace { id: face.id(), data: data.as_ptr() as usize, len: data.len(), index: face.index() }
|
|
}
|
|
}
|
|
|
|
thread_local! {
|
|
/// Shape plans made so far on this thread, by face: making one (the face's lookups for a
|
|
/// script, direction and feature set) can take longer than shaping the run.
|
|
static SHAPE_PLANS: std::cell::RefCell<Vec<(PlanFace, std::rc::Rc<ShapePlan>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
|
}
|
|
|
|
/// The plan the shaper would make for `buf` in `face` with `features`, made once per thread (the
|
|
/// oldest dropped past [`MAX_SHAPE_PLANS`]). A kept plan is used only for the same face and when
|
|
/// harfrust's own [`ShapePlanKey::matches`] accepts it (script, direction, language, the face's
|
|
/// variation instance and the features). `None` leaves making it to the shaper.
|
|
fn shape_plan(face: &FontFace, shaper: &Shaper, buf: &UnicodeBuffer, features: &[Feature]) -> Option<std::rc::Rc<ShapePlan>> {
|
|
// The buffer's script is unset when it reads as unknown: the guess never sets that.
|
|
let script = Some(buf.script()).filter(|s| *s != harfrust::script::UNKNOWN);
|
|
let language = buf.language();
|
|
let direction = buf.direction();
|
|
if direction == Direction::Invalid {
|
|
return None;
|
|
}
|
|
let key = ShapePlanKey::new(script, direction).language(language.as_ref()).instance(face.instance.as_ref()).features(features);
|
|
let of = PlanFace::of(face);
|
|
SHAPE_PLANS.with(|cell| {
|
|
let mut plans = cell.try_borrow_mut().ok()?;
|
|
if let Some((_, plan)) = plans.iter().find(|(f, plan)| *f == of && key.matches(plan)) {
|
|
return Some(plan.clone());
|
|
}
|
|
let plan = std::rc::Rc::new(ShapePlan::new(shaper, direction, script, language.as_ref(), features));
|
|
if plans.len() >= MAX_SHAPE_PLANS {
|
|
plans.remove(0);
|
|
}
|
|
plans.push((of, plan.clone()));
|
|
Some(plan)
|
|
})
|
|
}
|
|
|
|
/// How many shape plans this thread keeps.
|
|
#[cfg(test)]
|
|
fn kept_shape_plans() -> usize {
|
|
SHAPE_PLANS.with(|cell| cell.try_borrow().map_or(0, |p| p.len()))
|
|
}
|
|
|
|
/// Glyph id of the first of `chars` the face has (0 = .notdef).
|
|
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
|
|
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The shortcut before the Hebrew block gives the bidi class's answer for every character.
|
|
#[test]
|
|
fn is_rtl_matches_the_bidi_class() {
|
|
use unicode_bidi::BidiClass::{AL, R};
|
|
for c in (0..=0x10ffff).filter_map(char::from_u32) {
|
|
assert_eq!(is_rtl(c), matches!(unicode_bidi::bidi_class(c), R | AL), "{:04x}", c as u32);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn last_resort_face_parses() {
|
|
let f = fontdb::last_resort_face();
|
|
assert_eq!(f.family, fontdb::FALLBACK_FAMILY);
|
|
assert!(f.upem > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn bundled_families_load() {
|
|
let db = FontDb::global();
|
|
let fams = db.families();
|
|
assert!(fams.iter().any(|f| f == DEFAULT_FAMILY), "{fams:?}");
|
|
assert!(fams.iter().any(|f| f == "Source Sans 3"));
|
|
let styles = db.styles(DEFAULT_FAMILY);
|
|
for s in ["Regular", "Italic", "Bold", "Semibold"] {
|
|
assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shaping_produces_clusters_and_advances() {
|
|
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
|
let g = shape(&face, "Hello", &[], |c| c);
|
|
assert_eq!(g.len(), 5);
|
|
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![0, 1, 2, 3, 4]);
|
|
assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0));
|
|
}
|
|
|
|
/// Shaping with a plan kept from an earlier run gives what the shaper's own plan gives.
|
|
#[test]
|
|
fn kept_shape_plans_shape_as_fresh_ones() {
|
|
let fresh = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
|
|
let Some(hb) = face.hb() else { return Vec::new() };
|
|
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
|
let mut buf = UnicodeBuffer::new();
|
|
buf.push_str(text);
|
|
buf.guess_segment_properties();
|
|
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
|
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
|
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
|
|
};
|
|
let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
|
|
shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
|
|
};
|
|
let db = FontDb::global();
|
|
let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")];
|
|
let features =
|
|
[vec![], vec![feature("-liga").unwrap()], vec![feature_value(b"kern", 0)], vec![feature("smcp").unwrap(), feature("-kern").unwrap()]];
|
|
for _ in 0..2 {
|
|
for face in &faces {
|
|
for f in &features {
|
|
for (text, rtl) in [("office affine", false), ("12, 34.", false), ("AVATAR Wolf", false), ("abc", true), ("", false)] {
|
|
assert_eq!(kept(face, text, f, rtl), fresh(face, text, f, rtl), "{} {text:?} {f:?} {rtl}", face.family);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// However many feature sets and directions a thread shapes with, it keeps at most
|
|
/// [`MAX_SHAPE_PLANS`] plans, and shaping still matches a fresh plan once old ones are dropped.
|
|
#[test]
|
|
fn kept_shape_plans_are_bounded() {
|
|
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
|
let fresh = |features: &[Feature], rtl: bool| -> Vec<(u32, i32)> {
|
|
let Some(hb) = face.hb() else { return Vec::new() };
|
|
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
|
let mut buf = UnicodeBuffer::new();
|
|
buf.push_str("office");
|
|
buf.guess_segment_properties();
|
|
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
|
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
|
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, p.x_advance)).collect()
|
|
};
|
|
for n in 0..3 * MAX_SHAPE_PLANS as u32 {
|
|
let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
|
|
let rtl = n % 3 == 0;
|
|
let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect();
|
|
assert_eq!(kept, fresh(&features, rtl), "{n}");
|
|
assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans());
|
|
}
|
|
assert_eq!(kept_shape_plans(), MAX_SHAPE_PLANS);
|
|
}
|
|
|
|
#[test]
|
|
fn ligatures_can_be_disabled() {
|
|
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
|
let on = shape(&face, "office", &[], |c| c);
|
|
let off = shape(&face, "office", &[feature("-liga").unwrap()], |c| c);
|
|
assert!(on.len() <= off.len());
|
|
assert_eq!(off.len(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn mapping_keeps_clusters() {
|
|
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
|
let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase());
|
|
assert_eq!(g[0].gid, face.glyph_for('A'));
|
|
assert_eq!(g[1].cluster, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn variable_font_named_instances_are_styles() {
|
|
// Uses a variable system font when one is installed (macOS ships several).
|
|
let Some(data) =
|
|
["/System/Library/Fonts/Supplemental/Skia.ttf", "/System/Library/Fonts/NewYork.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
|
|
.iter()
|
|
.find_map(|p| std::fs::read(p).ok())
|
|
else {
|
|
return;
|
|
};
|
|
let db = FontDb::global();
|
|
db.add_font(data.clone());
|
|
let Some(fam) = skrifa::FontRef::new(&data).ok().and_then(|f| {
|
|
(f.named_instances().len() > 1)
|
|
.then(|| f.localized_strings(skrifa::string::StringId::FAMILY_NAME).english_or_first().map(|s| s.to_string()))?
|
|
}) else {
|
|
return;
|
|
};
|
|
let styles = db.styles(&fam);
|
|
assert!(styles.len() > 1, "{styles:?}");
|
|
let faces: Vec<_> = styles.iter().map(|s| db.face(&fam, s)).filter(|f| f.is_variable()).collect();
|
|
let light = faces.iter().min_by(|a, b| a.weight.total_cmp(&b.weight)).unwrap();
|
|
let heavy = faces.iter().max_by(|a, b| a.weight.total_cmp(&b.weight)).unwrap();
|
|
assert!(heavy.weight > light.weight, "{light:?} {heavy:?}");
|
|
let ink = |f: &FontFace| {
|
|
let g = shape(f, "H", &[], |c| c);
|
|
let b = kurbo::Shape::bounding_box(&*db.outline(f, g[0].gid));
|
|
((b.area() * 100.0).round() as i64, g[0].x_advance)
|
|
};
|
|
assert_ne!(ink(light), ink(heavy), "instances draw differently");
|
|
// Free axis values: a style with settings makes that instance.
|
|
let axes = db.axes(&fam, &light.style);
|
|
let wght = axes.iter().find(|a| a.0 == "wght").expect("a weight axis");
|
|
let mid = (wght.2 + wght.4) / 2.0;
|
|
let custom = db.face(&fam, &format!("{} {{wght:{mid}}}", light.style));
|
|
assert!(custom.is_variable() && custom.coords.iter().any(|(t, v)| t == b"wght" && *v == mid));
|
|
assert!(!db.styles(&fam).iter().any(|s| s.contains('{')), "instances aren't listed");
|
|
let static_face = db.face(DEFAULT_FAMILY, "Regular {wght:700}");
|
|
assert_eq!(static_face.style, "Regular", "static fonts ignore axis settings");
|
|
}
|
|
|
|
#[test]
|
|
fn outlines_and_metrics() {
|
|
let db = FontDb::global();
|
|
let face = db.face(DEFAULT_FAMILY, "Bold");
|
|
assert_eq!(face.style, "Bold");
|
|
let o = db.outline(&face, face.glyph_for('O'));
|
|
assert!(!o.elements().is_empty());
|
|
assert!(face.ascent > 0.0 && face.descent > 0.0 && face.cap_height > face.x_height);
|
|
assert!(feature("abc").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn craft_fonts_japanese_faces_cover_japanese_and_have_vertical_forms() {
|
|
if CRAFT_FONTS.is_empty() {
|
|
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
|
|
return;
|
|
}
|
|
let fonts = japanese_document_fonts();
|
|
assert!(!fonts.is_empty(), "craft-fonts has Japanese fonts");
|
|
assert!(fonts[0].family.contains("Mincho"), "documents fall back to a Mincho first");
|
|
assert_eq!(japanese_ui_fonts(false)[0].family, "BIZ UDPGothic");
|
|
assert_eq!(japanese_ui_fonts(true)[0].style, "Bold");
|
|
let db = FontDb::with_font_dirs(Vec::new());
|
|
db.set_system_fallback(false);
|
|
for cf in fonts {
|
|
// Inspect the embedded face directly so this cannot pass through an OS fallback.
|
|
let font = skrifa::FontRef::new(cf.bytes).unwrap();
|
|
for ch in "日本語縦書き横書きルビ、。「」".chars() {
|
|
assert_ne!(font.charmap().map(ch).unwrap_or_default(), skrifa::GlyphId::NOTDEF, "{} lacks {ch}", cf.family);
|
|
}
|
|
let face = db.face(cf.family, cf.style);
|
|
assert_eq!(face.family, cf.family);
|
|
let horizontal = shape(&face, "「」、。", &[], |c| c);
|
|
let vertical = shape(&face, "「」、。", &[feature("vert").unwrap(), feature("vrt2").unwrap()], |c| c);
|
|
assert_eq!(horizontal.len(), vertical.len());
|
|
assert!(horizontal.iter().zip(&vertical).any(|(h, v)| h.gid != v.gid), "{} has vertical forms", cf.family);
|
|
}
|
|
// Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts.
|
|
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
|
let fb = db.fallback_for('語', latin.id()).unwrap();
|
|
assert!(fb.family.contains("Mincho"), "{fb:?}");
|
|
}
|
|
|
|
/// Arabic text in a document needs no system font: the craft-fonts Arabic face is the
|
|
/// fallback (the browser build has no other), and its letters join when shaped.
|
|
#[test]
|
|
fn craft_fonts_arabic_face_is_the_document_fallback_for_arabic() {
|
|
if arabic_fonts().next().is_none() {
|
|
eprintln!("skipped: built without a craft-fonts Arabic face (set CRAFT_FONTS_DIR to a checkout)");
|
|
return;
|
|
}
|
|
let db = FontDb::with_font_dirs(Vec::new());
|
|
db.set_system_fallback(false);
|
|
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
|
for ch in "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأإةى،؛؟٠١٢٣٤٥٦٧٨٩ًٌٍَُِّْ".chars() {
|
|
assert!(!latin.covers(ch), "the bundled serif has no Arabic");
|
|
let fb = db.fallback_for(ch, latin.id()).unwrap_or_else(|| panic!("no fallback for {ch}"));
|
|
assert!(fb.family.contains("Arabic"), "{ch}: {}", fb.family);
|
|
}
|
|
let face = db.fallback_for('ع', latin.id()).unwrap();
|
|
// Joined forms: the same letter gets a different glyph alone and inside a word.
|
|
let alone = shape(&face, "ع", &[], |c| c);
|
|
let joined = shape(&face, "ععع", &[], |c| c);
|
|
assert_eq!((alone.len(), joined.len()), (1, 3));
|
|
assert!(joined.iter().all(|g| g.gid != 0));
|
|
assert!(joined.iter().any(|g| g.gid != alone[0].gid), "letters take their joining forms");
|
|
}
|
|
|
|
#[test]
|
|
fn east_asian_run_uses_one_face_covering_the_whole_run() {
|
|
let db = FontDb::with_font_dirs(Vec::new());
|
|
db.set_system_fallback(false);
|
|
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
|
if CRAFT_FONTS.is_empty() {
|
|
// No bundled CJK font and no system fallback: nothing can draw the run.
|
|
assert!(db.fallback_for_run(&['分', '类'], latin.id(), false, false).is_none());
|
|
return;
|
|
}
|
|
// JIS-covered ideographs alone would fall back to a Mincho and simplified-only ones to the
|
|
// SC sans: the run-level pick must cover every character in one face, or a run mixes
|
|
// serif and sans glyphs (the per-character fallback still applies when it returns None).
|
|
let run: Vec<char> = "分类监督学习".chars().collect();
|
|
let f = db.fallback_for_run(&run, latin.id(), false, false).expect("a CJK face covers the run");
|
|
for c in &run {
|
|
assert!(f.covers(*c), "{f:?} covers {c}");
|
|
}
|
|
// Pure Japanese still gets the document serif (Mincho first).
|
|
let jp: Vec<char> = "日本語".chars().collect();
|
|
let f2 = db.fallback_for_run(&jp, latin.id(), false, false).expect("a CJK face");
|
|
assert!(f2.family.contains("Mincho"), "{f2:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn arabic_and_persian_fall_back_to_the_craft_fonts_arabic_face() {
|
|
// #1652: with no system fonts (a headless CLI), Arabic and Persian drew as empty boxes.
|
|
let Some(arabic) = CRAFT_FONTS.iter().find(|f| f.scripts.contains(&"Arab")) else {
|
|
eprintln!("skipped: no Arabic face in the optional craft-fonts input");
|
|
return;
|
|
};
|
|
let db = FontDb::with_font_dirs(Vec::new());
|
|
db.set_system_fallback(false);
|
|
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
|
for c in "مرحبا پچژگ۱۲۳".chars().filter(|c| !c.is_whitespace()) {
|
|
let fb = db.fallback_for(c, latin.id()).unwrap_or_else(|| panic!("no fallback for {c}"));
|
|
assert_eq!(fb.family, arabic.family, "{c}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn works_without_craft_fonts() {
|
|
// Always true without CRAFT_FONTS_DIR; with it, the bundled fonts must still all load.
|
|
let db = FontDb::with_font_dirs(Vec::new());
|
|
db.set_system_fallback(false);
|
|
assert_eq!(db.face(DEFAULT_FAMILY, "Regular").family, DEFAULT_FAMILY);
|
|
assert!(!shape(&db.face(DEFAULT_FAMILY, "Regular"), "Hello", &[], |c| c).is_empty());
|
|
if CRAFT_FONTS.is_empty() {
|
|
assert!(japanese_fonts().next().is_none() && japanese_document_fonts().is_empty());
|
|
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
|
assert!(db.fallback_for('語', latin.id()).is_none(), "no bundled Japanese font");
|
|
}
|
|
}
|
|
}
|