mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 19:26:46 -06:00
Conflict: crates/render/src/text.rs: the branch's paint_glyphs with next's fill and stroke overprint (overprint_fill / overprint_stroke, an overprinting() Multiply layer around each paint); next's cell_stroke(kind, weight, length); path text strokes don't overprint, as before Conflict: crates/pdf/src/text.rs: the branch's text_run / paint_outlines / glyph_outlines split with next's skew tan, vertical mark offsets and split_clusters glyph order (#421) Conflict: crates/engine/src/cmd/paths.rs: next's shatai transform + the branch's stroke_behind_fill key Conflict: crates/idml/src/tests.rs: tests both Conflict: docs/typography-parity.md, ROADMAP.md: headers combined, logs both
1395 lines
60 KiB
Rust
1395 lines
60 KiB
Rust
//! Paragraph → styled, positioned glyphs (before line breaking).
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::{Arc, RwLock};
|
||
|
||
use designcraft_doc::{Capitalization, CharProps, Kerning, Leading, Position, Story, Styles, story};
|
||
use designcraft_fonts::{FaceRef, Feature, FontFace, ScopedFonts, ShapedGlyph, feature};
|
||
|
||
use crate::RunStyle;
|
||
|
||
/// One glyph with every character attribute applied, in points.
|
||
#[derive(Clone, Debug)]
|
||
pub struct Glyph {
|
||
pub face: FaceRef,
|
||
pub gid: u32,
|
||
/// Story byte offset of the cluster and its byte length (0 for generated glyphs).
|
||
pub byte: usize,
|
||
pub len: usize,
|
||
/// First source character of the cluster (special characters keep their code).
|
||
pub ch: char,
|
||
/// What a generated glyph (page number, list label) reads as in exported text, since its
|
||
/// `byte..byte + len` holds a marker or nothing: the cluster's text on its first glyph, empty
|
||
/// on the others. `None` for story text, which reads as its source bytes.
|
||
pub generated_text: Option<Box<str>>,
|
||
/// Advance in points (tracking and horizontal scale applied).
|
||
pub adv: f64,
|
||
pub dx: f64,
|
||
pub dy: f64,
|
||
/// Font units → points (incl. horizontal / vertical scale).
|
||
pub sx: f64,
|
||
pub sy: f64,
|
||
/// Baseline shift (positive = up), including super/subscript.
|
||
pub shift: f64,
|
||
pub ascent: f64,
|
||
/// The font's typographic ascender at the point size (vertical scale doesn't apply): the Ascent
|
||
/// first baseline offset.
|
||
pub typo_ascent: f64,
|
||
pub descent: f64,
|
||
/// The leading this character asks for (absolute, or auto = size × auto %).
|
||
pub leading: f64,
|
||
/// Cap height and x height, scaled like `ascent`: the Cap Height and x Height first baseline
|
||
/// offsets.
|
||
pub cap: f64,
|
||
pub xh: f64,
|
||
pub size: f64,
|
||
/// Index into the composed story's [`RunStyle`] table.
|
||
pub style: u32,
|
||
/// Unbreakable (No Break).
|
||
pub no_break: bool,
|
||
pub locked_advance: bool,
|
||
/// Tate-chu-yoko in a vertical frame: [centre of the group's em along the line from `x`,
|
||
/// this glyph's left edge across the line from that centre, the em] (see [`collapse_tcy`]).
|
||
pub tcy: Option<[f64; 3]>,
|
||
pub tcy_offset: [f64; 2],
|
||
/// Width of a word space (U+0020) in this glyph's font, size and horizontal scale: the unit of
|
||
/// word and letter spacing in justification.
|
||
pub space: f64,
|
||
/// Resolved CJK boundary constraint, independent of glyph outlines.
|
||
pub break_after: Option<bool>,
|
||
pub cjk_hang: f64,
|
||
pub moji: crate::mojikumi::Metrics,
|
||
pub ideographic_space_elastic: bool,
|
||
pub character_alignment: designcraft_doc::cjk::CharacterAlignment,
|
||
pub leading_model: designcraft_doc::cjk::LeadingModel,
|
||
pub character_direction: designcraft_doc::arabic::CharacterDirection,
|
||
pub display_char: char,
|
||
/// Actual shaped character, including generated labels, for glyph diagnostics.
|
||
pub rendered_char: char,
|
||
pub bidi_offset: usize,
|
||
pub bidi_end: usize,
|
||
pub allow_kashidas: bool,
|
||
pub safe_tatweel_before: bool,
|
||
pub shaping_rtl: bool,
|
||
/// The character's language as a BCP 47 tag ([`designcraft_doc::language_tag`]).
|
||
pub lang: Option<&'static str>,
|
||
/// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung
|
||
/// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]).
|
||
pub upright: bool,
|
||
/// Part of the bullet or number the paragraph's list adds: not the line's own text, so it
|
||
/// doesn't raise the Ascent first baseline.
|
||
pub list_label: bool,
|
||
}
|
||
|
||
impl Glyph {
|
||
pub fn is_space(&self) -> bool {
|
||
matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}') || (self.ch == '\u{3000}' && self.ideographic_space_elastic)
|
||
}
|
||
/// A space whose width justification and word spacing vary: U+0020, the nonbreaking space
|
||
/// U+00A0 and an elastic ideographic space. Fixed-width spaces (U+2002–U+200A, U+202F) get no
|
||
/// word-space stretch or shrink.
|
||
pub fn is_word_space(&self) -> bool {
|
||
matches!(self.ch, ' ' | NBSP) || (self.ch == '\u{3000}' && self.ideographic_space_elastic)
|
||
}
|
||
/// A space that justification treats as a word space rather than a letter: [`Self::is_space`]
|
||
/// plus U+00A0, which is never a break opportunity.
|
||
pub fn is_justify_space(&self) -> bool {
|
||
self.is_space() || self.ch == NBSP
|
||
}
|
||
pub fn is_letter(&self) -> bool {
|
||
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
|
||
}
|
||
}
|
||
|
||
pub const SOFT_HYPHEN: char = '\u{AD}';
|
||
pub const NBSP: char = '\u{A0}';
|
||
|
||
/// Shaped paragraph.
|
||
pub struct ShapedPara {
|
||
pub glyphs: Vec<Glyph>,
|
||
/// Byte range of the paragraph text (without its `\n`).
|
||
pub range: std::ops::Range<usize>,
|
||
}
|
||
|
||
/// A resolved piece of a paragraph: one character format (and overlay) over `range`.
|
||
struct Piece {
|
||
range: std::ops::Range<usize>,
|
||
props: CharProps,
|
||
style: u32,
|
||
/// Shaping settings for the style (a missing font turns glyph fallback on).
|
||
env: TypeEnv,
|
||
/// Hidden conditional text or tracked deletions: laid out as zero-width place-holders.
|
||
hidden: bool,
|
||
/// A note reference set in a composite font: its label is shaped alone, each part in the
|
||
/// composite entry's font.
|
||
composite_note: bool,
|
||
}
|
||
|
||
/// The style of each byte of a shaped run: `(first byte, style)` in story order.
|
||
struct StyleRuns<'a>(&'a [(usize, u32)]);
|
||
|
||
impl StyleRuns<'_> {
|
||
fn at(&self, byte: usize) -> u32 {
|
||
let k = self.0.partition_point(|&(start, _)| start <= byte);
|
||
self.0.get(k.saturating_sub(1)).map_or(0, |&(_, s)| s)
|
||
}
|
||
}
|
||
|
||
/// The attributes that decide how text is shaped: `p` with everything that only paints the
|
||
/// glyphs (colour, stroke, underline and strikethrough, skew, markers) reset, so two pieces with
|
||
/// equal keys can be shaped as one. Attributes not listed here keep pieces apart.
|
||
fn shaping_key(p: &CharProps) -> CharProps {
|
||
let d = CharProps::default();
|
||
CharProps {
|
||
skew: d.skew,
|
||
fill: d.fill,
|
||
fill_tint: d.fill_tint,
|
||
stroke: d.stroke,
|
||
stroke_tint: d.stroke_tint,
|
||
stroke_weight: d.stroke_weight,
|
||
underline: d.underline,
|
||
strikethrough: d.strikethrough,
|
||
underline_weight: d.underline_weight,
|
||
underline_offset: d.underline_offset,
|
||
underline_color: d.underline_color,
|
||
underline_tint: d.underline_tint,
|
||
strikethrough_weight: d.strikethrough_weight,
|
||
strikethrough_offset: d.strikethrough_offset,
|
||
strikethrough_color: d.strikethrough_color,
|
||
strikethrough_tint: d.strikethrough_tint,
|
||
ruby: d.ruby,
|
||
kenten: d.kenten,
|
||
kenten_character: d.kenten_character,
|
||
conditions: d.conditions,
|
||
change: d.change,
|
||
xml_tag: d.xml_tag,
|
||
..p.clone()
|
||
}
|
||
}
|
||
|
||
/// Context for substitutions (page number markers).
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct SubstCtx {
|
||
pub page_name: Option<String>,
|
||
/// The section marker text of the page being composed (`None` on parent pages: "Section").
|
||
pub section_marker: Option<String>,
|
||
/// Text variable values by index (on the page being composed).
|
||
pub vars: Arc<Vec<String>>,
|
||
/// Footnote reference labels by story byte of the reference character.
|
||
pub notes: HashMap<usize, String>,
|
||
/// Footnote reference formatting: position and character style (`None` = the text's).
|
||
pub note_position: designcraft_doc::Position,
|
||
pub note_style: Option<String>,
|
||
/// Cross-reference texts by story byte of the cross-reference mark.
|
||
pub xrefs: HashMap<usize, String>,
|
||
/// Anchored objects by story byte of their mark.
|
||
pub objects: HashMap<usize, ObjectSpec>,
|
||
/// Conditions currently hidden (conditional text with only these isn't shown).
|
||
pub hidden_conditions: Vec<String>,
|
||
/// Set in a Vertical Type frame: upright runs are shaped top to bottom (vertical forms and
|
||
/// metrics), the rest horizontally to be turned as a whole.
|
||
pub vertical: bool,
|
||
/// Auto tate-chu-yoko in vertical text: runs of up to this many half-width digits (with
|
||
/// `auto_tcy_roman`, also roman letters) are set across one em (0 = off).
|
||
pub auto_tcy: u32,
|
||
pub auto_tcy_roman: bool,
|
||
/// Half-width characters stand upright in vertical text (Rotate Roman Characters).
|
||
pub rotate_roman: bool,
|
||
}
|
||
|
||
impl SubstCtx {
|
||
fn upright(&self) -> Upright {
|
||
Upright { vertical: self.vertical, rotate_roman: self.rotate_roman }
|
||
}
|
||
}
|
||
|
||
/// How a frame sets characters upright: in vertical text only, and with Rotate Roman also the
|
||
/// half-width ones (see [`upright_runs`]).
|
||
#[derive(Clone, Copy, Debug, Default)]
|
||
pub(crate) struct Upright {
|
||
pub vertical: bool,
|
||
pub rotate_roman: bool,
|
||
}
|
||
|
||
/// Most characters auto tate-chu-yoko sets across one em (input-derived).
|
||
pub const AUTO_TCY_MAX: u32 = 10;
|
||
|
||
/// The stand-in character of hidden conditional text: no width, no break, not drawn.
|
||
pub const HIDDEN: char = '\u{2060}';
|
||
|
||
/// Size and placement of an anchored object for line layout.
|
||
#[derive(Clone, Copy, Debug, Default)]
|
||
pub struct ObjectSpec {
|
||
pub index: usize,
|
||
pub w: f64,
|
||
pub h: f64,
|
||
/// Inline: raise above the baseline. Above line: None.
|
||
pub y_offset: Option<f64>,
|
||
/// Above line: space before + after.
|
||
pub space: f64,
|
||
/// Custom position: takes no space in the text.
|
||
pub custom: bool,
|
||
}
|
||
|
||
pub(crate) struct StyleTable<'a> {
|
||
pub styles: &'a mut Vec<RunStyle>,
|
||
/// Font availability by family (looked up once per composition).
|
||
pub missing: &'a mut HashMap<String, bool>,
|
||
}
|
||
|
||
impl StyleTable<'_> {
|
||
fn intern(&mut self, db: &ScopedFonts<'_>, p: &CharProps) -> u32 {
|
||
// Kenten are drawn in a run style of their own (Kenten Color); it has no kenten itself.
|
||
let kenten_mark = if p.kenten {
|
||
use designcraft_doc::cjk_settings::AdornmentOverprint as O;
|
||
let mut style = self.intern(db, &crate::kenten::mark_props(p));
|
||
// Text has no overprint of its own, so `Auto` is off.
|
||
let (fill, stroke) = (p.kenten_overprint_fill == O::On, p.kenten_overprint_stroke == O::On);
|
||
if (fill || stroke)
|
||
&& let Some(base) = self.styles.get(style as usize)
|
||
{
|
||
style = self.push_unique(RunStyle { overprint_fill: fill, overprint_stroke: stroke, ..base.clone() });
|
||
}
|
||
Some(crate::kenten::KentenMark::of(p, style))
|
||
} else {
|
||
None
|
||
};
|
||
let rs = RunStyle {
|
||
fill: p.fill.clone(),
|
||
fill_tint: p.fill_tint,
|
||
stroke: p.stroke.clone(),
|
||
stroke_tint: p.stroke_tint,
|
||
stroke_weight: p.stroke_weight,
|
||
stroke_behind_fill: p.stroke_behind_fill,
|
||
underline: p.underline,
|
||
strikethrough: p.strikethrough,
|
||
underline_rule: crate::Rule {
|
||
offset: p.underline_offset.unwrap_or(p.size * 0.12),
|
||
weight: p.underline_weight.unwrap_or(p.size / 14.0),
|
||
color: if p.underline_color.is_empty() { p.fill.clone() } else { p.underline_color.clone() },
|
||
tint: if p.underline_color.is_empty() { p.fill_tint } else { p.underline_tint },
|
||
},
|
||
strike_rule: crate::Rule {
|
||
offset: -p.strikethrough_offset.unwrap_or(p.size * 0.3),
|
||
weight: p.strikethrough_weight.unwrap_or(p.size / 14.0),
|
||
color: if p.strikethrough_color.is_empty() { p.fill.clone() } else { p.strikethrough_color.clone() },
|
||
tint: if p.strikethrough_color.is_empty() { p.fill_tint } else { p.strikethrough_tint },
|
||
},
|
||
skew: p.skew,
|
||
size: p.size,
|
||
missing_font: *self.missing.entry(p.font_family.clone()).or_insert_with(|| !p.font_family.is_empty() && !db.has_family(&p.font_family)),
|
||
custom_tracking: p.tracking.abs() > 1e-9 || matches!(p.kerning, designcraft_doc::Kerning::Manual(_)),
|
||
condition: p.conditions.first().cloned(),
|
||
inserted: p.change == designcraft_doc::ChangeMark::Inserted,
|
||
xml_tag: (!p.xml_tag.is_empty()).then(|| p.xml_tag.clone()),
|
||
ruby: (!p.ruby.is_empty()).then(|| crate::ruby::capped(&p.ruby).to_string()),
|
||
ruby_spec: (!p.ruby.is_empty()).then(|| designcraft_doc::ruby::RubySpec::of(p)),
|
||
ruby_unit: None,
|
||
overprint_fill: false,
|
||
overprint_stroke: false,
|
||
warichu: p.warichu,
|
||
warichu_lines: p.warichu_lines,
|
||
warichu_size: p.warichu_size,
|
||
warichu_line_spacing: p.warichu_line_spacing,
|
||
warichu_align: p.warichu_alignment,
|
||
warichu_chars_before: p.warichu_chars_before_break,
|
||
warichu_chars_after: p.warichu_chars_after_break,
|
||
kenten_mark,
|
||
shatai: crate::shatai::Shatai::of(p),
|
||
};
|
||
self.push_unique(rs)
|
||
}
|
||
|
||
/// The index of `rs` in the table, added if it isn't there yet.
|
||
fn push_unique(&mut self, rs: RunStyle) -> u32 {
|
||
if let Some(i) = self.styles.iter().rposition(|s| *s == rs) {
|
||
return i as u32;
|
||
}
|
||
self.styles.push(rs);
|
||
(self.styles.len() - 1) as u32
|
||
}
|
||
|
||
/// `env` for text in run style `style`: a missing font's substitute draws the characters it
|
||
/// lacks from fallback fonts whatever the document's setting.
|
||
fn env_for(&self, env: TypeEnv, style: u32) -> TypeEnv {
|
||
let missing = self.styles.get(style as usize).is_some_and(|s| s.missing_font);
|
||
TypeEnv { glyph_fallback: env.glyph_fallback || missing, ..env }
|
||
}
|
||
}
|
||
|
||
/// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without
|
||
/// one) and takes the character style `drop.1` ("" = none).
|
||
pub(crate) fn shape_para(
|
||
db: &ScopedFonts<'_>,
|
||
styles: &Styles,
|
||
story: &Story,
|
||
pi: usize,
|
||
range: std::ops::Range<usize>,
|
||
para_chars: &CharProps,
|
||
auto_leading: TypeEnv,
|
||
sub: &SubstCtx,
|
||
table: &mut StyleTable<'_>,
|
||
nested: &[designcraft_doc::NestedStyle],
|
||
grep: &[designcraft_doc::GrepStyle],
|
||
lines: &[(std::ops::Range<usize>, String)],
|
||
drop: (usize, &str),
|
||
) -> ShapedPara {
|
||
let _ = pi;
|
||
let mut glyphs = Vec::with_capacity(range.len());
|
||
// Nested line styles lie under nested and GREP styles, and the drop cap's style over them all
|
||
// (later overlays win).
|
||
let mut overlays: Vec<(std::ops::Range<usize>, String)> = lines.to_vec();
|
||
// The drop cap's style is the nested styles' leading "through 1 Dropcap" entry (the form IDML
|
||
// stores it in). Set (`[None]` included), it restyles that entry; a real one stands in for a
|
||
// missing entry. Either way the nested styles after it start after the drop cap.
|
||
let set = Some(drop.1).filter(|s| !s.is_empty());
|
||
let cap_style = set.filter(|s| *s != designcraft_doc::NO_CHAR_STYLE);
|
||
let with_cap: Vec<designcraft_doc::NestedStyle>;
|
||
let nested = match set {
|
||
Some(s) if nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => {
|
||
with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().skip(1).cloned()).collect();
|
||
&with_cap[..]
|
||
}
|
||
Some(s) if cap_style.is_some() && !nested.is_empty() => {
|
||
with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().cloned()).collect();
|
||
&with_cap[..]
|
||
}
|
||
_ => nested,
|
||
};
|
||
if !nested.is_empty() || !grep.is_empty() {
|
||
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0));
|
||
}
|
||
if let Some(s) = cap_style
|
||
&& drop.0 > range.start
|
||
{
|
||
overlays.push((range.start..drop.0, s.to_string()));
|
||
}
|
||
// Each run, cut where nested / GREP styles start and end.
|
||
let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new();
|
||
for (rr, fmt) in story.runs() {
|
||
let a = rr.start.max(range.start);
|
||
let b = rr.end.min(range.end);
|
||
if a >= b {
|
||
continue;
|
||
}
|
||
if overlays.is_empty() {
|
||
segments.push((a, b, None, fmt));
|
||
continue;
|
||
}
|
||
let mut cuts: Vec<usize> = overlays.iter().flat_map(|(r, _)| [r.start, r.end]).filter(|x| *x > a && *x < b).collect();
|
||
cuts.push(a);
|
||
cuts.push(b);
|
||
cuts.sort_unstable();
|
||
cuts.dedup();
|
||
for w in cuts.windows(2) {
|
||
let top = overlays.iter().rev().find(|(r, _)| r.start <= w[0] && w[0] < r.end).map(|(_, s)| s.as_str());
|
||
segments.push((w[0], w[1], top, fmt));
|
||
}
|
||
}
|
||
// Resolve every segment, then shape runs of segments that differ only in how they are
|
||
// painted as one piece, so kerning, ligatures and joining work across a colour change.
|
||
let mut resolved = Vec::new();
|
||
for (a, b, overlay, fmt) in segments {
|
||
// A nested / GREP character style sits under the run's own style and overrides.
|
||
let base;
|
||
let para_chars = match overlay {
|
||
Some(st) => {
|
||
base = styles.resolve_char(para_chars, &designcraft_doc::CharFormat { style: st.to_string(), over: Default::default() });
|
||
&base
|
||
}
|
||
None => para_chars,
|
||
};
|
||
let props = styles.resolve_char(para_chars, fmt);
|
||
if let Some(font) = styles.composite_font(&props.font_family) {
|
||
let mut start = a;
|
||
let mut selected = None;
|
||
for (offset, c) in story.text.get(a..b).unwrap_or("").char_indices() {
|
||
let entry = font.entry(c);
|
||
if entry != selected && a + offset > start {
|
||
resolved.push((start, a + offset, composite_props(db, &props, selected), fmt, para_chars.clone()));
|
||
start = a + offset;
|
||
}
|
||
selected = entry;
|
||
}
|
||
resolved.push((start, b, composite_props(db, &props, selected), fmt, para_chars.clone()));
|
||
} else {
|
||
resolved.push((a, b, props, fmt, para_chars.clone()));
|
||
}
|
||
}
|
||
let auto_tcy =
|
||
if sub.vertical && sub.auto_tcy > 0 { auto_tcy_ranges(&story.text, range.clone(), sub.auto_tcy, sub.auto_tcy_roman) } else { Vec::new() };
|
||
let resolved = marks_join_their_base(&story.text, resolved);
|
||
let resolved = split_auto_tcy(resolved, &auto_tcy);
|
||
let mut pieces: Vec<Piece> = Vec::with_capacity(resolved.len());
|
||
for (a, b, props, fmt, resolved_base) in resolved {
|
||
let style = table.intern(db, &props);
|
||
// A missing font's substitute stands in for the whole font: fallback fonts help it.
|
||
let auto_leading = table.env_for(auto_leading, style);
|
||
let deleted = props.change == designcraft_doc::ChangeMark::Deleted;
|
||
if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) {
|
||
pieces.push(Piece { range: a..b, props, style, env: auto_leading, hidden: true, composite_note: false });
|
||
continue;
|
||
}
|
||
let is_ref = |c: char| c == designcraft_doc::FOOTNOTE_REF || c == designcraft_doc::ENDNOTE_REF;
|
||
if !story.text[a..b].contains(is_ref) {
|
||
pieces.push(Piece { range: a..b, props, style, env: auto_leading, hidden: false, composite_note: false });
|
||
continue;
|
||
}
|
||
// Footnote references take the reference position / character style; endnote references
|
||
// are superscript.
|
||
let mut rf = fmt.clone();
|
||
if let Some(cs) = &sub.note_style {
|
||
rf.style = cs.clone();
|
||
}
|
||
rf.over.position = Some(sub.note_position);
|
||
let rprops = styles.resolve_char(&resolved_base, &rf);
|
||
let rstyle = table.intern(db, &rprops);
|
||
let renv = table.env_for(auto_leading, rstyle);
|
||
let mut ef = fmt.clone();
|
||
ef.over.position = Some(designcraft_doc::Position::Superscript);
|
||
let eprops = styles.resolve_char(&resolved_base, &ef);
|
||
let estyle = table.intern(db, &eprops);
|
||
let eenv = table.env_for(auto_leading, estyle);
|
||
let mut k = a;
|
||
for (i, m) in story.text[a..b].match_indices(is_ref) {
|
||
let i = a + i;
|
||
if k < i {
|
||
pieces.push(Piece { range: k..i, props: props.clone(), style, env: auto_leading, hidden: false, composite_note: false });
|
||
}
|
||
let e = i + m.len();
|
||
if m.starts_with(designcraft_doc::ENDNOTE_REF) {
|
||
pieces.push(Piece { range: i..e, props: eprops.clone(), style: estyle, env: eenv, hidden: false, composite_note: false });
|
||
} else {
|
||
if styles.composite_font(&rprops.font_family).is_some() {
|
||
// Shaped entry by entry below, from the run's env: the composite font's own
|
||
// name is never an installed family, so `renv` would force glyph fallback.
|
||
pieces.push(Piece { range: i..e, props: rprops.clone(), style: rstyle, env: auto_leading, hidden: false, composite_note: true });
|
||
} else {
|
||
pieces.push(Piece { range: i..e, props: rprops.clone(), style: rstyle, env: renv, hidden: false, composite_note: false });
|
||
}
|
||
}
|
||
k = e;
|
||
}
|
||
if k < b {
|
||
pieces.push(Piece { range: k..b, props, style, env: auto_leading, hidden: false, composite_note: false });
|
||
}
|
||
}
|
||
let mut i = 0;
|
||
while i < pieces.len() {
|
||
let first = &pieces[i];
|
||
if first.hidden {
|
||
// Hidden conditional text: zero-width, unbreakable, undrawn place-holders keep every
|
||
// byte addressable (caret, selection) without taking space.
|
||
let face = db.face(&first.props.font_family, &first.props.font_style);
|
||
for (k, c) in story.text[first.range.clone()].char_indices() {
|
||
if c == '\n' {
|
||
continue;
|
||
}
|
||
let mut g = control_glyph(&face, &first.props, first.env, first.style, first.range.start + k, c);
|
||
g.ch = HIDDEN;
|
||
(g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||
glyphs.push(g);
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if first.composite_note {
|
||
if let Some(composite) = styles.composite_font(&first.props.font_family) {
|
||
let at = first.range.start;
|
||
let label = sub.notes.get(&at).map_or("#", String::as_str);
|
||
let begin = glyphs.len();
|
||
let mut segments: Vec<(usize, usize, Option<&designcraft_doc::cjk::CompositeFontEntry>)> = Vec::new();
|
||
for (at, c) in label.char_indices() {
|
||
let entry = composite.entry(c);
|
||
if let Some((_, end, _)) = segments.last_mut().filter(|(_, _, previous)| *previous == entry) {
|
||
*end = at + c.len_utf8();
|
||
} else {
|
||
segments.push((at, at + c.len_utf8(), entry));
|
||
}
|
||
}
|
||
for (a, b, entry) in segments {
|
||
let props = composite_props(db, &first.props, entry);
|
||
let style = table.intern(db, &props);
|
||
let env = table.env_for(first.env, style);
|
||
let face = db.face(&props.font_family, &props.font_style);
|
||
shape_segment(
|
||
db,
|
||
&story.text,
|
||
first.range.clone(),
|
||
label.get(a..b),
|
||
&props,
|
||
&face,
|
||
env,
|
||
&StyleRuns(&[(at, style)]),
|
||
&mut glyphs,
|
||
sub.upright(),
|
||
);
|
||
}
|
||
for (index, glyph) in glyphs[begin..].iter_mut().enumerate() {
|
||
glyph.len = if index == 0 { first.range.len() } else { 0 };
|
||
glyph.no_break = true;
|
||
}
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
let key = shaping_key(&first.props);
|
||
let mut j = i + 1;
|
||
// Jidori fits its own run into whole ems: such pieces stay apart.
|
||
while j < pieces.len()
|
||
&& first.props.jidori == 0
|
||
&& !pieces[j].hidden
|
||
&& !pieces[j].composite_note
|
||
&& pieces[j].range.start == pieces[j - 1].range.end
|
||
&& pieces[j].env.glyph_fallback == first.env.glyph_fallback
|
||
&& shaping_key(&pieces[j].props) == key
|
||
{
|
||
j += 1;
|
||
}
|
||
let group = &pieces[i..j];
|
||
let range = group[0].range.start..group[j - i - 1].range.end;
|
||
let runs: Vec<(usize, u32)> = group.iter().map(|p| (p.range.start, p.style)).collect();
|
||
let start = glyphs.len();
|
||
shape_run(db, &story.text, range, &first.props, first.env, &StyleRuns(&runs), sub, &mut glyphs);
|
||
// A cluster (a ligature, a letter with its marks) that spans two styles would paint one
|
||
// of them in the other's colour: shape those pieces apart instead.
|
||
let spans_cut = |g: &Glyph| runs[1..].iter().any(|&(cut, _)| g.byte < cut && cut < g.byte + g.len);
|
||
if group.len() > 1 && glyphs[start..].iter().any(spans_cut) {
|
||
glyphs.truncate(start);
|
||
for p in group {
|
||
shape_run(db, &story.text, p.range.clone(), &p.props, p.env, &StyleRuns(&[(p.range.start, p.style)]), sub, &mut glyphs);
|
||
}
|
||
}
|
||
i = j;
|
||
}
|
||
fit_auto_tcy(&mut glyphs, &auto_tcy);
|
||
collapse_tcy(&mut glyphs, sub.vertical);
|
||
crate::ruby::reserve(db, table.styles, &mut glyphs, auto_leading.glyph_fallback, sub.vertical);
|
||
ShapedPara { glyphs, range }
|
||
}
|
||
|
||
/// Combining marks that start a run are shaped with the letter before them when the two runs
|
||
/// differ only in OpenType features (ligatures, kerning, `otf_features`): the shaper attaches each
|
||
/// mark to its base, in its base's face. A run in another font, size or colour keeps its marks.
|
||
fn marks_join_their_base<'f>(
|
||
text: &str,
|
||
resolved: Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)>,
|
||
) -> Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)> {
|
||
let same_but_features = |a: &CharProps, b: &CharProps| {
|
||
*b == CharProps { ligatures: b.ligatures, kerning: b.kerning, otf_features: b.otf_features.clone(), ..a.clone() }
|
||
};
|
||
let is_base = |c: char| !c.is_whitespace() && !c.is_control() && !matches!(c, designcraft_doc::FOOTNOTE_REF | designcraft_doc::ENDNOTE_REF);
|
||
let mut out: Vec<(usize, usize, CharProps, &designcraft_doc::CharFormat, CharProps)> = Vec::with_capacity(resolved.len());
|
||
for (a, b, props, fmt, base) in resolved {
|
||
let run = text.get(a..b).unwrap_or("");
|
||
let marks = run.char_indices().find(|(_, c)| !is_mark(*c)).map_or(run.len(), |(i, _)| i);
|
||
if let Some(prev) = out.last_mut()
|
||
&& marks > 0
|
||
&& prev.1 == a
|
||
&& text.get(prev.0..prev.1).and_then(|s| s.chars().next_back()).is_some_and(is_base)
|
||
&& same_but_features(&prev.2, &props)
|
||
{
|
||
prev.1 = a + marks;
|
||
if a + marks < b {
|
||
out.push((a + marks, b, props, fmt, base));
|
||
}
|
||
continue;
|
||
}
|
||
out.push((a, b, props, fmt, base));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Tate-chu-yoko: in vertical text each run of marked glyphs takes one em along the line, its
|
||
/// glyphs set side by side across it; elsewhere the mark is dropped.
|
||
pub(crate) fn collapse_tcy(glyphs: &mut [Glyph], vertical: bool) {
|
||
let mut i = 0;
|
||
while i < glyphs.len() {
|
||
if glyphs[i].tcy.is_none() {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
let j = i + glyphs[i..].iter().position(|g| g.tcy.is_none()).unwrap_or(glyphs.len() - i);
|
||
if !vertical {
|
||
glyphs[i..j].iter_mut().for_each(|g| g.tcy = None);
|
||
i = j;
|
||
continue;
|
||
}
|
||
let run = &mut glyphs[i..j];
|
||
let width: f64 = run.iter().map(|g| g.adv).sum();
|
||
let em = run.iter().map(|g| g.size).fold(0.0, f64::max);
|
||
let step = em / run.len() as f64;
|
||
let mut across = -width / 2.0;
|
||
for (k, g) in run.iter_mut().enumerate() {
|
||
g.tcy = Some([em / 2.0 - k as f64 * step + g.tcy_offset[1], across + g.dx + g.tcy_offset[0], em]);
|
||
across += g.adv;
|
||
g.adv = step;
|
||
g.dx = 0.0;
|
||
}
|
||
i = j;
|
||
}
|
||
}
|
||
|
||
fn features_for(p: &CharProps) -> Vec<Feature> {
|
||
let mut v = Vec::new();
|
||
// Optical kerning replaces the font's pairs with measured ones (`crate::optical`).
|
||
if p.kerning != Kerning::Metrics {
|
||
v.extend(feature("-kern"));
|
||
}
|
||
if !p.ligatures || p.tracking.abs() > 1e-9 {
|
||
v.extend(feature("-liga"));
|
||
v.extend(feature("-clig"));
|
||
}
|
||
if p.capitalization == Capitalization::AllCaps {
|
||
v.extend(feature("case"));
|
||
}
|
||
if matches!(p.capitalization, Capitalization::SmallCaps | Capitalization::OpenTypeAllSmallCaps) {
|
||
v.extend(feature("smcp"));
|
||
}
|
||
if p.capitalization == Capitalization::OpenTypeAllSmallCaps {
|
||
v.extend(feature("c2sc"));
|
||
}
|
||
match p.position {
|
||
Position::OtSuperscript => v.extend(feature("sups")),
|
||
Position::OtSubscript => v.extend(feature("subs")),
|
||
Position::OtNumerator => v.extend(feature("numr")),
|
||
Position::OtDenominator => v.extend(feature("dnom")),
|
||
_ => {}
|
||
}
|
||
let form = match p.glyph_form.as_str() {
|
||
"ExpertForm" => "expt",
|
||
"TraditionalForm" => "trad",
|
||
"SimplifiedForm" => "smpl",
|
||
"JIS78Form" => "jp78",
|
||
"JIS83Form" => "jp83",
|
||
"JIS90Form" => "jp90",
|
||
"JIS04Form" => "jp04",
|
||
_ => "",
|
||
};
|
||
if !form.is_empty() {
|
||
v.extend(feature(form));
|
||
}
|
||
for f in &p.otf_features {
|
||
v.extend(feature(f));
|
||
}
|
||
let positional = match p.positional_form.as_str() {
|
||
"Initial" => Some("init"),
|
||
"Medial" => Some("medi"),
|
||
"Final" => Some("fina"),
|
||
"Isolated" => Some("isol"),
|
||
_ => None,
|
||
};
|
||
if let Some(tag) = positional {
|
||
for form in ["isol", "init", "medi", "fina"] {
|
||
v.extend(feature(&format!("{}{}", if form == tag { "" } else { "-" }, form)));
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
fn is_mark(c: char) -> bool {
|
||
unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM || matches!(c, '\u{200C}' | '\u{200D}')
|
||
}
|
||
|
||
fn composite_props(db: &ScopedFonts<'_>, base: &CharProps, entry: Option<&designcraft_doc::cjk::CompositeFontEntry>) -> CharProps {
|
||
let mut p = base.clone();
|
||
if let Some(e) = entry {
|
||
p.font_family.clone_from(&e.family);
|
||
p.font_style.clone_from(&e.style);
|
||
p.size *= e.relative_size.clamp(0.01, 100.0);
|
||
p.h_scale *= e.horizontal_scale.clamp(0.01, 100.0);
|
||
p.v_scale *= e.vertical_scale.clamp(0.01, 100.0);
|
||
p.baseline_shift += base.size * e.baseline_shift;
|
||
if e.scale_option {
|
||
let face = db.face(&p.font_family, &p.font_style);
|
||
let (asc, desc) = face.vertical_metrics();
|
||
let center = (asc - desc) / (2.0 * (asc + desc).max(1e-9));
|
||
p.baseline_shift += (base.size * base.v_scale - p.size * p.v_scale) * center;
|
||
}
|
||
}
|
||
p
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn shape_run(
|
||
db: &ScopedFonts<'_>,
|
||
text: &str,
|
||
range: std::ops::Range<usize>,
|
||
p: &CharProps,
|
||
env: TypeEnv,
|
||
styles: &StyleRuns<'_>,
|
||
sub: &SubstCtx,
|
||
out: &mut Vec<Glyph>,
|
||
) {
|
||
let start = out.len();
|
||
shape_run_raw(db, text, range, p, env, styles, sub, out);
|
||
let run = &mut out[start..];
|
||
if p.kerning == Kerning::Optical {
|
||
crate::optical::kern_run(db, run, p.tracking / 1000.0 * p.size);
|
||
}
|
||
let em = p.size * p.h_scale;
|
||
for g in run.iter_mut().filter(|g| g.adv > 0.0 && !g.ch.is_control()) {
|
||
let (mut left, mut right) = (0.0, 0.0);
|
||
if p.tsume > 0.0 && !g.ch.is_whitespace() {
|
||
let outline = db.outline(&g.face, g.gid);
|
||
if !outline.elements().is_empty() {
|
||
let bounds = designcraft_geom::Shape::bounding_box(&*outline);
|
||
left = (g.dx + bounds.x0 * g.sx).clamp(0.0, g.adv);
|
||
right = (g.adv - g.dx - bounds.x1 * g.sx).clamp(0.0, g.adv - left);
|
||
}
|
||
}
|
||
let tsume = p.tsume.clamp(0.0, 1.0);
|
||
let before = p.leading_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em;
|
||
let after = p.trailing_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em;
|
||
g.dx += before - left * tsume;
|
||
g.adv += before + after - (left + right) * tsume;
|
||
}
|
||
crate::shatai::fit_advances(run, p);
|
||
if p.jidori > 0 && !run.is_empty() && !run.iter().any(|g| g.ch.is_control()) {
|
||
let clusters: Vec<usize> = run.iter().enumerate().filter(|(_, g)| g.len > 0).map(|(i, _)| i).collect();
|
||
let natural: f64 = run.iter().map(|g| g.adv).sum();
|
||
let target = f64::from(p.jidori.min(10000)) * em;
|
||
if clusters.len() == 1 {
|
||
if let Some(g) = run.first_mut() {
|
||
g.dx += (target - natural) * 0.5;
|
||
g.adv += target - natural;
|
||
}
|
||
} else if clusters.len() > 1 {
|
||
let gap = (target - natural) / (clusters.len() - 1) as f64;
|
||
for &i in clusters.iter().skip(1) {
|
||
if let Some(g) = run.get_mut(i.saturating_sub(1)) {
|
||
g.adv += gap;
|
||
}
|
||
}
|
||
}
|
||
let end = run.len().saturating_sub(1);
|
||
for g in run.iter_mut().take(end) {
|
||
g.break_after = Some(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn shape_run_raw(
|
||
db: &ScopedFonts<'_>,
|
||
text: &str,
|
||
range: std::ops::Range<usize>,
|
||
p: &CharProps,
|
||
auto_leading: TypeEnv,
|
||
styles: &StyleRuns<'_>,
|
||
sub: &SubstCtx,
|
||
out: &mut Vec<Glyph>,
|
||
) {
|
||
let primary = db.face(&p.font_family, &p.font_style);
|
||
// The language picks the fallback for CJK characters the primary font lacks. Without
|
||
// fallback, the primary font sets them: the shaper gives its .notdef (glyph 0).
|
||
let lang = designcraft_doc::language_tag(&p.language);
|
||
let fallback = |c: char| {
|
||
if auto_leading.glyph_fallback { db.fallback_for(c, primary.id(), lang).unwrap_or_else(|| primary.clone()) } else { primary.clone() }
|
||
};
|
||
let strong = |c| match unicode_bidi::bidi_class(c) {
|
||
unicode_bidi::BidiClass::AL => Some(true),
|
||
unicode_bidi::BidiClass::L | unicode_bidi::BidiClass::R => Some(false),
|
||
_ => None,
|
||
};
|
||
let mut arabic_context = text.get(..range.start).unwrap_or("").chars().rev().take_while(|c| !matches!(c, '\n' | '\r')).find_map(strong);
|
||
// Split into segments: font coverage changes and special characters (markers, tabs, breaks).
|
||
let mut seg_start = range.start;
|
||
let mut seg_face = primary.clone();
|
||
let flush = |a: usize, b: usize, face: &Arc<FontFace>, out: &mut Vec<Glyph>| {
|
||
if a < b {
|
||
shape_segment(db, text, a..b, None, p, face, auto_leading, styles, out, sub.upright());
|
||
}
|
||
};
|
||
for (i, c) in text[range.clone()].char_indices() {
|
||
let i = range.start + i;
|
||
if let Some(context) = strong(c) {
|
||
arabic_context = Some(context);
|
||
}
|
||
// Digits drawn in another script (World-Ready Digits): each is set as its substitute.
|
||
let digits = {
|
||
use designcraft_doc::Digits;
|
||
if p.digits != Digits::Default {
|
||
p.digits
|
||
} else {
|
||
match arabic_context {
|
||
Some(false) => Digits::Arabic,
|
||
Some(true) if Digits::Native.map('0', &p.language) == '0' => Digits::Hindi,
|
||
_ => Digits::Native,
|
||
}
|
||
}
|
||
};
|
||
{
|
||
let d = digits.map(c, &p.language);
|
||
if d != c {
|
||
flush(seg_start, i, &seg_face, out);
|
||
seg_start = i + c.len_utf8();
|
||
let face = if primary.covers(d) { primary.clone() } else { fallback(d) };
|
||
shape_segment(db, text, i..i + c.len_utf8(), Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, styles, out, sub.upright());
|
||
continue;
|
||
}
|
||
}
|
||
let special = matches!(
|
||
c,
|
||
'\t' | story::FORCED_LINE_BREAK
|
||
| story::PAGE_NUMBER
|
||
| story::NEXT_PAGE_NUMBER
|
||
| story::PREV_PAGE_NUMBER
|
||
| story::SECTION_MARKER
|
||
| story::COLUMN_BREAK
|
||
| story::FRAME_BREAK
|
||
| story::PAGE_BREAK
|
||
| story::ODD_PAGE_BREAK
|
||
| story::EVEN_PAGE_BREAK
|
||
| story::INDENT_HERE
|
||
| story::RIGHT_INDENT_TAB
|
||
| story::TABLE_ANCHOR
|
||
| designcraft_doc::FOOTNOTE_REF
|
||
| designcraft_doc::ENDNOTE_REF
|
||
| designcraft_doc::NOTE_MARK
|
||
| designcraft_doc::XREF_MARK
|
||
| designcraft_doc::ANCHOR_MARK
|
||
| designcraft_doc::INDEX_MARK
|
||
| designcraft_doc::OBJECT_MARK
|
||
) || designcraft_doc::vars::var_index(c).is_some();
|
||
if special {
|
||
flush(seg_start, i, &seg_face, out);
|
||
seg_start = i + c.len_utf8();
|
||
if let Some(vi) = designcraft_doc::vars::var_index(c) {
|
||
match sub.vars.get(vi).filter(|v| !v.is_empty()) {
|
||
Some(v) => shape_segment(db, text, i..i + c.len_utf8(), Some(v), p, &primary, auto_leading, styles, out, sub.upright()),
|
||
None => {
|
||
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
|
||
g.adv = 0.0;
|
||
out.push(g);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
match c {
|
||
story::PAGE_NUMBER | story::NEXT_PAGE_NUMBER | story::PREV_PAGE_NUMBER | story::SECTION_MARKER => {
|
||
let s = if c == story::SECTION_MARKER {
|
||
sub.section_marker.clone().unwrap_or_else(|| "Section".into())
|
||
} else {
|
||
sub.page_name.clone().unwrap_or_else(|| "#".into())
|
||
};
|
||
if s.is_empty() {
|
||
// A section without marker text: the character takes no space.
|
||
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
|
||
g.adv = 0.0;
|
||
out.push(g);
|
||
} else {
|
||
// Page numbers take the digits of the text they stand in.
|
||
let s: String = s.chars().map(|ch| digits.map(ch, &p.language)).collect();
|
||
shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, styles, out, sub.upright());
|
||
}
|
||
}
|
||
designcraft_doc::OBJECT_MARK => {
|
||
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
|
||
if let Some(o) = sub.objects.get(&i).filter(|o| !o.custom) {
|
||
let auto = matches!(p.leading, Leading::Auto);
|
||
match o.y_offset {
|
||
// Sits on the baseline like a (big) character.
|
||
Some(y) => {
|
||
g.adv = o.w;
|
||
// The replacement character has no font ink. Its box alone
|
||
// supplies extents; adjacent text supplies its own font metrics.
|
||
let top = (o.h + y).max(0.0);
|
||
(g.ascent, g.typo_ascent, g.cap, g.xh) = (top, top, top, top);
|
||
g.descent = (-y).max(0.0);
|
||
}
|
||
// Pushes its line down by its height and spacing.
|
||
None => {
|
||
g.ascent += o.h + o.space;
|
||
g.typo_ascent += o.h + o.space;
|
||
g.cap += o.h + o.space;
|
||
g.xh += o.h + o.space;
|
||
}
|
||
}
|
||
if auto || o.y_offset.is_none() {
|
||
g.leading = g.leading.max(g.ascent + g.descent);
|
||
}
|
||
}
|
||
out.push(g);
|
||
}
|
||
designcraft_doc::XREF_MARK => match sub.xrefs.get(&i).filter(|t| !t.is_empty()) {
|
||
Some(t) => shape_segment(db, text, i..i + c.len_utf8(), Some(t), p, &primary, auto_leading, styles, out, sub.upright()),
|
||
None => {
|
||
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
|
||
g.adv = 0.0;
|
||
out.push(g);
|
||
}
|
||
},
|
||
designcraft_doc::FOOTNOTE_REF | designcraft_doc::ENDNOTE_REF => {
|
||
let s = sub.notes.get(&i).map_or("#", String::as_str);
|
||
shape_segment(db, text, i..i + c.len_utf8(), Some(s), p, &primary, auto_leading, styles, out, sub.upright());
|
||
}
|
||
_ => {
|
||
// Zero-width control glyph carrying metrics (tabs get their width at line layout).
|
||
let mut g = control_glyph(&primary, p, auto_leading, styles.at(i), i, c);
|
||
if c == '\t'
|
||
|| c == story::RIGHT_INDENT_TAB
|
||
|| c == story::TABLE_ANCHOR
|
||
|| c == designcraft_doc::ANCHOR_MARK
|
||
|| c == designcraft_doc::INDEX_MARK
|
||
{
|
||
g.adv = 0.0;
|
||
}
|
||
out.push(g);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
let covered = c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN || primary.covers(c);
|
||
let face = if covered || is_mark(c) { if is_mark(c) { seg_face.clone() } else { primary.clone() } } else { fallback(c) };
|
||
if face.id() != seg_face.id() {
|
||
flush(seg_start, i, &seg_face, out);
|
||
seg_start = i;
|
||
seg_face = face;
|
||
}
|
||
}
|
||
flush(seg_start, range.end, &seg_face, out);
|
||
}
|
||
|
||
type WordMap = HashMap<Box<str>, Arc<[ShapedGlyph]>>;
|
||
type WordKey = (u32, bool, bool, String, Option<&'static str>);
|
||
|
||
/// Shaped words (font units) by (face, vertical, caps, features, language), then text. Lookups
|
||
/// take read locks and misses are shaped outside any lock, so parallel composition doesn't
|
||
/// serialize here.
|
||
static WORD_CACHE: RwLock<Vec<(WordKey, Arc<RwLock<WordMap>>)>> = RwLock::new(Vec::new());
|
||
const WORD_CACHE_MAX: usize = 50_000;
|
||
|
||
fn word_map(key: WordKey) -> Arc<RwLock<WordMap>> {
|
||
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) {
|
||
return m.clone();
|
||
}
|
||
let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner());
|
||
if let Some((_, m)) = w.iter().find(|e| e.0 == key) {
|
||
return m.clone();
|
||
}
|
||
if w.len() > 64 {
|
||
w.clear();
|
||
}
|
||
let m = Arc::new(RwLock::new(HashMap::new()));
|
||
w.push((key, m.clone()));
|
||
m
|
||
}
|
||
|
||
/// Shape `src` word by word through a cache (like a browser's word cache): the text is split
|
||
/// after each U+0020 so repeated words are shaped once. Shaping does not cross word spaces in the
|
||
/// scripts we lay out, and clusters stay byte offsets into `src`.
|
||
fn shape_cached(
|
||
face: &FontFace,
|
||
src: &str,
|
||
feats: &[Feature],
|
||
lang: Option<&'static str>,
|
||
caps: bool,
|
||
vertical: bool,
|
||
(before, after): (&str, &str),
|
||
) -> Vec<ShapedGlyph> {
|
||
use designcraft_fonts::ShapeContext;
|
||
let map = |c: char| if caps { c.to_uppercase().next().unwrap_or(c) } else { c };
|
||
let shape = |s: &str, context: ShapeContext<'_>| {
|
||
if vertical {
|
||
designcraft_fonts::shape_vertical(face, s, feats, map, context)
|
||
} else {
|
||
designcraft_fonts::shape_with_context(face, s, feats, map, context)
|
||
}
|
||
};
|
||
if src.len() < 2 || !src.contains(' ') || src.chars().any(designcraft_fonts::is_rtl) {
|
||
return shape(src, ShapeContext { before, after, language: lang });
|
||
}
|
||
let words = word_map((face.id(), vertical, caps, format!("{feats:?}"), lang));
|
||
let pieces: Vec<&str> = src.split_inclusive(' ').collect();
|
||
let mut found: Vec<Option<Arc<[ShapedGlyph]>>> = {
|
||
let r = words.read().unwrap_or_else(|e| e.into_inner());
|
||
pieces.iter().map(|p| r.get(*p).cloned()).collect()
|
||
};
|
||
let mut fresh: Vec<(Box<str>, Arc<[ShapedGlyph]>)> = Vec::new();
|
||
for (k, f) in found.iter_mut().enumerate() {
|
||
if f.is_none() {
|
||
let g: Arc<[ShapedGlyph]> = shape(pieces[k], ShapeContext { language: lang, ..Default::default() }).into();
|
||
fresh.push((pieces[k].into(), g.clone()));
|
||
*f = Some(g);
|
||
}
|
||
}
|
||
if !fresh.is_empty() {
|
||
let mut w = words.write().unwrap_or_else(|e| e.into_inner());
|
||
if w.len() + fresh.len() > WORD_CACHE_MAX {
|
||
w.clear();
|
||
}
|
||
w.extend(fresh);
|
||
}
|
||
let mut out = Vec::with_capacity(src.len());
|
||
let mut start = 0;
|
||
for (piece, glyphs) in pieces.iter().zip(found) {
|
||
let glyphs = glyphs.unwrap_or_else(|| Arc::from(Vec::new()));
|
||
out.extend(glyphs.iter().map(|g| ShapedGlyph { cluster: g.cluster + start, ..*g }));
|
||
start += piece.len();
|
||
}
|
||
out
|
||
}
|
||
|
||
fn metrics(face: &FontFace, p: &CharProps, auto_leading: TypeEnv) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
|
||
let (size, shift) = effective_size(p, &auto_leading.adv);
|
||
let k = size / face.upem;
|
||
let vs = p.v_scale;
|
||
let leading = match p.leading {
|
||
Leading::Auto => p.size * auto_leading.auto_leading,
|
||
Leading::Points(v) => v,
|
||
};
|
||
(size, k, face.ascent * k * vs, face.descent * k * vs, leading, face.cap_height * k * vs, face.x_height * k * vs, shift)
|
||
}
|
||
|
||
/// Auto leading and the document's Advanced Type settings, for shaping.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct TypeEnv {
|
||
pub auto_leading: f64,
|
||
pub adv: designcraft_doc::AdvancedType,
|
||
/// Characters the font lacks are drawn from fallback fonts (else as its .notdef glyph).
|
||
pub glyph_fallback: bool,
|
||
}
|
||
|
||
/// Synthesised super/subscript: size and shift from Advanced Type (percent of the font size).
|
||
fn effective_size(p: &CharProps, adv: &designcraft_doc::AdvancedType) -> (f64, f64) {
|
||
match p.position {
|
||
Position::Superscript => (p.size * adv.superscript_size / 100.0, p.baseline_shift + p.size * adv.superscript_position / 100.0),
|
||
Position::Subscript => (p.size * adv.subscript_size / 100.0, p.baseline_shift - p.size * adv.subscript_position / 100.0),
|
||
_ => (p.size, p.baseline_shift),
|
||
}
|
||
}
|
||
|
||
fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, style: u32, byte: usize, ch: char) -> Glyph {
|
||
let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading);
|
||
Glyph {
|
||
face: FaceRef::of(face),
|
||
gid: face.glyph_for(' '),
|
||
byte,
|
||
len: ch.len_utf8(),
|
||
ch,
|
||
generated_text: None,
|
||
adv: 0.0,
|
||
dx: 0.0,
|
||
dy: 0.0,
|
||
sx: k * p.h_scale,
|
||
sy: k * p.v_scale,
|
||
shift,
|
||
ascent,
|
||
typo_ascent: face.typo_ascent * k,
|
||
descent,
|
||
leading,
|
||
cap,
|
||
xh,
|
||
size,
|
||
style,
|
||
no_break: p.no_break,
|
||
locked_advance: p.jidori > 0,
|
||
tcy: p.tate_chu_yoko.then_some([0.0; 3]),
|
||
tcy_offset: [p.tate_chu_yoko_x_offset, p.tate_chu_yoko_y_offset],
|
||
space: face.advance(face.glyph_for(' ')) * k * p.h_scale,
|
||
break_after: None,
|
||
cjk_hang: 0.0,
|
||
moji: crate::mojikumi::Metrics { explicit_before: p.leading_aki.is_some(), explicit_after: p.trailing_aki.is_some(), ..Default::default() },
|
||
ideographic_space_elastic: false,
|
||
character_alignment: p.character_alignment,
|
||
leading_model: p.leading_model,
|
||
character_direction: p.character_direction,
|
||
rendered_char: ch,
|
||
display_char: ch,
|
||
bidi_offset: 0,
|
||
bidi_end: 0,
|
||
allow_kashidas: p.allow_kashidas,
|
||
safe_tatweel_before: false,
|
||
shaping_rtl: false,
|
||
lang: designcraft_doc::language_tag(&p.language),
|
||
upright: false,
|
||
list_label: false,
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn shape_segment(
|
||
db: &ScopedFonts<'_>,
|
||
text: &str,
|
||
range: std::ops::Range<usize>,
|
||
replacement: Option<&str>,
|
||
p: &CharProps,
|
||
face: &Arc<FontFace>,
|
||
auto_leading: TypeEnv,
|
||
styles: &StyleRuns<'_>,
|
||
out: &mut Vec<Glyph>,
|
||
mode: Upright,
|
||
) {
|
||
// Generated labels need the same coverage fallback as ordinary text. Keep the
|
||
// original marker address and one carrier even when several faces draw the label.
|
||
if let Some(label) = replacement.filter(|_| auto_leading.glyph_fallback) {
|
||
let lang = designcraft_doc::language_tag(&p.language);
|
||
let mut segments: Vec<(usize, usize, Arc<FontFace>)> = Vec::new();
|
||
for (at, c) in label.char_indices() {
|
||
let selected = if face.covers(c) { face.clone() } else { db.fallback_for(c, face.id(), lang).unwrap_or_else(|| face.clone()) };
|
||
if let Some((_, end, previous)) = segments.last_mut().filter(|(_, _, previous)| previous.id() == selected.id()) {
|
||
*end = at + c.len_utf8();
|
||
let _ = previous;
|
||
} else {
|
||
segments.push((at, at + c.len_utf8(), selected));
|
||
}
|
||
}
|
||
if segments.iter().any(|(_, _, selected)| selected.id() != face.id()) {
|
||
let begin = out.len();
|
||
for (a, b, selected) in segments {
|
||
shape_segment(db, text, range.clone(), label.get(a..b), p, &selected, auto_leading, styles, out, mode);
|
||
}
|
||
for (i, glyph) in out[begin..].iter_mut().enumerate() {
|
||
glyph.len = if i == 0 { range.len() } else { 0 };
|
||
glyph.no_break = true;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
let vertical = mode.vertical;
|
||
let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading);
|
||
let hs = p.h_scale;
|
||
let tracking = p.tracking / 1000.0 * p.size;
|
||
let manual = if let Kerning::Manual(v) = p.kerning { v / 1000.0 * p.size } else { 0.0 };
|
||
let src = replacement.unwrap_or(&text[range.clone()]);
|
||
let caps = p.capitalization == Capitalization::AllCaps;
|
||
let feats = features_for(p);
|
||
// Upright in vertical text, Horizontal Kana takes the vertical kana alternates instead.
|
||
let mut upright_feats = feats.clone();
|
||
if vertical && designcraft_doc::otf::is_on(&p.otf_features, "hkna") {
|
||
upright_feats.extend(feature("-hkna"));
|
||
upright_feats.extend(feature("vkna"));
|
||
}
|
||
let space = face.advance(face.glyph_for(' ')) * k * hs;
|
||
// The language picks the font's localized forms (`locl`); the text around the run lets
|
||
// letters join across a change of style.
|
||
let lang = designcraft_doc::language_tag(&p.language);
|
||
// Vertical frames: upright runs top to bottom, the rest (turned as a whole) horizontally.
|
||
// Tate-chu-yoko is set across the line, so it stays horizontal.
|
||
let mut shaped: Vec<ShapedGlyph> = Vec::new();
|
||
let mut upright: Vec<bool> = Vec::new();
|
||
for (r, up) in upright_runs(src, vertical && !p.tate_chu_yoko, mode.rotate_roman) {
|
||
let around = match replacement {
|
||
None => (text.get(..range.start + r.start).unwrap_or(""), text.get(range.start + r.end..).unwrap_or("")),
|
||
Some(_) => ("", ""),
|
||
};
|
||
let g = shape_cached(face, &src[r.clone()], if up { &upright_feats } else { &feats }, lang, caps, up, around);
|
||
upright.extend(std::iter::repeat_n(up, g.len()));
|
||
shaped.extend(g.into_iter().map(|g| ShapedGlyph { cluster: g.cluster + r.start, ..g }));
|
||
}
|
||
// Upright glyphs fill the ideographic em box across the line.
|
||
let (em_top, em_bottom) = face.em_box();
|
||
let k_v = k * p.v_scale;
|
||
let n = shaped.len();
|
||
let fref = FaceRef::of(face);
|
||
for (gi, sg) in shaped.iter().enumerate() {
|
||
let up = upright.get(gi).copied().unwrap_or(false);
|
||
let (byte, len, ch) = if replacement.is_some() {
|
||
(range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' '))
|
||
} else {
|
||
let cl = range.start + sg.cluster;
|
||
let end = shaped[gi + 1..].iter().map(|r| range.start + r.cluster).find(|&c| c > cl).unwrap_or(range.end);
|
||
(cl, end.saturating_sub(cl), text[cl..].chars().next().unwrap_or(' '))
|
||
};
|
||
let last_in_cluster = gi + 1 == n || shaped[gi + 1].cluster != sg.cluster;
|
||
// Only the first glyph of a cluster owns the bytes (so ranges partition the text).
|
||
let first_in_cluster = gi == 0 || shaped[gi - 1].cluster != sg.cluster;
|
||
let generated_text = replacement.map(|src| {
|
||
if !first_in_cluster {
|
||
return Box::from("");
|
||
}
|
||
let end = shaped[gi + 1..].iter().map(|r| r.cluster).find(|&c| c > sg.cluster).unwrap_or(src.len());
|
||
Box::from(src.get(sg.cluster..end).unwrap_or(""))
|
||
});
|
||
// Upright: down the line is the shaper's -y, across it (text space -y) its x.
|
||
let mut adv = if up { -(sg.y_advance as f64) * k_v } else { sg.x_advance as f64 * k * hs };
|
||
let (dx, dy) = if up {
|
||
(-(sg.y_offset as f64) * k_v, -(sg.x_offset as f64) * k * hs)
|
||
} else {
|
||
(sg.x_offset as f64 * k * hs, -(sg.y_offset as f64) * k * p.v_scale)
|
||
};
|
||
let (ascent, descent) = if up { (em_top * k_v, -em_bottom * k_v) } else { (ascent, descent) };
|
||
let body = adv;
|
||
if ch == SOFT_HYPHEN {
|
||
adv = 0.0;
|
||
} else if last_in_cluster {
|
||
adv += tracking + manual;
|
||
}
|
||
let mark = face.glyph_is_mark(sg.gid);
|
||
let mark_x = if mark { p.diacritic_x_offset.clamp(-1000.0, 1000.0) / 1000.0 * p.size * hs } else { 0.0 };
|
||
let mark_y = if mark { p.diacritic_y_offset.clamp(-1000.0, 1000.0) / 1000.0 * p.size * p.v_scale } else { 0.0 };
|
||
out.push(Glyph {
|
||
face: fref,
|
||
gid: sg.gid,
|
||
byte,
|
||
len: if first_in_cluster || replacement.is_some() { len } else { 0 },
|
||
ch,
|
||
generated_text,
|
||
adv,
|
||
dx: dx + mark_x,
|
||
dy: dy - mark_y,
|
||
sx: k * hs,
|
||
sy: k * p.v_scale,
|
||
shift,
|
||
ascent,
|
||
typo_ascent: face.typo_ascent * k,
|
||
descent,
|
||
leading,
|
||
cap,
|
||
xh,
|
||
size,
|
||
style: styles.at(byte),
|
||
no_break: p.no_break,
|
||
locked_advance: p.jidori > 0,
|
||
tcy: p.tate_chu_yoko.then_some([0.0; 3]),
|
||
tcy_offset: [p.tate_chu_yoko_x_offset, p.tate_chu_yoko_y_offset],
|
||
space,
|
||
break_after: None,
|
||
cjk_hang: 0.0,
|
||
moji: crate::mojikumi::Metrics {
|
||
body,
|
||
explicit_before: p.leading_aki.is_some(),
|
||
explicit_after: p.trailing_aki.is_some(),
|
||
..Default::default()
|
||
},
|
||
ideographic_space_elastic: false,
|
||
character_alignment: p.character_alignment,
|
||
leading_model: p.leading_model,
|
||
character_direction: p.character_direction,
|
||
rendered_char: src.get(sg.cluster..).and_then(|s| s.chars().next()).unwrap_or(ch),
|
||
display_char: if replacement.is_some() && matches!(ch, '0'..='9' | '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}') {
|
||
src.chars().next().unwrap_or(ch)
|
||
} else {
|
||
ch
|
||
},
|
||
bidi_offset: 0,
|
||
bidi_end: 0,
|
||
allow_kashidas: p.allow_kashidas,
|
||
safe_tatweel_before: sg.safe_tatweel_before,
|
||
shaping_rtl: sg.rtl,
|
||
lang,
|
||
upright: up,
|
||
list_label: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// `src` cut into runs set upright in vertical text (CJK, and with `rotate_roman` half-width
|
||
/// characters too) and runs that turn with the line, as (byte range, upright); combining marks
|
||
/// stay with the run they follow. One horizontal run when `vertical` is false.
|
||
fn upright_runs(src: &str, vertical: bool, rotate_roman: bool) -> Vec<(std::ops::Range<usize>, bool)> {
|
||
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
|
||
for (i, c) in src.char_indices() {
|
||
let up = vertical && (crate::upright_in_vertical(c) || (rotate_roman && half_width_roman(c)));
|
||
match runs.last_mut() {
|
||
Some((r, u)) if *u == up || is_mark(c) => r.end = i + c.len_utf8(),
|
||
_ => runs.push((i..i + c.len_utf8(), up)),
|
||
}
|
||
}
|
||
runs
|
||
}
|
||
|
||
/// Half-width (single-byte) roman characters: what Rotate Roman Characters stands upright.
|
||
fn half_width_roman(c: char) -> bool {
|
||
matches!(c, '!'..='~') || (matches!(c as u32, 0xC0..=0x24F) && c.is_alphabetic())
|
||
}
|
||
|
||
/// Auto tate-chu-yoko: the byte ranges in `range` of `text` that are runs of at most `max`
|
||
/// half-width digits (with `roman`, digits and roman letters), each delimited by other characters.
|
||
pub(crate) fn auto_tcy_ranges(text: &str, range: std::ops::Range<usize>, max: u32, roman: bool) -> Vec<std::ops::Range<usize>> {
|
||
let max = max.min(AUTO_TCY_MAX) as usize;
|
||
let takes = |c: char| c.is_ascii_digit() || (roman && c.is_ascii_alphabetic());
|
||
let mut out = Vec::new();
|
||
let Some(src) = text.get(range.clone()) else { return out };
|
||
let mut run: Option<(usize, usize)> = None; // (start byte, characters)
|
||
let close = |run: &mut Option<(usize, usize)>, end: usize, out: &mut Vec<std::ops::Range<usize>>| {
|
||
if let Some((start, n)) = run.take()
|
||
&& n <= max
|
||
{
|
||
out.push(range.start + start..range.start + end);
|
||
}
|
||
};
|
||
for (i, c) in src.char_indices() {
|
||
if takes(c) {
|
||
run = Some(match run {
|
||
Some((start, n)) => (start, n + 1),
|
||
None => (i, 1),
|
||
});
|
||
} else {
|
||
close(&mut run, i, &mut out);
|
||
}
|
||
}
|
||
close(&mut run, src.len(), &mut out);
|
||
out
|
||
}
|
||
|
||
/// The resolved runs of a paragraph with auto tate-chu-yoko runs cut out and marked tate-chu-yoko
|
||
/// (runs already tate-chu-yoko keep theirs).
|
||
fn split_auto_tcy<'f>(
|
||
resolved: Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)>,
|
||
auto: &[std::ops::Range<usize>],
|
||
) -> Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)> {
|
||
if auto.is_empty() {
|
||
return resolved;
|
||
}
|
||
let mut out = Vec::with_capacity(resolved.len() + 2 * auto.len());
|
||
for (a, b, props, fmt, base) in resolved {
|
||
if props.tate_chu_yoko {
|
||
out.push((a, b, props, fmt, base));
|
||
continue;
|
||
}
|
||
let mut at = a;
|
||
for r in auto.iter().filter(|r| r.start < b && r.end > a) {
|
||
let (s, e) = (r.start.max(a), r.end.min(b));
|
||
if at < s {
|
||
out.push((at, s, props.clone(), fmt, base.clone()));
|
||
}
|
||
let mut tcy = props.clone();
|
||
tcy.tate_chu_yoko = true;
|
||
out.push((s, e, tcy, fmt, base.clone()));
|
||
at = e;
|
||
}
|
||
if at < b {
|
||
out.push((at, b, props, fmt, base));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Auto tate-chu-yoko groups wider than their em are scaled across to fit it.
|
||
fn fit_auto_tcy(glyphs: &mut [Glyph], auto: &[std::ops::Range<usize>]) {
|
||
for r in auto {
|
||
let group: Vec<usize> = glyphs.iter().enumerate().filter(|(_, g)| g.tcy.is_some() && r.contains(&g.byte)).map(|(i, _)| i).collect();
|
||
let width: f64 = group.iter().filter_map(|&i| glyphs.get(i)).map(|g| g.adv).sum();
|
||
let em = group.iter().filter_map(|&i| glyphs.get(i)).map(|g| g.size).fold(0.0, f64::max);
|
||
let k = if width > 0.0 { em / width } else { 1.0 };
|
||
if k > 0.0 && k < 1.0 {
|
||
for &i in &group {
|
||
if let Some(g) = glyphs.get_mut(i) {
|
||
g.adv *= k;
|
||
g.sx *= k;
|
||
g.dx *= k;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A hyphen glyph in the face/size of `g`, placed after it (zero source length).
|
||
pub(crate) fn hyphen_after(g: &Glyph) -> Glyph {
|
||
let gid = designcraft_fonts::first_glyph(&g.face, &['-', '\u{2010}']);
|
||
let mut h = g.clone();
|
||
h.gid = gid;
|
||
h.adv = g.face.advance(gid) * g.sx;
|
||
h.dx = 0.0;
|
||
h.dy = 0.0;
|
||
h.byte = g.byte + g.len;
|
||
h.len = 0;
|
||
h.ch = '-';
|
||
h.rendered_char = '-';
|
||
h.moji = Default::default();
|
||
h.generated_text = None;
|
||
h
|
||
}
|