Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 662fca6e97 Text: note that the 1.2 line pitch was measured on one Windows install
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:19 -04:00
Brandon ThomasandClaude Opus 5.5 f2db8f30e7 Fonts: line_split guard reads plainly (clippy)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:19 -04:00
Brandon ThomasandClaude Opus 5.5 ce5a88b785 Text: tidy the line pitch test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:19 -04:00
Brandon ThomasandClaude Opus 5.5 3e62501373 Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint
PowerPoint spaces single-spaced lines 1.2 x the font size apart in every
font (measured on twenty Latin, CJK, Thai and Indic fonts at 18 and 40 pt
with PowerPoint's slide export) and puts the baseline where the font's
Windows ascent/descent split it. DeckCraft used each font's own ascent plus
descent, so Calibri lines were 15% too tight and Segoe UI ones too loose.

FontFace gains line_split (usWinAscent / (usWinAscent + usWinDescent));
line_metrics uses it with LINE_PITCH = 1.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:41:18 -04:00
31 changed files with 88 additions and 2503 deletions
-2
View File
@@ -27,8 +27,6 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
## Bundled through dependencies
Generated
-1
View File
@@ -1108,7 +1108,6 @@ dependencies = [
"image",
"kurbo",
"log",
"skrifa 0.47.0",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
]
-35
View File
@@ -24,22 +24,6 @@ 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?
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
if cluster.contains(&'\u{FE0E}') {
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);
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{200D}') && cluster.iter().any(|&c| pictographic(c)))
|| cluster.contains(&'\u{20E3}')
}
/// Does `face` draw every character of the cluster?
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
@@ -71,28 +55,9 @@ impl FontDb {
/// bundled face for its script, else an installed face for the script, else any loaded
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
if is_emoji_cluster(cluster) {
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
return f.clone();
}
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
&& let Some(f) = self.color_face_for(key, cluster)
{
return f;
}
}
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();
}
}
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() };
let script = cluster.iter().find_map(|c| script_of(*c));
-70
View File
@@ -91,8 +91,6 @@ pub struct FontFace {
pub instance: Option<harfrust::ShaperInstance>,
/// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>,
/// Colour glyph tables present (`is_color`), looked up on first use.
color: std::sync::OnceLock<bool>,
}
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
@@ -160,11 +158,6 @@ impl FontFace {
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
/// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji
/// fonts have)?
pub fn is_color(&self) -> bool {
*self.color.get_or_init(|| has_color_tables(self.data(), self.index))
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
@@ -265,13 +258,6 @@ struct SysFallback {
misses: std::collections::HashSet<char>,
}
/// Does face `index` of `data` carry colour glyph tables?
fn has_color_tables(data: &[u8], index: u32) -> bool {
use skrifa::raw::types::Tag;
let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false };
[b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some())
}
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
#[cfg(not(target_arch = "wasm32"))]
const SYSTEM_FALLBACKS: &[&str] = &[
@@ -510,7 +496,6 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
bytes,
index,
bmp: std::sync::OnceLock::new(),
color: std::sync::OnceLock::new(),
})
}
@@ -917,61 +902,6 @@ impl FontDb {
false
}
/// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers
/// `key` if none is loaded yet. Misses are remembered.
pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
if let Some(f) = pick(self) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_color_fallback(key) {
return pick(self);
}
let _ = key;
None
}
/// 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 {
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
if c.is_control() || c.is_whitespace() {
return false;
}
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
if !sys.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() > 64 << 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
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
+1 -20
View File
@@ -13,7 +13,7 @@ mod fontdb;
pub mod script;
mod shape;
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fallback::{bundled_families, covers_cluster, ligature_carets};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
pub use harfrust::Feature;
use harfrust::Tag;
@@ -136,25 +136,6 @@ mod tests {
FontDb::global().face(DEFAULT_FAMILY, "Regular")
}
#[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}🌈"] {
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
}
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
}
}
#[test]
fn colour_fonts_are_recognised() {
let db = FontDb::with_font_dirs(Vec::new());
assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0);
assert!(db.face("DeckCraft Colr Squares", "Regular").is_color());
assert!(!fontdb::last_resort_face().is_color());
}
#[test]
fn last_resort_face_parses() {
let f = fontdb::last_resort_face();
-1
View File
@@ -512,7 +512,6 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
first_slide_number: 1,
next_id: next.max(256),
embedded_fonts: vec![],
table_styles: vec![],
raw_ext: None,
};
if title_slide {
-3
View File
@@ -798,9 +798,6 @@ pub struct Presentation {
pub next_id: u32,
/// Embedded fonts (family names; bytes in `media`).
pub embedded_fonts: Vec<(String, MediaId)>,
/// Table styles defined in the file (`ppt/tableStyles.xml`).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub table_styles: Vec<table::TableStyleDef>,
/// Unmodelled presentation-level XML kept for round-trip.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
+1 -109
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::style::{ColorRef, Fill, Line};
use crate::style::{Fill, Line};
use crate::text::{Anchor, TextBody};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -55,9 +55,6 @@ pub struct Table {
pub last_col: bool,
pub band_row: bool,
pub band_col: bool,
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<Fill>,
}
impl Default for Table {
@@ -72,7 +69,6 @@ impl Default for Table {
last_col: false,
band_row: true,
band_col: false,
background: None,
}
}
}
@@ -203,110 +199,6 @@ impl Table {
}
}
/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in
/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and
/// last rows and columns, corner cells) is filled, bordered and lettered.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableStyleDef {
/// The style's GUID (`a:tableStyleId` on tables).
pub id: String,
pub name: String,
pub parts: Vec<(TablePart, TablePartStyle)>,
/// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used).
pub background: Option<TablePartStyle>,
/// The definition as read, written back unchanged.
#[serde(skip_serializing_if = "String::is_empty")]
pub raw: String,
}
impl TableStyleDef {
pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> {
self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s)
}
}
/// The parts of a table style, in the order they apply (later ones win).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TablePart {
WholeTbl,
Band1H,
Band2H,
Band1V,
Band2V,
LastCol,
FirstCol,
LastRow,
FirstRow,
SeCell,
SwCell,
NeCell,
NwCell,
}
impl TablePart {
pub const ALL: [TablePart; 13] = [
TablePart::WholeTbl,
TablePart::Band1H,
TablePart::Band2H,
TablePart::Band1V,
TablePart::Band2V,
TablePart::LastCol,
TablePart::FirstCol,
TablePart::LastRow,
TablePart::FirstRow,
TablePart::SeCell,
TablePart::SwCell,
TablePart::NeCell,
TablePart::NwCell,
];
pub fn xml(self) -> &'static str {
match self {
TablePart::WholeTbl => "wholeTbl",
TablePart::Band1H => "band1H",
TablePart::Band2H => "band2H",
TablePart::Band1V => "band1V",
TablePart::Band2V => "band2V",
TablePart::LastCol => "lastCol",
TablePart::FirstCol => "firstCol",
TablePart::LastRow => "lastRow",
TablePart::FirstRow => "firstRow",
TablePart::SeCell => "seCell",
TablePart::SwCell => "swCell",
TablePart::NeCell => "neCell",
TablePart::NwCell => "nwCell",
}
}
pub fn from_xml(s: &str) -> Option<TablePart> {
TablePart::ALL.into_iter().find(|p| p.xml() == s)
}
}
/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's
/// style index with its colour.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TablePartStyle {
pub fill: Option<Fill>,
pub fill_ref: Option<(u32, ColorRef)>,
/// Left, right, top, bottom, inside horizontal, inside vertical.
pub borders: [Option<TableBorder>; 6],
pub text_color: Option<ColorRef>,
pub bold: Option<bool>,
pub italic: Option<bool>,
}
/// A border of a table style part: a line, or a theme line style reference.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TableBorder {
Line { line: Line },
Ref { idx: u32, color: ColorRef },
}
/// Built-in table styles: (id, label). Styles are generated from the theme accents.
pub fn table_styles() -> Vec<(String, String)> {
let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())];
-6
View File
@@ -223,10 +223,6 @@ pub struct ParaProps {
pub rtl: Option<bool>,
pub font_align: Option<String>,
pub east_asian_line_break: Option<bool>,
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
pub latin_line_break: Option<bool>,
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
pub hanging_punct: Option<bool>,
}
impl ParaProps {
@@ -257,8 +253,6 @@ impl ParaProps {
self.font_align.clone_from(&p.font_align);
}
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
}
}
-2
View File
@@ -345,8 +345,6 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
rtl: p.bool("rtl"),
font_align: p.attr("fontAlgn").map(String::from),
east_asian_line_break: p.bool("eaLnBrk"),
latin_line_break: p.bool("latinLnBrk"),
hanging_punct: p.bool("hangingPunct"),
default_tab: pt(p, "defTabSz"),
..Default::default()
};
-73
View File
@@ -30,8 +30,6 @@ pub struct Imp<'a> {
pub next: u32,
pub slide_by_part: HashMap<String, SlideId>,
pub custom_show_names: HashMap<String, String>,
/// GUIDs (upper case) of the table styles the file defines.
pub table_style_ids: Vec<String>,
}
impl Imp<'_> {
@@ -232,7 +230,6 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
next: max_id.max(1),
slide_by_part: HashMap::new(),
custom_show_names: HashMap::new(),
table_style_ids: Vec::new(),
};
// Slide ids first so hyperlinks anywhere can point at slides.
let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];
@@ -272,13 +269,6 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
if let Some(dts) = pres_el.child("defaultTextStyle") {
p.default_text_style = dml::list_style(&mut imp, &pres_part, dts);
}
if let Some(r) = pres_part.rels.first_of("tableStyles")
&& let Some(d) = load(&pkg, &r.target)
{
let part = Part::new(&pkg, &r.target, &d);
p.table_styles = table_styles(&mut imp, &part, &d.root);
imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect();
}
// Masters and layouts.
let mut layout_by_part: HashMap<String, LayoutId> = HashMap::new();
@@ -515,69 +505,6 @@ fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut
}
}
/// `a:tblStyleLst`: the table styles the file defines.
fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec<deckcraft_model::table::TableStyleDef> {
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
root.children_named("tblStyle")
.take(256)
.filter_map(|st| {
let id = st.attr("styleId")?.to_ascii_uppercase();
let mut def = TableStyleDef { id, name: st.attr("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() };
if let Some(bg) = st.child("tblBg") {
let mut ps = TablePartStyle::default();
if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) {
ps.fill = dml::fill_el(imp, part, f);
}
if let Some(fr) = bg.child("fillRef") {
ps.fill_ref = Some((
fr.u32("idx").unwrap_or(0),
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
));
}
def.background = Some(ps);
}
for el in st.elements() {
let Some(kind) = TablePart::from_xml(el.local()) else { continue };
let mut ps = TablePartStyle::default();
if let Some(tx) = el.child("tcTxStyle") {
ps.text_color = dml::color(tx);
ps.bold = tx.attr("b").map(|v| v == "on");
ps.italic = tx.attr("i").map(|v| v == "on");
}
if let Some(tc) = el.child("tcStyle") {
if let Some(f) = tc.child("fill").and_then(|f| f.elements().next()) {
ps.fill = dml::fill_el(imp, part, f);
}
if let Some(fr) = tc.child("fillRef") {
ps.fill_ref = Some((
fr.u32("idx").unwrap_or(0),
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
));
}
if let Some(bd) = tc.child("tcBdr") {
for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() {
let Some(b) = bd.child(side) else { continue };
let border = if let Some(ln) = b.child("ln") {
Some(TableBorder::Line { line: dml::line(imp, part, ln) })
} else {
b.child("lnRef").map(|r| TableBorder::Ref {
idx: r.u32("idx").unwrap_or(0),
color: dml::color(r).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
})
};
if let Some(slot) = ps.borders.get_mut(i) {
*slot = border;
}
}
}
}
def.parts.push((kind, ps));
}
Some(def)
})
.collect()
}
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
let bg = csld.child("bg")?;
if let Some(pr) = bg.child("bgPr") {
+1 -6
View File
@@ -394,7 +394,6 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() };
if let Some(pr) = tbl.child("tblPr") {
t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f));
t.first_row = pr.bool("firstRow").unwrap_or(false);
t.first_col = pr.bool("firstCol").unwrap_or(false);
t.last_row = pr.bool("lastRow").unwrap_or(false);
@@ -402,11 +401,7 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
t.band_row = pr.bool("bandRow").unwrap_or(false);
t.band_col = pr.bool("bandCol").unwrap_or(false);
if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) {
// A style the file defines is drawn from its definition (by its GUID).
// DeckCraft's own styles (written with GUIDs derived from their ids) map back to them.
let up = id.trim().to_ascii_uppercase();
t.style =
if imp.table_style_ids.contains(&up) && !crate::tables::is_own_guid(&up) { up } else { crate::tables::style_from_guid(id.trim()) };
t.style = crate::tables::style_from_guid(id.trim());
}
}
t.cols = tbl
+3 -16
View File
@@ -47,12 +47,6 @@ fn own_guid(style: &str) -> String {
guid(&format!("deckcraft-table-style:{style}"))
}
/// Is `g` the GUID DeckCraft writes for one of its own styles?
pub fn is_own_guid(g: &str) -> bool {
let up = g.to_ascii_uppercase();
deckcraft_model::table::table_styles().iter().any(|(id, _)| own_guid(id) == up)
}
/// Table style GUID → our style id (or the GUID itself when unknown).
pub fn style_from_guid(g: &str) -> String {
let up = g.to_ascii_uppercase();
@@ -204,9 +198,8 @@ fn style_def(w: &mut W, id: &str, name: &str) {
w.close("a:tblStyle");
}
/// `ppt/tableStyles.xml` with definitions for the styles in `used`: ours, generated, and those the
/// opened file defined (`defs`), written back as they were read.
pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableStyleDef]) -> Vec<u8> {
/// `ppt/tableStyles.xml` with definitions for the styles in `used`.
pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
let mut w = W::new();
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
let names = deckcraft_model::table::table_styles();
@@ -216,12 +209,6 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
continue;
}
done.push(s);
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
{
w.raw(&d.raw);
continue;
}
let (_, define) = guid_for_style(s);
if !define {
continue;
@@ -244,7 +231,7 @@ mod tests {
"nwCell",
];
let used: Vec<String> = deckcraft_model::table::table_styles().into_iter().map(|(id, _)| id).collect();
let d = crate::xml::parse(&table_styles_xml(&used, &[])).unwrap();
let d = crate::xml::parse(&table_styles_xml(&used)).unwrap();
let mut n = 0;
for st in d.root.children_named("tblStyle") {
n += 1;
-2
View File
@@ -432,8 +432,6 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
.b("rtl", p.rtl)
.b("eaLnBrk", p.east_asian_line_break)
.b("latinLnBrk", p.latin_line_break)
.b("hangingPunct", p.hanging_punct)
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
let has_kids = p.line_spacing.is_some()
|| p.space_before.is_some()
+1 -1
View File
@@ -512,7 +512,7 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
pres.rels.add("tableStyles", "tableStyles.xml", false);
x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p));
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml());
let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles);
let ts = crate::tables::table_styles_xml(&x.table_styles);
x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts);
let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids);
x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml);
-3
View File
@@ -494,9 +494,6 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
.t("bandRow", t.band_row)
.t("bandCol", t.band_col),
);
if let Some(f) = &t.background {
dml::fill(w, x, o, f);
}
w.elt("a:tableStyleId", &guid);
w.close("a:tblPr");
w.open0("a:tblGrid");
-30
View File
@@ -766,33 +766,3 @@ fn a_picture_duotone_round_trips() {
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
}
#[test]
fn line_break_options_round_trip() {
let mut p = defaults::new_presentation(None);
let first = p.slides[0].id;
let s = p.slide_mut(first).expect("slide");
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
t.paragraphs[0].props.latin_line_break = Some(true);
t.paragraphs[0].props.hanging_punct = Some(false);
let q = round(&p);
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
assert_eq!(props.latin_line_break, Some(true));
assert_eq!(props.hanging_punct, Some(false));
}
#[test]
fn a_table_fill_round_trips() {
let mut p = defaults::new_presentation(None);
let mut t = Table::new(2, 2, 200.0, 40.0);
t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192))));
let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0);
sh.kind = deckcraft_model::ShapeKind::Table(t);
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(sh);
let q = round(&p);
let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") };
assert!(
matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192)))
);
}
-84
View File
@@ -1,84 +0,0 @@
//! Fills and styles read from decks written by other tools.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::style::Fill;
use deckcraft_model::{Shape, ShapeId, defaults};
const STYLE_GUID: &str = "{ABCDEF01-2345-4678-9ABC-DEF012345678}";
/// A table style definition as PowerPoint writes one: red table background, see-through white
/// bands, 2 pt blue outer borders and no inside lines.
fn style_xml() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{STYLE_GUID}"><a:tblStyle styleId="{STYLE_GUID}" styleName="Probe Style"><a:tblBg><a:fillRef idx="1"><a:srgbClr val="FF0000"/></a:fillRef></a:tblBg><a:wholeTbl><a:tcTxStyle b="on"><a:srgbClr val="00FF00"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln w="25400"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln></a:left><a:insideH><a:ln><a:noFill/></a:ln></a:insideH></a:tcBdr><a:fill><a:noFill/></a:fill></a:tcStyle></a:wholeTbl><a:band1H><a:tcStyle><a:tcBdr/><a:fill><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="20000"/></a:srgbClr></a:solidFill></a:fill></a:tcStyle></a:band1H></a:tblStyle></a:tblStyleLst>"#
)
}
/// An exported deck with a 3x2 table using the probe style.
fn table_deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0);
t.style = "medium2-accent1".into();
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Styled".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 300.0, 90.0)),
kind: deckcraft_model::ShapeKind::Table(t),
..Default::default()
});
let bytes = deckcraft_pptx::export(&p).expect("export");
// Point the table at the probe style and replace the style part.
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
let name = f.name().to_string();
if name == "ppt/tableStyles.xml" {
d = style_xml().into_bytes();
} else if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
let s = String::from_utf8(d).expect("utf8");
let start = s.find("<a:tableStyleId>").map(|i| i + "<a:tableStyleId>".len());
let end = s.find("</a:tableStyleId>");
d = match (start, end) {
(Some(a), Some(b)) => format!("{}{}{}", &s[..a], STYLE_GUID, &s[b..]).into_bytes(),
_ => s.into_bytes(),
};
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn table_styles_the_file_defines_are_read_used_and_kept() {
use deckcraft_model::table::{TableBorder, TablePart};
let p = deckcraft_pptx::import(&table_deck()).expect("import");
let def = p.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("the probe style is read");
assert_eq!(def.name, "Probe Style");
let bg = def.background.as_ref().expect("table background");
assert!(matches!(bg.fill_ref, Some((1, _))));
let whole = def.part(TablePart::WholeTbl).expect("whole table");
assert_eq!(whole.bold, Some(true));
assert!(whole.text_color.is_some());
assert!(matches!(&whole.borders[0], Some(TableBorder::Line { line }) if line.width == Some(2.0)));
assert!(matches!(&whole.borders[4], Some(TableBorder::Line { line }) if matches!(line.fill, Some(Fill::None))));
assert!(matches!(def.part(TablePart::Band1H).and_then(|b| b.fill.clone()), Some(Fill::Solid { .. })));
// The table keeps the style's GUID, so it's drawn from the definition.
let s = p.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("table");
let deckcraft_model::ShapeKind::Table(t) = &s.kind else { panic!("table") };
assert_eq!(t.style, STYLE_GUID);
// Saving writes the definition back, unchanged.
let again = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&again).expect("reimport");
let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save");
assert_eq!(def2.parts, def.parts);
}
-1
View File
@@ -19,7 +19,6 @@ vello_common = { workspace = true }
image = { workspace = true }
log = { workspace = true }
kurbo = { workspace = true }
skrifa = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
vello_cpu = { workspace = true, features = ["multithreading"] }
-212
View File
@@ -1,212 +0,0 @@
//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw
//! them: layers of glyph outlines filled with solid colours or gradients.
use deckcraft_color::Rgba;
use deckcraft_fonts::FontFace;
use kurbo::{Affine, BezPath, Point, Rect};
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
use skrifa::raw::types::BoundingBox;
use skrifa::{GlyphId, MetadataProvider};
use vello_cpu::{RenderContext, peniko};
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
const MAX_DEPTH: usize = 64;
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
/// False when the glyph has no colour version, so the caller draws the plain outline.
pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool {
let Some(font) = face.skrifa() else { return false };
let glyphs = font.color_glyphs();
let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false };
let palette: Vec<Rgba> = font
.color_palettes()
.get(0)
.map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect())
.unwrap_or_default();
// Font units are y up; the slide is y down.
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
let ok = glyph.paint(face.location(), &mut p).is_ok();
// A malformed glyph may leave layers open: close them all.
while let Some(open) = p.opened.pop() {
if open {
p.ctx.pop_layer();
}
}
p.ctx.pop_layer();
ok
}
struct Painter<'a> {
ctx: &'a mut RenderContext,
face: &'a FontFace,
base: Affine,
/// Transforms pushed by the glyph (font units), innermost last.
stack: Vec<Affine>,
palette: Vec<Rgba>,
fg: Rgba,
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
opened: Vec<bool>,
}
fn affine(t: &Transform) -> Affine {
Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
}
fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
Point::new(p.x as f64, p.y as f64)
}
fn extend(e: Extend) -> peniko::Extend {
match e {
Extend::Repeat => peniko::Extend::Repeat,
Extend::Reflect => peniko::Extend::Reflect,
_ => peniko::Extend::Pad,
}
}
fn blend(m: CompositeMode) -> peniko::BlendMode {
use peniko::{Compose, Mix};
let (mix, compose) = match m {
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
CompositeMode::Src => (Mix::Normal, Compose::Copy),
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
_ => (Mix::Normal, Compose::SrcOver),
};
peniko::BlendMode::new(mix, compose)
}
impl Painter<'_> {
fn current(&self) -> Affine {
self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY)
}
fn color(&self, index: u16, alpha: f32) -> peniko::Color {
let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) };
let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
}
fn stops(&self, stops: &[ColorStop]) -> Vec<peniko::ColorStop> {
stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect()
}
/// The glyph outline in font units, y up.
fn outline(&self, gid: GlyphId) -> BezPath {
let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32());
Affine::scale_non_uniform(1.0, -1.0) * (*p).clone()
}
fn push_clip(&mut self, path: &BezPath) {
let open = self.opened.len() < MAX_DEPTH;
if open {
self.ctx.set_transform(self.current());
self.ctx.push_clip_layer(path);
}
self.opened.push(open);
}
fn pop(&mut self) {
if self.opened.pop() == Some(true) {
self.ctx.pop_layer();
}
}
/// Fill the open clip area (a large box in the current transform) with `paint`.
fn cover(&mut self, paint: impl Into<vello_cpu::PaintType>) {
self.ctx.set_transform(self.current());
self.ctx.set_paint(paint);
self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5));
}
}
impl ColorPainter for Painter<'_> {
fn push_transform(&mut self, t: Transform) {
let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t);
self.stack.push(next);
}
fn pop_transform(&mut self) {
if self.stack.len() > 1 {
self.stack.pop();
}
}
fn push_clip_glyph(&mut self, gid: GlyphId) {
let path = self.outline(gid);
self.push_clip(&path);
}
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
self.push_clip(&kurbo::Shape::to_path(&r, 0.1));
}
fn pop_clip(&mut self) {
self.pop();
}
fn fill(&mut self, brush: Brush<'_>) {
match brush {
Brush::Solid { palette_index, alpha } => {
let c = self.color(palette_index, alpha);
self.cover(c);
}
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e));
self.cover(g);
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0))
.with_stops(self.stops(color_stops).as_slice())
.with_extend(extend(e));
self.cover(g);
}
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians())
.with_stops(self.stops(color_stops).as_slice())
.with_extend(extend(e));
self.cover(g);
}
}
}
fn push_layer(&mut self, mode: CompositeMode) {
let open = self.opened.len() < MAX_DEPTH;
if open {
self.ctx.push_blend_layer(blend(mode));
}
self.opened.push(open);
}
fn pop_layer(&mut self) {
self.pop();
}
}
-242
View File
@@ -1,242 +0,0 @@
//! Compound outlines (`a:ln/@cmpd`): double, thick-thin, thin-thick and triple lines.
//!
//! PowerPoint splits the line's width into bands across the path, measured from its left side
//! (looking along the path) to its right, as these shares of the width (read from PowerPoint's
//! export of 24 pt lines of each kind):
//! - double: 1/3 line, 1/3 gap, 1/3 line;
//! - thin-thick: 1/5 line, 1/5 gap, 3/5 line; thick-thin the mirror;
//! - triple: 1/6 line, 1/6 gap, 1/3 line, 1/6 gap, 1/6 line.
//!
//! On a shape drawn clockwise (all presets) the left side is the outside.
use deckcraft_model::style::Compound;
use kurbo::{BezPath, CubicBez, ParamCurve, PathEl, PathSeg, Point, Vec2};
/// The bands of `c` as (from, to) shares of the width, left side first.
pub fn bands(c: Compound) -> &'static [(f64, f64)] {
match c {
Compound::Single => &[(0.0, 1.0)],
Compound::Double => &[(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0)],
Compound::ThinThick => &[(0.0, 0.2), (0.4, 1.0)],
Compound::ThickThin => &[(0.0, 0.6), (0.8, 1.0)],
Compound::Triple => &[(0.0, 1.0 / 6.0), (2.0 / 6.0, 4.0 / 6.0), (5.0 / 6.0, 1.0)],
}
}
/// Unit normal pointing to the right of travel (y down), or None for a zero-length tangent.
fn right(t: Vec2) -> Option<Vec2> {
let l = t.hypot();
(l > 1e-12 && l.is_finite()).then(|| Vec2::new(-t.y, t.x) / l)
}
/// `path` moved `d` to the right of travel (negative: to the left). Line corners meet at their
/// miter point (bevelled when sharper than about 11°); curves are offset by kurbo; other joins are
/// bridged by a straight line.
pub fn offset(path: &BezPath, d: f64, tolerance: f64) -> BezPath {
let mut out = BezPath::new();
if d.abs() < 1e-9 {
return path.clone();
}
// Each subpath as segments, offset one by one.
let mut segs: Vec<PathSeg> = Vec::new();
let mut closed = false;
let flush = |segs: &mut Vec<PathSeg>, closed: bool, out: &mut BezPath| {
let pieces: Vec<(PathSeg, BezPath)> = segs.iter().filter_map(|s| offset_seg(*s, d, tolerance).map(|p| (*s, p))).collect();
let begin = out.elements().len();
for (i, (seg, piece)) in pieces.iter().enumerate() {
let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() else { continue };
match i.checked_sub(1).and_then(|j| pieces.get(j)) {
None => out.move_to(p),
Some((prev, _)) => join(out, *prev, *seg, p, d),
}
for e in piece.elements().iter().skip(1) {
out.push(*e);
}
}
if closed && let (Some((last, _)), Some((first, piece))) = (pieces.last(), pieces.first()) {
if let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() {
// Lines meeting at the start: both ends move to their miter point.
match miter(*last, *first, d) {
Some(m) if is_line(*last) && is_line(*first) => {
set_last(out, m);
if let Some(PathEl::MoveTo(_)) = out.elements().get(begin) {
out.elements_mut()[begin] = PathEl::MoveTo(m);
}
}
_ => out.line_to(p),
}
}
out.close_path();
}
segs.clear();
};
let mut start = Point::ZERO;
let mut cur = Point::ZERO;
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
flush(&mut segs, closed, &mut out);
closed = false;
start = p;
cur = p;
}
PathEl::LineTo(p) => {
segs.push(PathSeg::Line(kurbo::Line::new(cur, p)));
cur = p;
}
PathEl::QuadTo(a, b) => {
segs.push(PathSeg::Quad(kurbo::QuadBez::new(cur, a, b)));
cur = b;
}
PathEl::CurveTo(a, b, c) => {
segs.push(PathSeg::Cubic(CubicBez::new(cur, a, b, c)));
cur = c;
}
PathEl::ClosePath => {
if (cur - start).hypot() > 1e-9 {
segs.push(PathSeg::Line(kurbo::Line::new(cur, start)));
}
closed = true;
flush(&mut segs, closed, &mut out);
closed = false;
cur = start;
}
}
}
flush(&mut segs, closed, &mut out);
out
}
fn offset_seg(s: PathSeg, d: f64, tolerance: f64) -> Option<BezPath> {
// Far-off or non-finite geometry (hostile files) is left out rather than offset.
let sane = |p: Point| p.is_finite() && p.x.abs() < 1e7 && p.y.abs() < 1e7;
let ok = match s {
PathSeg::Line(l) => sane(l.p0) && sane(l.p1),
PathSeg::Quad(q) => sane(q.p0) && sane(q.p1) && sane(q.p2),
PathSeg::Cubic(c) => sane(c.p0) && sane(c.p1) && sane(c.p2) && sane(c.p3),
};
if !ok || !d.is_finite() {
return None;
}
match s {
PathSeg::Line(l) => {
let n = right(l.p1 - l.p0)?;
let mut p = BezPath::new();
p.move_to(l.p0 + n * d);
p.line_to(l.p1 + n * d);
Some(p)
}
PathSeg::Quad(q) => offset_seg(PathSeg::Cubic(q.raise()), d, tolerance),
PathSeg::Cubic(c) => {
// Nearly straight cubics (collinear control points) are offset as lines.
let chord = c.p3 - c.p0;
let flat = |p: Point| chord.cross(p - c.p0).abs() <= 1e-9 * chord.hypot2().max(1e-18);
if flat(c.p1) && flat(c.p2) {
return offset_seg(PathSeg::Line(kurbo::Line::new(c.p0, c.p3)), d, tolerance);
}
let mut p = BezPath::new();
kurbo::offset::offset_cubic(c, d, tolerance, &mut p);
Some(p)
}
}
}
/// The segment's tangent at its start and at its end (the first non-zero control vector).
fn tangents(s: PathSeg) -> (Vec2, Vec2) {
let first = |v: &[Vec2]| v.iter().copied().find(|v| v.hypot2() > 1e-18).unwrap_or(Vec2::ZERO);
match s {
PathSeg::Line(l) => (l.p1 - l.p0, l.p1 - l.p0),
PathSeg::Quad(q) => (first(&[q.p1 - q.p0, q.p2 - q.p0]), first(&[q.p2 - q.p1, q.p2 - q.p0])),
PathSeg::Cubic(c) => (first(&[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0]), first(&[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0])),
}
}
fn is_line(s: PathSeg) -> bool {
matches!(s, PathSeg::Line(_))
}
/// Move the path's last point to `p`.
fn set_last(out: &mut BezPath, p: Point) {
if let Some(e) = out.elements_mut().last_mut() {
match e {
PathEl::MoveTo(q) | PathEl::LineTo(q) => *q = p,
PathEl::QuadTo(_, q) => *q = p,
PathEl::CurveTo(_, _, q) => *q = p,
PathEl::ClosePath => {}
}
}
}
/// Where the offsets of `prev` and `next` meet at their shared corner (their miter point), or
/// None for a corner sharper than about 11 degrees or a degenerate tangent.
fn miter(prev: PathSeg, next: PathSeg, d: f64) -> Option<Point> {
let (_, t0) = tangents(prev);
let (t1, _) = tangents(next);
let (n0, n1) = (right(t0)?, right(t1)?);
let denom = 1.0 + n0.dot(n1);
(denom > 0.02).then(|| prev.end() + (n0 + n1) * (d / denom)).filter(|m| m.is_finite())
}
/// Join the offset of `prev` (ending at `out`'s current point) to the offset of `next`, which
/// starts at `p`: two lines meet at their miter point (both ends moved there, so an inner corner
/// doesn't loop); anything else is bridged straight.
fn join(out: &mut BezPath, prev: PathSeg, next: PathSeg, p: Point, d: f64) {
match miter(prev, next, d) {
Some(m) if is_line(prev) && is_line(next) => set_last(out, m),
Some(m) => {
out.line_to(m);
out.line_to(p);
}
None => out.line_to(p),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
#[test]
fn bands_split_the_width_like_powerpoint() {
for c in [Compound::Single, Compound::Double, Compound::ThinThick, Compound::ThickThin, Compound::Triple] {
let b = bands(c);
assert_eq!(b.first().map(|x| x.0), Some(0.0));
assert_eq!(b.last().map(|x| x.1), Some(1.0));
assert!(b.windows(2).all(|w| w[0].1 < w[1].0), "{c:?}: bands in order with gaps");
}
// Thin-thick: the thin line on the left, thick-thin its mirror.
let (tt, th) = (bands(Compound::ThinThick), bands(Compound::ThickThin));
assert!((tt[0].1 - tt[0].0) < (tt[1].1 - tt[1].0));
assert!((th[0].1 - th[0].0) > (th[1].1 - th[1].0));
}
#[test]
fn offsetting_a_rectangle_grows_or_shrinks_it() {
// Clockwise in y-down coordinates: the left of travel is the outside.
let r = kurbo::Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let out = offset(&r, -5.0, 0.01).bounding_box();
assert!(
(out.x0 + 5.0).abs() < 1e-6 && (out.y0 + 5.0).abs() < 1e-6 && (out.x1 - 105.0).abs() < 1e-6 && (out.y1 - 55.0).abs() < 1e-6,
"{out:?}"
);
let inn = offset(&r, 5.0, 0.01).bounding_box();
assert!((inn.x0 - 5.0).abs() < 1e-6 && (inn.y1 - 45.0).abs() < 1e-6, "{inn:?}");
// Corners meet at their miter points: the area is the offset rectangle's.
assert!((offset(&r, -5.0, 0.01).area().abs() - 110.0 * 60.0).abs() < 1e-6);
}
#[test]
fn offsetting_a_circle_changes_its_radius() {
let c = kurbo::Circle::new((50.0, 50.0), 20.0).to_path(0.01);
let b = offset(&c, 4.0, 0.01).bounding_box();
let w = b.width() / 2.0;
assert!((w - 16.0).abs() < 0.2 || (w - 24.0).abs() < 0.2, "radius {w}");
// A hostile path never panics and yields finite output.
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((0.0, 0.0));
p.curve_to((f64::MAX, 0.0), (0.0, f64::MIN_POSITIVE), (1.0, 1.0));
p.close_path();
let _ = offset(&p, 3.0, 0.01);
}
}
+15 -187
View File
@@ -8,16 +8,10 @@
#![forbid(unsafe_code)]
mod chart;
mod colr;
mod compound;
mod images;
mod morph_path;
mod morph_text;
mod paint;
mod pattern;
mod pathgrad;
mod placed;
mod ramp;
mod table;
@@ -228,14 +222,11 @@ pub struct Renderer {
resources: Resources,
/// Outlines morphing in the frame being drawn by `blend`.
paths: Vec<PathMorph>,
/// The background of the slide (layout, master) being drawn, its placeholder colour and the
/// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
bg: Option<(Fill, Option<Rgba>, Affine)>,
}
impl Default for Renderer {
fn default() -> Self {
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None }
Renderer { resources: Resources::new(), paths: Vec::new() }
}
}
@@ -253,7 +244,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
return;
}
let (e, _) = resolve::effects(&rctx, s);
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
if e.is_some_and(|e| !e.is_empty()) {
any = true;
}
});
@@ -266,20 +257,6 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
any
}
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
/// text styles it inherits)? Such text is blurred, which needs filters.
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
use deckcraft_model::text::{ListStyle, RunProps};
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
let Some(t) = &s.text else { return false };
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|| styled(&t.list_style)
|| styled(&rctx.master.title_style)
|| styled(&rctx.master.body_style)
|| styled(&rctx.master.other_style)
}
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
@@ -612,12 +589,7 @@ impl Renderer {
let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
ctx.set_transform(view);
let path = r.to_path(0.1);
// PowerPoint lays every background over white: a picture stretched into part of the
// slide, or a see-through colour, leaves white around and under it.
ctx.set_paint(color(Rgba::WHITE, 1.0));
ctx.fill_path(&path);
paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres);
self.bg = Some((fill, ph, view));
}
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
@@ -739,16 +711,7 @@ impl Renderer {
}
ctx.set_transform(m);
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
match (fl, &self.bg) {
// `useBgFill`: the slide's background, laid out on the slide (not the
// shape), shows through the shape.
(Fill::Background, Some((bg, ph, view))) => {
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
let path = (view.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
}
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
}
paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres);
ctx.set_fill_rule(peniko::Fill::NonZero);
}
}
@@ -765,23 +728,18 @@ impl Renderer {
}
}
if let Some(r) = soft {
// Feather: the shape (fill and line) fades to nothing at its outline. Measured on
// PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of
// r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself.
// Feather: fade the shape's alpha towards the outline.
ctx.set_transform(m);
ctx.push_layer(
None,
Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)),
None,
None,
Some(blur(r / 3.0 * scale_of(m))),
Some(blur(r * 0.5 * scale_of(m))),
);
ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
ctx.fill_path(&outline);
ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut));
ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round));
ctx.stroke_path(&outline);
ctx.pop_layer();
let inset = Rect::new(0.0, 0.0, w, h).inset(-r * 0.6);
ctx.fill_rect(&inset);
ctx.pop_layer();
ctx.pop_layer();
}
@@ -793,14 +751,11 @@ impl Renderer {
ctx.push_clip_layer(&outline);
let c = rctx.color(&sh.color, fx_ph);
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
// blur radius.
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
ctx.set_paint(color(c, 1.0));
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
let mut hole = outline.clone();
hole.apply_affine(Affine::translate(-d));
hole.apply_affine(Affine::translate(d));
ring.extend(hole.iter());
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.fill_path(&ring);
@@ -871,8 +826,7 @@ impl Renderer {
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
ctx.set_transform(Affine::IDENTITY);
// Blurred by a third of the blur radius, as PowerPoint's export shows.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
ctx.set_transform(sm);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
@@ -881,12 +835,10 @@ impl Renderer {
if let Some(g) = &e.glow {
let c = rctx.color(&g.color, ph);
ctx.set_transform(Affine::IDENTITY);
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
// out from the outline and gone by r.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
ctx.set_transform(m);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
ctx.stroke_path(&silhouette);
ctx.fill_path(&silhouette);
ctx.pop_layer();
@@ -956,19 +908,6 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
}
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
}
// Compound lines: each band stroked along the path moved to its place across the width.
let compound = line.compound.unwrap_or_default();
if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 {
let tolerance = 0.1 / scale_of(m).max(1e-6);
for &(a, b) in compound::bands(compound) {
let d = (-0.5 + (a + b) / 2.0) * lw;
let mut band = sk.clone();
band.width = (b - a) * lw;
ctx.set_stroke(band);
ctx.stroke_path(&compound::offset(path, d, tolerance));
}
return;
}
ctx.set_stroke(sk);
ctx.stroke_path(path);
}
@@ -1167,15 +1106,6 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
}
// Text shadows, then glows, under all the text.
for pass in [TextEffect::Shadow, TextEffect::Glow] {
for run in &l.runs {
let ps = para(run.para);
if ps.visible && ps.opacity > 0.001 {
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
}
}
}
for run in &l.runs {
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
@@ -1185,24 +1115,8 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
continue;
}
let m = m * Affine::translate(ps.offset);
// A gradient fill starts over on every line and spans that line's text (from its top to
// 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 Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = line_at(l, run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
}
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
@@ -1210,106 +1124,20 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// 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
.iter()
.enumerate()
.filter(|(_, li)| li.para == para)
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| i)
.unwrap_or(0)
}
/// The box a line's text covers: its top and bottom, its first and last caret.
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
let r = Rect::new(x0, li.top, x1, li.bottom);
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
#[derive(Clone, Copy, PartialEq)]
enum TextEffect {
Shadow,
Glow,
}
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
let Some(fx) = run.effects else { return };
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let px = scale_of(m);
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
// past the glyph's edge and fades out by about two thirds of r.
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
_ => return,
};
let alpha = (c.a as f64 / 255.0) * opacity;
if alpha <= 0.0 {
return;
}
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
ctx.set_paint(color(c.with_alpha(255), 1.0));
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
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);
ctx.set_transform(at);
ctx.fill_path(&path);
if grow > 0.0 {
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
ctx.pop_layer();
}
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
draw_glyph_with(ctx, run, gid, at, opacity, None);
}
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
/// the glyph's origin to that space) instead of the run's colour.
fn draw_glyph_with(
ctx: &mut RenderContext,
run: &deckcraft_text::GlyphRun,
gid: u32,
at: Affine,
opacity: f64,
fill: Option<(&peniko::Gradient, Affine)>,
) {
let alpha = run.alpha * opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
return;
}
let k = run.size / run.face.upem.max(1.0);
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
// strokes the plain glyph below.
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
return;
}
let path = glyph(&run.face, gid);
if path.elements().is_empty() {
return;
}
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
ctx.set_transform(at * skew * Affine::scale(k));
if let Some((g, to_text)) = fill
&& alpha > 0.0
{
ctx.set_paint(g.clone());
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
ctx.fill_path(&path);
ctx.reset_paint_transform();
} else if alpha > 0.0 && run.color.a > 0 {
if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
+45 -43
View File
@@ -35,18 +35,8 @@ pub fn fill_path(
if g.stops.is_empty() {
return;
}
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
// Rectangle and outline paths: rasterised (see `pathgrad`).
if let GradientShape::Path { path: kind, focus } = &g.shape
&& matches!(kind.as_str(), "rect" | "shape")
{
crate::pathgrad::fill(ctx, kind, *focus, &crate::ramp::expand(&stops), bounds, path, m, alpha);
return;
}
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
@@ -82,11 +72,55 @@ pub fn fill_path(
Fill::Pattern(p) => {
let fg = rctx.color(&p.fg, ph);
let bg = rctx.color(&p.bg, ph);
crate::pattern::fill(ctx, &p.preset, fg, bg, alpha, path, m);
ctx.set_paint(color(bg, alpha));
ctx.fill_path(path);
ctx.push_clip_layer(path);
ctx.set_paint(color(fg, alpha));
pattern(ctx, &p.preset, bounds, m);
ctx.pop_layer();
}
}
}
/// Draw a pattern's foreground marks over `bounds` (8 pt cells).
fn pattern(ctx: &mut RenderContext, preset: &str, b: Rect, m: Affine) {
let k = crate::scale_of(m);
let cell = 6.0 / if k.is_finite() { k.clamp(0.25, 4.0) } else { 1.0 };
let cell = cell.max(2.0);
let mut p = BezPath::new();
let (nx, ny) = (((b.width() / cell).ceil() as i64).clamp(0, 2000), ((b.height() / cell).ceil() as i64).clamp(0, 2000));
let lw = cell * 0.18;
let dense = preset.starts_with("dk") || preset.contains("50") || preset.contains("75") || preset.contains("90");
for j in 0..=ny {
for i in 0..=nx {
let x = b.x0 + i as f64 * cell;
let y = b.y0 + j as f64 * cell;
let r = match preset {
p if p.contains("Horz") || p == "horz" || p.contains("horzBrick") => Rect::new(x, y, x + cell, y + lw),
p if p.contains("Vert") || p == "vert" => Rect::new(x, y, x + lw, y + cell),
p if p.contains("cross") || p.contains("Grid") || p.contains("grid") => {
p_add(&mut p_rect(x, y, cell, lw), &mut BezPath::new());
Rect::new(x, y, x + cell, y + lw).union(Rect::new(x, y, x + lw, y + cell))
}
_ => {
let d = if dense { cell * 0.35 } else { cell * 0.2 };
Rect::new(x, y, x + d, y + d)
}
};
p.extend(r.to_path(0.1).iter());
if preset.contains("cross") || preset.contains("Grid") || preset.contains("grid") {
p.extend(Rect::new(x, y, x + lw, y + cell).to_path(0.1).iter());
}
}
}
ctx.fill_path(&p);
}
fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
Rect::new(x, y, x + w, y + h).to_path(0.1)
}
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
/// cache can apply and key them without a colour scheme.
@@ -97,38 +131,6 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::b
std::borrow::Cow::Owned(out)
}
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
for s in &mut stops {
s.0 = s.0.clamp(0.0, 1.0);
}
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
match shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
}
}
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
/// GIF shows its frame at `gif_time` seconds.
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
-220
View File
@@ -1,220 +0,0 @@
//! Path gradients (`a:path`) that follow a rectangle or the shape's own outline, which no vello
//! gradient draws: they're rasterised into a small image of the bounds and painted with it.
//!
//! Measured on PowerPoint's export (black to grey to white from the centre):
//! - `rect`: the colour position is the larger of the horizontal and vertical distances from the
//! focus, each as a share of the distance to that side's edge (X-shaped contours);
//! - `shape`: contours are the outline scaled about the focus (an ellipse gives ellipses, a
//! triangle triangles): the share of the way from the focus to the outline along the same ray.
//!
//! `circle` stays a radial vello gradient (see `paint::gradient`).
use std::sync::Arc;
use deckcraft_color::Rgba;
use kurbo::{Affine, BezPath, PathEl, Point, Rect};
use vello_cpu::{Pixmap, RenderContext, peniko};
/// Longest side of the rasterised gradient, in pixels (bilinear sampling smooths the rest).
const MAX_SIDE: f64 = 384.0;
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
/// shape's flattened polygon(s) for `shape`.
pub fn position(kind: &str, bounds: Rect, focus: [f64; 4], outline: &[Vec<Point>], p: Point) -> f64 {
let f = Rect::new(
bounds.x0 + bounds.width() * focus[0].clamp(0.0, 1.0),
bounds.y0 + bounds.height() * focus[1].clamp(0.0, 1.0),
bounds.x1 - bounds.width() * focus[2].clamp(0.0, 1.0),
bounds.y1 - bounds.height() * focus[3].clamp(0.0, 1.0),
);
let f = if f.x0 <= f.x1 && f.y0 <= f.y1 { f } else { Rect::from_center_size(f.center(), (0.0, 0.0)) };
match kind {
"shape" if !outline.is_empty() => shape_position(outline, f.center(), p),
_ => {
// Distance outside the focus rect along each axis, over the room to that side's edge.
let axis = |v: f64, lo: f64, hi: f64, edge_lo: f64, edge_hi: f64| {
if v < lo {
(lo - v) / (lo - edge_lo).max(1e-9)
} else if v > hi {
(v - hi) / (edge_hi - hi).max(1e-9)
} else {
0.0
}
};
axis(p.x, f.x0, f.x1, bounds.x0, bounds.x1).max(axis(p.y, f.y0, f.y1, bounds.y0, bounds.y1)).clamp(0.0, 1.0)
}
}
}
/// The share of the way from `c` to the outline along the ray through `p` (1 when the ray meets
/// no edge).
fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
let d = p - c;
let len = d.hypot();
if len < 1e-9 {
return 0.0;
}
// Nearest crossing of the ray c + s·d (s > 0) with an edge.
let mut best = f64::INFINITY;
for poly in outline {
for w in poly.windows(2) {
let (a, b) = (w[0], w[1]);
let e = b - a;
let den = d.cross(e);
if den.abs() < 1e-12 {
continue;
}
let ac = a - c;
let s = ac.cross(e) / den;
let u = ac.cross(d) / den;
if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) {
best = best.min(s);
}
}
}
if best.is_finite() { (1.0 / best).clamp(0.0, 1.0) } else { 1.0 }
}
/// The outline flattened into closed polygons.
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
let mut out: Vec<Vec<Point>> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
kurbo::flatten(path.iter(), 0.25, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![p];
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
for poly in &mut out {
if let (Some(f), Some(l)) = (poly.first().copied(), poly.last().copied())
&& (f - l).hypot() > 1e-9
{
poly.push(f);
}
}
out.retain(|p| p.len() > 2 && p.len() < 100_000);
out
}
/// A 256-entry colour ramp for the sorted, already expanded `stops` (linear in sRGB between them).
pub fn ramp(stops: &[(f64, Rgba)]) -> Vec<Rgba> {
(0..256)
.map(|i| {
let t = i as f64 / 255.0;
let after = stops.iter().position(|s| s.0 >= t);
match after {
Some(0) | None => stops.get(after.unwrap_or(stops.len().saturating_sub(1))).map(|s| s.1).unwrap_or(Rgba::BLACK),
Some(j) => {
let (Some(a), Some(b)) = (stops.get(j - 1), stops.get(j)) else { return Rgba::BLACK };
let k = if b.0 - a.0 > 1e-12 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
a.1.lerp(b.1, k)
}
}
})
.collect()
}
/// Fill `path` with a `rect` or `shape` path gradient of `stops` (sorted and expanded, see
/// `ramp::expand`) over `bounds`, faded by `alpha`; `m` maps the path to the output.
#[allow(clippy::too_many_arguments)]
pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64, Rgba)], bounds: Rect, path: &BezPath, m: Affine, alpha: f64) {
if bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() || stops.is_empty() {
return;
}
let k = crate::scale_of(m);
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
let longest = bounds.width().max(bounds.height()) * k;
let s = (MAX_SIDE / longest).min(1.0) * k;
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
let outline = if kind == "shape" { polygons(path) } else { Vec::new() };
let lut = ramp(stops);
let a = alpha.clamp(0.0, 1.0);
let mut data = Vec::with_capacity((pw * ph) as usize);
for j in 0..ph {
for i in 0..pw {
let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height());
let t = position(kind, bounds, focus, &outline, p);
let c = lut.get((t * 255.0).round().clamp(0.0, 255.0) as usize).copied().unwrap_or(Rgba::BLACK);
let ca = (c.a as f64 * a).round().clamp(0.0, 255.0);
let mul = |v: u8| (v as f64 * ca / 255.0).round().clamp(0.0, 255.0) as u8;
data.push(vello_cpu::color::PremulRgba8 { r: mul(c.r), g: mul(c.g), b: mul(c.b), a: ca as u8 });
}
}
let pm = Arc::new(Pixmap::from_parts(data, pw as u16, ph as u16));
ctx.set_transform(m);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() },
});
ctx.set_paint_transform(
Affine::translate(bounds.origin().to_vec2()) * Affine::scale_non_uniform(bounds.width() / pw as f64, bounds.height() / ph as f64),
);
ctx.fill_path(path);
ctx.reset_paint_transform();
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
const CENTRE: [f64; 4] = [0.5, 0.5, 0.5, 0.5];
#[test]
fn rect_paths_take_the_larger_axis_share() {
// PowerPoint, 280x200 box from the centre: 20 px across is 1/7, 20 px down is 1/5.
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
let at = |x: f64, y: f64| position("rect", b, CENTRE, &[], Point::new(x, y));
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 1e-9);
assert!((at(140.0, 120.0) - 20.0 / 100.0).abs() < 1e-9);
assert!((at(168.0, 120.0) - 0.2).abs() < 1e-9, "the diagonal takes the larger share");
assert_eq!(at(0.0, 0.0), 1.0);
// From a corner (fillToRect 0,0,100%,100%): the far corner is the end.
let corner = [0.0, 0.0, 1.0, 1.0];
assert!((position("rect", b, corner, &[], Point::new(140.0, 50.0)) - 0.5).abs() < 1e-9);
}
#[test]
fn shape_paths_follow_the_outline() {
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01));
let at = |x: f64, y: f64| position("shape", b, CENTRE, &ellipse, Point::new(x, y));
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 0.01);
assert!((at(140.0, 120.0) - 0.2).abs() < 0.01);
// Half way to the outline along any ray is 0.5.
let (dx, dy) = (140.0 * 0.6, 100.0 * 0.8);
assert!((at(140.0 + dx * 0.5, 100.0 + dy * 0.5) - 0.5).abs() < 0.01);
// A triangle: half way from its centre to its apex is 0.5.
let mut tri = BezPath::new();
tri.move_to((140.0, 0.0));
tri.line_to((280.0, 200.0));
tri.line_to((0.0, 200.0));
tri.close_path();
let polys = polygons(&tri);
assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6);
}
#[test]
fn the_ramp_runs_through_the_stops() {
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
let r = ramp(&stops);
assert_eq!(r.len(), 256);
assert_eq!(r[0], Rgba::rgb(0, 0, 0));
assert_eq!(r[255], Rgba::rgb(255, 255, 255));
assert!((r[64].r as i32 - 64).abs() <= 2);
assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9)));
}
}
-208
View File
@@ -1,208 +0,0 @@
//! Preset pattern fills (`a:pattFill`): an 8×8 two-colour tile, each cell one 96-dpi pixel
//! (0.75 pt) of the slide, as PowerPoint draws them.
//!
//! The tiles were read from PowerPoint's own slide export of every preset (black on white, at
//! exactly two output pixels per pattern pixel), so the marks, their density and their phase
//! match it. Bit 7 of each row is the leftmost cell; a set bit is the foreground colour.
use std::sync::Arc;
use deckcraft_color::Rgba;
use kurbo::{Affine, BezPath};
use vello_cpu::{Pixmap, RenderContext, peniko};
/// Side of one pattern cell, in points.
pub const CELL: f64 = 0.75;
const TILES: &[(&str, [u8; 8])] = &[
("pct5", [0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00]),
("pct10", [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00]),
("pct20", [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00]),
("pct25", [0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22]),
("pct30", [0xAA, 0x44, 0xAA, 0x11, 0xAA, 0x44, 0xAA, 0x11]),
("pct40", [0xAA, 0x55, 0xAA, 0x51, 0xAA, 0x55, 0xAA, 0x15]),
("pct50", [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]),
("pct60", [0xEE, 0x55, 0xBB, 0x55, 0xEE, 0x55, 0xBB, 0x55]),
("pct70", [0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD]),
("pct75", [0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF]),
("pct80", [0xEF, 0xFF, 0xFE, 0xFF, 0xEF, 0xFF, 0xFE, 0xFF]),
("pct90", [0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF, 0x7F]),
("horz", [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
("vert", [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("ltHorz", [0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]),
("ltVert", [0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88]),
("dkHorz", [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00]),
("dkVert", [0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC]),
("narHorz", [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00]),
("narVert", [0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55]),
("dashHorz", [0xF0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00]),
("dashVert", [0x80, 0x80, 0x80, 0x80, 0x08, 0x08, 0x08, 0x08]),
("cross", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("dnDiag", [0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01]),
("upDiag", [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]),
("ltDnDiag", [0x88, 0x44, 0x22, 0x11, 0x88, 0x44, 0x22, 0x11]),
("ltUpDiag", [0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88]),
("dkDnDiag", [0xCC, 0x66, 0x33, 0x99, 0xCC, 0x66, 0x33, 0x99]),
("dkUpDiag", [0x33, 0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99]),
("wdDnDiag", [0xC1, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x83]),
("wdUpDiag", [0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xC1]),
("dashDnDiag", [0x00, 0x00, 0x88, 0x44, 0x22, 0x11, 0x00, 0x00]),
("dashUpDiag", [0x00, 0x00, 0x11, 0x22, 0x44, 0x88, 0x00, 0x00]),
("diagCross", [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81]),
("smCheck", [0x99, 0x66, 0x66, 0x99, 0x99, 0x66, 0x66, 0x99]),
("lgCheck", [0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F]),
("smGrid", [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88]),
("lgGrid", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("dotGrid", [0xAA, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00]),
("smConfetti", [0x80, 0x08, 0x40, 0x02, 0x10, 0x01, 0x20, 0x04]),
("lgConfetti", [0xB1, 0x30, 0x03, 0x1B, 0xD8, 0xC0, 0x0C, 0x8D]),
("horzBrick", [0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08]),
("diagBrick", [0x01, 0x02, 0x04, 0x08, 0x18, 0x24, 0x42, 0x81]),
("solidDmnd", [0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x10, 0x00]),
("openDmnd", [0x82, 0x44, 0x28, 0x10, 0x28, 0x44, 0x82, 0x01]),
("dotDmnd", [0x80, 0x00, 0x22, 0x00, 0x08, 0x00, 0x22, 0x00]),
("plaid", [0xAA, 0x55, 0xAA, 0x55, 0xF0, 0xF0, 0xF0, 0xF0]),
("sphere", [0x77, 0x89, 0x8F, 0x8F, 0x77, 0x98, 0xF8, 0xF8]),
("weave", [0x88, 0x54, 0x22, 0x45, 0x88, 0x14, 0x22, 0x51]),
("divot", [0x00, 0x10, 0x08, 0x10, 0x00, 0x80, 0x01, 0x80]),
("shingle", [0x03, 0x84, 0x48, 0x30, 0x0C, 0x02, 0x01, 0x01]),
("wave", [0x00, 0x18, 0x25, 0xC0, 0x00, 0x18, 0x25, 0xC0]),
("trellis", [0xFF, 0x66, 0xFF, 0x99, 0xFF, 0x66, 0xFF, 0x99]),
("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]),
];
/// The 8×8 tile of preset `name` (`pct50` for an unknown name).
pub fn tile(name: &str) -> [u8; 8] {
TILES
.iter()
.find(|(n, _)| *n == name)
.or_else(|| TILES.iter().find(|(n, _)| *n == "pct50"))
.map(|(_, t)| *t)
.unwrap_or([0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55])
}
/// Is cell (`x`, `y`) of the tile the foreground?
pub fn is_fg(tile: &[u8; 8], x: usize, y: usize) -> bool {
tile.get(y % 8).is_some_and(|row| row & (0x80 >> (x % 8)) != 0)
}
fn premul(c: Rgba, alpha: f64) -> vello_cpu::color::PremulRgba8 {
let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0);
let m = |v: u8| (v as f64 * a / 255.0).round().clamp(0.0, 255.0) as u8;
vello_cpu::color::PremulRgba8 { r: m(c.r), g: m(c.g), b: m(c.b), a: a as u8 }
}
/// Fill `path` (in the space `m` maps to the output) with preset `name` in `fg` on `bg`, faded by
/// `alpha`.
pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64, path: &BezPath, m: Affine) {
let det = m.determinant();
if !det.is_finite() || det.abs() <= 1e-12 {
return;
}
let t = tile(name);
let (f, b) = (premul(fg, alpha), premul(bg, alpha));
let data: Vec<vello_cpu::color::PremulRgba8> = (0..64).map(|i| if is_fg(&t, i % 8, i / 8) { f } else { b }).collect();
let pm = Arc::new(Pixmap::from_parts(data, 8, 8));
ctx.set_transform(m);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler {
x_extend: peniko::Extend::Repeat,
y_extend: peniko::Extend::Repeat,
quality: peniko::ImageQuality::Low,
alpha: 1.0,
},
});
// Cells are laid out on the output from its origin (the slide's top-left corner), so
// neighbouring shapes' patterns line up, as in PowerPoint.
let k = crate::scale_of(m);
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
ctx.set_paint_transform(m.inverse() * Affine::scale(CELL * k));
ctx.fill_path(path);
ctx.reset_paint_transform();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_drawingml_preset_has_a_tile() {
// ECMA-376 ST_PresetPatternVal.
let all = [
"pct5",
"pct10",
"pct20",
"pct25",
"pct30",
"pct40",
"pct50",
"pct60",
"pct70",
"pct75",
"pct80",
"pct90",
"horz",
"vert",
"ltHorz",
"ltVert",
"dkHorz",
"dkVert",
"narHorz",
"narVert",
"dashHorz",
"dashVert",
"cross",
"dnDiag",
"upDiag",
"ltDnDiag",
"ltUpDiag",
"dkDnDiag",
"dkUpDiag",
"wdDnDiag",
"wdUpDiag",
"dashDnDiag",
"dashUpDiag",
"diagCross",
"smCheck",
"lgCheck",
"smGrid",
"lgGrid",
"dotGrid",
"smConfetti",
"lgConfetti",
"horzBrick",
"diagBrick",
"solidDmnd",
"openDmnd",
"dotDmnd",
"plaid",
"sphere",
"weave",
"divot",
"shingle",
"wave",
"trellis",
"zigZag",
];
assert_eq!(all.len(), 54);
for n in all {
assert!(TILES.iter().any(|(t, _)| *t == n), "{n}");
}
assert_eq!(TILES.len(), 54);
}
#[test]
fn percent_presets_get_denser() {
// The pctN tiles set more cells as N grows (PowerPoint's own: pct5 sets 2 of 64, pct50
// half, pct90 all but 2).
let share = |n: &str| {
let t = tile(n);
(0..64).filter(|i| is_fg(&t, i % 8, i / 8)).count()
};
let names = ["pct5", "pct10", "pct20", "pct25", "pct30", "pct40", "pct50", "pct60", "pct70", "pct75", "pct80", "pct90"];
assert!(names.windows(2).all(|w| share(w[0]) <= share(w[1])), "{:?}", names.map(share));
assert_eq!((share("pct5"), share("pct50"), share("pct90")), (2, 32, 62));
assert_eq!(tile("nonsense"), tile("pct50"));
}
}
+11 -149
View File
@@ -3,7 +3,7 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::resolve::Ctx;
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::table::{Cell, Table, TableBorder, TablePart, TablePartStyle, TableStyleDef};
use deckcraft_model::table::{Cell, Table};
use deckcraft_model::text::{Anchor, TextBody, TextDir};
use deckcraft_model::{Presentation, Shape};
use deckcraft_text::Fields;
@@ -18,12 +18,6 @@ pub struct CellLook {
pub text: Option<Rgba>,
pub bold: bool,
pub border: Option<(Rgba, f64)>,
/// Left, right, top and bottom borders from a file-defined style (a fully transparent colour
/// means no border); `None` falls back to `border`.
pub sides: [Option<(Rgba, f64)>; 4],
/// A file-defined style's fill as a paint (gradients, see-through colours), with its
/// placeholder colour; drawn instead of `fill`.
pub paint: Option<(Fill, Option<Rgba>)>,
}
fn accent(ctx: &Ctx, style: &str) -> Rgba {
@@ -39,109 +33,7 @@ fn accent(ctx: &Ctx, style: &str) -> Rgba {
ctx.color(&ColorRef::scheme(slot), None)
}
/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to
/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the
/// fill, text and the borders on its region's edges (its inside lines within it).
fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook {
let (rows, cols) = (t.rows.len(), t.cols.len());
let (r1, c1) = t.cell(r, c).map(|cell| (r + cell.row_span.max(1) as usize - 1, c + cell.grid_span.max(1) as usize - 1)).unwrap_or((r, c));
let (r1, c1) = (r1.min(rows.saturating_sub(1)), c1.min(cols.saturating_sub(1)));
let last_r = rows.saturating_sub(1);
let last_c = cols.saturating_sub(1);
let header = t.first_row && r == 0;
let total = t.last_row && r1 == last_r && rows > 1;
let first_col = t.first_col && c == 0;
let last_col = t.last_col && c1 == last_c && cols > 1;
let body_r = r.saturating_sub(usize::from(t.first_row));
let body_c = c.saturating_sub(usize::from(t.first_col));
let mut look = CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None };
for part in TablePart::ALL {
// (applies, region rows, region cols)
let (on, region) = match part {
TablePart::WholeTbl => (true, (0, last_r, 0, last_c)),
TablePart::Band1H => (t.band_row && !header && !total && body_r % 2 == 0, (r, r1, 0, last_c)),
TablePart::Band2H => (t.band_row && !header && !total && body_r % 2 == 1, (r, r1, 0, last_c)),
TablePart::Band1V => (t.band_col && !first_col && !last_col && body_c % 2 == 0, (0, last_r, c, c1)),
TablePart::Band2V => (t.band_col && !first_col && !last_col && body_c % 2 == 1, (0, last_r, c, c1)),
TablePart::LastCol => (last_col, (0, last_r, c, last_c)),
TablePart::FirstCol => (first_col, (0, last_r, 0, c1)),
TablePart::LastRow => (total, (r, last_r, 0, last_c)),
TablePart::FirstRow => (header, (0, r1, 0, last_c)),
TablePart::SeCell => (total && last_col, (r, r1, c, c1)),
TablePart::SwCell => (total && first_col, (r, r1, c, c1)),
TablePart::NeCell => (header && last_col, (r, r1, c, c1)),
TablePart::NwCell => (header && first_col, (r, r1, c, c1)),
};
if !on {
continue;
}
let Some(ps) = def.part(part) else { continue };
if let Some(p) = style_fill(ctx, ps) {
look.fill = fill_color(ctx, &p.0, p.1);
look.paint = Some(p);
}
if let Some(tc) = &ps.text_color {
look.text = Some(ctx.color(tc, None));
}
if let Some(b) = ps.bold {
look.bold = b;
}
let (r0, rr1, c0, cc1) = region;
// Left, right, top, bottom of the cell: the part's edge or its inside line.
let pick = [if c == c0 { 0 } else { 5 }, if c1 == cc1 { 1 } else { 5 }, if r == r0 { 2 } else { 4 }, if r1 == rr1 { 3 } else { 4 }];
for (side, i) in pick.iter().enumerate() {
if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) {
*slot = Some(border_look(ctx, b));
}
}
}
look
}
/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as
/// the placeholder colour.
fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option<Rgba>)> {
if let Some((idx, col)) = &ps.fill_ref {
let ph = ctx.color(col, None);
return Some(match ctx.master.theme.format.fills.get((*idx as usize).saturating_sub(1)) {
Some(f) if *idx > 0 => (f.clone(), Some(ph)),
_ if *idx == 0 => (Fill::None, None),
_ => (Fill::solid(ColorRef::rgb(ph)), None),
});
}
ps.fill.clone().map(|f| (f, None))
}
/// A fill's colour for a table cell (gradients and pictures by their first colour).
fn fill_color(ctx: &Ctx, f: &Fill, ph: Option<Rgba>) -> Option<Rgba> {
match f {
Fill::None => Some(Rgba::TRANSPARENT),
Fill::Solid { color: c } => Some(ctx.color(c, ph)),
Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)),
Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)),
_ => None,
}
}
/// A style border's colour and width (a transparent colour for no line).
fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
match b {
TableBorder::Line { line } => match &line.fill {
Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)),
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), line.width.unwrap_or(0.75)),
_ => (Rgba::TRANSPARENT, 0.0),
},
TableBorder::Ref { idx, color } => {
let w = ctx.master.theme.format.lines.get((*idx as usize).saturating_sub(1)).and_then(|l| l.width).unwrap_or(0.75);
if *idx == 0 { (Rgba::TRANSPARENT, 0.0) } else { (ctx.color(color, None), w) }
}
}
}
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
return look_def(ctx, def, t, r, c);
}
let a = accent(ctx, &t.style);
let kind = t.style.split('-').next().unwrap_or("medium2");
let header = t.first_row && r == 0;
@@ -155,23 +47,19 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
let white = Rgba::WHITE;
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
match kind {
"none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None },
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None },
"none" => CellLook { fill: None, text: None, bold: false, border: None },
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) },
"light1" => CellLook {
fill: if band || band_c { Some(tint(20000)) } else { None },
text: None,
bold: header || total || first_col || last_col,
border: if header { Some((a, 1.0)) } else { None },
sides: [None; 4],
paint: None,
},
"light2" => CellLook {
fill: if header { Some(a) } else { None },
text: if header { Some(white) } else { None },
bold: header || total || first_col,
border: Some((a, 1.0)),
sides: [None; 4],
paint: None,
},
"dark1" => CellLook {
fill: Some(if header {
@@ -184,16 +72,12 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
text: Some(white),
bold: header || total || first_col,
border: Some((white, 1.0)),
sides: [None; 4],
paint: None,
},
"medium4" => CellLook {
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
text: None,
bold: header || total || first_col || last_col,
border: Some((a, 0.75)),
sides: [None; 4],
paint: None,
},
_ => CellLook {
// medium2: strong header, banded light tints, white grid.
@@ -207,8 +91,6 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
text: if header || total || first_col || last_col { Some(white) } else { None },
bold: header || total || first_col || last_col,
border: Some((white, 1.0)),
sides: [None; 4],
paint: None,
},
}
}
@@ -300,21 +182,6 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
let _ = pres;
let rects = cell_rects(t, &row_heights(rctx, t, fields));
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
let style_bg = rctx
.pres
.table_styles
.iter()
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
.and_then(|d| d.background.as_ref())
.and_then(|b| style_fill(rctx, b));
let own_bg = t.background.clone().map(|f| (f, None));
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
ctx.set_transform(m);
crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
}
}
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
@@ -323,17 +190,12 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
Some(f) => {
crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
}
None => match &lk.paint {
Some((f, ph)) => {
crate::paint::fill_path(ctx, rctx, f, *ph, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
None => {
if let Some(fc) = lk.fill {
ctx.set_paint(color(fc, 1.0));
ctx.fill_rect(rect);
}
None => {
if let Some(fc) = lk.fill.filter(|f| f.a > 0) {
ctx.set_paint(color(fc, 1.0));
ctx.fill_rect(rect);
}
}
},
}
}
}
// Borders: explicit cell borders, else the style's grid.
@@ -354,9 +216,9 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
Some(Fill::None) => continue,
_ => continue,
},
None => match lk.sides.get(i).copied().flatten().or(lk.border) {
Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
_ => continue,
None => match lk.border {
Some(b) => b,
None => continue,
},
};
let mut p = BezPath::new();
+3 -425
View File
@@ -119,10 +119,9 @@ fn effects_tables_charts_render() {
all.extend(charts);
let p = deck_with(all);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its
// 8 pt blur radius.
let px = img.pixel(255, 155);
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
// Shadow darkens below-right of the shape.
let px = img.pixel(258, 156);
assert!(px[0] < 250, "{px:?}");
}
#[test]
@@ -573,424 +572,3 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() {
assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}");
}
}
#[test]
fn a_shape_filled_with_the_background_shows_the_slide_background() {
// `useBgFill`: PowerPoint paints the slide's background into the shape, laid out on the
// slide. A white panel covers a black-to-grey background; a shape on it with the background
// fill is a window onto that background.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let none = || Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
let panel = Shape {
id: ShapeId(710),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: none(),
..Default::default()
};
let window = Shape {
id: ShapeId(711),
xfrm: Some(Xfrm::new(100.0, 100.0, 760.0, 340.0)),
fill: Some(Fill::Background),
line: none(),
..Default::default()
};
let mut p = deck_with(vec![panel, window]);
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill {
fill: Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
}),
});
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(img.pixel(50, 50)[0], 255, "the panel stays white outside the window");
assert!(img.pixel(300, 270)[0] < 8, "the window shows the background's black half: {:?}", img.pixel(300, 270));
// Laid out on the slide: three quarters across the slide is half way from black to grey.
let v = img.pixel(720, 270)[0] as i32;
assert!((v - 100).abs() <= 6, "{v}");
}
#[test]
fn a_background_is_laid_over_white() {
// A background picture stretched into the bottom-right quarter (fillRect l=50% t=50%), and a
// half-transparent colour: PowerPoint shows white around and under them, not transparency.
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200]))).write_to(&mut png, image::ImageFormat::Png).unwrap();
let mut p = deck_with(vec![]);
let media = p.add_media("bg.png", "image/png", png.into_inner());
let pf = PictureFill { media, mode: PictureMode::Stretch { fill_rect: [0.5, 0.5, 0.0, 0.0] }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(pf) });
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
assert_eq!(img.pixel(20, 20), [255, 255, 255, 255], "white where the picture doesn't reach");
let q = img.pixel(400, 230);
assert!(q[2] > 190 && q[0] < 10 && q[3] == 255, "{q:?}");
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 });
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(see_through) });
let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() });
let g = img.pixel(20, 20);
assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}");
}
#[test]
fn colour_glyphs_paint_their_layers() {
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
// square in its middle, U+E001 a square in the text colour.
deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec());
let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("\u{E000} \u{E001}");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(72.0);
props.font = Some("DeckCraft Colr Squares".into());
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30);
let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230);
let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30);
// 72 pt squares: the blue one is 40 % of the side (16 % of the area).
assert!(red > 2500 && blue > 500, "red {red} blue {blue}");
let share = blue as f64 / (red + blue) as f64;
assert!((share - 0.16).abs() < 0.03, "blue share {share}");
// The text-colour glyph is as big as the other square.
assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue);
}
#[test]
fn a_text_gradient_starts_over_on_every_line() {
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
// (black at the top of every line to white at its bottom), not the text box.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("HHHH\nHHHH");
for p in &mut t.paragraphs {
let props = &mut p.runs[0].props;
props.size = Some(80.0);
props.fill = Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
}));
}
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Ink rows (any pixel not white), grouped into the two lines.
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
let mut bands: Vec<(u32, u32)> = vec![];
for y in 0..500 {
if ink(y) {
match bands.last_mut() {
Some(b) if b.1 + 1 == y => b.1 = y,
_ => bands.push((y, y)),
}
}
}
assert_eq!(bands.len(), 2, "{bands:?}");
// The darkest ink in the first rows of each line.
let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
let (a, b) = (top(bands[0]), top(bands[1]));
assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again");
// ...and each line ends light.
let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
for band in bands {
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
}
}
/// A text box of big black "IIII" with `set` applied to its run.
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("IIII");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(100.0);
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
set(props);
sh.text = Some(t);
deck_with(vec![sh])
}
#[test]
fn text_glow_and_shadow_are_drawn() {
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
let plain = effect_text(|_| {});
assert!(!needs_filters(&plain, &plain.slides[0]));
let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(count(&img, &green), 0);
// Glow: a green halo hugging the letters, needing the blur filter.
let glow =
effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 }));
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let halo = count(&img, &green);
assert!(halo > 300, "green halo pixels {halo}");
// Shadow: grey ink below and right of the letters, where the plain text has none.
let shadow = effect_text(|p| {
p.shadow = Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }),
blur: 2.0,
dist: 12.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
})
});
let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears");
}
#[test]
fn pattern_fills_draw_powerpoints_tiles() {
// dkVert at one output pixel per pattern cell (0.75 pt): columns 11001100… across, as
// PowerPoint's export draws it; pct50 is a checkerboard.
use deckcraft_model::style::{Fill, PatternFill};
let rgb = |v| ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v));
let shape = |id, x: f64, preset: &str| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 0.0, 60.0, 60.0)),
fill: Some(Fill::Pattern(PatternFill { preset: preset.into(), fg: rgb(0), bg: rgb(255) })),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![shape(750, 0.0, "dkVert"), shape(751, 120.0, "pct50")]);
let img = render_slide(&p, 0, &RenderOpts { scale: 4.0 / 3.0, ..Default::default() });
let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 128;
let row: Vec<bool> = (8..16).map(|x| dark(x, 20)).collect();
assert_eq!(row, [true, true, false, false, true, true, false, false], "dkVert columns");
assert!((8..16).all(|y| dark(8, y) == dark(8, 20)), "dkVert is vertical");
// pct50 starts at slide x = 120 pt = 160 px.
for (x, y) in [(168, 8), (169, 9), (170, 12)] {
assert_ne!(dark(x, y), dark(x + 1, y), "pct50 alternates across at {x},{y}");
assert_eq!(dark(x, y), dark(x + 1, y + 1), "pct50 repeats diagonally at {x},{y}");
}
}
#[test]
fn compound_lines_draw_their_bands() {
// A 24 pt outline on a rectangle, as PowerPoint's export draws each kind (top edge, outside
// first): double 8/8/8, thin-thick 4.8/4.8/14.4, triple 4/4/8/4/4.
use deckcraft_model::style::{Compound, Fill, Line};
let runs = |c: Compound| {
let sh = Shape {
id: ShapeId(760),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: Some(Line {
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
width: Some(24.0),
compound: Some(c),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 2.0, ..Default::default() });
// Down the middle of the top edge (y 100 pt), from 80 pt to 120 pt: lengths of dark and
// light runs in points.
let mut out: Vec<(bool, f64)> = vec![];
for y in 160..240 {
let dark = img.pixel(500, y)[0] < 128;
match out.last_mut() {
Some((d, n)) if *d == dark => *n += 0.5,
_ => out.push((dark, 0.5)),
}
}
out.into_iter().filter(|(d, _)| *d).map(|(_, n)| n).collect::<Vec<f64>>()
};
let close = |got: Vec<f64>, want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0);
assert!(close(runs(Compound::Single), &[24.0]));
let d = runs(Compound::Double);
assert!(close(d.clone(), &[8.0, 8.0]), "{d:?}");
let t = runs(Compound::ThinThick);
assert!(close(t.clone(), &[4.8, 14.4]), "{t:?}");
let k = runs(Compound::ThickThin);
assert!(close(k.clone(), &[14.4, 4.8]), "{k:?}");
let tri = runs(Compound::Triple);
assert!(close(tri.clone(), &[4.0, 8.0, 4.0]), "{tri:?}");
}
#[test]
fn rect_path_gradients_draw_x_shaped_contours() {
// path="rect" from the centre, black to grey to white (linear): PowerPoint's colour position is
// the larger axis share, so 20 pt right of centre in a 280x200 box is 1/7 and 20 pt down 1/5.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
let sh = Shape {
id: ShapeId(770),
xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let v = |x: u32, y: u32| img.pixel(x, y)[0] as f64;
assert!((v(160, 100) - 255.0 / 7.0).abs() < 6.0, "{}", v(160, 100));
assert!((v(140, 120) - 51.0).abs() < 6.0, "{}", v(140, 120));
// Along the diagonal to a corner the colour follows the vertical share.
assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120));
}
#[test]
fn soft_edges_fade_to_the_outline() {
// An ellipse with a 12 pt soft edge: transparent at its outline, half opaque about 10 pt
// inside, opaque further in, as in PowerPoint's export (the fade follows the curve, not the
// bounding box).
let sh = Shape {
id: ShapeId(780),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
geom: Geom::Preset { name: "ellipse".into(), adj: vec![] },
fill: Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
line: Some(deckcraft_model::style::Line { fill: Some(deckcraft_model::style::Fill::None), ..Default::default() }),
effects: Some(deckcraft_model::Effects { soft_edge: Some(12.0), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let alpha = |x: u32, y: u32| 1.0 - img.pixel(x, y)[0] as f64 / 255.0;
// Top of the ellipse is at y = 100, x = 250.
assert!(alpha(250, 101) < 0.08, "edge {}", alpha(250, 101));
let mid = alpha(250, 110);
assert!((0.3..0.7).contains(&mid), "0.85 r inside: {mid}");
assert!(alpha(250, 130) > 0.97, "inside {}", alpha(250, 130));
// Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn.
assert!(alpha(110, 105) < 0.01);
}
#[test]
fn a_file_defined_table_style_draws_its_parts() {
// Red table background (tblBg), see-through white on banded rows, a 4 pt blue left border and
// no inside lines: drawn from the definition, not DeckCraft's own look for the style.
use deckcraft_model::style::{Fill, Line};
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
let mut t = Table::new(4, 2, 400.0, 50.0);
t.style = "{ABCDEF01-2345-4678-9ABC-DEF012345678}".into();
t.first_row = false;
t.band_row = true;
let line = |c: ColorRef, w| TableBorder::Line { line: Line { fill: Some(Fill::solid(c)), width: Some(w), ..Default::default() } };
let mut whole = TablePartStyle { fill: Some(Fill::None), ..Default::default() };
whole.borders[0] = Some(line(rgb(0, 0, 255), 4.0));
whole.borders[4] = Some(TableBorder::Line { line: Line { fill: Some(Fill::None), ..Default::default() } });
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 255, g: 255, b: 255, a: 51 });
let band = TablePartStyle { fill: Some(Fill::solid(see_through)), ..Default::default() };
let def = TableStyleDef {
id: t.style.clone(),
name: "Probe".into(),
parts: vec![(TablePart::WholeTbl, whole), (TablePart::Band1H, band)],
background: Some(TablePartStyle { fill: Some(Fill::solid(rgb(255, 0, 0))), ..Default::default() }),
raw: String::new(),
};
let tbl = Shape { id: ShapeId(790), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Table(t), ..Default::default() };
let mut p = deck_with(vec![tbl]);
p.table_styles.push(def);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let px = |x, y| img.pixel(x, y);
// Row 1 (band1H): red with 20 % white over it; row 2 (band2H, undefined): plain red.
let banded = px(300, 125);
assert!(banded[0] > 250 && (40..65).contains(&banded[1]), "{banded:?}");
assert_eq!(px(300, 175)[..3], [255, 0, 0]);
// No line between the rows; a blue left border.
let edge = px(300, 150);
assert!(edge[0] > 240, "no inside line: {edge:?}");
let left = px(100, 175);
assert!(left[2] > 200 && left[0] < 60, "{left:?}");
}
#[test]
fn a_table_fill_shows_behind_cells_without_their_own() {
// `a:tblPr` solidFill (tdf#127237): cells with no fill show the table's blue.
use deckcraft_model::style::Fill;
let mut t = Table::new(2, 2, 200.0, 50.0);
t.style = "none".into();
t.background = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 112, 192))));
for r in 0..2 {
for c in 0..2 {
t.cell_mut(r, c).unwrap().fill = Some(Fill::None);
}
}
let tbl = Shape { id: ShapeId(795), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() };
let p = deck_with(vec![tbl]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]);
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
}
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
fn effect_box(effects: deckcraft_model::Effects) -> Image {
use deckcraft_model::style::{Fill, Line};
let sh = Shape {
id: ShapeId(800),
xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: Some(Line { fill: Some(Fill::None), ..Default::default() }),
effects: Some(effects),
..Default::default()
};
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow {
deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
blur,
dist,
dir,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}
}
#[test]
fn shadows_and_glows_spread_like_powerpoints() {
let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32;
// Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5
// px outside the left edge.
let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() });
for (d, want) in [(1, 144), (3, 204), (5, 238)] {
assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120));
}
// Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right
// edges, not the top and left.
let mut sh = shadow(0.0, 10.0, 45.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120));
assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216));
assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear");
// Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside.
let mut sh = shadow(9.0, 0.0, 0.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
for (d, want) in [(0, 144), (2, 204), (4, 238)] {
assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120));
}
// Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10.
let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 };
let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() });
assert!(red(&img, 19, 120) < 20);
assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120));
assert!(red(&img, 10, 120) > 245);
}
Binary file not shown.
-51
View File
@@ -1,51 +0,0 @@
"""Build the COLR test font: DeckCraft Colr Squares (contributor-original, CC0-1.0).
Usage: python make_colr_font.py colr-squares.ttf (needs fontTools)
U+E000: a 1000x1000 red square (palette 0) with a 400x400 blue square (palette 1) in its middle.
U+E001: a 1000x1000 square in the text colour (palette index 0xFFFF).
Plain outlines of the base glyphs are a single square, as colour fonts carry for renderers
without colour support.
"""
import sys
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.colorLib.builder import buildCOLR, buildCPAL
def square(x0, y0, x1, y1):
pen = TTGlyphPen(None)
pen.moveTo((x0, y0))
pen.lineTo((x0, y1))
pen.lineTo((x1, y1))
pen.lineTo((x1, y0))
pen.closePath()
return pen.glyph()
def empty():
return TTGlyphPen(None).glyph()
order = [".notdef", "space", "redblue", "ink", "big", "small"]
fb = FontBuilder(1000, isTTF=True)
fb.setupGlyphOrder(order)
fb.setupCharacterMap({0x20: "space", 0xE000: "redblue", 0xE001: "ink"})
fb.setupGlyf({
".notdef": empty(),
"space": empty(),
"redblue": square(0, 0, 1000, 1000),
"ink": square(0, 0, 1000, 1000),
"big": square(0, 0, 1000, 1000),
"small": square(300, 300, 700, 700),
})
fb.setupHorizontalMetrics({g: (1000, 0) for g in order})
fb.setupHorizontalHeader(ascent=1000, descent=0)
fb.setupNameTable({"familyName": "DeckCraft Colr Squares", "styleName": "Regular"})
fb.setupOS2(sTypoAscender=1000, sTypoDescender=0, usWinAscent=1000, usWinDescent=0)
fb.setupPost()
fb.font["COLR"] = buildCOLR({"redblue": [("big", 0), ("small", 1)], "ink": [("big", 0xFFFF)]}, version=0)
fb.font["CPAL"] = buildCPAL([[(1.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0)]])
fb.save(sys.argv[1])
print("saved", sys.argv[1])
+7 -172
View File
@@ -57,25 +57,6 @@ pub struct GlyphRun {
/// Alpha of the fill (0–1).
pub alpha: f64,
pub gradient: Option<deckcraft_model::style::Gradient>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
}
/// A text run's shadow and glow, colours resolved.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RunEffects {
pub shadow: Option<TextShadow>,
/// Glow colour (with its alpha) and radius in points.
pub glow: Option<(Rgba, f64)>,
}
/// An outer shadow: colour (with its alpha), blur radius and offset in points.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextShadow {
pub color: Rgba,
pub blur: f64,
pub dx: f64,
pub dy: f64,
}
impl std::fmt::Debug for GlyphRun {
@@ -193,7 +174,6 @@ struct Style {
outline: Option<(Rgba, f64)>,
link: bool,
gradient: Option<deckcraft_model::style::Gradient>,
effects: Option<RunEffects>,
/// "Regular", "Bold"… for the slot and fallback faces.
style_name: &'static str,
/// BCP 47 language (`a:rPr/@lang`), for shaping.
@@ -265,14 +245,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
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),
Some(Fill::Gradient(g)) => {
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
let mut g = g.clone();
for s in &mut g.stops {
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
}
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
}
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g.clone())),
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
_ => (resolve::text_color(ctx, r), 1.0, None),
};
@@ -308,7 +281,6 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
outline,
link: r.link.is_some(),
gradient,
effects: run_effects(ctx, r, scale),
style_name: style_name(bold, italic),
lang: r.lang.clone().filter(|l| !l.is_empty()),
ea,
@@ -492,15 +464,6 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
st.baseline = 0.0;
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
bullet_face(db, &mut st, &ch);
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
// at U+F000 + the character code as such fonts map them; otherwise common symbol
// bullets are mapped to Unicode look-alikes.
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
Some(c) => c.to_string(),
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
};
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
st.face = db.face("Inter", "Regular");
}
@@ -513,10 +476,8 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
}
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
rp.underline = None;
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
bullet_face(db, &mut st, &label);
Some((label, st))
let st = make_style(ctx, &rp, scale, opts.prompt_color);
Some((autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))), st))
}
_ => None,
}
@@ -524,27 +485,8 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
}
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
/// and other symbols missing from the text font), by the usual fallback.
fn bullet_face(db: &FontDb, st: &mut Style, label: &str) {
let chars: Vec<char> = label.chars().collect();
if !chars.is_empty() && !deckcraft_fonts::covers_cluster(&st.face, &chars) {
st.face = db.cascade(&chars, std::slice::from_ref(&st.face), st.style_name);
}
}
/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
/// script, face and shaping style, each shaped with the whole paragraph as context.
/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.
fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| {
let a = s.dir.to_radians();
TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale }
});
let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale));
(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> {
let db = FontDb::global();
let chars: Vec<char> = text.chars().collect();
@@ -582,9 +524,7 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin));
// Spaces, digits and punctuation stay in the face of the text before them when it can
// draw them, so a phrase isn't cut into items at every space.
// Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
let neutral =
cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');
let neutral = cluster.iter().all(|c| script::script_of(*c).is_none());
let face = match prev.as_ref() {
Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
_ => {
@@ -726,59 +666,11 @@ fn shape_item(
}
}
/// Is `family` a symbol font (characters at U+F000 + code)?
fn is_symbol_font(family: &str) -> bool {
let f = family.to_ascii_lowercase();
f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett")
}
/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a
/// symbol font that `face` is (not a substitute) and it has the glyph.
fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {
let font = font?;
if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) {
return None;
}
let first = c.chars().next()?;
let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?;
if face.covers(pua) {
Some(pua)
} else if face.covers(first) {
Some(first)
} else {
None
}
}
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
let f = font.unwrap_or("").to_ascii_lowercase();
let first = c.chars().next().unwrap_or('•');
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
let code = (first as u32) & 0xff;
// Wingdings 2 and 3 put different shapes at the same codes.
if f.starts_with("wingdings 2") {
let m = match code {
0x97 | 0x98 => '●',
0x9e..=0xa1 => '■',
0xa2..=0xa5 => '◆',
0xea..=0xf3 => '★',
0xb7..=0xbf => '○',
0x50 => '✓',
_ => '•',
};
return m.to_string();
}
if f.starts_with("wingdings 3") {
let m = match code {
0x7d | 0x75 => '►',
0x7c | 0x74 => '◄',
0x70 => '▲',
0x71 => '▼',
0xc6 | 0xc7 => '➔',
_ => '▶',
};
return m.to_string();
}
let m = match code {
0xa7 => '▪',
0xd8 => '➢',
@@ -826,44 +718,8 @@ fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
(h * face.line_split, h * (1.0 - face.line_split))
}
/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between
/// syllables.
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`).
fn is_hanging_punct(c: char) -> bool {
matches!(
c,
'、' | '。'
| ','
| '.'
| ':'
| ';'
| '!'
| '?'
| ')'
| '」'
| '』'
| '】'
| '〕'
| '〉'
| '》'
| '〙'
| '〛'
| '}'
| ']'
| '」'
| '、'
| '。'
)
}
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
/// Korean breaks only between words.
fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
/// Break opportunities: allowed[i] = a line may start at char i.
fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
let mut mandatory = vec![false; n + 1];
@@ -884,17 +740,6 @@ 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() };
if !keep
&& !mandatory.get(i + 1).copied().unwrap_or(false)
&& let Some(a) = allowed.get_mut(i + 1)
{
*a = false;
}
}
(allowed, mandatory)
}
@@ -916,8 +761,7 @@ fn break_lines(
x0_rest: f64,
) -> Vec<RawLine> {
let text: String = p.cells.iter().map(|c| c.ch).collect();
let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
let hang = p.props.hanging_punct.unwrap_or(false);
let (allowed, _) = break_opportunities(&text);
let n = p.cells.len();
let mut lines = vec![];
let mut start = 0;
@@ -944,13 +788,6 @@ fn break_lines(
if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
last_break = Some(i);
}
// Closing East Asian punctuation hangs past the end instead of wrapping
// (`hangingPunct`); the line breaks after it.
if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start {
end = i + 1;
next = i + 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
@@ -1330,7 +1167,6 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
link: false,
alpha: st.alpha,
gradient: None,
effects: None,
});
}
// Group visually consecutive cells by (style, face).
@@ -1364,7 +1200,6 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
link: st.link,
alpha: st.alpha,
gradient: st.gradient.clone(),
effects: st.effects,
});
cur_key = key;
}
-129
View File
@@ -520,132 +520,3 @@ fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
}
}
/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings.
fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec<String> {
let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left);
let t = sh.text.as_mut().unwrap();
t.body.inset_l = Some(0.0);
t.body.inset_r = Some(0.0);
set(&mut t.paragraphs[0].props);
let l = lay(&p, &sh, w, 600.0);
let chars: Vec<char> = text.chars().collect();
l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect()
}
/// Width of `text` on one line.
fn one_line(text: &str) -> f64 {
let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left);
let l = lay(&p, &sh, 5000.0, 300.0);
l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0]
}
#[test]
fn a_word_does_not_break_after_a_slash_or_backslash() {
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
// where it no longer fits; Unicode line breaking would allow a break after each slash.
for sep in ["/", "\\"] {
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
let w = one_line(&text) * 0.6;
let lines = wrapped(&text, w, |_| {});
assert!(lines.len() >= 2, "{lines:?}");
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
}
// latinLnBrk on: Unicode's break after a slash is allowed again.
let text = "alpha/beta/gamma/delta/epsilon";
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
assert!(lines[0].ends_with('/'), "{lines:?}");
// A slash followed by a space still breaks, and hyphens still break.
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
assert_eq!(lines[0], "alpha-beta-gamma-");
}
#[test]
fn korean_wraps_between_words() {
let text = "한국어 안녕하세요 반갑습니다 감사합니다";
let w = one_line("한국어 안녕하세요 반") + 0.5;
let lines = wrapped(text, w, |_| {});
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}");
// Japanese and Chinese still break between characters.
let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {});
assert_eq!(lines[0], "日本語の文章");
}
#[test]
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
// PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the
// edge instead of pulling the character before it onto the next line.
let text = "白なしで続きます。句読点も含めて。";
let w = one_line("白なしで続きます") + 0.5;
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
// Without it, "。" can't start a line, so "す。" moves down together.
let lines = wrapped(text, w, |p| p.hanging_punct = Some(false));
assert_eq!(lines[0], "白なしで続きま", "{lines:?}");
}
#[test]
fn emoji_take_a_colour_face_when_one_is_installed() {
// PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font
// even when the run's font has a plain glyph for some of them. Skipped without an installed
// colour emoji font.
let db = FontDb::global();
let probe = db.cascade(&['😀'], &[], "Regular");
if !probe.is_color() {
return;
}
for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] {
let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap();
assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family);
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
}
}
#[test]
fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
// circleNumDbPlain numbers ①②…: the text font usually has no circled digits; PowerPoint draws
// them from a fallback font instead of a missing-glyph box. Skipped when no installed or
// bundled face has ①.
let db = FontDb::global();
if !db.cascade(&['①'], &[], "Regular").covers('①') {
return;
}
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum { scheme: "circleNumDbPlain".into(), start_at: 1 });
}
let l = lay(&p, &sh, 600.0, 300.0);
let bullets: Vec<_> = l.runs.iter().filter(|r| r.cells.is_empty()).collect();
assert_eq!(bullets.len(), 2);
for b in bullets {
assert!(b.face.covers('①'), "drawn with {}, which lacks the circled digit", b.face.family);
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
}
}
#[test]
fn symbol_font_bullets_map_by_font() {
// Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain
// Wingdings keeps its own table (0xA7 is a small square).
let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA
assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★");
assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►");
assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪");
assert_eq!(map_symbol_bullet("•", Some("Arial")), "•");
assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial"));
}
#[test]
fn an_installed_symbol_font_draws_its_own_bullet() {
// Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a
// look-alike. Skipped without Wingdings 2.
let db = FontDb::global();
if !db.has_family("Wingdings 2") {
return;
}
let face = db.face("Wingdings 2", "Regular");
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}'));
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None);
}