Compare commits
21 Commits
main
...
fix/wild-t
| Author | SHA1 | Date |
|---|---|---|
|
|
c2cc712156 | |
|
|
c26c564a6f | |
|
|
f495922c4e | |
|
|
c858c11bf6 | |
|
|
973af106bb | |
|
|
bde32dfcf1 | |
|
|
3bab2e4e4f | |
|
|
af9a520981 | |
|
|
b828bfc321 | |
|
|
de85c8bdbe | |
|
|
1b1b21210a | |
|
|
bbaa1aa7b1 | |
|
|
5a64fdbc3e | |
|
|
6c4bcedbbc | |
|
|
f5d055b919 | |
|
|
8561a83819 | |
|
|
7f4db9db64 | |
|
|
696529a257 | |
|
|
2f4c78e149 | |
|
|
a6eeb38cfd | |
|
|
324756ae5d |
|
|
@ -129,6 +129,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
|||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
|
|
@ -148,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ use skrifa::raw::TableProvider;
|
|||
use crate::script::{ScriptTag, script_of};
|
||||
use crate::{CRAFT_FONTS, FontDb, FontFace};
|
||||
|
||||
/// Variation selector 15: text presentation.
|
||||
const TEXT_SELECTOR: char = '\u{FE0E}';
|
||||
/// Combining enclosing keycap.
|
||||
const KEYCAP: char = '\u{20E3}';
|
||||
|
||||
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
|
||||
fn ignorable(c: char) -> bool {
|
||||
c.is_whitespace()
|
||||
|
|
@ -24,20 +29,72 @@ fn ignorable(c: char) -> bool {
|
|||
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
|
||||
}
|
||||
|
||||
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
|
||||
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
|
||||
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
|
||||
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation
|
||||
/// (Unicode's Emoji_Presentation, including BMP ones such as ⭐ ✅ ☔ ⌚ ⏩), or a sequence with an
|
||||
/// emoji presentation selector (U+FE0F), a zero-width joiner between pictographs, a skin tone
|
||||
/// modifier or regional indicators (flags)? Keycaps ("1️⃣") are not: PowerPoint draws them as text
|
||||
/// (an outlined key), as it draws ♥ ✔ ☎ ⬆ ▶ without a selector.
|
||||
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
if cluster.contains(&'\u{FE0E}') || cluster.contains(&'\u{20E3}') {
|
||||
return false;
|
||||
}
|
||||
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
||||
let default_emoji =
|
||||
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
|
||||
let default_emoji = |c: char| {
|
||||
matches!(
|
||||
c as u32,
|
||||
0x1F300..=0x1F64F
|
||||
| 0x1F680..=0x1F6FF
|
||||
| 0x1F900..=0x1F9FF
|
||||
| 0x1FA70..=0x1FAFF
|
||||
| 0x1F1E6..=0x1F1FF
|
||||
| 0x1F004
|
||||
| 0x1F0CF
|
||||
| 0x1F18E
|
||||
| 0x1F191..=0x1F19A
|
||||
| 0x1F201
|
||||
| 0x1F21A
|
||||
| 0x1F22F
|
||||
| 0x1F232..=0x1F236
|
||||
| 0x1F238..=0x1F23A
|
||||
| 0x1F250..=0x1F251
|
||||
| 0x231A..=0x231B
|
||||
| 0x23E9..=0x23EC
|
||||
| 0x23F0
|
||||
| 0x23F3
|
||||
| 0x25FD..=0x25FE
|
||||
| 0x2614..=0x2615
|
||||
| 0x2648..=0x2653
|
||||
| 0x267F
|
||||
| 0x2693
|
||||
| 0x26A1
|
||||
| 0x26AA..=0x26AB
|
||||
| 0x26BD..=0x26BE
|
||||
| 0x26C4..=0x26C5
|
||||
| 0x26CE
|
||||
| 0x26D4
|
||||
| 0x26EA
|
||||
| 0x26F2..=0x26F3
|
||||
| 0x26F5
|
||||
| 0x26FA
|
||||
| 0x26FD
|
||||
| 0x2705
|
||||
| 0x270A..=0x270B
|
||||
| 0x2728
|
||||
| 0x274C
|
||||
| 0x274E
|
||||
| 0x2753..=0x2755
|
||||
| 0x2757
|
||||
| 0x2795..=0x2797
|
||||
| 0x27B0
|
||||
| 0x27BF
|
||||
| 0x2B1B..=0x2B1C
|
||||
| 0x2B50
|
||||
| 0x2B55
|
||||
)
|
||||
};
|
||||
cluster.iter().any(|&c| default_emoji(c))
|
||||
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|
||||
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c)))
|
||||
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|
||||
|| cluster.contains(&'\u{20E3}')
|
||||
}
|
||||
|
||||
/// Does `face` draw every character of the cluster?
|
||||
|
|
@ -86,12 +143,13 @@ impl FontDb {
|
|||
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
|
||||
if cluster.contains(&'\u{FE0E}') {
|
||||
let faces = self.read_faces();
|
||||
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
|
||||
return f.clone();
|
||||
}
|
||||
// Text presentation (U+FE0E) and keycaps: a plain face, not a colour emoji one.
|
||||
let text_style = cluster.contains(&TEXT_SELECTOR) || cluster.contains(&KEYCAP);
|
||||
if text_style
|
||||
&& let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
|
||||
&& let Some(f) = self.plain_face_for(key, cluster)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
|
||||
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
|
||||
|
|
@ -124,11 +182,23 @@ impl FontDb {
|
|||
}
|
||||
}
|
||||
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
|
||||
match self.fallback_for(key, exclude) {
|
||||
let f = match self.fallback_for(key, exclude) {
|
||||
Some(f) if covers_cluster(&f, cluster) => f,
|
||||
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
|
||||
_ => first(),
|
||||
// A letter with marks the run's font lacks (stacked combining marks): a loaded plain
|
||||
// face that draws the whole cluster, as PowerPoint draws the marks, before the run's
|
||||
// font with missing-glyph boxes.
|
||||
_ => self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned().unwrap_or_else(first),
|
||||
};
|
||||
// A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the
|
||||
// colour emoji font: PowerPoint draws it from a plain symbol font.
|
||||
if f.is_color()
|
||||
&& !is_emoji_cluster(cluster)
|
||||
&& let Some(plain) = self.plain_face_for(key, cluster)
|
||||
{
|
||||
return plain;
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog
|
||||
|
|
|
|||
|
|
@ -134,6 +134,12 @@ pub struct FontFace {
|
|||
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
|
||||
/// the split PowerPoint uses (computed by `line_split`).
|
||||
pub line_split: f64,
|
||||
/// Underline position (top edge, below the baseline) and thickness in font units (`post`
|
||||
/// table; 0.1 and 0.05 em when the font has none). PowerPoint draws single underlines there.
|
||||
pub underline: (f64, f64),
|
||||
/// Strikethrough position (top edge, above the baseline) and thickness in font units (OS/2;
|
||||
/// 0.26 and 0.05 em when the font has none). PowerPoint draws single strikethroughs there.
|
||||
pub strikeout: (f64, f64),
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
|
||||
/// the normalised location and the shaper's view of it.
|
||||
|
|
@ -586,6 +592,8 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
|||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
|
||||
underline: underline_metrics(m.underline, m.units_per_em.max(1) as f64),
|
||||
strikeout: strikeout_metrics(m.strikeout, m.units_per_em.max(1) as f64),
|
||||
shaper,
|
||||
coords,
|
||||
location,
|
||||
|
|
@ -597,6 +605,30 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
|||
})
|
||||
}
|
||||
|
||||
/// The underline's top edge below the baseline and its thickness, font units, from the `post`
|
||||
/// table (its position is the top edge: Arial's -217/2048 is where PowerPoint's underline starts,
|
||||
/// 10 px below the baseline at 96 pt); fonts without a usable one get 0.1 em and 0.05 em.
|
||||
fn underline_metrics(d: Option<skrifa::metrics::Decoration>, upem: f64) -> (f64, f64) {
|
||||
match d {
|
||||
Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && (d.offset as f64).abs() < upem * 0.5 => {
|
||||
(-(d.offset as f64), d.thickness as f64)
|
||||
}
|
||||
_ => (upem * 0.1, upem * 0.05),
|
||||
}
|
||||
}
|
||||
|
||||
/// The strikethrough's top edge above the baseline and its thickness, font units, from OS/2 (its
|
||||
/// position is the top edge: Arial's 530/2048 is where PowerPoint's strikethrough starts, 25 px
|
||||
/// above the baseline at 96 pt); fonts without a usable one get 0.26 em and 0.05 em.
|
||||
fn strikeout_metrics(d: Option<skrifa::metrics::Decoration>, upem: f64) -> (f64, f64) {
|
||||
match d {
|
||||
Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && d.offset > 0.0 && (d.offset as f64) < upem => {
|
||||
(d.offset as f64, d.thickness as f64)
|
||||
}
|
||||
_ => (upem * 0.26, upem * 0.05),
|
||||
}
|
||||
}
|
||||
|
||||
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
|
||||
pub fn base_style(style: &str) -> &str {
|
||||
style.split('{').next().unwrap_or(style).trim()
|
||||
|
|
@ -1060,6 +1092,79 @@ impl FontDb {
|
|||
None
|
||||
}
|
||||
|
||||
/// A loaded plain (not colour) face drawing all of `cluster`, loading an installed plain font
|
||||
/// that covers `key` if none is loaded yet. Misses are remembered.
|
||||
pub(crate) fn plain_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
|
||||
// Symbol fonts first (PowerPoint draws ⚠ ♥ ⬆ from the system's symbol font), then the
|
||||
// usual fallback families in order, then any other.
|
||||
let rank = |f: &FontFace| {
|
||||
if f.family.contains("Symbol") {
|
||||
return 0;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(i) = SYSTEM_FALLBACKS.iter().position(|s| s.eq_ignore_ascii_case(&f.family)) {
|
||||
return i + 1;
|
||||
}
|
||||
1000
|
||||
};
|
||||
let pick = |db: &FontDb| {
|
||||
let faces = db.read_faces();
|
||||
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| !f.is_color() && crate::covers_cluster(f, cluster)).collect();
|
||||
v.sort_by_key(|f| (rank(f), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
v.first().map(|f| (*f).clone())
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
|
||||
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
self.load_cataloged(fam);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = pick(self) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_plain_fallback(key) {
|
||||
return pick(self);
|
||||
}
|
||||
let _ = key;
|
||||
None
|
||||
}
|
||||
|
||||
/// Load an installed font without colour tables covering `c`. True if one was loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_plain_fallback(&self, c: char) -> bool {
|
||||
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
|
||||
return false;
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
|
||||
!has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
|
||||
});
|
||||
if hit && self.add_font(data) > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Load an installed colour font covering `c`. True if one was loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_color_fallback(&self, c: char) -> bool {
|
||||
|
|
|
|||
|
|
@ -141,10 +141,15 @@ mod tests {
|
|||
#[test]
|
||||
fn emoji_clusters_are_recognised() {
|
||||
let c = |s: &str| s.chars().collect::<Vec<char>>();
|
||||
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
|
||||
// As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even
|
||||
// without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and
|
||||
// keycaps always as text.
|
||||
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"]
|
||||
{
|
||||
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
|
||||
}
|
||||
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
|
||||
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"]
|
||||
{
|
||||
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ pub enum TextDir {
|
|||
Vertical270,
|
||||
Stacked,
|
||||
EaVertical,
|
||||
/// `mongolianVert`: turned like `vert`, lines running from left to right.
|
||||
MongolianVertical,
|
||||
/// `wordArtVertRtl`: stacked, lines running from right to left.
|
||||
StackedRtl,
|
||||
}
|
||||
|
||||
impl TextDir {
|
||||
|
|
@ -65,14 +69,18 @@ impl TextDir {
|
|||
TextDir::Vertical270 => "vert270",
|
||||
TextDir::Stacked => "wordArtVert",
|
||||
TextDir::EaVertical => "eaVert",
|
||||
TextDir::MongolianVertical => "mongolianVert",
|
||||
TextDir::StackedRtl => "wordArtVertRtl",
|
||||
}
|
||||
}
|
||||
pub fn from_xml(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"horz" => TextDir::Horizontal,
|
||||
"vert" | "mongolianVert" => TextDir::Vertical,
|
||||
"vert" => TextDir::Vertical,
|
||||
"mongolianVert" => TextDir::MongolianVertical,
|
||||
"vert270" => TextDir::Vertical270,
|
||||
"wordArtVert" | "wordArtVertRtl" => TextDir::Stacked,
|
||||
"wordArtVert" => TextDir::Stacked,
|
||||
"wordArtVertRtl" => TextDir::StackedRtl,
|
||||
"eaVert" => TextDir::EaVertical,
|
||||
_ => return None,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -616,5 +616,13 @@ mod tests {
|
|||
assert_eq!(b.vert, Some(TextDir::Vertical270));
|
||||
assert_eq!(b.columns, Some(2));
|
||||
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 }));
|
||||
// mongolianVert is its own direction (PowerPoint runs its lines left to right) and is
|
||||
// written back as it was read.
|
||||
let d = parse(br#"<a:bodyPr vert="mongolianVert"/>"#).unwrap();
|
||||
let b = body_pr(&d.root);
|
||||
assert_eq!(b.vert, Some(TextDir::MongolianVertical));
|
||||
assert_eq!(b.vert.map(|v| v.xml()), Some("mongolianVert"));
|
||||
let d = parse(br#"<a:bodyPr vert="wordArtVertRtl"/>"#).unwrap();
|
||||
assert_eq!(body_pr(&d.root).vert.map(|v| v.xml()), Some("wordArtVertRtl"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1399,7 +1399,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
|||
}
|
||||
ctx.set_transform(m * Affine::translate(ps.offset));
|
||||
ctx.set_paint(color(d.color, ps.opacity));
|
||||
ctx.fill_rect(&d.rect);
|
||||
fill_deco(ctx, d);
|
||||
};
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
|
|
@ -1426,7 +1426,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
|||
// its bottom, from its first to its last character), as in PowerPoint.
|
||||
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let at = m * Affine::translate((*x, *y));
|
||||
let at = m * glyph_place(run, *gid, *x, *y);
|
||||
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
|
||||
draw_glyph(ctx, run, *gid, at, ps.opacity);
|
||||
continue;
|
||||
|
|
@ -1447,6 +1447,37 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
|||
}
|
||||
}
|
||||
|
||||
/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a
|
||||
/// quarter turn back about its em box's centre when it stands upright in vertical text.
|
||||
pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine {
|
||||
let at = Affine::translate((x, y));
|
||||
match run.upright_centre(gid, x, y) {
|
||||
Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at,
|
||||
None => at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill an underline, strikethrough or highlight with the current paint and transform: its box,
|
||||
/// its dashes, or its zigzag stroked.
|
||||
pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
|
||||
if let Some((pts, w)) = d.wave() {
|
||||
let mut path = BezPath::new();
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if i == 0 {
|
||||
path.move_to(*p);
|
||||
} else {
|
||||
path.line_to(*p);
|
||||
}
|
||||
}
|
||||
ctx.set_stroke(kurbo::Stroke::new(w.max(0.01)));
|
||||
ctx.stroke_path(&path);
|
||||
return;
|
||||
}
|
||||
for r in d.pieces() {
|
||||
ctx.fill_rect(&r);
|
||||
}
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
l.lines
|
||||
|
|
@ -1497,7 +1528,7 @@ fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: T
|
|||
continue;
|
||||
}
|
||||
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
|
||||
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
|
||||
let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k);
|
||||
ctx.set_transform(at);
|
||||
ctx.fill_path(&path);
|
||||
if grow > 0.0 {
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ pub(crate) fn draw(
|
|||
for d in &l.decos {
|
||||
ctx.set_transform(place);
|
||||
ctx.set_paint(color(d.color, alpha));
|
||||
ctx.fill_rect(&d.rect);
|
||||
crate::fill_deco(ctx, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1644,3 +1644,65 @@ fn a_fill_overlay_blends_over_the_fill() {
|
|||
let (over, _) = draw([200, 100, 50], "over");
|
||||
assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_and_wavy_underlines_draw_their_pattern() {
|
||||
// Underlined spaces only, so the underline is the only ink: a red line under " ".
|
||||
let row_runs = |u: &str| {
|
||||
let mut sh = Shape { id: ShapeId(760), xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 300.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text(" ");
|
||||
let props = &mut t.paragraphs[0].runs[0].props;
|
||||
props.size = Some(96.0);
|
||||
props.underline = Some(u.into());
|
||||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let red = |x: u32, y: u32| {
|
||||
let px = img.pixel(x, y);
|
||||
px[0] > 150 && px[1] < 120
|
||||
};
|
||||
// The ink's rows, and the number of separate red stretches along the most inked row.
|
||||
let rows: Vec<u32> = (0..300).filter(|&y| (0..900).any(|x| red(x, y))).collect();
|
||||
let best = rows.iter().copied().max_by_key(|&y| (0..900).filter(|&x| red(x, y)).count()).unwrap_or(0);
|
||||
let stretches = (1..900).filter(|&x| red(x, best) && !red(x - 1, best)).count();
|
||||
(rows.len(), stretches)
|
||||
};
|
||||
let (sng_rows, sng_runs) = row_runs("sng");
|
||||
assert_eq!(sng_runs, 1, "a single line is one stretch");
|
||||
let (_, dotted_runs) = row_runs("dotted");
|
||||
assert!(dotted_runs > 10, "dots: {dotted_runs}");
|
||||
let (wavy_rows, _) = row_runs("wavy");
|
||||
assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_vertical_text_stands_ideographs_upright() {
|
||||
// "一" is a horizontal stroke. In eaVert text it stays horizontal (upright) while the line runs
|
||||
// down the shape; in vert it lies on its side like Latin text.
|
||||
let ink = |dir: deckcraft_model::text::TextDir| {
|
||||
let mut sh = Shape { id: ShapeId(770), xfrm: Some(Xfrm::new(100.0, 50.0, 200.0, 400.0)), text_box: true, ..Default::default() };
|
||||
let mut t = TextBody::from_text("一");
|
||||
t.body.vert = Some(dir);
|
||||
let props = &mut t.paragraphs[0].runs[0].props;
|
||||
props.size = Some(80.0);
|
||||
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
|
||||
sh.text = Some(t);
|
||||
let p = deck_with(vec![sh]);
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
|
||||
for y in 0..540 {
|
||||
for x in 0..960 {
|
||||
if img.pixel(x, y)[0] < 128 {
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(x1 >= x0 && y1 >= y0, "no ink for {dir:?}");
|
||||
(x1 - x0, y1 - y0)
|
||||
};
|
||||
let (w, h) = ink(deckcraft_model::text::TextDir::EaVertical);
|
||||
assert!(w > h * 3, "eaVert: upright stroke {w}x{h}");
|
||||
let (w, h) = ink(deckcraft_model::text::TextDir::Vertical);
|
||||
assert!(h > w * 3, "vert: stroke on its side {w}x{h}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,10 +140,12 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
|
|||
}
|
||||
let y = (d.rect.y0 + d.rect.y1) / 2.0;
|
||||
let Some(li) = line_of(l, d.para, y) else { return };
|
||||
let path = warp_path(&d.rect.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
|
||||
ctx.set_transform(m * Affine::translate(ps.offset));
|
||||
ctx.set_paint(color(d.color, ps.opacity));
|
||||
ctx.fill_path(&path);
|
||||
for r in d.pieces() {
|
||||
let path = warp_path(&r.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
|
||||
ctx.fill_path(&path);
|
||||
}
|
||||
};
|
||||
for d in l.decos.iter().filter(|d| d.behind) {
|
||||
deco(ctx, d);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws
|
||||
//! mirrorable characters in right-to-left items with their mirrored glyphs.
|
||||
|
||||
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS};
|
||||
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, CS, EN, ES, ET, FSI, L, LRE, LRI, LRO, NSM, ON, PDF, PDI, R, RLE, RLI, RLO, S, WS};
|
||||
use unicode_bidi::{BidiInfo, Level, bidi_class};
|
||||
|
||||
/// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or
|
||||
|
|
@ -27,6 +27,149 @@ pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec<u8> {
|
|||
text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect()
|
||||
}
|
||||
|
||||
/// Is `lang` (BCP 47) a language written right to left? Its runs resolve neutral characters and
|
||||
/// digits the right-to-left way (see [`levels_lang`]).
|
||||
pub(crate) fn is_rtl_lang(lang: &str) -> bool {
|
||||
let primary = lang.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
|
||||
matches!(primary.as_str(), "ar" | "he" | "iw" | "fa" | "ur" | "yi" | "ji" | "ps" | "sd" | "ug" | "dv" | "syr" | "ckb" | "arc")
|
||||
}
|
||||
|
||||
/// How a character resolves before levels are assigned.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Dir {
|
||||
L,
|
||||
R,
|
||||
/// A number inside right-to-left text: kept left to right, placed with the right-to-left text.
|
||||
Num,
|
||||
/// Not resolved yet (neutral).
|
||||
Open,
|
||||
}
|
||||
|
||||
/// Embedding levels as PowerPoint resolves them: the language of each character's run
|
||||
/// (`rtl_lang`, from `a:rPr/@lang`) decides how neutral characters and digits go, where UAX #9
|
||||
/// would look at the surrounding text only. Observed on desktop PowerPoint:
|
||||
/// - in a run of a left-to-right language (or none), digits and punctuation are left to right,
|
||||
/// and a space takes the direction of the character before it (so the space after a Hebrew word
|
||||
/// travels with that word);
|
||||
/// - in a run of a right-to-left language (Hebrew, Arabic…), punctuation and spaces between text
|
||||
/// of one direction take it, everywhere else they are right to left; digits are a number in
|
||||
/// right-to-left text unless left-to-right text precedes them.
|
||||
///
|
||||
/// Paragraphs with explicit embeddings or isolates keep the plain algorithm ([`levels`]).
|
||||
pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[bool]) -> Vec<u8> {
|
||||
let base = u8::from(rtl);
|
||||
let any_rtl_lang = rtl_lang.iter().any(|r| *r);
|
||||
if !rtl && !any_rtl_lang && !needs_bidi(chars) {
|
||||
return vec![0; chars.len()];
|
||||
}
|
||||
let classes: Vec<BidiClass> = chars.iter().map(|c| bidi_class(*c)).collect();
|
||||
if classes.iter().any(|c| matches!(c, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) {
|
||||
return levels(text, chars, rtl);
|
||||
}
|
||||
let n = chars.len();
|
||||
let rl = |i: usize| rtl_lang.get(i).copied().unwrap_or(false);
|
||||
let sos = if rtl { Dir::R } else { Dir::L };
|
||||
// W1: combining marks take the class of the character they sit on.
|
||||
let mut cls = classes;
|
||||
for i in 0..n {
|
||||
if cls.get(i) == Some(&NSM) {
|
||||
let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) };
|
||||
if let Some(c) = cls.get_mut(i) {
|
||||
*c = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut dir = vec![Dir::Open; n];
|
||||
// Strong letters and digits.
|
||||
let mut last_strong: Option<BidiClass> = None;
|
||||
for i in 0..n {
|
||||
let c = cls.get(i).copied().unwrap_or(ON);
|
||||
let d = match c {
|
||||
L => Dir::L,
|
||||
R | AL => Dir::R,
|
||||
AN => Dir::Num,
|
||||
EN if !rl(i) => Dir::L,
|
||||
// W7: European digits after left-to-right text are left to right.
|
||||
EN if last_strong == Some(L) => Dir::L,
|
||||
EN => Dir::Num,
|
||||
_ => Dir::Open,
|
||||
};
|
||||
if matches!(c, L | R | AL) {
|
||||
last_strong = Some(c);
|
||||
}
|
||||
if let Some(x) = dir.get_mut(i) {
|
||||
*x = d;
|
||||
}
|
||||
}
|
||||
// W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next
|
||||
// to them, belong to the number.
|
||||
for i in 0..n {
|
||||
if !rl(i) || dir.get(i) != Some(&Dir::Open) {
|
||||
continue;
|
||||
}
|
||||
let c = cls.get(i).copied().unwrap_or(ON);
|
||||
let num = |j: Option<usize>| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num);
|
||||
let joined = match c {
|
||||
ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)),
|
||||
ET => num(i.checked_sub(1)) || (i + 1..n).find(|&j| cls.get(j) != Some(&ET)).is_some_and(|j| num(Some(j))),
|
||||
_ => false,
|
||||
};
|
||||
if joined && let Some(x) = dir.get_mut(i) {
|
||||
*x = Dir::Num;
|
||||
}
|
||||
}
|
||||
// Punctuation in left-to-right runs is left to right.
|
||||
for i in 0..n {
|
||||
if !rl(i)
|
||||
&& dir.get(i) == Some(&Dir::Open)
|
||||
&& !matches!(cls.get(i), Some(WS | S))
|
||||
&& let Some(x) = dir.get_mut(i)
|
||||
{
|
||||
*x = Dir::L;
|
||||
}
|
||||
}
|
||||
// Neutrals in right-to-left runs: the direction around them when both sides agree, else
|
||||
// right to left. Unresolved spaces of left-to-right runs are looked through.
|
||||
let strong_at = |dir: &[Dir], j: usize| match dir.get(j) {
|
||||
Some(Dir::L) => Some(Dir::L),
|
||||
Some(Dir::R | Dir::Num) => Some(Dir::R),
|
||||
_ => None,
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
if !(rl(i) && dir.get(i) == Some(&Dir::Open)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) {
|
||||
i += 1;
|
||||
}
|
||||
let before = (0..start).rev().find_map(|j| strong_at(&dir, j)).unwrap_or(sos);
|
||||
let after = (i..n).find_map(|j| strong_at(&dir, j)).unwrap_or(sos);
|
||||
let d = if before == after { before } else { Dir::R };
|
||||
for x in dir.get_mut(start..i).into_iter().flatten() {
|
||||
*x = d;
|
||||
}
|
||||
}
|
||||
// Spaces in left-to-right runs follow the character before them.
|
||||
let mut prev = Dir::L;
|
||||
for d in dir.iter_mut() {
|
||||
if *d == Dir::Open {
|
||||
*d = prev;
|
||||
}
|
||||
prev = if *d == Dir::Num { Dir::R } else { *d };
|
||||
}
|
||||
dir.iter()
|
||||
.map(|d| match (d, base % 2 == 1) {
|
||||
(Dir::L, false) | (Dir::R, true) => base,
|
||||
(Dir::L, true) | (Dir::R, false) | (Dir::Open, _) => base + 1,
|
||||
(Dir::Num, false) => base + 2,
|
||||
(Dir::Num, true) => base + 1,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a
|
||||
/// segment/paragraph separator.
|
||||
fn l1_whitespace(c: BidiClass) -> bool {
|
||||
|
|
@ -131,6 +274,40 @@ mod tests {
|
|||
assert_eq!(lv("ab", true), vec![2, 2]);
|
||||
}
|
||||
|
||||
/// Visual string of `s` laid out on one line with every character in a run of language
|
||||
/// direction `rtl_lang`, as PowerPoint orders it.
|
||||
fn vis_lang(s: &str, rtl: bool, rtl_lang: bool) -> String {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let l = levels_lang(s, &chars, rtl, &vec![rtl_lang; chars.len()]);
|
||||
visual_order(&line_levels(&chars, &l, u8::from(rtl))).iter().map(|&i| chars[i]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_language_decides_neutrals_like_powerpoint() {
|
||||
// Observed in PowerPoint with each space highlighted. An English run: the space after a
|
||||
// Hebrew word goes with it, punctuation is left to right.
|
||||
assert_eq!(vis_lang("x אב גד y", false, false), "x דג באy");
|
||||
assert_eq!(vis_lang("אב גד x y", false, false), " דג באx y");
|
||||
assert_eq!(vis_lang("x אב, גד! y", false, false), "x בא, דג! y");
|
||||
// Digits after Hebrew stay left to right in an English run.
|
||||
assert_eq!(vis_lang("אב 12", false, false), " בא12");
|
||||
// A Hebrew run: neutrals between different directions are right to left, and digits are
|
||||
// a number inside the right-to-left text.
|
||||
assert_eq!(vis_lang("אב גד x y", false, true), " דג באx y");
|
||||
assert_eq!(vis_lang("12 אב 34 x", false, true), " 34 בא 12x");
|
||||
assert_eq!(vis_lang("x אב, גד! y", false, true), "x !דג ,בא y");
|
||||
// Between two left-to-right words a Hebrew run's space stays left to right.
|
||||
assert_eq!(vis_lang("Version 2.0 ok", false, true), "Version 2.0 ok");
|
||||
// An English sentence in a right-to-left paragraph keeps its full stop at its end.
|
||||
assert_eq!(vis_lang("Hello world.", true, false), "Hello world.");
|
||||
// Right-to-left runs keep W4: "45.6%" is one number.
|
||||
assert_eq!(vis_lang("אב 45.6% גד", false, true), "דג 45.6% בא");
|
||||
// Without right-to-left text or runs, the fast path.
|
||||
let chars: Vec<char> = "plain, text".chars().collect();
|
||||
assert_eq!(levels_lang("plain, text", &chars, false, &[false; 11]), vec![0; 11]);
|
||||
assert!(is_rtl_lang("he-IL") && is_rtl_lang("ar") && is_rtl_lang("FA-ir") && !is_rtl_lang("en-US") && !is_rtl_lang(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l1_resets_trailing_whitespace_and_tabs() {
|
||||
let chars: Vec<char> = "ab\tcd ".chars().collect();
|
||||
|
|
|
|||
|
|
@ -54,11 +54,31 @@ pub struct GlyphRun {
|
|||
pub para: usize,
|
||||
pub chars: (usize, usize),
|
||||
pub link: bool,
|
||||
/// Alpha of the fill (0–1).
|
||||
/// Opacity of the fill on top of `color`'s own alpha (0 for no fill).
|
||||
pub alpha: f64,
|
||||
pub gradient: Option<deckcraft_model::style::Gradient>,
|
||||
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
|
||||
pub effects: Option<RunEffects>,
|
||||
/// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn
|
||||
/// back about the centre of its em box ([`GlyphRun::upright_centre`]) inside text that is
|
||||
/// laid out turned a quarter turn.
|
||||
pub upright: bool,
|
||||
}
|
||||
|
||||
/// Share of the size the ideographic em box's centre sits above the baseline (CJK fonts put the
|
||||
/// em box from 0.88 em above to 0.12 em below it).
|
||||
pub const UPRIGHT_CENTRE: f64 = 0.38;
|
||||
|
||||
impl GlyphRun {
|
||||
/// The point an upright glyph at pen position (`x`, `y`) turns about: the centre of its em box,
|
||||
/// `advance` wide (`None` when the run is not upright).
|
||||
pub fn upright_centre(&self, gid: u32, x: f64, y: f64) -> Option<(f64, f64)> {
|
||||
if !self.upright {
|
||||
return None;
|
||||
}
|
||||
let adv = self.face.advance(gid) * self.size / self.face.upem.max(1.0);
|
||||
Some((x + adv / 2.0, y - self.size * UPRIGHT_CENTRE))
|
||||
}
|
||||
}
|
||||
|
||||
/// A text run's shadow and glow, colours resolved.
|
||||
|
|
@ -93,6 +113,150 @@ pub struct Deco {
|
|||
pub behind: bool,
|
||||
/// Paragraph the decoration belongs to (by-paragraph animation).
|
||||
pub para: usize,
|
||||
/// How the line fills `rect`.
|
||||
pub line: DecoLine,
|
||||
}
|
||||
|
||||
/// How an underline fills its box.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum DecoLine {
|
||||
/// The whole box.
|
||||
#[default]
|
||||
Solid,
|
||||
/// Dashes or dots across the box's height, from its left end.
|
||||
Dashed(Dash),
|
||||
/// A zigzag between the box's top and bottom: `stroke` wide, one wave every `period`.
|
||||
Wave { stroke: f64, period: f64 },
|
||||
}
|
||||
|
||||
/// Dash patterns of the dotted and dashed underlines, in units of the line's thickness. Measured
|
||||
/// on PowerPoint's export: dots and their gaps as long as the line is thick, dashes three
|
||||
/// thicknesses long with gaps of two.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dash {
|
||||
Dot,
|
||||
Dash,
|
||||
Long,
|
||||
DotDash,
|
||||
DotDotDash,
|
||||
}
|
||||
|
||||
impl Dash {
|
||||
/// On, off, on, off… lengths in thicknesses.
|
||||
fn pattern(self) -> &'static [f64] {
|
||||
match self {
|
||||
Dash::Dot => &[1.0, 1.0],
|
||||
Dash::Dash => &[3.0, 2.0],
|
||||
Dash::Long => &[6.0, 2.0],
|
||||
Dash::DotDash => &[3.0, 2.0, 1.0, 2.0],
|
||||
Dash::DotDotDash => &[3.0, 2.0, 1.0, 2.0, 1.0, 2.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Deco {
|
||||
/// The filled boxes of a solid or dashed line (dashes start at the box's left end). A wavy
|
||||
/// line gives a thin box along its middle, for drawing that can't stroke a path.
|
||||
pub fn pieces(&self) -> Vec<Rect> {
|
||||
let r = self.rect;
|
||||
match self.line {
|
||||
DecoLine::Solid => vec![r],
|
||||
DecoLine::Wave { stroke, .. } => {
|
||||
let c = (r.y0 + r.y1) / 2.0;
|
||||
vec![Rect::new(r.x0, c - stroke / 2.0, r.x1, c + stroke / 2.0)]
|
||||
}
|
||||
DecoLine::Dashed(d) => {
|
||||
let unit = r.height().max(0.1);
|
||||
let pat = d.pattern();
|
||||
let mut out = Vec::new();
|
||||
let mut x = r.x0;
|
||||
let mut k = 0usize;
|
||||
while x < r.x1 && out.len() < 10_000 {
|
||||
let len = pat.get(k % pat.len()).copied().unwrap_or(1.0) * unit;
|
||||
if k.is_multiple_of(2) {
|
||||
out.push(Rect::new(x, r.y0, (x + len).min(r.x1), r.y1));
|
||||
}
|
||||
x += len;
|
||||
k += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The zigzag of a wavy underline, left to right from the box's top, and its stroke width;
|
||||
/// None for other lines.
|
||||
pub fn wave(&self) -> Option<(Vec<Point>, f64)> {
|
||||
let DecoLine::Wave { stroke, period } = self.line else { return None };
|
||||
let r = self.rect;
|
||||
let half = (period / 2.0).max(0.1);
|
||||
let n = ((r.width().max(0.0) / half).ceil() as usize).min(20_000);
|
||||
let pts = (0..=n)
|
||||
.map(|i| {
|
||||
let x = (r.x0 + i as f64 * half).min(r.x1);
|
||||
Point::new(x, if i % 2 == 0 { r.y0 } else { r.y1 })
|
||||
})
|
||||
.collect();
|
||||
Some((pts, stroke))
|
||||
}
|
||||
}
|
||||
|
||||
/// An underline style (`a:rPr/@u`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Underline {
|
||||
Single,
|
||||
Double,
|
||||
Heavy,
|
||||
/// Dotted or dashed, heavy or not.
|
||||
Dashed(Dash, bool),
|
||||
/// Wavy, heavy or not.
|
||||
Wavy(bool),
|
||||
}
|
||||
|
||||
impl Underline {
|
||||
fn from_xml(u: &str) -> Option<Self> {
|
||||
Some(match u {
|
||||
"none" => return None,
|
||||
// PowerPoint underlines the spaces of a "words" underline too.
|
||||
"words" => Underline::Single,
|
||||
// At text sizes PowerPoint's double wave reads as two straight lines.
|
||||
"dbl" | "wavyDbl" => Underline::Double,
|
||||
"heavy" => Underline::Heavy,
|
||||
"dotted" => Underline::Dashed(Dash::Dot, false),
|
||||
"dottedHeavy" => Underline::Dashed(Dash::Dot, true),
|
||||
"dash" => Underline::Dashed(Dash::Dash, false),
|
||||
"dashHeavy" => Underline::Dashed(Dash::Dash, true),
|
||||
"dashLong" => Underline::Dashed(Dash::Long, false),
|
||||
"dashLongHeavy" => Underline::Dashed(Dash::Long, true),
|
||||
"dotDash" => Underline::Dashed(Dash::DotDash, false),
|
||||
"dotDashHeavy" => Underline::Dashed(Dash::DotDash, true),
|
||||
"dotDotDash" => Underline::Dashed(Dash::DotDotDash, false),
|
||||
"dotDotDashHeavy" => Underline::Dashed(Dash::DotDotDash, true),
|
||||
"wavy" => Underline::Wavy(false),
|
||||
"wavyHeavy" => Underline::Wavy(true),
|
||||
_ => Underline::Single,
|
||||
})
|
||||
}
|
||||
|
||||
/// The boxes of this underline below the baseline (top, bottom, fill), from the font's
|
||||
/// underline position `p` and thickness `t` at the run's size. Measured on PowerPoint's export
|
||||
/// (Arial 96 pt: p = 10.2, t = 7.0): single at p..p+t; heavy from p-t to p+0.55t; double as two
|
||||
/// lines 0.55t thick spanning p-t..p+t; wavy a thin zigzag from p-0.6t to p+1.1t, a wave every
|
||||
/// 3.9t.
|
||||
fn boxes(self, p: f64, t: f64) -> Vec<(f64, f64, DecoLine)> {
|
||||
let heavy = (p - t, p + 0.55 * t);
|
||||
match self {
|
||||
Underline::Single => vec![(p, p + t, DecoLine::Solid)],
|
||||
Underline::Heavy => vec![(heavy.0, heavy.1, DecoLine::Solid)],
|
||||
Underline::Double => vec![(p - t, p - 0.45 * t, DecoLine::Solid), (p + 0.45 * t, p + t, DecoLine::Solid)],
|
||||
Underline::Dashed(d, false) => vec![(p, p + t, DecoLine::Dashed(d))],
|
||||
Underline::Dashed(d, true) => vec![(heavy.0, heavy.1, DecoLine::Dashed(d))],
|
||||
Underline::Wavy(h) => {
|
||||
let stroke = (t * 0.15).max(0.4) * if h { 2.0 } else { 1.0 };
|
||||
vec![(p - 0.6 * t, p + 1.1 * t, DecoLine::Wave { stroke, period: t * 3.9 })]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -187,7 +351,7 @@ struct Style {
|
|||
baseline: f64,
|
||||
spacing: f64,
|
||||
caps: Caps,
|
||||
underline: Option<Rgba>,
|
||||
underline: Option<(Rgba, Underline)>,
|
||||
strike: Option<Strike>,
|
||||
highlight: Option<Rgba>,
|
||||
outline: Option<(Rgba, f64)>,
|
||||
|
|
@ -227,6 +391,8 @@ struct Cell {
|
|||
head: usize,
|
||||
/// A grapheme starts here: carets stand and lines break only before such cells.
|
||||
boundary: bool,
|
||||
/// Drawn upright in East Asian vertical text.
|
||||
upright: bool,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
|
|
@ -269,7 +435,8 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
|||
let fake_italic = italic && !face.italic;
|
||||
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
|
||||
let (mut color, alpha, gradient) = match &r.fill {
|
||||
Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None),
|
||||
// The resolved colour carries the fill's transparency (`a:alpha`): it is applied once.
|
||||
Some(Fill::Solid { color }) => (ctx.color(color, None), 1.0, None),
|
||||
Some(Fill::Gradient(g)) => {
|
||||
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
|
||||
let mut g = g.clone();
|
||||
|
|
@ -292,7 +459,8 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
|||
};
|
||||
let ea = slot_family(&r.font_ea, if major { "+mj-ea" } else { "+mn-ea" });
|
||||
let cs = slot_family(&r.font_cs, if major { "+mj-cs" } else { "+mn-cs" });
|
||||
let underline = r.underline.as_deref().filter(|u| *u != "none").map(|_| r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color));
|
||||
let underline =
|
||||
r.underline.as_deref().and_then(Underline::from_xml).map(|u| (r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color), u));
|
||||
let outline = r.outline.as_ref().and_then(|l| match &l.fill {
|
||||
Some(Fill::Solid { color: c }) => Some((ctx.color(c, None), l.width.unwrap_or(0.75))),
|
||||
_ => None,
|
||||
|
|
@ -404,37 +572,129 @@ fn alpha_num(n: u32, upper: bool) -> String {
|
|||
if upper { s.to_uppercase() } else { s }
|
||||
}
|
||||
|
||||
/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.".
|
||||
/// Letters of a numbering alphabet: 1 → the first, past the end the letter doubles ("aa"), as
|
||||
/// the Latin alphabetic schemes do.
|
||||
fn letters(list: &[char], n: u32) -> String {
|
||||
let len = list.len().max(1) as u32;
|
||||
let Some(i) = n.checked_sub(1) else { return String::new() };
|
||||
let c = list.get((i % len) as usize).copied().unwrap_or('?');
|
||||
std::iter::repeat_n(c, (i / len + 1).min(50) as usize).collect()
|
||||
}
|
||||
|
||||
/// `n` written with the digits `zero..zero+9` (Thai, Devanagari, full-width digits).
|
||||
fn digits_in(n: u32, zero: char) -> String {
|
||||
n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| char::from_u32(zero as u32 + v)).unwrap_or(d)).collect()
|
||||
}
|
||||
|
||||
const CJK_DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
|
||||
|
||||
/// Chinese numerals: 10 → 十, 11 → 十一, 25 → 二十五, 105 → 一百零五.
|
||||
fn chinese_num(n: u32) -> String {
|
||||
if n == 0 || n > 99_999 {
|
||||
return n.to_string();
|
||||
}
|
||||
let units = ['\0', '十', '百', '千', '万'];
|
||||
let ds: Vec<u32> = n.to_string().chars().filter_map(|c| c.to_digit(10)).collect();
|
||||
let mut out = String::new();
|
||||
let mut zero = false;
|
||||
for (i, d) in ds.iter().enumerate() {
|
||||
let place = ds.len() - 1 - i;
|
||||
if *d == 0 {
|
||||
zero = true;
|
||||
continue;
|
||||
}
|
||||
if zero {
|
||||
out.push('零');
|
||||
zero = false;
|
||||
}
|
||||
// "十" alone for 10–19, not "一十".
|
||||
if !(*d == 1 && place == 1 && i == 0) {
|
||||
out.push(CJK_DIGITS.get(*d as usize).copied().unwrap_or('?'));
|
||||
}
|
||||
if let Some(u) = units.get(place).filter(|u| **u != '\0') {
|
||||
out.push(*u);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.". The East Asian, Thai, Hindi, Arabic
|
||||
/// and Hebrew schemes are written as PowerPoint draws them: `ea1ChsPeriod` 10 → "十.",
|
||||
/// `ea1JpnKorPeriod` 10 → "一〇." (digit by digit), `arabicDbPeriod` 13 → "13.",
|
||||
/// `thaiAlphaParenR` 4 → "ง)", `hindiAlpha1Period` 1 → "क.", `arabic2Minus` 10 → "ي-"
|
||||
/// (abjad order), `circleNumWdWhitePlain` 13 → "③" (those circles go to ten).
|
||||
pub fn autonum(scheme: &str, n: u32) -> String {
|
||||
let (core, upper) = if scheme.starts_with("romanUc") {
|
||||
(roman(n, true), true)
|
||||
const THAI: [char; 41] = [
|
||||
'ก', 'ข', 'ค', 'ง', 'จ', 'ฉ', 'ช', 'ซ', 'ฌ', 'ญ', 'ฎ', 'ฏ', 'ฐ', 'ฑ', 'ฒ', 'ณ', 'ด', 'ต', 'ถ', 'ท', 'ธ', 'น', 'บ', 'ป', 'ผ', 'ฝ', 'พ', 'ฟ',
|
||||
'ภ', 'ม', 'ย', 'ร', 'ล', 'ว', 'ศ', 'ษ', 'ส', 'ห', 'ฬ', 'อ', 'ฮ',
|
||||
];
|
||||
const HINDI_VOWELS: [char; 11] = ['अ', 'आ', 'इ', 'ई', 'उ', 'ऊ', 'ऋ', 'ए', 'ऐ', 'ओ', 'औ'];
|
||||
const HINDI_CONSONANTS: [char; 33] = [
|
||||
'क', 'ख', 'ग', 'घ', 'ङ', 'च', 'छ', 'ज', 'झ', 'ञ', 'ट', 'ठ', 'ड', 'ढ', 'ण', 'त', 'थ', 'द', 'ध', 'न', 'प', 'फ', 'ब', 'भ', 'म', 'य', 'र', 'ल',
|
||||
'व', 'श', 'ष', 'स', 'ह',
|
||||
];
|
||||
const ARABIC_ALPHABET: [char; 28] =
|
||||
['ا', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
|
||||
const ARABIC_ABJAD: [char; 28] =
|
||||
['ا', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
|
||||
const HEBREW: [char; 22] = ['א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט', 'י', 'כ', 'ל', 'מ', 'נ', 'ס', 'ע', 'פ', 'צ', 'ק', 'ר', 'ש', 'ת'];
|
||||
let circled = |first: u32, count: u32| char::from_u32(first + n.saturating_sub(1) % count).map(String::from).unwrap_or_else(|| n.to_string());
|
||||
let core = if scheme.starts_with("romanUc") {
|
||||
roman(n, true)
|
||||
} else if scheme.starts_with("romanLc") {
|
||||
(roman(n, false), false)
|
||||
roman(n, false)
|
||||
} else if scheme.starts_with("alphaUc") {
|
||||
(alpha_num(n, true), true)
|
||||
alpha_num(n, true)
|
||||
} else if scheme.starts_with("alphaLc") {
|
||||
(alpha_num(n, false), false)
|
||||
alpha_num(n, false)
|
||||
} else if scheme.starts_with("circleNumWdWhite") {
|
||||
return circled(0x2460, 10);
|
||||
} else if scheme.starts_with("circleNumWdBlack") {
|
||||
return circled(0x2776, 10);
|
||||
} else if scheme.starts_with("circleNum") {
|
||||
let c = char::from_u32(0x2460 + n.saturating_sub(1).min(19)).unwrap_or('•');
|
||||
return c.to_string();
|
||||
return char::from_u32(0x2460 + n.saturating_sub(1).min(19)).map(String::from).unwrap_or_else(|| n.to_string());
|
||||
} else if scheme.starts_with("ea1Chs") || scheme.starts_with("ea1Cht") {
|
||||
chinese_num(n)
|
||||
} else if scheme.starts_with("ea1Jpn") {
|
||||
n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| CJK_DIGITS.get(v as usize).copied()).unwrap_or(d)).collect()
|
||||
} else if scheme.starts_with("arabicDb") {
|
||||
digits_in(n, '0')
|
||||
} else if scheme.starts_with("thaiAlpha") {
|
||||
letters(&THAI, n)
|
||||
} else if scheme.starts_with("thaiNum") {
|
||||
digits_in(n, '๐')
|
||||
} else if scheme.starts_with("hindiAlpha1") {
|
||||
letters(&HINDI_CONSONANTS, n)
|
||||
} else if scheme.starts_with("hindiAlpha") {
|
||||
letters(&HINDI_VOWELS, n)
|
||||
} else if scheme.starts_with("hindiNum") {
|
||||
digits_in(n, '०')
|
||||
} else if scheme.starts_with("arabic1") {
|
||||
letters(&ARABIC_ALPHABET, n)
|
||||
} else if scheme.starts_with("arabic2") {
|
||||
letters(&ARABIC_ABJAD, n)
|
||||
} else if scheme.starts_with("hebrew2") {
|
||||
letters(&HEBREW, n)
|
||||
} else {
|
||||
(n.to_string(), false)
|
||||
n.to_string()
|
||||
};
|
||||
let _ = upper;
|
||||
// East Asian schemes end with a full-width full stop.
|
||||
let wide = scheme.starts_with("ea1") || scheme.starts_with("arabicDb");
|
||||
if scheme.ends_with("ParenBoth") {
|
||||
format!("({core})")
|
||||
} else if scheme.ends_with("ParenR") {
|
||||
format!("{core})")
|
||||
} else if scheme.ends_with("Period") {
|
||||
format!("{core}.")
|
||||
format!("{core}{}", if wide { '.' } else { '.' })
|
||||
} else if scheme.ends_with("Minus") {
|
||||
format!("- {core} -")
|
||||
format!("{core}-")
|
||||
} else {
|
||||
core
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option<u32>) -> ParaShaped {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option<u32>, vertical: Upright) -> ParaShaped {
|
||||
let db = FontDb::global();
|
||||
let Some(para) = body.paragraphs.get(pi) else {
|
||||
let face = db.face("Inter", "Regular");
|
||||
|
|
@ -471,7 +731,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
|||
char_style.extend(std::iter::repeat_n(si, t.chars().count()));
|
||||
text.push_str(&t);
|
||||
}
|
||||
let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl);
|
||||
let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl, vertical);
|
||||
let lead = para.runs.first().map(|r| resolve::run(ctx, shape, para, &r.props)).unwrap_or_else(|| resolve::run(ctx, shape, para, ¶.end_props));
|
||||
let lead_style = make_style(ctx, &lead, scale, opts.prompt_color);
|
||||
let lead_size = lead_style.size;
|
||||
|
|
@ -552,7 +812,16 @@ fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
|
|||
(shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow })
|
||||
}
|
||||
|
||||
fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shape_cells(
|
||||
ctx: &Ctx,
|
||||
text: &str,
|
||||
char_style: &[usize],
|
||||
styles: &[Style],
|
||||
faces: &mut Vec<Arc<FontFace>>,
|
||||
rtl: bool,
|
||||
vertical: Upright,
|
||||
) -> Vec<Cell> {
|
||||
let db = FontDb::global();
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let n = chars.len();
|
||||
|
|
@ -560,7 +829,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
|||
let mut byte_of: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
|
||||
byte_of.push(text.len());
|
||||
let char_at_byte: HashMap<usize, usize> = byte_of.iter().enumerate().map(|(i, b)| (*b, i)).collect();
|
||||
let levels = bidi::levels(text, &chars, rtl);
|
||||
let style_rtl: Vec<bool> = styles.iter().map(|st| st.lang.as_deref().is_some_and(bidi::is_rtl_lang)).collect();
|
||||
let rtl_lang: Vec<bool> = char_style.iter().map(|&si| style_rtl.get(si).copied().unwrap_or(false)).collect();
|
||||
let levels = bidi::levels_lang(text, &chars, rtl, &rtl_lang);
|
||||
let scripts = script::resolve(&chars);
|
||||
let mut boundary = vec![false; n];
|
||||
let mut graphemes: Vec<(usize, usize)> = Vec::new();
|
||||
|
|
@ -602,7 +873,11 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
|||
.filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
|
||||
let face = match (sym_face, prev.as_ref()) {
|
||||
(Some(f), _) => f,
|
||||
(None, Some(p)) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
|
||||
(None, Some(p))
|
||||
if neutral && !p.is_color() && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) =>
|
||||
{
|
||||
p.clone()
|
||||
}
|
||||
(None, _) => {
|
||||
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
|
||||
db.cascade(cluster, chain, st.style_name)
|
||||
|
|
@ -624,25 +899,118 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
|||
adv: 0.0,
|
||||
head: i,
|
||||
boundary: boundary.get(i).copied().unwrap_or(true),
|
||||
upright: false,
|
||||
})
|
||||
.collect();
|
||||
// In East Asian vertical text, ideographs, kana, Hangul, full-width forms and the like stand
|
||||
// upright (a whole grapheme at a time); other text lies on its side.
|
||||
if vertical != Upright::None {
|
||||
for &(a, b) in &graphemes {
|
||||
let up = chars.get(a).is_some_and(|c| if vertical == Upright::All { !c.is_whitespace() } else { is_upright(*c) });
|
||||
for c in cells.get_mut(a..b).into_iter().flatten() {
|
||||
c.upright = up;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Items: maximal ranges of one level, face, script and shaping style (case mapping, language).
|
||||
let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None);
|
||||
let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref());
|
||||
let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern);
|
||||
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i));
|
||||
let up = |i: usize| cells.get(i).is_some_and(|c| c.upright);
|
||||
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i), up(i));
|
||||
let mut items: Vec<(usize, usize)> = Vec::new();
|
||||
let mut a = 0;
|
||||
while a < n {
|
||||
let mut b = a + 1;
|
||||
while b < n && key(b) == key(a) {
|
||||
b += 1;
|
||||
}
|
||||
shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a));
|
||||
items.push((a, b));
|
||||
a = b;
|
||||
}
|
||||
// Upright glyphs keep their horizontal forms: PowerPoint turns "、", "。" and "「" as they are
|
||||
// (no `vert` alternates).
|
||||
for (a, b) in items {
|
||||
shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a));
|
||||
}
|
||||
// Stacked text: each character takes one em down the line, centred across it (PowerPoint
|
||||
// stacks 18 pt Latin letters about 18 pt apart, a space only its own width).
|
||||
if vertical == Upright::All {
|
||||
for i in 0..n {
|
||||
let Some(c) = cells.get(i) else { continue };
|
||||
if !c.upright || c.head != i || c.glyphs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let size = styles.get(c.style).map(|st| st.size).unwrap_or(18.0);
|
||||
let width: f64 = (i..n).take_while(|&j| cells.get(j).is_some_and(|d| d.head == i)).filter_map(|j| cells.get(j)).map(|d| d.adv).sum();
|
||||
let pad = (size - width) / 2.0;
|
||||
if let Some(c) = cells.get_mut(i) {
|
||||
for g in &mut c.glyphs {
|
||||
g.1 += pad;
|
||||
}
|
||||
}
|
||||
for j in i..n {
|
||||
match cells.get_mut(j) {
|
||||
Some(d) if d.head == i => d.adv = if j == i { size } else { 0.0 },
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// A character in capitals (`cap="all"`/`"small"`): its uppercase when that is one character.
|
||||
/// PowerPoint leaves characters whose capital is a sequence as they are: "ß" stays "ß" (not "SS"),
|
||||
/// "fi" and "ʼn" stay.
|
||||
fn caps_char(ch: char) -> char {
|
||||
let mut up = ch.to_uppercase();
|
||||
match (up.next(), up.next()) {
|
||||
(Some(u), None) => u,
|
||||
_ => ch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `c` stand upright in East Asian vertical text? Ideographs, kana, Hangul, Bopomofo, CJK
|
||||
/// symbols and punctuation, full-width forms, enclosed and compatibility CJK, circled numbers
|
||||
/// and emoji (Unicode's Vertical_Orientation U and T, roughly); Latin, digits and the rest of the
|
||||
/// half-width text lie on their side as in `vert`.
|
||||
fn is_upright(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x1100..=0x11FF
|
||||
| 0x2460..=0x24FF
|
||||
| 0x2E80..=0x2FFF
|
||||
| 0x3000..=0x303F
|
||||
| 0x3040..=0x30FF
|
||||
| 0x3100..=0x312F
|
||||
| 0x3130..=0x318F
|
||||
| 0x3190..=0x31FF
|
||||
| 0x3200..=0x33FF
|
||||
| 0x3400..=0x4DBF
|
||||
| 0x4E00..=0x9FFF
|
||||
| 0xA960..=0xA97F
|
||||
| 0xAC00..=0xD7FF
|
||||
| 0xF900..=0xFAFF
|
||||
| 0xFE10..=0xFE1F
|
||||
| 0xFE30..=0xFE6F
|
||||
| 0xFF01..=0xFF60
|
||||
| 0xFFE0..=0xFFE6
|
||||
| 0x1F000..=0x1FAFF
|
||||
| 0x20000..=0x3FFFF
|
||||
)
|
||||
}
|
||||
|
||||
/// Which characters stand upright in vertical text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Upright {
|
||||
/// None (horizontal text, vert, vert270, mongolianVert).
|
||||
None,
|
||||
/// Ideographs, kana and the like (eaVert).
|
||||
Cjk,
|
||||
/// Every character, one under the other (wordArtVert).
|
||||
All,
|
||||
}
|
||||
|
||||
/// Size of superscript and subscript text relative to the run's size.
|
||||
const SUB_SUPER: f64 = 0.66;
|
||||
|
||||
|
|
@ -686,7 +1054,7 @@ fn shape_item(
|
|||
let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return };
|
||||
let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
|
||||
let params = ShapeParams { features: &features, rtl, script, language: lang };
|
||||
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
|
||||
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { caps_char(ch) } else { ch });
|
||||
let upem = face.upem.max(1.0);
|
||||
let scale_of = |cell: &Cell| {
|
||||
let st = styles.get(cell.style);
|
||||
|
|
@ -896,31 +1264,50 @@ fn is_hangul(c: char) -> bool {
|
|||
matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}')
|
||||
}
|
||||
|
||||
/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`).
|
||||
/// East Asian commas and full stops, the punctuation PowerPoint lets hang past the end of a line
|
||||
/// (`hangingPunct`). Closing brackets don't hang: "「。」" overflowing by its "」" moves to the next
|
||||
/// line whole.
|
||||
fn is_hanging_punct(c: char) -> bool {
|
||||
matches!(c, '、' | '。' | ',' | '.' | '、' | '。')
|
||||
}
|
||||
|
||||
/// Small kana, the prolonged sound mark and iteration marks: PowerPoint keeps them off the start
|
||||
/// of a line (strict Japanese line breaking), where UAX #14's default allows a break before them.
|
||||
fn is_cj_nonstarter(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'、' | '。'
|
||||
| ','
|
||||
| '.'
|
||||
| ':'
|
||||
| ';'
|
||||
| '!'
|
||||
| '?'
|
||||
| ')'
|
||||
| '」'
|
||||
| '』'
|
||||
| '】'
|
||||
| '〕'
|
||||
| '〉'
|
||||
| '》'
|
||||
| '〙'
|
||||
| '〛'
|
||||
| '}'
|
||||
| ']'
|
||||
| '」'
|
||||
| '、'
|
||||
| '。'
|
||||
'ぁ' | 'ぃ'
|
||||
| 'ぅ'
|
||||
| 'ぇ'
|
||||
| 'ぉ'
|
||||
| 'っ'
|
||||
| 'ゃ'
|
||||
| 'ゅ'
|
||||
| 'ょ'
|
||||
| 'ゎ'
|
||||
| 'ゕ'
|
||||
| 'ゖ'
|
||||
| 'ァ'
|
||||
| 'ィ'
|
||||
| 'ゥ'
|
||||
| 'ェ'
|
||||
| 'ォ'
|
||||
| 'ッ'
|
||||
| 'ャ'
|
||||
| 'ュ'
|
||||
| 'ョ'
|
||||
| 'ヮ'
|
||||
| 'ヵ'
|
||||
| 'ヶ'
|
||||
| 'ー'
|
||||
| '々'
|
||||
| '〻'
|
||||
| 'ゝ'
|
||||
| 'ゞ'
|
||||
| 'ヽ'
|
||||
| 'ヾ'
|
||||
| '\u{31F0}'..='\u{31FF}'
|
||||
| 'ァ'..='ー'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -951,7 +1338,12 @@ fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>)
|
|||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, w) in chars.windows(2).enumerate() {
|
||||
let (prev, next) = (w[0], w[1]);
|
||||
let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() };
|
||||
// PowerPoint never breaks after a zero-width space, nor before an em dash (only after it).
|
||||
let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) {
|
||||
false
|
||||
} else {
|
||||
latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace()
|
||||
};
|
||||
if !keep
|
||||
&& !mandatory.get(i + 1).copied().unwrap_or(false)
|
||||
&& let Some(a) = allowed.get_mut(i + 1)
|
||||
|
|
@ -974,7 +1366,7 @@ fn break_lines(
|
|||
first_w: f64,
|
||||
rest_w: f64,
|
||||
wrap: bool,
|
||||
tab_stops: &[f64],
|
||||
tab_stops: &[Tab],
|
||||
default_tab: f64,
|
||||
x0_first: f64,
|
||||
x0_rest: f64,
|
||||
|
|
@ -1003,7 +1395,11 @@ fn break_lines(
|
|||
next = i + 1;
|
||||
break;
|
||||
}
|
||||
let adv = if c == '\t' { tab_advance(x0 + x, tab_stops, default_tab) } else { p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) };
|
||||
let adv = if c == '\t' {
|
||||
tab_advance(x0 + x, tab_stops, default_tab, tab_segment(&p.cells, i))
|
||||
} else {
|
||||
p.cells.get(i).map(|c| c.adv).unwrap_or(0.0)
|
||||
};
|
||||
let breakable = |j: usize| p.cells.get(j).is_none_or(|c| c.breakable(j));
|
||||
if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
|
||||
last_break = Some(i);
|
||||
|
|
@ -1015,6 +1411,15 @@ fn break_lines(
|
|||
next = i + 1;
|
||||
break;
|
||||
}
|
||||
// A tab that would run past the end moves to the next line (and to its first stop there).
|
||||
if wrap && c == '\t' && x + adv > avail + 0.01 && i > start {
|
||||
end = i;
|
||||
next = i;
|
||||
while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') {
|
||||
end -= 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start {
|
||||
// Break at the last opportunity, or force mid-word (between clusters).
|
||||
let b = last_break
|
||||
|
|
@ -1056,10 +1461,72 @@ fn break_lines(
|
|||
lines
|
||||
}
|
||||
|
||||
fn tab_advance(x: f64, stops: &[f64], default_tab: f64) -> f64 {
|
||||
/// How a tab stop aligns the text after it (`a:tab/@algn`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum TabAlign {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
/// The first "." of the text sits at the stop (text without one ends there).
|
||||
Decimal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Tab {
|
||||
pos: f64,
|
||||
align: TabAlign,
|
||||
}
|
||||
|
||||
fn tabs_of(props: &ParaProps) -> Vec<Tab> {
|
||||
props
|
||||
.tabs
|
||||
.as_ref()
|
||||
.map(|t| {
|
||||
t.iter()
|
||||
.map(|s| Tab {
|
||||
pos: s.pos,
|
||||
align: match s.align.as_str() {
|
||||
"ctr" => TabAlign::Center,
|
||||
"r" => TabAlign::Right,
|
||||
"dec" => TabAlign::Decimal,
|
||||
_ => TabAlign::Left,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of
|
||||
/// that text before its first ".".
|
||||
fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) {
|
||||
let mut w = 0.0;
|
||||
let mut dec = None;
|
||||
for c in cells.iter().skip(i + 1) {
|
||||
if matches!(c.ch, '\t' | '\u{b}' | '\n') {
|
||||
break;
|
||||
}
|
||||
if c.ch == '.' && dec.is_none() {
|
||||
dec = Some(w);
|
||||
}
|
||||
w += c.adv;
|
||||
}
|
||||
(w, dec.unwrap_or(w))
|
||||
}
|
||||
|
||||
/// Advance of a tab at `x` (from the start edge) to the next stop, placing the text after it
|
||||
/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never
|
||||
/// moving back, as PowerPoint does; past the last stop, to the next default stop.
|
||||
fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 {
|
||||
for s in stops {
|
||||
if *s > x + 0.5 {
|
||||
return s - x;
|
||||
if s.pos > x + 0.5 {
|
||||
let to = s.pos - x;
|
||||
return match s.align {
|
||||
TabAlign::Left => to,
|
||||
TabAlign::Right => (to - seg.0).max(0.0),
|
||||
TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
|
||||
TabAlign::Decimal => (to - seg.1).max(0.0),
|
||||
};
|
||||
}
|
||||
}
|
||||
let d = if default_tab > 1.0 { default_tab } else { 72.0 };
|
||||
|
|
@ -1094,11 +1561,22 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay
|
|||
l
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout {
|
||||
let r = opts.rect;
|
||||
let vert = bp.vert.unwrap_or(TextDir::Horizontal);
|
||||
// East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text
|
||||
// stands every character upright, one under the other.
|
||||
let upright_text = match vert {
|
||||
TextDir::EaVertical => Upright::Cjk,
|
||||
TextDir::Stacked | TextDir::StackedRtl => Upright::All,
|
||||
_ => Upright::None,
|
||||
};
|
||||
// Mongolian vertical and stacked text: turned like vert, their lines run from left to right
|
||||
// (as PowerPoint draws both).
|
||||
let lines_ltr = matches!(vert, TextDir::MongolianVertical | TextDir::Stacked);
|
||||
let rotation = match vert {
|
||||
TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked => 90.0,
|
||||
TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0,
|
||||
TextDir::Vertical270 => 270.0,
|
||||
TextDir::Horizontal => 0.0,
|
||||
} + bp.rot.unwrap_or(0.0);
|
||||
|
|
@ -1147,7 +1625,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
}
|
||||
None
|
||||
};
|
||||
shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number));
|
||||
shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright_text));
|
||||
}
|
||||
for (pi, p) in shaped_paras.iter().enumerate() {
|
||||
let props = &p.props;
|
||||
|
|
@ -1166,7 +1644,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
(margin + indent).max(0.0)
|
||||
};
|
||||
let rest_x = margin;
|
||||
let tabs: Vec<f64> = props.tabs.as_ref().map(|t| t.iter().map(|s| s.pos).collect()).unwrap_or_default();
|
||||
let tabs = tabs_of(props);
|
||||
let first_w = (col_w - first_x - mr).max(1.0);
|
||||
let rest_w = (col_w - rest_x - mr).max(1.0);
|
||||
let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x);
|
||||
|
|
@ -1179,7 +1657,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) {
|
||||
if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) {
|
||||
let sz = st.size;
|
||||
let (a, d) = line_metrics(face, sz);
|
||||
// The run's own fonts set the line; a fallback face (emoji, a symbol or a
|
||||
// script the run's fonts lack) does not move the baseline in PowerPoint.
|
||||
let own = Arc::ptr_eq(face, &st.face)
|
||||
|| [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f));
|
||||
let (a, d) = line_metrics(if own { face } else { &st.face }, sz);
|
||||
asc = asc.max(a);
|
||||
desc = desc.max(d);
|
||||
size = size.max(sz);
|
||||
|
|
@ -1208,8 +1690,16 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
// Logical advances; tabs measure from the start edge.
|
||||
let mut adv = Vec::with_capacity(n);
|
||||
let mut x = 0.0;
|
||||
for c in line_cells {
|
||||
let a = if c.ch == '\t' { tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0)) } else { c.adv };
|
||||
// A line that breaks at a soft hyphen ends with a visible hyphen.
|
||||
let shy_end = li + 1 < raw.len() && line_cells.last().is_some_and(|c| c.ch == '\u{AD}');
|
||||
for (k, c) in line_cells.iter().enumerate() {
|
||||
let a = if shy_end && k + 1 == n {
|
||||
p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0)
|
||||
} else if c.ch == '\t' {
|
||||
tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k))
|
||||
} else {
|
||||
c.adv
|
||||
};
|
||||
adv.push(a);
|
||||
x += a;
|
||||
}
|
||||
|
|
@ -1338,6 +1828,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
}
|
||||
let total = y;
|
||||
out.content_height = total;
|
||||
// Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the
|
||||
// first column on the right).
|
||||
let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1;
|
||||
let col_x = |c: usize| (if rtl_cols { ncols.saturating_sub(1 + c) } else { c }) as f64 * (col_w + col_gap);
|
||||
// Columns: distribute lines by height.
|
||||
let col_h = if ncols > 1 { inner.height() } else { f64::INFINITY };
|
||||
let mut col = 0usize;
|
||||
|
|
@ -1353,16 +1847,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
li.top -= dy;
|
||||
li.baseline -= dy;
|
||||
li.bottom -= dy;
|
||||
li.shift_x(col as f64 * (col_w + col_gap));
|
||||
}
|
||||
let cx = col_x(col);
|
||||
if cx != 0.0 {
|
||||
li.shift_x(cx);
|
||||
}
|
||||
}
|
||||
let used_h = if ncols > 1 { all_lines.iter().map(|(l, ..)| l.bottom).fold(0.0, f64::max) } else { total };
|
||||
out.overflow = used_h > inner.height() + 0.5 || (ncols > 1 && all_lines.last().is_some_and(|(l, ..)| l.bottom > inner.height() + 0.5));
|
||||
let anchor = bp.anchor.unwrap_or(Anchor::Top);
|
||||
let dy = match anchor {
|
||||
Anchor::Top | Anchor::Justified | Anchor::Distributed => 0.0,
|
||||
Anchor::Top => 0.0,
|
||||
Anchor::Middle => (inner.height() - used_h) / 2.0,
|
||||
Anchor::Bottom => inner.height() - used_h,
|
||||
// PowerPoint draws justified and distributed anchoring at the bottom.
|
||||
Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h,
|
||||
};
|
||||
// anchorCtr: centre the block horizontally.
|
||||
let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 };
|
||||
|
|
@ -1374,8 +1872,15 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
line.baseline += oy;
|
||||
line.bottom += oy;
|
||||
line.shift_x(ox);
|
||||
if lines_ltr {
|
||||
// The line's place mirrored across the box: the first line ends up on the left.
|
||||
let d = inner.y0 + inner.y1 - line.top - line.bottom;
|
||||
line.top += d;
|
||||
line.baseline += d;
|
||||
line.bottom += d;
|
||||
}
|
||||
let Some(p) = shaped_paras.get(line.para) else { continue };
|
||||
if let Some((s, bx, by)) = bullet.take()
|
||||
if let Some((s, bx, _)) = bullet.take()
|
||||
&& let Some((_, st)) = p.bullet.as_ref()
|
||||
{
|
||||
let (_, g) = measure_str(&st.face, st.size, &s);
|
||||
|
|
@ -1383,10 +1888,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
face: st.face.clone(),
|
||||
size: st.size,
|
||||
color: st.color,
|
||||
glyphs: g
|
||||
.into_iter()
|
||||
.map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, by + oy))
|
||||
.collect(),
|
||||
glyphs: g.into_iter().map(|(gid, x)| (gid, ox + bx + x + col_x(line.column), line.baseline)).collect(),
|
||||
cells: vec![],
|
||||
fake_bold: false,
|
||||
fake_italic: false,
|
||||
|
|
@ -1397,11 +1899,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
alpha: st.alpha,
|
||||
gradient: None,
|
||||
effects: None,
|
||||
upright: false,
|
||||
});
|
||||
}
|
||||
// Group visually consecutive cells by (style, face).
|
||||
let mut cur: Option<GlyphRun> = None;
|
||||
let mut cur_key = (usize::MAX, usize::MAX);
|
||||
let mut cur_key = (usize::MAX, usize::MAX, false, false);
|
||||
for &k in &place.order {
|
||||
let ci = line.start + k;
|
||||
let Some(cell) = p.cells.get(ci) else { continue };
|
||||
|
|
@ -1410,7 +1913,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
let base_y = line.baseline - st.baseline * st.size;
|
||||
let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size };
|
||||
let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size };
|
||||
let key = (cell.style, cell.face);
|
||||
// Small capitals are drawn smaller than the capitals beside them: a run of their own.
|
||||
let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase(), cell.upright);
|
||||
if key != cur_key {
|
||||
if let Some(r) = cur.take() {
|
||||
out.runs.push(r);
|
||||
|
|
@ -1431,12 +1935,19 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
alpha: st.alpha,
|
||||
gradient: st.gradient.clone(),
|
||||
effects: st.effects,
|
||||
upright: cell.upright,
|
||||
});
|
||||
cur_key = key;
|
||||
}
|
||||
if let Some(r) = cur.as_mut() {
|
||||
r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1));
|
||||
if cell.head == ci && !cell.glyphs.is_empty() {
|
||||
if cell.ch == '\u{AD}' && cx1 - cx > 0.01 {
|
||||
// The soft hyphen a line breaks at is drawn as a hyphen.
|
||||
if let Some(f) = p.faces.get(cell.face) {
|
||||
r.glyphs.push((f.glyph_for('-'), cx, base_y));
|
||||
r.cells.push((ci, cell.ch));
|
||||
}
|
||||
} else if cell.head == ci && !cell.glyphs.is_empty() {
|
||||
// The cluster's glyphs start at its leftmost cell; kashida (right-to-left
|
||||
// only) fills the gap on the cluster's left with tatweel glyphs.
|
||||
let cells_of =
|
||||
|
|
@ -1469,20 +1980,49 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
continue;
|
||||
}
|
||||
if let Some(hl) = st.highlight {
|
||||
out.decos.push(Deco { rect: Rect::new(cx, line.top, cx1, line.bottom), color: hl, behind: true, para: line.para });
|
||||
out.decos.push(Deco {
|
||||
rect: Rect::new(cx, line.top, cx1, line.bottom),
|
||||
color: hl,
|
||||
behind: true,
|
||||
para: line.para,
|
||||
line: DecoLine::Solid,
|
||||
});
|
||||
}
|
||||
if !cell.ch.is_whitespace() || st.underline.is_some() {
|
||||
if let Some(uc) = st.underline {
|
||||
let t = (st.size * 0.06).max(0.5);
|
||||
let uy = line.baseline + st.size * 0.12;
|
||||
out.decos.push(Deco { rect: Rect::new(cx, uy, cx1, uy + t), color: uc, behind: false, para: line.para });
|
||||
if let Some((uc, u)) = st.underline {
|
||||
// At the underline position and thickness of the run's own font.
|
||||
let k = st.size / st.face.upem.max(1.0);
|
||||
let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5));
|
||||
for (y0, y1, kind) in u.boxes(p, t) {
|
||||
out.decos.push(Deco {
|
||||
rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1),
|
||||
color: uc,
|
||||
behind: false,
|
||||
para: line.para,
|
||||
line: kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(sk) = st.strike {
|
||||
let t = (st.size * 0.05).max(0.5);
|
||||
let sy = line.baseline - st.size * 0.3;
|
||||
out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, behind: false, para: line.para });
|
||||
// At the strikeout position and thickness of the run's own font.
|
||||
let k = st.size / st.face.upem.max(1.0);
|
||||
let t = (st.face.strikeout.1 * k).max(0.5);
|
||||
let sy = line.baseline - st.face.strikeout.0 * k;
|
||||
out.decos.push(Deco {
|
||||
rect: Rect::new(cx, sy, cx1, sy + t),
|
||||
color: st.color,
|
||||
behind: false,
|
||||
para: line.para,
|
||||
line: DecoLine::Solid,
|
||||
});
|
||||
if sk == Strike::Double {
|
||||
out.decos.push(Deco { rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), color: st.color, behind: false, para: line.para });
|
||||
out.decos.push(Deco {
|
||||
rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t),
|
||||
color: st.color,
|
||||
behind: false,
|
||||
para: line.para,
|
||||
line: DecoLine::Solid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1511,13 +2051,15 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
fn merge_decos(v: Vec<Deco>) -> Vec<Deco> {
|
||||
let mut out: Vec<Deco> = Vec::with_capacity(v.len());
|
||||
for d in v {
|
||||
if let Some(last) = out.last_mut()
|
||||
&& last.color == d.color
|
||||
&& last.behind == d.behind
|
||||
&& (last.rect.y0 - d.rect.y0).abs() < 0.01
|
||||
&& (last.rect.y1 - d.rect.y1).abs() < 0.01
|
||||
&& (last.rect.x1 - d.rect.x0).abs() < 0.5
|
||||
{
|
||||
// Lines drawn together (a double underline, a strikethrough) interleave: look a few back.
|
||||
if let Some(last) = out.iter_mut().rev().take(4).find(|last| {
|
||||
last.color == d.color
|
||||
&& last.behind == d.behind
|
||||
&& last.line == d.line
|
||||
&& (last.rect.y0 - d.rect.y0).abs() < 0.01
|
||||
&& (last.rect.y1 - d.rect.y1).abs() < 0.01
|
||||
&& (last.rect.x1 - d.rect.x0).abs() < 0.5
|
||||
}) {
|
||||
last.rect.x1 = d.rect.x1;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,11 +101,55 @@ fn pairs_are_kerned_only_from_the_kern_size() {
|
|||
assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold");
|
||||
}
|
||||
|
||||
/// Width of the first paragraph "AVATAR WAVE" (kerned from 12 pt) when a second paragraph's run
|
||||
/// has character spacing `spc`.
|
||||
fn kerned_width_beside_spacing(family: &str, spc: f64) -> f64 {
|
||||
let (p, mut sh) = setup("AVATAR WAVE", 2000.0, 300.0);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
let mut second = t.paragraphs[0].clone();
|
||||
second.runs[0].props.spacing = Some(spc);
|
||||
t.paragraphs.push(second);
|
||||
for para in &mut t.paragraphs {
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
for r in &mut para.runs {
|
||||
r.props.font = Some(family.into());
|
||||
r.props.size = Some(40.0);
|
||||
r.props.kern = Some(12.0);
|
||||
}
|
||||
}
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
l.lines[0].extent.1 - l.lines[0].extent.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_spacing_in_the_body_keeps_kerning() {
|
||||
let kerns = |f: &FontFace| {
|
||||
let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::<i32>();
|
||||
w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::<Vec<_>>())
|
||||
};
|
||||
let Some(family) = first_family(LATIN_FAMILIES, kerns) else {
|
||||
eprintln!("skipped: no font that kerns AVATAR");
|
||||
return;
|
||||
};
|
||||
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
|
||||
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
|
||||
assert!(on < off);
|
||||
// Spacing elsewhere in the box leaves the pairs kerned (Calibri, Segoe UI, Times New Roman in
|
||||
// PowerPoint; only Arial drops them).
|
||||
for spc in [0.0, 0.5, -1.0, 0.6, 2.0] {
|
||||
let w = kerned_width_beside_spacing(&family, spc);
|
||||
assert!((w - on).abs() < 0.01, "{family} spc {spc}: {w} vs {on}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_stay_for_scripts_that_need_them() {
|
||||
let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag));
|
||||
let cells =
|
||||
|s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::<Vec<_>>();
|
||||
let cells = |s: &str| {
|
||||
s.chars()
|
||||
.map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true, upright: false })
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
|
||||
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
|
||||
// Arabic needs its ligatures; emoji sequences are joined by them.
|
||||
|
|
@ -264,7 +308,11 @@ fn arabic_face() -> Option<Arc<FontFace>> {
|
|||
|
||||
#[test]
|
||||
fn rtl_mixed_string_is_reordered_per_line() {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
|
||||
// An Arabic run (PowerPoint tags typed Arabic `ar-…`): its digits and brackets go with the
|
||||
// right-to-left text.
|
||||
let mut run = Run::new(MIXED);
|
||||
run.props.lang = Some("ar-SA".into());
|
||||
let (p, sh) = bidi_setup(vec![run], 2000.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
let line = &l.lines[0];
|
||||
|
|
@ -293,6 +341,20 @@ fn rtl_mixed_string_is_reordered_per_line() {
|
|||
assert_eq!(line.levels[10], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_english_run_keeps_digits_and_brackets_left_to_right() {
|
||||
// The same text in a run without a right-to-left language: PowerPoint keeps "2024 (Retrait) "
|
||||
// one left-to-right stretch, its number on the left.
|
||||
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let line = &l.lines[0];
|
||||
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
|
||||
assert!(mid(0) > mid(10), "first Arabic word right of the Latin");
|
||||
assert!(mid(4) < mid(10), "number left of the Latin word");
|
||||
assert!(mid(9) < mid(10) && mid(17) > mid(16), "brackets around Retrait, unmirrored");
|
||||
assert!(mid(19) < mid(4), "last Arabic word on the left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
|
||||
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
|
||||
|
|
@ -781,3 +843,376 @@ fn symbol_font_characters_draw_in_the_sym_font() {
|
|||
// The letters around it keep the run's font.
|
||||
assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_capitals_draw_smaller_than_the_capitals_beside_them() {
|
||||
let (p, mut sh) = setup("HxHx", 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.caps = Some(Caps::Small);
|
||||
para.runs[0].props.size = Some(40.0);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let sizes: Vec<(usize, f64)> = l.runs.iter().map(|r| (r.glyphs.len(), r.size)).collect();
|
||||
// H at 40 pt, x as a capital at 80 % (four runs: the size changes at every letter).
|
||||
assert_eq!(sizes, vec![(1, 40.0), (1, 32.0), (1, 40.0), (1, 32.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitals_keep_characters_whose_capital_is_a_sequence() {
|
||||
assert_eq!(caps_char('a'), 'A');
|
||||
assert_eq!(caps_char('dž'), 'DŽ');
|
||||
// PowerPoint draws "ß", "fi" and "ʼn" unchanged in all caps.
|
||||
assert_eq!(caps_char('ß'), 'ß');
|
||||
assert_eq!(caps_char('\u{FB01}'), '\u{FB01}');
|
||||
assert_eq!(caps_char('ʼn'), 'ʼn');
|
||||
let (p, mut sh) = setup("aß", 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.runs[0].props.caps = Some(Caps::All);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
let face = &r.face;
|
||||
if face.covers('ß') {
|
||||
assert_eq!(r.glyphs[1].0, face.glyph_for('ß'), "ß is not drawn as S");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_text_is_faded_once() {
|
||||
let (p, mut sh) = setup("Faded", 2000.0, 300.0);
|
||||
let run = &mut sh.text.as_mut().unwrap().paragraphs[0].runs[0];
|
||||
run.props.fill = Some(deckcraft_model::Fill::solid(
|
||||
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)).with(deckcraft_color::ColorTransform::Alpha(40000)),
|
||||
));
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
// 60 % transparency: 40 % opaque in all, not 40 % of 40 %.
|
||||
let opacity = r.color.a as f64 / 255.0 * r.alpha;
|
||||
assert!((opacity - 0.4).abs() < 0.01, "{opacity}");
|
||||
}
|
||||
|
||||
/// One paragraph `text` with tab stops `tabs` (position, `algn`), no bullet or indent.
|
||||
fn tabbed(text: &str, tabs: &[(f64, &str)], w: f64) -> TextLayout {
|
||||
let (p, mut sh) = setup(text, w, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(18.0);
|
||||
}
|
||||
let pp = &mut para.props;
|
||||
pp.bullet = Some(Bullet::None);
|
||||
pp.margin_left = Some(0.0);
|
||||
pp.indent = Some(0.0);
|
||||
pp.default_tab = Some(72.0);
|
||||
pp.tabs = Some(tabs.iter().map(|(pos, a)| deckcraft_model::text::TabStop { pos: *pos, align: a.to_string() }).collect());
|
||||
lay(&p, &sh, w, 300.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_stops_align_the_text_after_them() {
|
||||
let x0 = |l: &TextLayout| l.inner.x0;
|
||||
// Right: the text ends at the stop. Centre: centred on it. Decimal: the "." at the stop.
|
||||
let l = tabbed("a\tright", &[(200.0, "r")], 600.0);
|
||||
assert!((l.lines[0].extent.1 - x0(&l) - 200.0).abs() < 0.01, "{:?}", l.lines[0].extent);
|
||||
let l = tabbed("a\tmid", &[(200.0, "ctr")], 600.0);
|
||||
let (s, e) = (l.lines[0].edges[2].0, l.lines[0].edges[4].1);
|
||||
assert!(((s + e) / 2.0 - x0(&l) - 200.0).abs() < 0.01);
|
||||
let l = tabbed("x\t1234.567", &[(150.0, "dec")], 600.0);
|
||||
assert!((l.lines[0].edges[6].0 - x0(&l) - 150.0).abs() < 0.01, "the point sits at the stop");
|
||||
// Text without a point ends at a decimal stop.
|
||||
let l = tabbed("x\t12,5", &[(150.0, "dec")], 600.0);
|
||||
assert!((l.lines[0].extent.1 - x0(&l) - 150.0).abs() < 0.01);
|
||||
// Text wider than the room before a right stop starts where the tab is; it never moves back.
|
||||
let l = tabbed("x\twider than the gap", &[(20.0, "r")], 600.0);
|
||||
assert!((l.lines[0].edges[2].0 - l.lines[0].edges[0].1).abs() < 0.01);
|
||||
// A left stop and then default stops every 72 pt.
|
||||
let l = tabbed("a\tb\tc", &[(30.0, "l")], 600.0);
|
||||
assert!((l.lines[0].edges[2].0 - x0(&l) - 30.0).abs() < 0.01);
|
||||
assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_past_the_end_moves_to_the_next_line() {
|
||||
// PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next
|
||||
// line, so the text after it lands on that line's first stop.
|
||||
let l = tabbed("abcdefghij\tk", &[], 130.0);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
assert_eq!(l.lines[1].start, 10, "the tab starts the second line: {:?}", l.lines.iter().map(|x| (x.start, x.end, x.extent)).collect::<Vec<_>>());
|
||||
assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Underline decorations of "ab cd" underlined `u` at 40 pt.
|
||||
fn underline_decos(u: &str) -> (TextLayout, Vec<Deco>) {
|
||||
let (p, mut sh) = setup("ab cd", 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.size = Some(40.0);
|
||||
para.runs[0].props.underline = Some(u.into());
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let d = l.decos.iter().filter(|d| !d.behind).cloned().collect();
|
||||
(l, d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underlines_sit_at_the_font_underline_in_their_style() {
|
||||
let (l, sng) = underline_decos("sng");
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
let k = 40.0 / r.face.upem;
|
||||
let base = l.lines[0].baseline;
|
||||
assert_eq!(sng.len(), 1);
|
||||
assert!((sng[0].rect.y0 - base - r.face.underline.0 * k).abs() < 0.01, "top at the font's underline position");
|
||||
assert!((sng[0].rect.height() - (r.face.underline.1 * k).max(0.5)).abs() < 0.01);
|
||||
let t = sng[0].rect.height();
|
||||
// Double: two thin lines spanning from one thickness above the single line to its bottom.
|
||||
let (_, dbl) = underline_decos("dbl");
|
||||
assert_eq!(dbl.len(), 2);
|
||||
assert!((dbl[0].rect.y0 - (sng[0].rect.y0 - t)).abs() < 0.01 && (dbl[1].rect.y1 - sng[0].rect.y1).abs() < 0.01);
|
||||
// Heavy is thicker; dotted is dots as long as the line is thick.
|
||||
let (_, heavy) = underline_decos("heavy");
|
||||
assert!(heavy[0].rect.height() > t * 1.4);
|
||||
let (_, dotted) = underline_decos("dotted");
|
||||
assert_eq!(dotted[0].line, DecoLine::Dashed(Dash::Dot));
|
||||
let pieces = dotted[0].pieces();
|
||||
assert!(pieces.len() > 3);
|
||||
assert!((pieces[0].width() - t).abs() < 0.01 && (pieces[1].x0 - pieces[0].x1 - t).abs() < 0.01);
|
||||
// Wavy: a zigzag between the box's top and bottom.
|
||||
let (_, wavy) = underline_decos("wavy");
|
||||
let (pts, w) = wavy[0].wave().expect("a wave");
|
||||
assert!(pts.len() > 4 && w > 0.0 && w < t);
|
||||
assert!(pts[0].y < pts[1].y);
|
||||
// "words" underlines the spaces too, as PowerPoint draws it: one line.
|
||||
let (_, words) = underline_decos("words");
|
||||
assert_eq!(words.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_break_after_a_zero_width_space_or_before_an_em_dash() {
|
||||
let (allowed, _) = break_opportunities("ab\u{200B}cd", false);
|
||||
assert!(!allowed[3], "PowerPoint does not break after a zero-width space");
|
||||
let (allowed, _) = break_opportunities("ab\u{2014}cd", false);
|
||||
assert!(!allowed[2] && allowed[3], "an em dash ends a line, never starts one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_broken_at_a_soft_hyphen_shows_a_hyphen() {
|
||||
let (p, mut sh) = setup("Soft\u{AD}hyphenated\u{AD}words", 150.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.size = Some(24.0);
|
||||
let l = lay(&p, &sh, 150.0, 300.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let first = &l.lines[0];
|
||||
let shy = first.end - 1;
|
||||
assert_eq!(first.end, 5, "breaks after the soft hyphen: {:?}", l.lines.iter().map(|x| (x.start, x.end)).collect::<Vec<_>>());
|
||||
let (x0, x1) = first.edges[shy - first.start];
|
||||
assert!(x1 - x0 > 2.0, "the hyphen takes room");
|
||||
let r = l.runs.iter().find(|r| r.cells.iter().any(|c| c.0 == shy)).expect("a glyph for the soft hyphen");
|
||||
assert_eq!(r.glyphs[r.cells.iter().position(|c| c.0 == shy).unwrap()].0, r.face.glyph_for('-'));
|
||||
// Soft hyphens draw only where a line ends; in a wide box, nowhere.
|
||||
let ends: Vec<usize> = l.lines.iter().map(|x| x.end.saturating_sub(1)).collect();
|
||||
for r in &l.runs {
|
||||
for (g, c) in r.glyphs.iter().zip(&r.cells) {
|
||||
assert!(c.1 != '\u{AD}' || ends.contains(&c.0) || g.0 != r.face.glyph_for('-'), "mid-line soft hyphen drawn");
|
||||
}
|
||||
}
|
||||
let wide = lay(&p, &sh, 2000.0, 300.0);
|
||||
assert_eq!(wide.lines.len(), 1);
|
||||
assert!(wide.runs.iter().all(|r| r.cells.iter().all(|c| c.1 != '\u{AD}' || r.glyphs.iter().all(|g| g.0 != r.face.glyph_for('-')))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_kana_and_closing_brackets_follow_powerpoint_kinsoku() {
|
||||
// "っ" and "ー" never start a line: the character before them moves down too.
|
||||
let w = one_line("あいうえお") + 0.5;
|
||||
for (text, first) in [("あいうえおっかき", "あいうえ"), ("あいうえおーかき", "あいうえ"), ("あいうえおかき", "あいうえお")]
|
||||
{
|
||||
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
|
||||
assert_eq!(lines[0], first, "{text}: {lines:?}");
|
||||
}
|
||||
// A closing bracket doesn't hang like "。" does: "「。」" overflowing by "」" moves down whole.
|
||||
let w = one_line("あいうえ「。") + 0.5;
|
||||
let lines = wrapped("あいうえ「。」かき", w, |p| p.hanging_punct = Some(true));
|
||||
assert_eq!(lines[0], "あいうえ", "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonumber_schemes_follow_powerpoint() {
|
||||
// As PowerPoint's export draws them (start values as in the probe deck).
|
||||
for (scheme, n, want) in [
|
||||
("ea1ChsPlain", 4, "四"),
|
||||
("ea1ChsPeriod", 10, "十."),
|
||||
("ea1ChtPeriod", 21, "二十一."),
|
||||
("ea1JpnKorPeriod", 10, "一〇."),
|
||||
("ea1JpnChsDbPeriod", 13, "一三."),
|
||||
("arabicDbPlain", 10, "10"),
|
||||
("arabicDbPeriod", 13, "13."),
|
||||
("circleNumDbPlain", 13, "⑬"),
|
||||
("circleNumWdWhitePlain", 13, "③"),
|
||||
("circleNumWdBlackPlain", 2, "❷"),
|
||||
("thaiAlphaPeriod", 1, "ก."),
|
||||
("thaiAlphaParenR", 4, "ง)"),
|
||||
("thaiAlphaParenBoth", 7, "(ช)"),
|
||||
("thaiNumPeriod", 10, "๑๐."),
|
||||
("hindiAlphaPeriod", 4, "ई."),
|
||||
("hindiAlpha1Period", 1, "क."),
|
||||
("hindiNumParenR", 13, "१३)"),
|
||||
("hebrew2Minus", 1, "א-"),
|
||||
("arabic1Minus", 13, "ش-"),
|
||||
("arabic2Minus", 10, "ي-"),
|
||||
("arabicPlain", 10, "10"),
|
||||
] {
|
||||
assert_eq!(autonum(scheme, n), want, "{scheme} {n}");
|
||||
}
|
||||
assert_eq!(chinese_num(105), "一百零五");
|
||||
assert_eq!(chinese_num(20), "二十");
|
||||
// Past the end of an alphabet the letter doubles, like "aa".
|
||||
assert_eq!(autonum("hebrew2Minus", 23), "אא-");
|
||||
assert_eq!(autonum("thaiAlphaPeriod", 0), ".");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_vertical_text_marks_cjk_upright() {
|
||||
let (p, mut sh) = setup("日本、ABC 12", 300.0, 600.0);
|
||||
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::EaVertical);
|
||||
let l = lay(&p, &sh, 300.0, 600.0);
|
||||
assert_eq!(l.rotation, 90.0);
|
||||
let upright: Vec<bool> = l
|
||||
.runs
|
||||
.iter()
|
||||
.filter(|r| !r.cells.is_empty())
|
||||
.flat_map(|r| r.cells.iter().map(move |c| (c.1, r.upright)))
|
||||
.filter(|(c, _)| *c != ' ')
|
||||
.map(|(_, u)| u)
|
||||
.collect();
|
||||
assert_eq!(upright, vec![true, true, true, false, false, false, false, false]);
|
||||
// In vert nothing stands upright.
|
||||
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);
|
||||
let l = lay(&p, &sh, 300.0, 600.0);
|
||||
assert!(l.runs.iter().all(|r| !r.upright));
|
||||
assert!(is_upright('あ') && is_upright('、') && is_upright('A') && !is_upright('A') && !is_upright('1'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() {
|
||||
let (p, mut sh) = setup("one\ntwo", 200.0, 400.0);
|
||||
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);
|
||||
let v = lay(&p, &sh, 200.0, 400.0);
|
||||
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::MongolianVertical);
|
||||
let m = lay(&p, &sh, 200.0, 400.0);
|
||||
assert_eq!(m.rotation, 90.0);
|
||||
// In layout space (turned a quarter turn) vert puts the first line at the top, which shows on
|
||||
// the right; mongolianVert mirrors the line places, so the first line shows on the left.
|
||||
assert!(v.lines[0].top < v.lines[1].top);
|
||||
assert!(m.lines[0].top > m.lines[1].top);
|
||||
let mid = (m.inner.y0 + m.inner.y1) / 2.0;
|
||||
assert!(((m.lines[0].top + m.lines[0].bottom) / 2.0 - mid + ((v.lines[0].top + v.lines[0].bottom) / 2.0 - mid)).abs() < 0.01);
|
||||
// Stacked: every letter upright, one em apart.
|
||||
let (p, mut sh) = setup("Lil", 200.0, 400.0);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.vert = Some(deckcraft_model::text::TextDir::Stacked);
|
||||
t.paragraphs[0].runs[0].props.size = Some(20.0);
|
||||
let s = lay(&p, &sh, 200.0, 400.0);
|
||||
assert!(s.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright));
|
||||
// Its lines run left to right too.
|
||||
let (p2, mut sh2) = setup("one\ntwo", 200.0, 400.0);
|
||||
sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Stacked);
|
||||
let st = lay(&p2, &sh2, 200.0, 400.0);
|
||||
assert!(st.lines[0].top > st.lines[1].top);
|
||||
// wordArtVertRtl stacks the same way with lines from right to left.
|
||||
sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::StackedRtl);
|
||||
let sr = lay(&p2, &sh2, 200.0, 400.0);
|
||||
assert!(sr.lines[0].top < sr.lines[1].top);
|
||||
assert!(sr.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright));
|
||||
let e = &s.lines[0].edges;
|
||||
assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_after_an_emoji_keep_a_text_face() {
|
||||
// "✅ ⚠ ™": PowerPoint draws ✅ (emoji presentation) in colour and the symbols after it as text.
|
||||
let db = FontDb::global();
|
||||
if !db.cascade(&['✅'], &[], "Regular").is_color() {
|
||||
return;
|
||||
}
|
||||
let (p, sh) = bidi_setup(vec![Run::new("✅ ⚠ ™")], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
for r in &l.runs {
|
||||
for (_, c) in &r.cells {
|
||||
assert_eq!(r.face.is_color(), *c == '✅', "{c:?} drawn with {}", r.face.family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_faces_do_not_move_the_baseline() {
|
||||
// PowerPoint keeps a line's baseline and pitch from the run's own font when an emoji, a symbol
|
||||
// or a script the font lacks is drawn from another face.
|
||||
let base = |text: &str| {
|
||||
let (p, sh) = bidi_setup(vec![Run::new(text)], 2000.0, false, Align::Left);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
(l.lines[0].baseline, l.lines[0].bottom)
|
||||
};
|
||||
let plain = base("Hxg Hxg");
|
||||
for text in ["Hxg \u{1F600} Hxg", "Hxg \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}", "Hxg \u{222B}\u{2211}"] {
|
||||
let b = base(text);
|
||||
assert!((b.0 - plain.0).abs() < 0.01 && (b.1 - plain.1).abs() < 0.01, "{text:?}: {b:?} vs {plain:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strikethrough_sits_at_the_font_strikeout_position() {
|
||||
let (p, mut sh) = setup("Struck", 2000.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.size = Some(96.0);
|
||||
para.runs[0].props.strike = Some(Strike::Single);
|
||||
let l = lay(&p, &sh, 2000.0, 300.0);
|
||||
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
|
||||
let k = 96.0 / r.face.upem;
|
||||
let d = l.decos.iter().find(|d| !d.behind).expect("a strikethrough");
|
||||
assert!((l.lines[0].baseline - d.rect.y0 - r.face.strikeout.0 * k).abs() < 0.01, "top at the strikeout position");
|
||||
assert!((d.rect.height() - (r.face.strikeout.1 * k).max(0.5)).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_columns_start_on_the_right_and_just_anchoring_sits_at_the_bottom() {
|
||||
let text = "word ".repeat(200);
|
||||
let (p, mut sh) = setup(&text, 400.0, 200.0);
|
||||
let t = sh.text.as_mut().unwrap();
|
||||
t.body.columns = Some(2);
|
||||
t.body.col_spacing = Some(20.0);
|
||||
t.paragraphs[0].runs[0].props.size = Some(12.0);
|
||||
let ltr = lay(&p, &sh, 400.0, 200.0);
|
||||
sh.text.as_mut().unwrap().body.rtl_col = Some(true);
|
||||
let rtl = lay(&p, &sh, 400.0, 200.0);
|
||||
let first = |l: &TextLayout| l.lines.iter().find(|x| x.column == 0).map(|x| x.extent.0).unwrap();
|
||||
let second = |l: &TextLayout| l.lines.iter().find(|x| x.column == 1).map(|x| x.extent.0).unwrap();
|
||||
assert!(first(<r) < second(<r));
|
||||
assert!(first(&rtl) > second(&rtl), "with rtlCol the first column is on the right");
|
||||
// anchor="just" and "dist" draw like "b".
|
||||
let (p, mut sh) = setup("one line", 400.0, 300.0);
|
||||
sh.text.as_mut().unwrap().body.anchor = Some(Anchor::Bottom);
|
||||
let b = lay(&p, &sh, 400.0, 300.0).lines[0].top;
|
||||
for a in [Anchor::Justified, Anchor::Distributed] {
|
||||
sh.text.as_mut().unwrap().body.anchor = Some(a);
|
||||
assert!((lay(&p, &sh, 400.0, 300.0).lines[0].top - b).abs() < 0.01, "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_combining_marks_take_a_face_that_draws_them() {
|
||||
// A letter with marks the run's font lacks draws in a face that has them all (no boxes).
|
||||
let db = FontDb::global();
|
||||
let cluster = ['Z', '\u{0334}', '\u{0321}', '\u{031B}'];
|
||||
let run_face = db.face("Inter", "Regular");
|
||||
let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular");
|
||||
if !deckcraft_fonts::covers_cluster(&run_face, &cluster) && ["DejaVu Sans", "Segoe UI"].iter().any(|fam| db.has_family(fam)) {
|
||||
// Make sure a face with the marks is loaded, then the cluster must take one.
|
||||
for fam in ["DejaVu Sans", "Segoe UI"] {
|
||||
if db.has_family(fam) {
|
||||
let _ = db.face(fam, "Regular");
|
||||
}
|
||||
}
|
||||
let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular");
|
||||
assert!(deckcraft_fonts::covers_cluster(&f, &cluster), "{} lacks the marks", f.family);
|
||||
}
|
||||
let _ = f;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue