diff --git a/src/export/pdf.rs b/src/export/pdf.rs index 46f3d389a..aa7acd41d 100644 --- a/src/export/pdf.rs +++ b/src/export/pdf.rs @@ -521,17 +521,18 @@ impl<'a> PageExporter<'a> { } fn write_text(&mut self, x: f32, y: f32, text: &Text) { + *self.languages.entry(text.lang).or_insert(0) += text.glyphs.len(); self.glyphs .entry(text.face_id) .or_default() .extend(text.glyphs.iter().map(|g| g.id)); - self.content.begin_text(); - self.set_font(text.face_id, text.size); - self.set_fill(text.fill); - let face = self.fonts.get(text.face_id); + self.set_fill(text.fill); + self.set_font(text.face_id, text.size); + self.content.begin_text(); + // Position the text. self.content.set_text_matrix([1.0, 0.0, 0.0, -1.0, x, y]); @@ -568,11 +569,6 @@ impl<'a> PageExporter<'a> { items.show(Str(&encoded)); } - self.languages - .entry(text.lang) - .and_modify(|x| *x += text.glyphs.len()) - .or_insert_with(|| text.glyphs.len()); - items.finish(); positioned.finish(); self.content.end_text(); @@ -583,6 +579,14 @@ impl<'a> PageExporter<'a> { return; } + if let Some(fill) = shape.fill { + self.set_fill(fill); + } + + if let Some(stroke) = shape.stroke { + self.set_stroke(stroke); + } + match shape.geometry { Geometry::Rect(size) => { let w = size.x.to_f32(); @@ -606,14 +610,6 @@ impl<'a> PageExporter<'a> { } } - if let Some(fill) = shape.fill { - self.set_fill(fill); - } - - if let Some(stroke) = shape.stroke { - self.set_stroke(stroke); - } - match (shape.fill, shape.stroke) { (None, None) => unreachable!(), (Some(_), None) => self.content.fill_nonzero(), diff --git a/src/library/mod.rs b/src/library/mod.rs index 9708475b5..d78e38caa 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -28,6 +28,8 @@ pub fn new() -> Scope { std.def_node::("underline"); std.def_node::("strike"); std.def_node::("overline"); + std.def_node::("super"); + std.def_node::("sub"); std.def_node::("link"); std.def_node::("repeat"); std.def_fn("lower", text::lower); diff --git a/src/library/text/deco.rs b/src/library/text/deco.rs index cec4ca9ef..c5217e8ef 100644 --- a/src/library/text/deco.rs +++ b/src/library/text/deco.rs @@ -90,6 +90,7 @@ pub fn decorate( deco: &Decoration, fonts: &FontStore, text: &Text, + shift: Length, pos: Point, width: Length, ) { @@ -102,7 +103,7 @@ pub fn decorate( }; let evade = deco.evade && deco.line != STRIKETHROUGH; - let offset = deco.offset.unwrap_or(-metrics.position.at(text.size)); + let offset = deco.offset.unwrap_or(-metrics.position.at(text.size)) - shift; let stroke = deco.stroke.unwrap_or(Stroke { paint: text.fill, thickness: metrics.thickness.at(text.size), diff --git a/src/library/text/mod.rs b/src/library/text/mod.rs index a10894863..be20b3efa 100644 --- a/src/library/text/mod.rs +++ b/src/library/text/mod.rs @@ -8,6 +8,7 @@ mod quotes; mod raw; mod repeat; mod shaping; +mod shift; pub use deco::*; pub use lang::*; @@ -17,6 +18,7 @@ pub use quotes::*; pub use raw::*; pub use repeat::*; pub use shaping::*; +pub use shift::*; use std::borrow::Cow; @@ -60,6 +62,9 @@ impl TextNode { /// The width of spaces relative to the default space width. #[property(resolve)] pub const SPACING: Relative = Relative::one(); + /// The offset of the baseline. + #[property(resolve)] + pub const BASELINE: RawLength = RawLength::zero(); /// Whether glyphs can hang over into the margin. pub const OVERHANG: bool = true; /// The top end of the text bounding box. @@ -98,8 +103,6 @@ impl TextNode { pub const NUMBER_TYPE: Smart = Smart::Auto; /// The width of numbers / figures. pub const NUMBER_WIDTH: Smart = Smart::Auto; - /// How to position numbers. - pub const NUMBER_POSITION: NumberPosition = NumberPosition::Normal; /// Whether to have a slash through the zero glyph. ("zero") pub const SLASHED_ZERO: bool = false; /// Whether to convert fractions. ("frac") diff --git a/src/library/text/par.rs b/src/library/text/par.rs index a6f7c2738..695d80667 100644 --- a/src/library/text/par.rs +++ b/src/library/text/par.rs @@ -551,7 +551,13 @@ fn prepare<'a>( } else { let size = Size::new(regions.first.x, regions.base.y); let pod = Regions::one(size, regions.base, Spec::splat(false)); - let frame = node.layout(ctx, &pod, styles)?.remove(0); + let mut frame = node.layout(ctx, &pod, styles)?.remove(0); + let shift = styles.get(TextNode::BASELINE); + + if !shift.is_zero() { + Arc::make_mut(&mut frame).translate(Point::with_y(shift)); + } + items.push(Item::Frame(frame)); } } diff --git a/src/library/text/shaping.rs b/src/library/text/shaping.rs index 68499a013..1f3d2f55f 100644 --- a/src/library/text/shaping.rs +++ b/src/library/text/shaping.rs @@ -41,6 +41,8 @@ pub struct ShapedGlyph { pub x_advance: Em, /// The horizontal offset of the glyph. pub x_offset: Em, + /// The vertical offset of the glyph. + pub y_offset: Em, /// A value that is the same for all glyphs belong to one cluster. pub cluster: usize, /// Whether splitting the shaping result before this glyph would yield the @@ -84,10 +86,17 @@ impl<'a> ShapedText<'a> { let mut frame = Frame::new(size); frame.baseline = Some(top); - for (face_id, group) in self.glyphs.as_ref().group_by_key(|g| g.face_id) { - let pos = Point::new(offset, top); + let shift = self.styles.get(TextNode::BASELINE); + let lang = self.styles.get(TextNode::LANG); + let decos = self.styles.get(TextNode::DECO); + let fill = self.styles.get(TextNode::FILL); + let link = self.styles.get(TextNode::LINK); + + for ((face_id, y_offset), group) in + self.glyphs.as_ref().group_by_key(|g| (g.face_id, g.y_offset)) + { + let pos = Point::new(offset, top + shift + y_offset.at(self.size)); - let fill = self.styles.get(TextNode::FILL); let glyphs = group .iter() .map(|glyph| Glyph { @@ -107,7 +116,7 @@ impl<'a> ShapedText<'a> { let text = Text { face_id, size: self.size, - lang: self.styles.get(TextNode::LANG), + lang, fill, glyphs, }; @@ -115,8 +124,8 @@ impl<'a> ShapedText<'a> { let width = text.width(); // Apply line decorations. - for deco in self.styles.get(TextNode::DECO) { - decorate(&mut frame, &deco, fonts, &text, pos, width); + for deco in &decos { + decorate(&mut frame, &deco, fonts, &text, shift, pos, width); } frame.insert(text_layer, pos, Element::Text(text)); @@ -124,8 +133,8 @@ impl<'a> ShapedText<'a> { } // Apply link if it exists. - if let Some(url) = self.styles.get(TextNode::LINK) { - frame.link(url.clone()); + if let Some(dest) = link { + frame.link(dest.clone()); } frame @@ -212,11 +221,14 @@ impl<'a> ShapedText<'a> { let x_advance = face.to_em(ttf.glyph_hor_advance(glyph_id)?); let cluster = self.glyphs.last().map(|g| g.cluster).unwrap_or_default(); self.width += x_advance.at(self.size); + let baseline_shift = self.styles.get(TextNode::BASELINE); + self.glyphs.to_mut().push(ShapedGlyph { face_id, glyph_id: glyph_id.0, x_advance, x_offset: Em::zero(), + y_offset: Em::from_length(baseline_shift, self.size), cluster, safe_to_break: true, c: '-', @@ -402,12 +414,13 @@ fn shape_segment<'a>( if info.glyph_id != 0 { // Add the glyph to the shaped output. - // TODO: Don't ignore y_advance and y_offset. + // TODO: Don't ignore y_advance. 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), + y_offset: face.to_em(pos[i].y_offset), cluster: base + cluster, safe_to_break: !info.unsafe_to_break(), c: text[cluster ..].chars().next().unwrap(), @@ -478,6 +491,7 @@ fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, face_id: FaceI glyph_id: 0, x_advance, x_offset: Em::zero(), + y_offset: Em::from_length(ctx.styles.get(TextNode::BASELINE), ctx.size), cluster: base + cluster, safe_to_break: true, c, @@ -602,12 +616,6 @@ fn tags(styles: StyleChain) -> Vec { Smart::Custom(NumberWidth::Tabular) => feat(b"tnum", 1), } - match styles.get(TextNode::NUMBER_POSITION) { - NumberPosition::Normal => {} - NumberPosition::Subscript => feat(b"subs", 1), - NumberPosition::Superscript => feat(b"sups", 1), - } - if styles.get(TextNode::SLASHED_ZERO) { feat(b"zero", 1); } diff --git a/src/library/text/shift.rs b/src/library/text/shift.rs new file mode 100644 index 000000000..4eacd3c8a --- /dev/null +++ b/src/library/text/shift.rs @@ -0,0 +1,178 @@ +use super::{variant, TextNode, TextSize}; +use crate::font::FontStore; +use crate::library::prelude::*; +use crate::util::EcoString; + +/// Sub or superscript text. The text is rendered smaller and its baseline is raised. +/// +/// To provide the best typography possible, we first try to transform the +/// text to superscript codepoints. If that fails, we fall back to rendering +/// shrunk normal letters in a raised way. +#[derive(Debug, Hash)] +pub struct ShiftNode(pub Content); + +/// Shift the text into superscript. +pub type SuperNode = ShiftNode; + +/// Shift the text into subscript. +pub type SubNode = ShiftNode; + +#[node] +impl ShiftNode { + /// Whether to prefer the dedicated sub- and superscript characters of the font. + pub const TYPOGRAPHIC: bool = true; + /// The baseline shift for synthetic sub- and superscripts. + pub const BASELINE: RawLength = + Em::new(if S == SUPERSCRIPT { -0.5 } else { 0.2 }).into(); + /// The font size for synthetic sub- and superscripts. + pub const SIZE: TextSize = TextSize(Em::new(0.6).into()); + + fn construct(_: &mut Machine, args: &mut Args) -> TypResult { + Ok(Content::show(Self(args.expect("body")?))) + } +} + +impl Show for ShiftNode { + fn unguard(&self, _: Selector) -> ShowNode { + Self(self.0.clone()).pack() + } + + fn encode(&self, _: StyleChain) -> Dict { + dict! { "body" => Value::Content(self.0.clone()) } + } + + fn realize(&self, ctx: &mut Context, styles: StyleChain) -> TypResult { + let mut transformed = None; + if styles.get(Self::TYPOGRAPHIC) { + if let Some(text) = search_text(&self.0, S) { + if is_shapable(&mut ctx.fonts, &text, styles) { + transformed = Some(Content::Text(text)); + } + } + }; + + Ok(transformed.unwrap_or_else(|| { + let mut map = StyleMap::new(); + map.set(TextNode::BASELINE, styles.get(Self::BASELINE)); + map.set(TextNode::SIZE, styles.get(Self::SIZE)); + self.0.clone().styled_with_map(map) + })) + } +} + +/// Find and transform the text contained in `content` iff it only consists of +/// `Text`, `Space`, and `Empty` leaf nodes. The text is transformed to the +/// given script kind. +fn search_text(content: &Content, mode: ScriptKind) -> Option { + match content { + Content::Text(_) => { + if let Content::Text(t) = content { + if let Some(sup) = convert_script(t, mode) { + return Some(sup); + } + } + None + } + Content::Space => Some(' '.into()), + Content::Empty => Some(EcoString::new()), + Content::Sequence(seq) => { + let mut full = EcoString::new(); + for item in seq.iter() { + match search_text(item, mode) { + Some(text) => full.push_str(&text), + None => return None, + } + } + Some(full) + } + _ => None, + } +} + +/// Checks whether the first retrievable family contains all code points of the +/// given string. +fn is_shapable(fonts: &mut FontStore, text: &str, styles: StyleChain) -> bool { + for family in styles.get(TextNode::FAMILY).iter() { + if let Some(face_id) = fonts.select(family.as_str(), variant(styles)) { + let ttf = fonts.get(face_id).ttf(); + return text.chars().all(|c| ttf.glyph_index(c).is_some()); + } + } + + false +} + +/// Convert a string to sub- or superscript codepoints if all characters +/// can be mapped to such a codepoint. +fn convert_script(text: &str, mode: ScriptKind) -> Option { + let mut result = EcoString::with_capacity(text.len()); + let converter = match mode { + SUPERSCRIPT => to_superscript_codepoint, + SUBSCRIPT | _ => to_subscript_codepoint, + }; + + for c in text.chars() { + match converter(c) { + Some(c) => result.push(c), + None => return None, + } + } + + Some(result) +} + +/// Convert a character to its corresponding Unicode superscript. +fn to_superscript_codepoint(c: char) -> Option { + char::from_u32(match c { + '0' => 0x2070, + '1' => 0x00B9, + '2' => 0x00B2, + '3' => 0x00B3, + '4' ..= '9' => 0x2070 + (c as u32 + 4 - '4' as u32), + '+' => 0x207A, + '-' => 0x207B, + '=' => 0x207C, + '(' => 0x207D, + ')' => 0x207E, + 'n' => 0x207F, + 'i' => 0x2071, + ' ' => 0x0020, + _ => return None, + }) +} + +/// Convert a character to its corresponding Unicode subscript. +fn to_subscript_codepoint(c: char) -> Option { + char::from_u32(match c { + '0' => 0x2080, + '1' ..= '9' => 0x2080 + (c as u32 - '0' as u32), + '+' => 0x208A, + '-' => 0x208B, + '=' => 0x208C, + '(' => 0x208D, + ')' => 0x208E, + 'a' => 0x2090, + 'e' => 0x2091, + 'o' => 0x2092, + 'x' => 0x2093, + 'h' => 0x2095, + 'k' => 0x2096, + 'l' => 0x2097, + 'm' => 0x2098, + 'n' => 0x2099, + 'p' => 0x209A, + 's' => 0x209B, + 't' => 0x209C, + ' ' => 0x0020, + _ => return None, + }) +} + +/// A category of script. +pub type ScriptKind = usize; + +/// Text that is rendered smaller and raised, also known as superior. +const SUPERSCRIPT: ScriptKind = 0; + +/// Text that is rendered smaller and lowered, also known as inferior. +const SUBSCRIPT: ScriptKind = 1; diff --git a/tests/ref/layout/columns.png b/tests/ref/layout/columns.png index 3f4714152..6f2105100 100644 Binary files a/tests/ref/layout/columns.png and b/tests/ref/layout/columns.png differ diff --git a/tests/ref/text/baseline.png b/tests/ref/text/baseline.png index 71f75d9b9..37bb19499 100644 Binary files a/tests/ref/text/baseline.png and b/tests/ref/text/baseline.png differ diff --git a/tests/ref/text/features.png b/tests/ref/text/features.png index cde62d8c0..36609d89f 100644 Binary files a/tests/ref/text/features.png and b/tests/ref/text/features.png differ diff --git a/tests/ref/text/indent.png b/tests/ref/text/indent.png index 09d8e68df..2196c33cb 100644 Binary files a/tests/ref/text/indent.png and b/tests/ref/text/indent.png differ diff --git a/tests/ref/text/shifts.png b/tests/ref/text/shifts.png new file mode 100644 index 000000000..c1e9dec6b Binary files /dev/null and b/tests/ref/text/shifts.png differ diff --git a/tests/typ/text/baseline.typ b/tests/typ/text/baseline.typ index 5f4515632..cd331c777 100644 --- a/tests/typ/text/baseline.typ +++ b/tests/typ/text/baseline.typ @@ -1,4 +1,9 @@ -// Test text baseline. - ---- -Hi #text(1.5em)[You], #text(0.75em)[how are you?] +// Test text baseline. + +--- +Hi #text(1.5em)[You], #text(0.75em)[how are you?] + +Our cockatoo was one of the +#text(baseline: -0.2em)[#circle(radius: 2pt) first] +#text(baseline: 0.2em)[birds #circle(radius: 2pt)] +that ever learned to mimic a human voice. diff --git a/tests/typ/text/features.typ b/tests/typ/text/features.typ index 739230c9f..070fdcdf6 100644 --- a/tests/typ/text/features.typ +++ b/tests/typ/text/features.typ @@ -34,13 +34,6 @@ fi vs. #text(ligatures: false)[No fi] #text(number-width: "tabular")[3456789123] \ #text(number-width: "tabular")[0123456789] ---- -// Test number position. -#set text("IBM Plex Sans") -#text(number-position: "normal")[C2H4] \ -#text(number-position: "subscript")[C2H4] \ -#text(number-position: "superscript")[C2H4] - --- // Test extra number stuff. #set text("IBM Plex Sans") @@ -65,10 +58,6 @@ fi vs. #text(features: (liga: 0))[No fi] // Error: 24-25 expected string or auto, found integer #set text(number-type: 2) ---- -// Error: 24-35 expected "lining" or "old-style" -#set text(number-type: "different") - --- // Error: 21-26 expected array of strings or dictionary mapping tags to integers, found boolean #set text(features: false) diff --git a/tests/typ/text/shifts.typ b/tests/typ/text/shifts.typ new file mode 100644 index 000000000..b70425c4e --- /dev/null +++ b/tests/typ/text/shifts.typ @@ -0,0 +1,19 @@ +// Test sub- and superscipt shifts. + +--- +#table(columns: 3, + [Typo.], [Fallb.], [Synth], + [x#super[1]], [x#super[5n]], [x#super[2 #square(width: 6pt)]], + [x#sub[1]], [x#sub[5n]], [x#sub[2 #square(width: 6pt)]], +) + +--- +#set super(typographic: false, baseline: -0.25em, size: 0.7em) +n#super[1], n#sub[2], ... n#super[N] + +--- +#set underline(stroke: 0.5pt, offset: 0.15em) + +#underline[The claim#super[\[4\]]] has been disputed. \ +The claim#super[#underline[\[4\]]] has been disputed. \ +It really has been#super(box(text(baseline: 0pt, underline[\[4\]]))) \