Use OpenType features when possible

This commit is contained in:
Malo 2025-04-19 17:41:49 +02:00
parent 3e6691a93b
commit d55bebeaa1
5 changed files with 241 additions and 149 deletions

View File

@ -5,14 +5,15 @@ use std::sync::Arc;
use az::SaturatingAs;
use ecow::EcoString;
use rustybuzz::{BufferFlags, ShapePlan, UnicodeBuffer};
use rustybuzz::{BufferFlags, Feature, ShapePlan, UnicodeBuffer};
use ttf_parser::gsub::SubstitutionSubtable;
use ttf_parser::Tag;
use typst_library::engine::Engine;
use typst_library::foundations::{Smart, StyleChain};
use typst_library::layout::{Abs, Dir, Em, Frame, FrameItem, Point, Size};
use typst_library::text::{
families, features, is_default_ignorable, variant, Font, FontFamily, FontVariant,
Glyph, Lang, Region, TextEdgeBounds, TextElem, TextItem,
Glyph, Lang, Region, ScriptSettings, TextEdgeBounds, TextElem, TextItem,
};
use typst_library::World;
use typst_utils::SliceExt;
@ -64,6 +65,8 @@ pub struct ShapedGlyph {
pub x_offset: Em,
/// The vertical offset of the glyph.
pub y_offset: Em,
/// How much to scale the glyph compared to its normal size.
pub scale: Em,
/// The adjustability of the glyph.
pub adjustability: Adjustability,
/// The byte range of this glyph's cluster in the full inline layout. A
@ -230,8 +233,10 @@ impl<'a> ShapedText<'a> {
let stroke = TextElem::stroke_in(self.styles);
let span_offset = TextElem::span_offset_in(self.styles);
for ((font, y_offset), group) in
self.glyphs.as_ref().group_by_key(|g| (g.font.clone(), g.y_offset))
for ((font, y_offset, scale), group) in self
.glyphs
.as_ref()
.group_by_key(|g| (g.font.clone(), g.y_offset, g.scale))
{
let mut range = group[0].range.clone();
for glyph in group {
@ -304,7 +309,7 @@ impl<'a> ShapedText<'a> {
let item = TextItem {
font,
size: self.size,
size: scale.at(self.size),
lang: self.lang,
region: self.region,
fill: fill.clone(),
@ -420,7 +425,7 @@ impl<'a> ShapedText<'a> {
styles: self.styles,
size: self.size,
variant: self.variant,
width: glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(self.size),
width: glyphs_width(glyphs).at(self.size),
glyphs: Cow::Borrowed(glyphs),
}
} else {
@ -491,6 +496,7 @@ impl<'a> ShapedText<'a> {
x_advance,
x_offset: Em::zero(),
y_offset: Em::zero(),
scale: Em::one(),
adjustability: Adjustability::default(),
range,
safe_to_break: true,
@ -666,6 +672,7 @@ fn shape<'a>(
region: Option<Region>,
) -> ShapedText<'a> {
let size = TextElem::size_in(styles);
let script_settings = TextElem::subperscript_in(styles);
let mut ctx = ShapingContext {
engine,
size,
@ -679,7 +686,7 @@ fn shape<'a>(
};
if !text.is_empty() {
shape_segment(&mut ctx, base, text, families(styles));
shape_segment(&mut ctx, base, text, families(styles), script_settings);
}
track_and_space(&mut ctx);
@ -699,11 +706,17 @@ fn shape<'a>(
styles,
variant: ctx.variant,
size,
width: ctx.glyphs.iter().map(|g| g.x_advance).sum::<Em>().at(size),
width: glyphs_width(&ctx.glyphs).at(size),
glyphs: Cow::Owned(ctx.glyphs),
}
}
/// Computes the width of a run of glyphs relative to the font size, accounting
/// for their individual scaling factors and other font metrics.
fn glyphs_width(glyphs: &[ShapedGlyph]) -> Em {
glyphs.iter().map(|g| g.x_advance * g.scale.get()).sum()
}
/// Holds shaping results and metadata common to all shaped segments.
struct ShapingContext<'a, 'v> {
engine: &'a Engine<'v>,
@ -723,6 +736,7 @@ fn shape_segment<'a>(
base: usize,
text: &str,
mut families: impl Iterator<Item = &'a FontFamily> + Clone,
script_settings: Option<ScriptSettings>,
) {
// Don't try shaping newlines, tabs, or default ignorables.
if text
@ -789,6 +803,52 @@ fn shape_segment<'a>(
// text extraction.
buffer.set_flags(BufferFlags::REMOVE_DEFAULT_IGNORABLES);
// Those values determine how the rendered text should be transformed to
// display sub-/super-scripts properly. If the text is not scripted, or if
// the OpenType feature can be used, the rendered text should not be
// transformed in any way, and so those values are neutral (`(0, 0, 1)`). If
// scripts should be synthesized, those values determine how to transform
// the rendered text to display scripts as expected.
let (script_shift, script_compensation, scale) = match script_settings {
None => (Em::zero(), Em::zero(), Em::one()),
Some(settings) => settings
.typographic
.then(|| {
// If typographic scripts are enabled (i.e., we want to use the
// OpenType feature instead of synthesizing if possible), we add
// "subs"/"sups" to the feature list if supported by the font.
// In case of a problem, we just early exit
let gsub = font.rusty().tables().gsub?;
let subtable_index =
gsub.features.find(settings.kind.feature())?.lookup_indices.get(0)?;
let coverage = gsub
.lookups
.get(subtable_index)?
.subtables
.get::<SubstitutionSubtable>(0)?
.coverage();
text.chars()
.all(|c| {
font.rusty().glyph_index(c).is_some_and(|i| coverage.contains(i))
})
.then(|| {
ctx.features.push(Feature::new(settings.kind.feature(), 1, ..));
(Em::zero(), Em::zero(), Em::one())
})
})
// Reunite the cases where `typographic` is `false` or where using
// the OpenType feature would not work.
.flatten()
.unwrap_or_else(|| {
let script_metrics = settings.kind.read_metrics(font.metrics());
(
settings.shift.unwrap_or(script_metrics.vertical_offset),
script_metrics.horizontal_offset,
settings.size.unwrap_or(script_metrics.height),
)
}),
};
// Prepare the shape plan. This plan depends on direction, script, language,
// and features, but is independent from the text and can thus be memoized.
let plan = create_shape_plan(
@ -869,8 +929,9 @@ fn shape_segment<'a>(
glyph_id: info.glyph_id as u16,
// TODO: Don't ignore y_advance.
x_advance,
x_offset: font.to_em(pos[i].x_offset),
y_offset: font.to_em(pos[i].y_offset),
x_offset: font.to_em(pos[i].x_offset) + script_compensation,
y_offset: font.to_em(pos[i].y_offset) + script_shift,
scale,
adjustability: Adjustability::default(),
range: start..end,
safe_to_break: !info.unsafe_to_break(),
@ -923,7 +984,13 @@ fn shape_segment<'a>(
}
// Recursively shape the tofu sequence with the next family.
shape_segment(ctx, base + start, &text[start..end], families.clone());
shape_segment(
ctx,
base + start,
&text[start..end],
families.clone(),
script_settings,
);
}
i += 1;
@ -963,6 +1030,7 @@ fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) {
x_advance,
x_offset: Em::zero(),
y_offset: Em::zero(),
scale: Em::one(),
adjustability: Adjustability::default(),
range: start..end,
safe_to_break: true,

View File

@ -91,6 +91,10 @@ impl Length {
hint: "or use `length.abs.{unit}()` instead to ignore its em component"
)
}
pub fn to_em(&self, text_size: Abs) -> Em {
self.em + Em::new(self.abs / text_size)
}
}
#[scope]

View File

@ -215,6 +215,10 @@ pub struct FontMetrics {
pub underline: LineMetrics,
/// Recommended metrics for an overline.
pub overline: LineMetrics,
/// Metrics for subscripts.
pub subscript: ScriptMetrics,
/// Metrics for superscripts.
pub superscript: ScriptMetrics,
}
impl FontMetrics {
@ -227,6 +231,7 @@ impl FontMetrics {
let cap_height = ttf.capital_height().filter(|&h| h > 0).map_or(ascender, to_em);
let x_height = ttf.x_height().filter(|&h| h > 0).map_or(ascender, to_em);
let descender = to_em(ttf.typographic_descender().unwrap_or(ttf.descender()));
let strikeout = ttf.strikeout_metrics();
let underline = ttf.underline_metrics();
@ -249,6 +254,26 @@ impl FontMetrics {
thickness: underline.thickness,
};
let subscript = match ttf.subscript_metrics() {
None => ScriptMetrics::default_subscript(),
Some(metrics) => ScriptMetrics {
width: to_em(metrics.x_size),
height: to_em(metrics.y_size),
horizontal_offset: to_em(metrics.x_offset),
vertical_offset: -to_em(metrics.y_offset),
},
};
let superscript = match ttf.superscript_metrics() {
None => ScriptMetrics::default_superscript(),
Some(metrics) => ScriptMetrics {
width: to_em(metrics.x_size),
height: to_em(metrics.y_size),
horizontal_offset: to_em(metrics.x_offset),
vertical_offset: to_em(metrics.y_offset),
},
};
Self {
units_per_em,
ascender,
@ -258,6 +283,8 @@ impl FontMetrics {
strikethrough,
underline,
overline,
superscript,
subscript,
}
}
@ -283,6 +310,34 @@ pub struct LineMetrics {
pub thickness: Em,
}
/// Metrics for subscripts or superscripts.
#[derive(Debug, Copy, Clone)]
pub struct ScriptMetrics {
pub width: Em,
pub height: Em,
pub horizontal_offset: Em,
pub vertical_offset: Em,
}
impl ScriptMetrics {
pub const fn default_with_vertical_offset(offset: Em) -> Self {
Self {
width: Em::new(0.6),
height: Em::new(0.6),
horizontal_offset: Em::zero(),
vertical_offset: offset,
}
}
pub const fn default_subscript() -> Self {
Self::default_with_vertical_offset(Em::new(-0.2))
}
pub const fn default_superscript() -> Self {
Self::default_with_vertical_offset(Em::new(0.5))
}
}
/// Identifies a vertical metric of a font.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
pub enum VerticalFontMetric {

View File

@ -752,6 +752,11 @@ pub struct TextElem {
#[internal]
#[ghost]
pub smallcaps: Option<Smallcaps>,
/// Whether subscripts or superscripts are enabled.
#[internal]
#[ghost]
pub subperscript: Option<ScriptSettings>,
}
impl TextElem {

View File

@ -1,11 +1,10 @@
use ecow::EcoString;
use crate::diag::SourceResult;
use crate::engine::Engine;
use crate::foundations::{elem, Content, Packed, SequenceElem, Show, StyleChain};
use crate::foundations::{elem, Content, Packed, Show, Smart, StyleChain};
use crate::layout::{Em, Length};
use crate::text::{variant, SpaceElem, TextElem, TextSize};
use crate::World;
use crate::text::{FontMetrics, TextElem, TextSize};
use ttf_parser::Tag;
use typst_library::text::ScriptMetrics;
/// Renders text in subscript.
///
@ -17,11 +16,17 @@ use crate::World;
/// ```
#[elem(title = "Subscript", Show)]
pub struct SubElem {
/// Whether to prefer the dedicated subscript characters of the font.
/// Whether to create artificial subscripts by shifting and scaling down
/// regular glyphs.
///
/// If this is enabled, Typst first tries to transform the text to subscript
/// codepoints. If that fails, it falls back to rendering lowered and shrunk
/// normal letters.
/// Ideally, subscripts glyphs should be provided by the font (using the
/// `subs` OpenType feature). Otherwise, Typst is able to synthesize
/// subscripts as explained above.
///
/// When this is set to `{false}`, synthesized glyphs will be used
/// regardless of whether the font provides dedicated subscript glyphs. When
/// `{true}`, synthesized glyphs may still be used in case the font does not
/// provide the necessary subscript glyphs.
///
/// ```example
/// N#sub(typographic: true)[1]
@ -30,17 +35,19 @@ pub struct SubElem {
#[default(true)]
pub typographic: bool,
/// The baseline shift for synthetic subscripts. Does not apply if
/// `typographic` is true and the font has subscript codepoints for the
/// given `body`.
#[default(Em::new(0.2).into())]
pub baseline: Length,
/// The baseline shift for synthesized subscripts.
///
/// This only applies to synthesized subscripts. In other words, this has no
/// effect if `typographic` is `{true}` and the font provides the necessary
/// subscript glyphs.
pub baseline: Smart<Length>,
/// The font size for synthetic subscripts. Does not apply if
/// `typographic` is true and the font has subscript codepoints for the
/// given `body`.
#[default(TextSize(Em::new(0.6).into()))]
pub size: TextSize,
/// The font size for synthesized subscripts.
///
/// This only applies to synthesized subscripts. In other words, this has no
/// effect if `typographic` is `{true}` and the font provides the necessary
/// subscript glyphs.
pub size: Smart<TextSize>,
/// The text to display in subscript.
#[required]
@ -49,20 +56,17 @@ pub struct SubElem {
impl Show for Packed<SubElem> {
#[typst_macros::time(name = "sub", span = self.span())]
fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
let body = self.body.clone();
if self.typographic(styles) {
if let Some(text) = convert_script(&body, true) {
if is_shapable(engine, &text, styles) {
return Ok(TextElem::packed(text));
}
}
};
Ok(body
.styled(TextElem::set_baseline(self.baseline(styles)))
.styled(TextElem::set_size(self.size(styles))))
fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
let outer_text_size = TextElem::size_in(styles);
Ok(self
.body
.clone()
.styled(TextElem::set_subperscript(Some(ScriptSettings {
typographic: self.typographic(styles),
shift: self.baseline(styles).map(|l| -l.to_em(outer_text_size)),
size: self.size(styles).map(|t| t.0.to_em(outer_text_size)),
kind: ScriptKind::Sub,
}))))
}
}
@ -76,11 +80,17 @@ impl Show for Packed<SubElem> {
/// ```
#[elem(title = "Superscript", Show)]
pub struct SuperElem {
/// Whether to prefer the dedicated superscript characters of the font.
/// Whether to create artificial superscripts by shifting and scaling down
/// regular glyphs.
///
/// If this is enabled, Typst first tries to transform the text to
/// superscript codepoints. If that fails, it falls back to rendering
/// raised and shrunk normal letters.
/// Ideally, superscripts glyphs should be provided by the font (using the
/// `subs` OpenType feature). Otherwise, Typst is able to synthesize
/// superscripts as explained above.
///
/// When this is set to `{false}`, synthesized glyphs will be used
/// regardless of whether the font provides dedicated superscript glyphs.
/// When `{true}`, synthesized glyphs may still be used in case the font
/// does not provide the necessary superscript glyphs.
///
/// ```example
/// N#super(typographic: true)[1]
@ -89,17 +99,19 @@ pub struct SuperElem {
#[default(true)]
pub typographic: bool,
/// The baseline shift for synthetic superscripts. Does not apply if
/// `typographic` is true and the font has superscript codepoints for the
/// given `body`.
#[default(Em::new(-0.5).into())]
pub baseline: Length,
/// The baseline shift for synthesized superscripts.
///
/// This only applies to synthesized superscripts. In other words, this has
/// no effect if `typographic` is `{true}` and the font provides the
/// necessary superscript glyphs.
pub baseline: Smart<Length>,
/// The font size for synthetic superscripts. Does not apply if
/// `typographic` is true and the font has superscript codepoints for the
/// given `body`.
#[default(TextSize(Em::new(0.6).into()))]
pub size: TextSize,
/// The font size for synthesized superscripts.
///
/// This only applies to synthesized superscripts. In other words, this has
/// no effect if `typographic` is `{true}` and the font provides the
/// necessary superscript glyphs.
pub size: Smart<TextSize>,
/// The text to display in superscript.
#[required]
@ -108,107 +120,55 @@ pub struct SuperElem {
impl Show for Packed<SuperElem> {
#[typst_macros::time(name = "super", span = self.span())]
fn show(&self, engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
let body = self.body.clone();
if self.typographic(styles) {
if let Some(text) = convert_script(&body, false) {
if is_shapable(engine, &text, styles) {
return Ok(TextElem::packed(text));
}
}
};
Ok(body
.styled(TextElem::set_baseline(self.baseline(styles)))
.styled(TextElem::set_size(self.size(styles))))
fn show(&self, _: &mut Engine, styles: StyleChain) -> SourceResult<Content> {
let outer_text_size = TextElem::size_in(styles);
Ok(self
.body
.clone()
.styled(TextElem::set_subperscript(Some(ScriptSettings {
typographic: self.typographic(styles),
shift: self.baseline(styles).map(|l| -l.to_em(outer_text_size)),
size: self.size(styles).map(|t| t.0.to_em(outer_text_size)),
kind: ScriptKind::Super,
}))))
}
}
/// Find and transform the text contained in `content` to the given script kind
/// if and only if it only consists of `Text`, `Space`, and `Empty` leaves.
fn convert_script(content: &Content, sub: bool) -> Option<EcoString> {
if content.is::<SpaceElem>() {
Some(' '.into())
} else if let Some(elem) = content.to_packed::<TextElem>() {
if sub {
elem.text.chars().map(to_subscript_codepoint).collect()
} else {
elem.text.chars().map(to_superscript_codepoint).collect()
}
} else if let Some(sequence) = content.to_packed::<SequenceElem>() {
sequence
.children
.iter()
.map(|item| convert_script(item, sub))
.collect()
} else {
None
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum ScriptKind {
Sub,
Super,
}
/// Checks whether the first retrievable family contains all code points of the
/// given string.
fn is_shapable(engine: &Engine, text: &str, styles: StyleChain) -> bool {
let world = engine.world;
for family in TextElem::font_in(styles) {
if let Some(font) = world
.book()
.select(family.as_str(), variant(styles))
.and_then(|id| world.font(id))
{
let covers = family.covers();
return text.chars().all(|c| {
covers.is_none_or(|cov| cov.is_match(c.encode_utf8(&mut [0; 4])))
&& font.ttf().glyph_index(c).is_some()
});
impl ScriptKind {
pub const fn default_metrics(self) -> ScriptMetrics {
match self {
Self::Sub => ScriptMetrics::default_subscript(),
Self::Super => ScriptMetrics::default_superscript(),
}
}
false
}
pub const fn read_metrics(self, font_metrics: &FontMetrics) -> ScriptMetrics {
match self {
Self::Sub => font_metrics.subscript,
Self::Super => font_metrics.superscript,
}
}
/// Convert a character to its corresponding Unicode superscript.
fn to_superscript_codepoint(c: char) -> Option<char> {
match c {
'1' => Some('¹'),
'2' => Some('²'),
'3' => Some('³'),
'0' | '4'..='9' => char::from_u32(c as u32 - '0' as u32 + '⁰' as u32),
'+' => Some('⁺'),
'' => Some('⁻'),
'=' => Some('⁼'),
'(' => Some('⁽'),
')' => Some('⁾'),
'n' => Some('ⁿ'),
'i' => Some('ⁱ'),
' ' => Some(' '),
_ => None,
/// The corresponding OpenType feature.
pub const fn feature(self) -> Tag {
match self {
Self::Sub => Tag::from_bytes(b"subs"),
Self::Super => Tag::from_bytes(b"sups"),
}
}
}
/// Convert a character to its corresponding Unicode subscript.
fn to_subscript_codepoint(c: char) -> Option<char> {
match c {
'0'..='9' => char::from_u32(c as u32 - '0' as u32 + '₀' as u32),
'+' => Some('₊'),
'' => Some('₋'),
'=' => Some('₌'),
'(' => Some('₍'),
')' => Some('₎'),
'a' => Some('ₐ'),
'e' => Some('ₑ'),
'o' => Some('ₒ'),
'x' => Some('ₓ'),
'h' => Some('ₕ'),
'k' => Some('ₖ'),
'l' => Some('ₗ'),
'm' => Some('ₘ'),
'n' => Some('ₙ'),
'p' => Some('ₚ'),
's' => Some('ₛ'),
't' => Some('ₜ'),
' ' => Some(' '),
_ => None,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct ScriptSettings {
/// Whether the OpenType feature should be used if possible.
pub typographic: bool,
pub shift: Smart<Em>,
pub size: Smart<Em>,
pub kind: ScriptKind,
}