Font fallback

This commit is contained in:
Laurenz 2022-04-02 21:55:25 +02:00
parent beca01c826
commit 23d108c8e0
41 changed files with 877 additions and 591 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
.vscode
_things
desktop.ini
.DS_Store
# Tests and benchmarks
tests/png

13
NOTICE
View File

@ -3,16 +3,15 @@ Licenses for third party components used by this project can be found below.
================================================================================
The SIL Open Font License Version 1.1 applies to:
* IBM Plex fonts in fonts/IBMPlex-*.ttf
* IBM Plex fonts in fonts/IBMPlex*.ttf
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
(https://github.com/IBM/plex)
* Noto fonts in fonts/Noto-*.ttf
* Noto fonts in fonts/Noto*.ttf
Copyright 2018 The Noto Project Authors
(https://github.com/googlefonts/noto-fonts)
(https://github.com/googlefonts/noto-cjk)
(github.com/googlei18n/noto-fonts)
* PT Sans fonts in fonts/PTSans-*.ttf
* PT Sans fonts in fonts/PTSans*.ttf
Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public),
with Reserved Font Names "PT Sans" and "ParaType".
@ -135,7 +134,7 @@ THE SOFTWARE.
================================================================================
The Apache License Version 2.0 applies to:
* Roboto fonts in fonts/Roboto-*.ttf
* Roboto fonts in fonts/Roboto*.ttf
(https://github.com/googlefonts/roboto)
Apache License
@ -319,7 +318,7 @@ The Apache License Version 2.0 applies to:
================================================================================
The GUST Font License Version 1.0 applies to:
* Latin Modern Math font in fonts/LatinModernMath.otf
* Latin Modern fonts in fonts/LatinModern*.otf
(http://www.gust.org.pl/projects/e-foundry/lm-math)
% This is version 1.0, dated 22 June 2009, of the GUST Font License.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
fonts/NotoColorEmoji.ttf Normal file

Binary file not shown.

View File

@ -13,7 +13,7 @@ use crate::diag::StrResult;
use crate::library::layout::{FlowChild, FlowNode, PageNode, PlaceNode, Spacing};
use crate::library::prelude::*;
use crate::library::structure::{ListItem, ListKind, ListNode, ORDERED, UNORDERED};
use crate::library::text::{DecoNode, ParChild, ParNode, TextNode, UNDERLINE};
use crate::library::text::{DecoNode, ParChild, ParNode, UNDERLINE};
use crate::util::EcoString;
/// Composable representation of styled content.
@ -133,11 +133,6 @@ impl Content {
Self::Styled(Arc::new((self, styles)))
}
/// Style this content in monospace.
pub fn monospaced(self) -> Self {
self.styled(TextNode::MONOSPACED, true)
}
/// Underline this content.
pub fn underlined(self) -> Self {
Self::show(DecoNode::<UNDERLINE>(self))

View File

@ -6,7 +6,7 @@ use std::sync::Arc;
use super::{Args, Content, Func, Span, Value};
use crate::diag::{At, TypResult};
use crate::library::layout::PageNode;
use crate::library::text::ParNode;
use crate::library::text::{FontFamily, ParNode, TextNode};
use crate::Context;
/// A map of style properties.
@ -48,6 +48,17 @@ impl StyleMap {
}
}
/// Set a font family composed of a preferred family and existing families
/// from a style chain.
pub fn set_family(&mut self, family: FontFamily, existing: StyleChain) {
self.set(
TextNode::FAMILY,
std::iter::once(family)
.chain(existing.get_ref(TextNode::FAMILY).iter().cloned())
.collect(),
);
}
/// Set a recipe.
pub fn set_recipe(&mut self, node: TypeId, func: Func, span: Span) {
self.recipes.push(Recipe { node, func, span });

View File

@ -7,6 +7,7 @@ use std::sync::Arc;
use super::{ops, Args, Array, Content, Context, Dict, Func, Layout, StrExt};
use crate::diag::{with_alternative, At, StrResult, TypResult};
use crate::geom::{Angle, Color, Fractional, Length, Linear, Relative, RgbaColor};
use crate::library::text::RawNode;
use crate::syntax::{Span, Spanned};
use crate::util::EcoString;
@ -115,9 +116,10 @@ impl Value {
Value::Float(v) => Content::Text(format_eco!("{}", v)),
Value::Str(v) => Content::Text(v),
Value::Content(v) => v,
// For values which can't be shown "naturally", we print the
// representation in monospace.
v => Content::Text(v.repr()).monospaced(),
// For values which can't be shown "naturally", we return the raw
// representation.
v => Content::show(RawNode { text: v.repr(), block: false }),
}
}

View File

@ -90,9 +90,10 @@ impl<'a> PdfExporter<'a> {
let glyphs = &self.glyph_sets[&face_id];
let face = self.fonts.get(face_id);
let metrics = face.metrics();
let ttf = face.ttf();
let postscript_name = find_name(ttf.names(), name_id::POST_SCRIPT_NAME)
let postscript_name = find_name(ttf, name_id::POST_SCRIPT_NAME)
.unwrap_or_else(|| "unknown".to_string());
let base_font = format_eco!("ABCDEF+{}", postscript_name);
@ -155,9 +156,9 @@ impl<'a> PdfExporter<'a> {
);
let italic_angle = ttf.italic_angle().unwrap_or(0.0);
let ascender = face.ascender.to_font_units();
let descender = face.descender.to_font_units();
let cap_height = face.cap_height.to_font_units();
let ascender = metrics.ascender.to_font_units();
let descender = metrics.descender.to_font_units();
let cap_height = metrics.cap_height.to_font_units();
let stem_v = 10.0 + 0.244 * (f32::from(ttf.weight().to_number()) - 50.0);
// Write the font descriptor (contains metrics about the font).

View File

@ -160,7 +160,7 @@ fn render_svg_glyph(
// If there's no viewbox defined, use the em square for our scale
// transformation ...
let upem = face.units_per_em as f32;
let upem = face.units_per_em() as f32;
let (mut width, mut height) = (upem, upem);
// ... but if there's a viewbox or width, use that.
@ -232,7 +232,7 @@ fn render_outline_glyph(
// Flip vertically because font design coordinate
// system is Y-up.
let scale = text.size.to_f32() / face.units_per_em as f32;
let scale = text.size.to_f32() / face.units_per_em() as f32;
let ts = ts.pre_scale(scale, -scale);
canvas.fill_path(&path, &paint, rule, ts, mask)?;
return Some(());

View File

@ -1,12 +1,14 @@
//! Font handling.
use std::cmp::Reverse;
use std::collections::{hash_map::Entry, BTreeMap, HashMap};
use std::fmt::{self, Debug, Formatter};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use ttf_parser::{name_id, GlyphId, PlatformId};
use ttf_parser::{name_id, GlyphId, PlatformId, Tag};
use unicode_segmentation::UnicodeSegmentation;
use crate::geom::{Em, Length, Linear};
use crate::loading::{FileHash, Loader};
@ -47,13 +49,19 @@ impl FontStore {
let mut failed = vec![];
let mut families = BTreeMap::<String, Vec<FaceId>>::new();
for (i, info) in loader.faces().iter().enumerate() {
let infos = loader.faces();
for (i, info) in infos.iter().enumerate() {
let id = FaceId(i as u32);
faces.push(None);
failed.push(false);
families.entry(info.family.to_lowercase()).or_default().push(id);
}
for faces in families.values_mut() {
faces.sort_by_key(|id| infos[id.0 as usize].variant);
faces.dedup_by_key(|id| infos[id.0 as usize].variant);
}
Self {
loader,
faces,
@ -63,33 +71,88 @@ impl FontStore {
}
}
/// Query for and load the font face from the given `family` that most
/// closely matches the given `variant`.
/// Try to find and load a font face from the given `family` that matches
/// the given `variant` as closely as possible.
pub fn select(&mut self, family: &str, variant: FontVariant) -> Option<FaceId> {
// Check whether a family with this name exists.
let ids = self.families.get(family)?;
let id = self.find_best_variant(None, variant, ids.iter().copied())?;
self.load(id)
}
/// Try to find and load a fallback font that
/// - is as close as possible to the face `like` (if any)
/// - is as close as possible to the given `variant`
/// - is suitable for shaping the given `text`
pub fn select_fallback(
&mut self,
like: Option<FaceId>,
variant: FontVariant,
text: &str,
) -> Option<FaceId> {
// Find the faces that contain the text's first char ...
let c = text.chars().next()?;
let ids = self
.loader
.faces()
.iter()
.enumerate()
.filter(|(_, info)| info.coverage.contains(c as u32))
.map(|(i, _)| FaceId(i as u32));
// ... and find the best variant among them.
let id = self.find_best_variant(like, variant, ids)?;
self.load(id)
}
/// Find the face in the passed iterator that
/// - is closest to the face `like` (if any)
/// - is closest to the given `variant`
///
/// To do that we compute a key for all variants and select the one with the
/// minimal key. This key prioritizes:
/// - If `like` is some other face:
/// - Are both faces (not) monospaced.
/// - Do both faces (not) have serifs.
/// - How many words do the families share in their prefix? E.g. "Noto
/// Sans" and "Noto Sans Arabic" share two words, whereas "IBM Plex
/// Arabic" shares none with "Noto Sans", so prefer "Noto Sans Arabic"
/// if `like` is "Noto Sans". In case there are two equally good
/// matches, we prefer the shorter one because it is less special (e.g.
/// if `like` is "Noto Sans Arabic", we prefer "Noto Sans" over "Noto
/// Sans CJK HK".)
/// - The style (normal / italic / oblique). If we want italic or oblique
/// but it doesn't exist, the other one of the two is still better than
/// normal.
/// - The absolute distance to the target stretch.
/// - The absolute distance to the target weight.
fn find_best_variant(
&self,
like: Option<FaceId>,
variant: FontVariant,
ids: impl IntoIterator<Item = FaceId>,
) -> Option<FaceId> {
let infos = self.loader.faces();
let like = like.map(|id| &infos[id.0 as usize]);
let mut best = None;
let mut best_key = None;
// Find the best matching variant of this font.
for &id in ids {
let current = infos[id.0 as usize].variant;
for id in ids {
let current = &infos[id.0 as usize];
// This is a perfect match, no need to search further.
if current == variant {
best = Some(id);
break;
}
// If this is not a perfect match, we compute a key that we want to
// minimize among all variants. This key prioritizes style, then
// stretch distance and then weight distance.
let key = (
current.style != variant.style,
current.stretch.distance(variant.stretch),
current.weight.distance(variant.weight),
like.map(|like| {
(
current.monospaced != like.monospaced,
like.serif.is_some() && current.serif != like.serif,
Reverse(shared_prefix_words(&current.family, &like.family)),
current.family.len(),
)
}),
current.variant.style.distance(variant.style),
current.variant.stretch.distance(variant.stretch),
current.variant.weight.distance(variant.weight),
);
if best_key.map_or(true, |b| key < b) {
@ -98,59 +161,79 @@ impl FontStore {
}
}
let id = best?;
best
}
/// Load the face with the given id.
///
/// Returns `Some(id)` if the face was loaded successfully.
fn load(&mut self, id: FaceId) -> Option<FaceId> {
let idx = id.0 as usize;
let slot = &mut self.faces[idx];
if slot.is_some() {
return Some(id);
}
if self.failed[idx] {
return None;
}
// Load the face if it's not already loaded.
if slot.is_none() {
let FaceInfo { ref path, index, .. } = infos[idx];
self.failed[idx] = true;
let FaceInfo { ref path, index, .. } = self.loader.faces()[idx];
self.failed[idx] = true;
// Check the buffer cache since multiple faces may
// refer to the same data (font collection).
let hash = self.loader.resolve(path).ok()?;
let buffer = match self.buffers.entry(hash) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
let buffer = self.loader.load(path).ok()?;
entry.insert(Arc::new(buffer))
}
};
// Check the buffer cache since multiple faces may
// refer to the same data (font collection).
let hash = self.loader.resolve(path).ok()?;
let buffer = match self.buffers.entry(hash) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
let buffer = self.loader.load(path).ok()?;
entry.insert(Arc::new(buffer))
}
};
let face = Face::new(Arc::clone(buffer), index)?;
*slot = Some(face);
self.failed[idx] = false;
}
let face = Face::new(Arc::clone(buffer), index)?;
*slot = Some(face);
self.failed[idx] = false;
Some(id)
}
/// Get a reference to a loaded face.
///
/// This panics if no face with this `id` was loaded. This function should
/// only be called with ids returned by this store's
/// [`select()`](Self::select) method.
/// This panics if the face with this `id` was not loaded. This function
/// should only be called with ids returned by this store's
/// [`select()`](Self::select) and
/// [`select_fallback()`](Self::select_fallback) methods.
#[track_caller]
pub fn get(&self, id: FaceId) -> &Face {
self.faces[id.0 as usize].as_ref().expect("font face was not loaded")
}
/// Returns an ordered iterator over all font family names this loader
/// knows.
pub fn families(&self) -> impl Iterator<Item = &str> + '_ {
/// An ordered iterator over all font families this loader knows and details
/// about the faces that are part of them.
pub fn families(
&self,
) -> impl Iterator<Item = (&str, impl Iterator<Item = &FaceInfo>)> + '_ {
// Since the keys are lowercased, we instead use the family field of the
// first face's info.
let faces = self.loader.faces();
self.families
.values()
.map(move |id| faces[id[0].0 as usize].family.as_str())
self.families.values().map(|ids| {
let family = faces[ids[0].0 as usize].family.as_str();
let infos = ids.iter().map(|&id| &faces[id.0 as usize]);
(family, infos)
})
}
}
/// How many words the two strings share in their prefix.
fn shared_prefix_words(left: &str, right: &str) -> usize {
left.unicode_words()
.zip(right.unicode_words())
.take_while(|(l, r)| l == r)
.count()
}
/// A font face.
pub struct Face {
/// The raw face data, possibly shared with other faces from the same
@ -161,6 +244,71 @@ pub struct Face {
index: u32,
/// The underlying ttf-parser/rustybuzz face.
ttf: rustybuzz::Face<'static>,
/// The faces metrics.
metrics: FaceMetrics,
}
impl Face {
/// Parse a font face from a buffer and collection index.
pub fn new(buffer: Arc<Vec<u8>>, index: u32) -> Option<Self> {
// Safety:
// - The slices's location is stable in memory:
// - We don't move the underlying vector
// - Nobody else can move it since we have a strong ref to the `Arc`.
// - The internal static lifetime is not leaked because its rewritten
// to the self-lifetime in `ttf()`.
let slice: &'static [u8] =
unsafe { std::slice::from_raw_parts(buffer.as_ptr(), buffer.len()) };
let ttf = rustybuzz::Face::from_slice(slice, index)?;
let metrics = FaceMetrics::from_ttf(&ttf);
Some(Self { buffer, index, ttf, metrics })
}
/// The underlying buffer.
pub fn buffer(&self) -> &Arc<Vec<u8>> {
&self.buffer
}
/// The collection index.
pub fn index(&self) -> u32 {
self.index
}
/// A reference to the underlying `ttf-parser` / `rustybuzz` face.
pub fn ttf(&self) -> &rustybuzz::Face<'_> {
// We can't implement Deref because that would leak the internal 'static
// lifetime.
&self.ttf
}
/// The number of units per em.
pub fn units_per_em(&self) -> f64 {
self.metrics.units_per_em
}
/// Access the face's metrics.
pub fn metrics(&self) -> &FaceMetrics {
&self.metrics
}
/// Convert from font units to an em length.
pub fn to_em(&self, units: impl Into<f64>) -> Em {
Em::from_units(units, self.units_per_em())
}
/// Look up the horizontal advance width of a glyph.
pub fn advance(&self, glyph: u16) -> Option<Em> {
self.ttf
.glyph_hor_advance(GlyphId(glyph))
.map(|units| self.to_em(units))
}
}
/// Metrics for a font face.
#[derive(Debug, Copy, Clone)]
pub struct FaceMetrics {
/// How many font units represent one em unit.
pub units_per_em: f64,
/// The distance from the baseline to the typographic ascender.
@ -179,30 +327,10 @@ pub struct Face {
pub overline: LineMetrics,
}
/// Metrics for a decorative line.
#[derive(Debug, Copy, Clone)]
pub struct LineMetrics {
/// The vertical offset of the line from the baseline. Positive goes
/// upwards, negative downwards.
pub position: Em,
/// The thickness of the line.
pub thickness: Em,
}
impl Face {
/// Parse a font face from a buffer and collection index.
pub fn new(buffer: Arc<Vec<u8>>, index: u32) -> Option<Self> {
// Safety:
// - The slices's location is stable in memory:
// - We don't move the underlying vector
// - Nobody else can move it since we have a strong ref to the `Arc`.
// - The internal static lifetime is not leaked because its rewritten
// to the self-lifetime in `ttf()`.
let slice: &'static [u8] =
unsafe { std::slice::from_raw_parts(buffer.as_ptr(), buffer.len()) };
let ttf = rustybuzz::Face::from_slice(slice, index)?;
let units_per_em = f64::from(ttf.units_per_em());
impl FaceMetrics {
/// Extract the face's metrics.
pub fn from_ttf(ttf: &ttf_parser::Face) -> Self {
let units_per_em = f64::from(ttf.units_per_em().unwrap_or(0));
let to_em = |units| Em::from_units(units, units_per_em);
let ascender = to_em(ttf.typographic_ascender().unwrap_or(ttf.ascender()));
@ -231,10 +359,7 @@ impl Face {
thickness: underline.thickness,
};
Some(Self {
buffer,
index,
ttf,
Self {
units_per_em,
ascender,
cap_height,
@ -243,40 +368,11 @@ impl Face {
strikethrough,
underline,
overline,
})
}
/// The underlying buffer.
pub fn buffer(&self) -> &Arc<Vec<u8>> {
&self.buffer
}
/// The collection index.
pub fn index(&self) -> u32 {
self.index
}
/// A reference to the underlying `ttf-parser` / `rustybuzz` face.
pub fn ttf(&self) -> &rustybuzz::Face<'_> {
// We can't implement Deref because that would leak the internal 'static
// lifetime.
&self.ttf
}
/// Convert from font units to an em length.
pub fn to_em(&self, units: impl Into<f64>) -> Em {
Em::from_units(units, self.units_per_em)
}
/// Look up the horizontal advance width of a glyph.
pub fn advance(&self, glyph: u16) -> Option<Em> {
self.ttf
.glyph_hor_advance(GlyphId(glyph))
.map(|units| self.to_em(units))
}
}
/// Look up a vertical metric at the given font size.
pub fn vertical_metric(&self, metric: VerticalFontMetric, size: Length) -> Length {
pub fn vertical(&self, metric: VerticalFontMetric, size: Length) -> Length {
match metric {
VerticalFontMetric::Ascender => self.ascender.resolve(size),
VerticalFontMetric::CapHeight => self.cap_height.resolve(size),
@ -288,6 +384,16 @@ impl Face {
}
}
/// Metrics for a decorative line.
#[derive(Debug, Copy, Clone)]
pub struct LineMetrics {
/// The vertical offset of the line from the baseline. Positive goes
/// upwards, negative downwards.
pub position: Em,
/// The thickness of the line.
pub thickness: Em,
}
/// Identifies a vertical metric of a font.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum VerticalFontMetric {
@ -323,50 +429,112 @@ pub struct FaceInfo {
pub family: String,
/// Properties that distinguish this face from other faces in the same
/// family.
#[serde(flatten)]
pub variant: FontVariant,
/// Whether the face is monospaced.
pub monospaced: bool,
/// Whether the face has serifs (if known).
pub serif: Option<bool>,
/// The unicode coverage of the face.
pub coverage: Coverage,
}
impl FaceInfo {
/// Determine metadata about all faces that are found in the given data.
pub fn parse<'a>(
/// Compute metadata for all faces in the given data.
pub fn from_data<'a>(
path: &'a Path,
data: &'a [u8],
) -> impl Iterator<Item = FaceInfo> + 'a {
let count = ttf_parser::fonts_in_collection(data).unwrap_or(1);
(0 .. count).filter_map(move |index| {
let face = ttf_parser::Face::from_slice(data, index).ok()?;
let mut family = find_name(face.names(), name_id::TYPOGRAPHIC_FAMILY)
.or_else(|| find_name(face.names(), name_id::FAMILY))?;
Self::from_ttf(path, index, &face)
})
}
// Remove weird leading dot appearing in some fonts.
if let Some(undotted) = family.strip_prefix('.') {
family = undotted.to_string();
/// Compute metadata for a single ttf-parser face.
pub fn from_ttf(path: &Path, index: u32, ttf: &ttf_parser::Face) -> Option<Self> {
// We cannot use Name ID 16 "Typographic Family", because for some
// fonts it groups together more than just Style / Weight / Stretch
// variants (e.g. Display variants of Noto fonts) and then some
// variants become inaccessible from Typst. And even though the
// fsSelection bit WWS should help us decide whether that is the
// case, it's wrong for some fonts (e.g. for some faces of "Noto
// Sans Display").
//
// So, instead we use Name ID 1 "Family" and trim many common
// suffixes for which know that they just describe styling (e.g.
// "ExtraBold").
//
// Also, for Noto fonts we use Name ID 4 "Full Name" instead,
// because Name ID 1 "Family" sometimes contains "Display" and
// sometimes doesn't for the Display variants and that mixes things
// up.
let family = {
let mut family = find_name(ttf, name_id::FAMILY)?;
if family.starts_with("Noto") {
family = find_name(ttf, name_id::FULL_NAME)?;
}
trim_styles(&family).to_string()
};
let variant = FontVariant {
style: match (face.is_italic(), face.is_oblique()) {
(false, false) => FontStyle::Normal,
(true, _) => FontStyle::Italic,
(_, true) => FontStyle::Oblique,
},
weight: FontWeight::from_number(face.weight().to_number()),
stretch: FontStretch::from_number(face.width().to_number()),
let variant = {
let mut full = find_name(ttf, name_id::FULL_NAME).unwrap_or_default();
full.make_ascii_lowercase();
// Some fonts miss the relevant bits for italic or oblique, so
// we also try to infer that from the full name.
let italic = ttf.is_italic() || full.contains("italic");
let oblique =
ttf.is_oblique() || full.contains("oblique") || full.contains("slanted");
let style = match (italic, oblique) {
(false, false) => FontStyle::Normal,
(true, _) => FontStyle::Italic,
(_, true) => FontStyle::Oblique,
};
Some(FaceInfo {
path: path.to_owned(),
index,
family,
variant,
})
let weight = FontWeight::from_number(ttf.weight().to_number());
let stretch = FontStretch::from_number(ttf.width().to_number());
FontVariant { style, weight, stretch }
};
// Determine the unicode coverage.
let mut codepoints = vec![];
for subtable in ttf.character_mapping_subtables() {
if subtable.is_unicode() {
subtable.codepoints(|c| codepoints.push(c));
}
}
// Determine whether this is a serif or sans-serif font.
let mut serif = None;
if let Some(panose) = ttf
.table_data(Tag::from_bytes(b"OS/2"))
.and_then(|os2| os2.get(32 .. 45))
{
match panose {
[2, 2 ..= 10, ..] => serif = Some(true),
[2, 11 ..= 15, ..] => serif = Some(false),
_ => {}
}
}
Some(FaceInfo {
path: path.to_owned(),
index,
family,
variant,
monospaced: ttf.is_monospaced(),
serif,
coverage: Coverage::from_vec(codepoints),
})
}
}
/// Find a decodable entry in a name table iterator.
pub fn find_name(mut names: ttf_parser::Names<'_>, name_id: u16) -> Option<String> {
names.find_map(|entry| {
/// Try to find and decode the name with the given id.
pub fn find_name(ttf: &ttf_parser::Face, name_id: u16) -> Option<String> {
ttf.names().find_map(|entry| {
if entry.name_id() == name_id {
if let Some(string) = entry.to_string() {
return Some(string);
@ -381,8 +549,63 @@ pub fn find_name(mut names: ttf_parser::Names<'_>, name_id: u16) -> Option<Strin
})
}
/// Trim style naming from a family name.
fn trim_styles(mut family: &str) -> &str {
// Separators between names, modifiers and styles.
const SEPARATORS: [char; 3] = [' ', '-', '_'];
// Modifiers that can appear in combination with suffixes.
const MODIFIERS: &[&str] = &[
"extra", "ext", "ex", "x", "semi", "sem", "sm", "demi", "dem", "ultra",
];
// Style suffixes.
#[rustfmt::skip]
const SUFFIXES: &[&str] = &[
"normal", "italic", "oblique", "slanted",
"thin", "th", "hairline", "light", "lt", "regular", "medium", "med",
"md", "bold", "bd", "demi", "extb", "black", "blk", "bk", "heavy",
"narrow", "condensed", "cond", "cn", "cd", "compressed", "expanded", "exp"
];
// Trim spacing and weird leading dots in Apple fonts.
family = family.trim().trim_start_matches('.');
// Lowercase the string so that the suffixes match case-insensitively.
let lower = family.to_ascii_lowercase();
let mut len = usize::MAX;
let mut trimmed = lower.as_str();
// Trim style suffixes repeatedly.
while trimmed.len() < len {
len = trimmed.len();
// Find style suffix.
let mut t = match SUFFIXES.iter().find_map(|s| trimmed.strip_suffix(s)) {
Some(t) => t,
None => break,
};
// Strip optional separator.
if let Some(s) = t.strip_suffix(SEPARATORS) {
trimmed = s;
t = s;
}
// Also allow an extra modifier, but apply it only if it is separated it
// from the text before it (to prevent false positives).
if let Some(t) = MODIFIERS.iter().find_map(|s| t.strip_suffix(s)) {
if let Some(stripped) = t.strip_suffix(SEPARATORS) {
trimmed = stripped;
}
}
}
&family[.. len]
}
/// Properties that distinguish a face from other faces in the same family.
#[derive(Default, Copy, Clone, Eq, PartialEq, Hash)]
#[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[derive(Serialize, Deserialize)]
pub struct FontVariant {
/// The style of the face (normal / italic / oblique).
@ -419,6 +642,19 @@ pub enum FontStyle {
Oblique,
}
impl FontStyle {
/// The conceptual distance between the styles, expressed as a number.
pub fn distance(self, other: Self) -> u16 {
if self == other {
0
} else if self != Self::Normal && other != Self::Normal {
1
} else {
2
}
}
}
impl Default for FontStyle {
fn default() -> Self {
Self::Normal
@ -572,6 +808,66 @@ impl Debug for FontStretch {
}
}
/// A compactly encoded set of codepoints.
///
/// The set is represented by alternating specifications of how many codepoints
/// are not in the set and how many are in the set.
///
/// For example, for the set `{2, 3, 4, 9, 10, 11, 15, 18, 19}`, there are:
/// - 2 codepoints not inside (0, 1)
/// - 3 codepoints inside (2, 3, 4)
/// - 4 codepoints not inside (5, 6, 7, 8)
/// - 3 codepoints inside (9, 10, 11)
/// - 3 codepoints not inside (12, 13, 14)
/// - 1 codepoint inside (15)
/// - 2 codepoints not inside (16, 17)
/// - 2 codepoints inside (18, 19)
///
/// So the resulting encoding is `[2, 3, 4, 3, 3, 1, 2, 2]`.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Coverage(Vec<u32>);
impl Coverage {
/// Encode a vector of codepoints.
pub fn from_vec(mut codepoints: Vec<u32>) -> Self {
codepoints.sort();
codepoints.dedup();
let mut runs = Vec::new();
let mut next = 0;
for c in codepoints {
if let Some(run) = runs.last_mut().filter(|_| c == next) {
*run += 1;
} else {
runs.push(c - next);
runs.push(1);
}
next = c + 1;
}
Self(runs)
}
/// Whether the codepoint is covered.
pub fn contains(&self, c: u32) -> bool {
let mut inside = false;
let mut cursor = 0;
for &run in &self.0 {
if (cursor .. cursor + run).contains(&c) {
return inside;
}
cursor += run;
inside = !inside;
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -589,4 +885,47 @@ mod tests {
fn test_font_stretch_debug() {
assert_eq!(format!("{:?}", FontStretch::EXPANDED), "125%")
}
#[test]
fn test_trim_styles() {
assert_eq!(trim_styles("Atma Light"), "Atma");
assert_eq!(trim_styles("eras bold"), "eras");
assert_eq!(trim_styles("footlight mt light"), "footlight mt");
assert_eq!(trim_styles("times new roman"), "times new roman");
assert_eq!(trim_styles("noto sans mono cond sembd"), "noto sans mono");
assert_eq!(trim_styles("noto serif SEMCOND sembd"), "noto serif");
assert_eq!(trim_styles("crimson text"), "crimson text");
assert_eq!(trim_styles("footlight light"), "footlight");
assert_eq!(trim_styles("Noto Sans"), "Noto Sans");
assert_eq!(trim_styles("Noto Sans Light"), "Noto Sans");
assert_eq!(trim_styles("Noto Sans Semicondensed Heavy"), "Noto Sans");
assert_eq!(trim_styles("Familx"), "Familx");
assert_eq!(trim_styles("Font Ultra"), "Font Ultra");
assert_eq!(trim_styles("Font Ultra Bold"), "Font");
}
#[test]
fn test_coverage() {
#[track_caller]
fn test(set: &[u32], runs: &[u32]) {
let coverage = Coverage::from_vec(set.to_vec());
assert_eq!(coverage.0, runs);
let max = 5 + set.iter().copied().max().unwrap_or_default();
for c in 0 .. max {
assert_eq!(set.contains(&c), coverage.contains(c));
}
}
test(&[], &[]);
test(&[0], &[0, 1]);
test(&[1], &[1, 1]);
test(&[0, 1], &[0, 2]);
test(&[0, 1, 3], &[0, 2, 1, 1]);
test(
// [2, 3, 4, 9, 10, 11, 15, 18, 19]
&[18, 19, 2, 4, 9, 11, 15, 3, 3, 10],
&[2, 3, 4, 3, 3, 1, 2, 2],
)
}
}

View File

@ -1,6 +1,7 @@
//! Mathematical formulas.
use crate::library::prelude::*;
use crate::library::text::FontFamily;
/// A mathematical formula.
#[derive(Debug, Hash)]
@ -13,6 +14,10 @@ pub struct MathNode {
#[node(showable)]
impl MathNode {
/// The raw text's font family. Just the normal text family if `none`.
pub const FAMILY: Smart<FontFamily> =
Smart::Custom(FontFamily::new("Latin Modern Math"));
fn construct(_: &mut Context, args: &mut Args) -> TypResult<Content> {
Ok(Content::show(Self {
formula: args.expect("formula")?,
@ -23,17 +28,24 @@ impl MathNode {
impl Show for MathNode {
fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult<Content> {
Ok(styles
let mut content = styles
.show(self, ctx, [
Value::Str(self.formula.clone()),
Value::Bool(self.display),
])?
.unwrap_or_else(|| {
let mut content = Content::Text(self.formula.trim().into());
if self.display {
content = Content::Block(content.pack());
}
content.monospaced()
}))
.unwrap_or_else(|| Content::Text(self.formula.trim().into()));
let mut map = StyleMap::new();
if let Smart::Custom(family) = styles.get_cloned(Self::FAMILY) {
map.set_family(family, styles);
}
content = content.styled_with_map(map);
if self.display {
content = Content::Block(content.pack());
}
Ok(content)
}
}

View File

@ -119,9 +119,6 @@ pub fn new() -> Scope {
std.def_const("top", Align::Top);
std.def_const("horizon", Align::Horizon);
std.def_const("bottom", Align::Bottom);
std.def_const("serif", text::FontFamily::Serif);
std.def_const("sans-serif", text::FontFamily::SansSerif);
std.def_const("monospace", text::FontFamily::Monospace);
std
}

View File

@ -63,12 +63,7 @@ impl Show for HeadingNode {
map.set(TextNode::SIZE, resolve!(Self::SIZE));
if let Smart::Custom(family) = resolve!(Self::FAMILY) {
map.set(
TextNode::FAMILY,
std::iter::once(family)
.chain(styles.get_ref(TextNode::FAMILY).iter().cloned())
.collect(),
);
map.set_family(family, styles);
}
if let Smart::Custom(fill) = resolve!(Self::FILL) {
@ -101,6 +96,7 @@ impl Show for HeadingNode {
}
let mut content = Content::sequence(seq).styled_with_map(map);
if resolve!(Self::BLOCK) {
content = Content::block(content);
}

View File

@ -94,10 +94,11 @@ pub fn decorate(
width: Length,
) {
let face = fonts.get(text.face_id);
let face_metrics = face.metrics();
let metrics = match deco.line {
STRIKETHROUGH => face.strikethrough,
OVERLINE => face.overline,
UNDERLINE | _ => face.underline,
STRIKETHROUGH => face_metrics.strikethrough,
OVERLINE => face_metrics.overline,
UNDERLINE | _ => face_metrics.underline,
};
let evade = deco.evade && deco.line != STRIKETHROUGH;
@ -146,7 +147,8 @@ pub fn decorate(
for glyph in text.glyphs.iter() {
let dx = glyph.x_offset.resolve(text.size) + x;
let mut builder = BezPathBuilder::new(face.units_per_em, text.size, dx.to_raw());
let mut builder =
BezPathBuilder::new(face_metrics.units_per_em, text.size, dx.to_raw());
let bbox = face.ttf().outline_glyph(GlyphId(glyph.id), &mut builder);
let path = builder.finish();

View File

@ -29,13 +29,7 @@ pub struct TextNode;
impl TextNode {
/// A prioritized sequence of font families.
#[variadic]
pub const FAMILY: Vec<FontFamily> = vec![FontFamily::SansSerif];
/// The serif font family/families.
pub const SERIF: Vec<NamedFamily> = vec![NamedFamily::new("IBM Plex Serif")];
/// The sans-serif font family/families.
pub const SANS_SERIF: Vec<NamedFamily> = vec![NamedFamily::new("IBM Plex Sans")];
/// The monospace font family/families.
pub const MONOSPACE: Vec<NamedFamily> = vec![NamedFamily::new("IBM Plex Mono")];
pub const FAMILY: Vec<FontFamily> = vec![FontFamily::new("IBM Plex Sans")];
/// Whether to allow font fallback when the primary font list contains no
/// match.
pub const FALLBACK: bool = true;
@ -100,9 +94,6 @@ impl TextNode {
#[skip]
#[fold(bool::bitxor)]
pub const EMPH: bool = false;
/// Whether a monospace font should be preferred.
#[skip]
pub const MONOSPACED: bool = false;
/// The case transformation that should be applied to the next.
#[skip]
pub const CASE: Option<Case> = None;
@ -160,50 +151,11 @@ impl Show for EmphNode {
}
}
/// A generic or named font family.
/// A font family like "Arial".
#[derive(Clone, Eq, PartialEq, Hash)]
pub enum FontFamily {
/// A family that has "serifs", small strokes attached to letters.
Serif,
/// A family in which glyphs do not have "serifs", small attached strokes.
SansSerif,
/// A family in which (almost) all glyphs are of equal width.
Monospace,
/// A specific font family like "Arial".
Named(NamedFamily),
}
pub struct FontFamily(EcoString);
impl Debug for FontFamily {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self {
Self::Serif => f.pad("serif"),
Self::SansSerif => f.pad("sans-serif"),
Self::Monospace => f.pad("monospace"),
Self::Named(s) => s.fmt(f),
}
}
}
dynamic! {
FontFamily: "font family",
Value::Str(string) => Self::Named(NamedFamily::new(&string)),
}
castable! {
Vec<FontFamily>,
Expected: "string, generic family or array thereof",
Value::Str(string) => vec![FontFamily::Named(NamedFamily::new(&string))],
Value::Array(values) => {
values.into_iter().filter_map(|v| v.cast().ok()).collect()
},
@family: FontFamily => vec![family.clone()],
}
/// A specific font family like "Arial".
#[derive(Clone, Eq, PartialEq, Hash)]
pub struct NamedFamily(EcoString);
impl NamedFamily {
impl FontFamily {
/// Create a named font family variant.
pub fn new(string: &str) -> Self {
Self(string.to_lowercase().into())
@ -215,20 +167,26 @@ impl NamedFamily {
}
}
impl Debug for NamedFamily {
impl Debug for FontFamily {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
self.0.fmt(f)
}
}
castable! {
Vec<NamedFamily>,
FontFamily,
Expected: "string",
Value::Str(string) => Self::new(&string),
}
castable! {
Vec<FontFamily>,
Expected: "string or array of strings",
Value::Str(string) => vec![NamedFamily::new(&string)],
Value::Str(string) => vec![FontFamily::new(&string)],
Value::Array(values) => values
.into_iter()
.filter_map(|v| v.cast().ok())
.map(|string: EcoString| NamedFamily::new(&string))
.map(|string: EcoString| FontFamily::new(&string))
.collect(),
}

View File

@ -3,8 +3,8 @@ use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Highlighter, Style, Theme, ThemeSet};
use syntect::parsing::SyntaxSet;
use super::{FontFamily, TextNode};
use crate::library::prelude::*;
use crate::library::text::TextNode;
use crate::source::SourceId;
use crate::syntax::{self, RedNode};
@ -26,6 +26,8 @@ pub struct RawNode {
#[node(showable)]
impl RawNode {
/// The raw text's font family. Just the normal text family if `none`.
pub const FAMILY: Smart<FontFamily> = Smart::Custom(FontFamily::new("IBM Plex Mono"));
/// The language to syntax-highlight in.
pub const LANG: Option<EcoString> = None;
@ -40,18 +42,6 @@ impl RawNode {
impl Show for RawNode {
fn show(&self, ctx: &mut Context, styles: StyleChain) -> TypResult<Content> {
let lang = styles.get_ref(Self::LANG).as_ref();
if let Some(content) = styles.show(self, ctx, [
Value::Str(self.text.clone()),
match lang {
Some(lang) => Value::Str(lang.clone()),
None => Value::None,
},
Value::Bool(self.block),
])? {
return Ok(content);
}
let foreground = THEME
.settings
.foreground
@ -59,7 +49,16 @@ impl Show for RawNode {
.unwrap_or(Color::BLACK)
.into();
let mut content = if matches!(
let mut content = if let Some(content) = styles.show(self, ctx, [
Value::Str(self.text.clone()),
match lang {
Some(lang) => Value::Str(lang.clone()),
None => Value::None,
},
Value::Bool(self.block),
])? {
content
} else if matches!(
lang.map(|s| s.to_lowercase()).as_deref(),
Some("typ" | "typst")
) {
@ -93,11 +92,18 @@ impl Show for RawNode {
Content::Text(self.text.clone())
};
let mut map = StyleMap::new();
if let Smart::Custom(family) = styles.get_cloned(Self::FAMILY) {
map.set_family(family, styles);
}
content = content.styled_with_map(map);
if self.block {
content = Content::Block(content.pack());
}
Ok(content.monospaced())
Ok(content)
}
}

View File

@ -236,6 +236,18 @@ impl<'a> ShapedText<'a> {
}
}
/// Holds shaping results and metadata common to all shaped segments.
struct ShapingContext<'a> {
fonts: &'a mut FontStore,
glyphs: Vec<ShapedGlyph>,
used: Vec<FaceId>,
styles: StyleChain<'a>,
variant: FontVariant,
tags: Vec<rustybuzz::Feature>,
fallback: bool,
dir: Dir,
}
/// Shape text into [`ShapedText`].
pub fn shape<'a>(
fonts: &mut FontStore,
@ -248,28 +260,24 @@ pub fn shape<'a>(
None => Cow::Borrowed(text),
};
let mut glyphs = vec![];
let mut ctx = ShapingContext {
fonts,
glyphs: vec![],
used: vec![],
styles,
variant: variant(styles),
tags: tags(styles),
fallback: styles.get(TextNode::FALLBACK),
dir,
};
if !text.is_empty() {
shape_segment(
fonts,
&mut glyphs,
0,
&text,
variant(styles),
families(styles),
None,
dir,
&tags(styles),
);
shape_segment(&mut ctx, 0, &text, families(styles));
}
track_and_space(
&mut glyphs,
styles.get(TextNode::TRACKING),
styles.get(TextNode::SPACING),
);
track_and_space(&mut ctx);
let (size, baseline) = measure(fonts, &glyphs, styles);
let (size, baseline) = measure(ctx.fonts, &ctx.glyphs, styles);
ShapedText {
text,
@ -277,10 +285,223 @@ pub fn shape<'a>(
styles,
size,
baseline,
glyphs: Cow::Owned(glyphs),
glyphs: Cow::Owned(ctx.glyphs),
}
}
/// Shape text with font fallback using the `families` iterator.
fn shape_segment<'a>(
ctx: &mut ShapingContext,
base: usize,
text: &str,
mut families: impl Iterator<Item = &'a str> + Clone,
) {
// Fonts dont have newlines and tabs.
if text.chars().all(|c| c == '\n' || c == '\t') {
return;
}
// Find the next available family.
let mut selection = families.find_map(|family| {
ctx.fonts
.select(family, ctx.variant)
.filter(|id| !ctx.used.contains(id))
});
// Do font fallback if the families are exhausted and fallback is enabled.
if selection.is_none() && ctx.fallback {
let first = ctx.used.first().copied();
selection = ctx
.fonts
.select_fallback(first, ctx.variant, text)
.filter(|id| !ctx.used.contains(id));
}
// Extract the face id or shape notdef glyphs if we couldn't find any face.
let face_id = if let Some(id) = selection {
id
} else {
if let Some(&face_id) = ctx.used.first() {
shape_tofus(ctx, base, text, face_id);
}
return;
};
ctx.used.push(face_id);
// Fill the buffer with our text.
let mut buffer = UnicodeBuffer::new();
buffer.push_str(text);
buffer.set_direction(match ctx.dir {
Dir::LTR => rustybuzz::Direction::LeftToRight,
Dir::RTL => rustybuzz::Direction::RightToLeft,
_ => unimplemented!("vertical text layout"),
});
// Shape!
let mut face = ctx.fonts.get(face_id);
let buffer = rustybuzz::shape(face.ttf(), &ctx.tags, buffer);
let infos = buffer.glyph_infos();
let pos = buffer.glyph_positions();
// Collect the shaped glyphs, doing fallback and shaping parts again with
// the next font if necessary.
let mut i = 0;
while i < infos.len() {
let info = &infos[i];
let cluster = info.cluster as usize;
if info.glyph_id != 0 {
// Add the glyph to the shaped output.
// TODO: Don't ignore y_advance and y_offset.
ctx.glyphs.push(ShapedGlyph {
face_id,
glyph_id: info.glyph_id as u16,
x_advance: face.to_em(pos[i].x_advance),
x_offset: face.to_em(pos[i].x_offset),
cluster: base + cluster,
safe_to_break: !info.unsafe_to_break(),
c: text[cluster ..].chars().next().unwrap(),
});
} else {
// Determine the source text range for the tofu sequence.
let range = {
// First, search for the end of the tofu sequence.
let k = i;
while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) {
i += 1;
}
// Then, determine the start and end text index.
//
// Examples:
// Everything is shown in visual order. Tofus are written as "_".
// We want to find out that the tofus span the text `2..6`.
// Note that the clusters are longer than 1 char.
//
// Left-to-right:
// Text: h a l i h a l l o
// Glyphs: A _ _ C E
// Clusters: 0 2 4 6 8
// k=1 i=2
//
// Right-to-left:
// Text: O L L A H I L A H
// Glyphs: E C _ _ A
// Clusters: 8 6 4 2 0
// k=2 i=3
let ltr = ctx.dir.is_positive();
let first = if ltr { k } else { i };
let start = infos[first].cluster as usize;
let last = if ltr { i.checked_add(1) } else { k.checked_sub(1) };
let end = last
.and_then(|last| infos.get(last))
.map_or(text.len(), |info| info.cluster as usize);
start .. end
};
// Trim half-baked cluster.
let remove = base + range.start .. base + range.end;
while ctx.glyphs.last().map_or(false, |g| remove.contains(&g.cluster)) {
ctx.glyphs.pop();
}
// Recursively shape the tofu sequence with the next family.
shape_segment(ctx, base + range.start, &text[range], families.clone());
face = ctx.fonts.get(face_id);
}
i += 1;
}
ctx.used.pop();
}
/// Shape the text with tofus from the given face.
fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, face_id: FaceId) {
let face = ctx.fonts.get(face_id);
let x_advance = face.advance(0).unwrap_or_default();
for (cluster, c) in text.char_indices() {
ctx.glyphs.push(ShapedGlyph {
face_id,
glyph_id: 0,
x_advance,
x_offset: Em::zero(),
cluster: base + cluster,
safe_to_break: true,
c,
});
}
}
/// Apply tracking and spacing to a slice of shaped glyphs.
fn track_and_space(ctx: &mut ShapingContext) {
let tracking = ctx.styles.get(TextNode::TRACKING);
let spacing = ctx.styles.get(TextNode::SPACING);
if tracking.is_zero() && spacing.is_one() {
return;
}
let mut glyphs = ctx.glyphs.iter_mut().peekable();
while let Some(glyph) = glyphs.next() {
if glyph.is_space() {
glyph.x_advance *= spacing.get();
}
if glyphs.peek().map_or(false, |next| glyph.cluster != next.cluster) {
glyph.x_advance += tracking;
}
}
}
/// Measure the size and baseline of a run of shaped glyphs with the given
/// properties.
fn measure(
fonts: &mut FontStore,
glyphs: &[ShapedGlyph],
styles: StyleChain,
) -> (Size, Length) {
let mut width = Length::zero();
let mut top = Length::zero();
let mut bottom = Length::zero();
let size = styles.get(TextNode::SIZE).abs;
let top_edge = styles.get(TextNode::TOP_EDGE);
let bottom_edge = styles.get(TextNode::BOTTOM_EDGE);
// Expand top and bottom by reading the face's vertical metrics.
let mut expand = |face: &Face| {
let metrics = face.metrics();
top.set_max(metrics.vertical(top_edge, size));
bottom.set_max(-metrics.vertical(bottom_edge, size));
};
if glyphs.is_empty() {
// When there are no glyphs, we just use the vertical metrics of the
// first available font.
let variant = variant(styles);
for family in families(styles) {
if let Some(face_id) = fonts.select(family, variant) {
expand(fonts.get(face_id));
break;
}
}
} else {
for (face_id, group) in glyphs.group_by_key(|g| g.face_id) {
let face = fonts.get(face_id);
expand(face);
for glyph in group {
width += glyph.x_advance.resolve(size);
}
}
}
(Size::new(width, top + bottom), top)
}
/// Resolve the font variant with `STRONG` and `EMPH` factored in.
fn variant(styles: StyleChain) -> FontVariant {
let mut variant = FontVariant::new(
@ -306,30 +527,19 @@ fn variant(styles: StyleChain) -> FontVariant {
/// Resolve a prioritized iterator over the font families.
fn families(styles: StyleChain) -> impl Iterator<Item = &str> + Clone {
let head = if styles.get(TextNode::MONOSPACED) {
styles.get_ref(TextNode::MONOSPACE).as_slice()
} else {
&[]
};
const FALLBACKS: &[&str] = &[
"ibm plex sans",
"twitter color emoji",
"noto color emoji",
"apple color emoji",
"segoe ui emoji",
];
let core = styles.get_ref(TextNode::FAMILY).iter().flat_map(move |family| {
match family {
FontFamily::Named(name) => std::slice::from_ref(name),
FontFamily::Serif => styles.get_ref(TextNode::SERIF),
FontFamily::SansSerif => styles.get_ref(TextNode::SANS_SERIF),
FontFamily::Monospace => styles.get_ref(TextNode::MONOSPACE),
}
});
let tail: &[&str] = if styles.get(TextNode::FALLBACK) {
&["ibm plex sans", "latin modern math", "twitter color emoji"]
} else {
&[]
};
head.iter()
.chain(core)
.map(|named| named.as_str())
let tail = if styles.get(TextNode::FALLBACK) { FALLBACKS } else { &[] };
styles
.get_ref(TextNode::FAMILY)
.iter()
.map(|family| family.as_str())
.chain(tail.iter().copied())
}
@ -405,197 +615,3 @@ fn tags(styles: StyleChain) -> Vec<Feature> {
tags
}
/// Shape text with font fallback using the `families` iterator.
fn shape_segment<'a>(
fonts: &mut FontStore,
glyphs: &mut Vec<ShapedGlyph>,
base: usize,
text: &str,
variant: FontVariant,
mut families: impl Iterator<Item = &'a str> + Clone,
mut first_face: Option<FaceId>,
dir: Dir,
tags: &[rustybuzz::Feature],
) {
// No font has newlines.
if text.chars().all(|c| c == '\n') {
return;
}
// Select the font family.
let (face_id, fallback) = loop {
// Try to load the next available font family.
match families.next() {
Some(family) => {
if let Some(id) = fonts.select(family, variant) {
break (id, true);
}
}
// We're out of families, so we don't do any more fallback and just
// shape the tofus with the first face we originally used.
None => match first_face {
Some(id) => break (id, false),
None => return,
},
}
};
// Remember the id if this the first available face since we use that one to
// shape tofus.
first_face.get_or_insert(face_id);
// Fill the buffer with our text.
let mut buffer = UnicodeBuffer::new();
buffer.push_str(text);
buffer.set_direction(match dir {
Dir::LTR => rustybuzz::Direction::LeftToRight,
Dir::RTL => rustybuzz::Direction::RightToLeft,
_ => unimplemented!(),
});
// Shape!
let mut face = fonts.get(face_id);
let buffer = rustybuzz::shape(face.ttf(), tags, buffer);
let infos = buffer.glyph_infos();
let pos = buffer.glyph_positions();
// Collect the shaped glyphs, doing fallback and shaping parts again with
// the next font if necessary.
let mut i = 0;
while i < infos.len() {
let info = &infos[i];
let cluster = info.cluster as usize;
if info.glyph_id != 0 || !fallback {
// Add the glyph to the shaped output.
// TODO: Don't ignore y_advance and y_offset.
glyphs.push(ShapedGlyph {
face_id,
glyph_id: info.glyph_id as u16,
x_advance: face.to_em(pos[i].x_advance),
x_offset: face.to_em(pos[i].x_offset),
cluster: base + cluster,
safe_to_break: !info.unsafe_to_break(),
c: text[cluster ..].chars().next().unwrap(),
});
} else {
// Determine the source text range for the tofu sequence.
let range = {
// First, search for the end of the tofu sequence.
let k = i;
while infos.get(i + 1).map_or(false, |info| info.glyph_id == 0) {
i += 1;
}
// Then, determine the start and end text index.
//
// Examples:
// Everything is shown in visual order. Tofus are written as "_".
// We want to find out that the tofus span the text `2..6`.
// Note that the clusters are longer than 1 char.
//
// Left-to-right:
// Text: h a l i h a l l o
// Glyphs: A _ _ C E
// Clusters: 0 2 4 6 8
// k=1 i=2
//
// Right-to-left:
// Text: O L L A H I L A H
// Glyphs: E C _ _ A
// Clusters: 8 6 4 2 0
// k=2 i=3
let ltr = dir.is_positive();
let first = if ltr { k } else { i };
let start = infos[first].cluster as usize;
let last = if ltr { i.checked_add(1) } else { k.checked_sub(1) };
let end = last
.and_then(|last| infos.get(last))
.map_or(text.len(), |info| info.cluster as usize);
start .. end
};
// Recursively shape the tofu sequence with the next family.
shape_segment(
fonts,
glyphs,
base + range.start,
&text[range],
variant,
families.clone(),
first_face,
dir,
tags,
);
face = fonts.get(face_id);
}
i += 1;
}
}
/// Apply tracking and spacing to a slice of shaped glyphs.
fn track_and_space(glyphs: &mut [ShapedGlyph], tracking: Em, spacing: Relative) {
if tracking.is_zero() && spacing.is_one() {
return;
}
let mut glyphs = glyphs.iter_mut().peekable();
while let Some(glyph) = glyphs.next() {
if glyph.is_space() {
glyph.x_advance *= spacing.get();
}
if glyphs.peek().map_or(false, |next| glyph.cluster != next.cluster) {
glyph.x_advance += tracking;
}
}
}
/// Measure the size and baseline of a run of shaped glyphs with the given
/// properties.
fn measure(
fonts: &mut FontStore,
glyphs: &[ShapedGlyph],
styles: StyleChain,
) -> (Size, Length) {
let mut width = Length::zero();
let mut top = Length::zero();
let mut bottom = Length::zero();
let size = styles.get(TextNode::SIZE).abs;
let top_edge = styles.get(TextNode::TOP_EDGE);
let bottom_edge = styles.get(TextNode::BOTTOM_EDGE);
// Expand top and bottom by reading the face's vertical metrics.
let mut expand = |face: &Face| {
top.set_max(face.vertical_metric(top_edge, size));
bottom.set_max(-face.vertical_metric(bottom_edge, size));
};
if glyphs.is_empty() {
// When there are no glyphs, we just use the vertical metrics of the
// first available font.
let variant = variant(styles);
for family in families(styles) {
if let Some(face_id) = fonts.select(family, variant) {
expand(fonts.get(face_id));
break;
}
}
} else {
for (face_id, group) in glyphs.group_by_key(|g| g.face_id) {
let face = fonts.get(face_id);
expand(face);
for glyph in group {
width += glyph.x_advance.resolve(size);
}
}
}
(Size::new(width, top + bottom), top)
}

View File

@ -116,7 +116,7 @@ impl FsLoader {
let path = path.strip_prefix(".").unwrap_or(path);
if let Ok(file) = File::open(path) {
if let Ok(mmap) = unsafe { Mmap::map(&file) } {
self.faces.extend(FaceInfo::parse(path, &mmap));
self.faces.extend(FaceInfo::from_data(path, &mmap));
}
}
}
@ -142,34 +142,3 @@ impl Loader for FsLoader {
fs::read(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_index_font_dir() {
let faces = FsLoader::new().with_path("fonts").faces;
let mut paths: Vec<_> = faces.into_iter().map(|info| info.path).collect();
paths.sort();
assert_eq!(paths, [
Path::new("fonts/CMU-Serif-Bold.ttf"),
Path::new("fonts/CMU-Serif-Regular.ttf"),
Path::new("fonts/IBMPlexMono-Regular.ttf"),
Path::new("fonts/IBMPlexSans-Bold.ttf"),
Path::new("fonts/IBMPlexSans-BoldItalic.ttf"),
Path::new("fonts/IBMPlexSans-Italic.ttf"),
Path::new("fonts/IBMPlexSans-Regular.ttf"),
Path::new("fonts/IBMPlexSerif-Regular.ttf"),
Path::new("fonts/LatinModernMath.otf"),
Path::new("fonts/NotoSansArabic-Regular.ttf"),
Path::new("fonts/NotoSerifCJKsc-Regular.otf"),
Path::new("fonts/NotoSerifHebrew-Bold.ttf"),
Path::new("fonts/NotoSerifHebrew-Regular.ttf"),
Path::new("fonts/PTSans-Regular.ttf"),
Path::new("fonts/Roboto-Regular.ttf"),
Path::new("fonts/TwitterColorEmoji.ttf"),
]);
}
}

View File

@ -49,7 +49,7 @@ impl MemLoader {
{
let path = path.as_ref().normalize();
let data = data.into();
self.faces.extend(FaceInfo::parse(&path, &data));
self.faces.extend(FaceInfo::from_data(&path, &data));
self.files.insert(path, data);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
tests/ref/text/emoji.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
tests/ref/text/fallback.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -4,7 +4,7 @@
// Test normal operation and RTL directions.
#set page(height: 3.25cm, width: 7.05cm, columns: 2)
#set columns(gutter: 30pt)
#set text("Noto Sans Arabic", serif)
#set text("Noto Sans Arabic", "IBM Plex Serif")
#set par(lang: "ar")
#rect(fill: conifer, height: 8pt, width: 6pt) وتحفيز

View File

@ -23,7 +23,7 @@
)[Level 3]
---
// Error: 22-26 expected font family or auto or function, found length
// Error: 22-26 expected string or auto or function, found length
#set heading(family: 10pt)
= Heading
@ -33,7 +33,7 @@
= Heading
---
// Error: 22-34 expected font family or auto, found boolean
// Error: 22-34 expected string or auto, found boolean
#set heading(family: lvl => false)
= Heading

View File

@ -3,7 +3,7 @@
---
// Test reordering with different top-level paragraph directions.
#let content = [Text טֶקסט]
#set text(serif, "Noto Serif Hebrew")
#set text("IBM Plex Serif")
#par(lang: "he", content)
#par(lang: "de", content)
@ -11,7 +11,7 @@
// Test that consecutive, embedded LTR runs stay LTR.
// Here, we have two runs: "A" and italic "B".
#let content = [أنت A#emph[B]مطرC]
#set text(serif, "Noto Sans Arabic")
#set text("IBM Plex Serif", "Noto Sans Arabic")
#par(lang: "ar", content)
#par(lang: "de", content)
@ -19,32 +19,32 @@
// Test that consecutive, embedded RTL runs stay RTL.
// Here, we have three runs: "גֶ", bold "שֶׁ", and "ם".
#let content = [Aגֶ#strong[שֶׁ]םB]
#set text(serif, "Noto Serif Hebrew")
#set text("IBM Plex Serif", "Noto Serif Hebrew")
#par(lang: "he", content)
#par(lang: "de", content)
---
// Test embedding up to level 4 with isolates.
#set text(serif, "Noto Serif Hebrew", "Twitter Color Emoji")
#set text("IBM Plex Serif")
#set par(dir: rtl)
א\u{2066}A\u{2067}Bב\u{2069}?
---
// Test hard line break (leads to two paragraphs in unicode-bidi).
#set text("Noto Sans Arabic", serif)
#set text("Noto Sans Arabic", "IBM Plex Serif")
#set par(lang: "ar")
Life المطر هو الحياة \
الحياة تمطر is rain.
---
// Test spacing.
#set text(serif, "Noto Serif Hebrew")
#set text("IBM Plex Serif")
L #h(1cm) ריווחR \
יווח #h(1cm) R
---
// Test inline object.
#set text("Noto Serif Hebrew", serif)
#set text("IBM Plex Serif")
#set par(lang: "he")
קרנפיםRh#image("../../res/rhino.png", height: 11pt)inoחיים

18
tests/typ/text/emoji.typ Normal file
View File

@ -0,0 +1,18 @@
// Test emoji shaping.
---
// This should form a three-member family.
👩‍👩‍👦
// This should form a pride flag.
🏳️‍🌈
// Skin tone modifier should be applied.
👍🏿
// This should be a 1 in a box.
1
---
// These two shouldn't be affected by a zero-width joiner.
🏞‍🌋

View File

@ -0,0 +1,20 @@
// Test font fallback.
---
// Font fallback for emoji.
A😀B
// Font fallback for entire text.
دع النص يمطر عليك
// Font fallback in right-to-left text.
ب🐈😀سم
// Multi-layer font fallback.
Aب😀🏞سمB
// Font fallback with composed emojis and multiple fonts.
01⃣2
// Tofus are rendered with the first font.
A🐈ዲሞB

View File

@ -19,7 +19,7 @@
#text(stretch: 50%)[Condensed]
// Set family.
#text(family: serif)[Serif]
#text(family: "IBM Plex Serif")[Serif]
// Emoji.
Emoji: 🐪, 🌋, 🏞
@ -38,21 +38,13 @@ Emoji: 🐪, 🌋, 🏞
#set text("PT Sans", "Twitter Color Emoji", fallback: false)
= 𝛼 + 𝛽.
---
// Test class definitions.
#set text(sans-serif: "PT Sans")
#text(family: sans-serif)[Sans-serif.] \
#text(monospace)[Monospace.] \
#text(monospace, monospace: ("Nope", "Latin Modern Math"))[Math.]
---
// Test top and bottom edge.
#set page(width: 150pt)
#set text(size: 8pt)
#let try(top, bottom) = rect(fill: conifer)[
#set text(monospace, top-edge: top, bottom-edge: bottom)
#set text("IBM Plex Mono", top-edge: top, bottom-edge: bottom)
From #top to #bottom
]
@ -79,10 +71,6 @@ Emoji: 🐪, 🌋, 🏞
// Error: 21-23 unknown font metric
#set text(top-edge: "")
---
// Error: 18-19 expected string or array of strings, found integer
#set text(serif: 0)
---
// Error: 23-27 unexpected argument
#set text(size: 10pt, 12pt)

View File

@ -19,7 +19,7 @@ starts a paragraph without indent.
Except if you have another paragraph in them.
#set text(8pt, "Noto Sans Arabic")
#set text(8pt, "Noto Sans Arabic", "IBM Plex Sans")
#set par(lang: "ar", leading: 8pt)
= Arabic

View File

@ -1,6 +1,6 @@
#set page(width: auto, height: auto)
#set par(lang: "en", leading: 3pt, justify: true)
#set text(family: "CMU Serif")
#set par(lang: "en", leading: 4pt, justify: true)
#set text(family: "Latin Modern Roman")
#let story = [
In olden times when wishing still helped one, there lived a king whose

View File

@ -24,7 +24,7 @@ A#box["]B
---
#set par(lang: "ar")
#set text("Noto Sans Arabic")
#set text("Noto Sans Arabic", "IBM Plex Sans")
"المطر هو الحياة" \
المطر هو الحياة

View File

@ -1,45 +0,0 @@
// Test complex text shaping.
---
// Test ligatures.
// This should create an "fi" ligature.
Le fira
// This should just shape nicely.
#set text("Noto Sans Arabic")
دع النص يمطر عليك
// This should form a three-member family.
#set text("Twitter Color Emoji")
👩‍👩‍👦 🤚🏿
// These two shouldn't be affected by a zero-width joiner.
🏞‍🌋
---
// Test font fallback.
#set text(sans-serif, "Noto Sans Arabic", "Twitter Color Emoji")
// Font fallback for emoji.
A😀B
// Font fallback for entire text.
دع النص يمطر عليك
// Font fallback in right-to-left text.
ب🐈😀سم
// Multi-layer font fallback.
Aب😀🏞سمB
// Tofus are rendered with the first font.
A🐈中文B
---
// Test reshaping.
#set text("Noto Serif Hebrew")
#set par(lang: "he")
ס \ טֶ

View File

@ -19,11 +19,11 @@ A /**/B/**/ C
---
// Test that a run consisting only of whitespace isn't trimmed.
A[#set text(serif); ]B
A[#set text("IBM Plex Serif"); ]B
---
// Test font change after space.
Left [#set text(serif);Right].
Left [#set text("IBM Plex Serif");Right].
---
// Test that linebreak consumed surrounding spaces.

View File

@ -38,6 +38,7 @@
---
// Test integrated lower, upper and symbols.
// Ref: true
#upper("Abc 8")
#upper[def]