Font fallback
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
.vscode
|
||||
_things
|
||||
desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# Tests and benchmarks
|
||||
tests/png
|
||||
|
13
NOTICE
@ -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.
|
||||
|
BIN
fonts/LatinModernRoman-Bold.otf
Normal file
BIN
fonts/LatinModernRoman-Regular.otf
Normal file
BIN
fonts/NotoColorEmoji.ttf
Normal 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))
|
||||
|
@ -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 });
|
||||
|
@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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).
|
||||
|
@ -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(());
|
||||
|
603
src/font.rs
@ -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(¤t.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],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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(),
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.5 KiB |
BIN
tests/ref/text/emoji.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
tests/ref/text/fallback.png
Normal file
After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@ -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) وتحفيز
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 \
|
||||
Lריווח #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
@ -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.
|
||||
🏞🌋
|
20
tests/typ/text/fallback.typ
Normal 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
|
@ -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)
|
||||
2π = 𝛼 + 𝛽. ✅
|
||||
|
||||
---
|
||||
// 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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -24,7 +24,7 @@ A#box["]B
|
||||
|
||||
---
|
||||
#set par(lang: "ar")
|
||||
#set text("Noto Sans Arabic")
|
||||
#set text("Noto Sans Arabic", "IBM Plex Sans")
|
||||
"المطر هو الحياة" \
|
||||
المطر هو الحياة
|
||||
|
||||
|
@ -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")
|
||||
ס \ טֶ
|
@ -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.
|
||||
|
@ -38,6 +38,7 @@
|
||||
---
|
||||
// Test integrated lower, upper and symbols.
|
||||
// Ref: true
|
||||
|
||||
#upper("Abc 8")
|
||||
#upper[def]
|
||||
|
||||
|