Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 6e90c955ca Canvas: one caret, drawn at a wrapped line's end after End
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:09:29 -04:00
Brandon Thomas 6a0ca98550 rustfmt 2026-10-12 22:05:56 -04:00
Brandon ThomasandClaude Opus 5.5 4e780d52cc Text: Ctrl+arrows, Ctrl+Backspace/Delete and the other text keys work like PowerPoint (#268)
Observed black-box in PowerPoint for Windows on synthetic text:
- Word stops (crates/model/src/words.rs): words, punctuation runs, symbol
  runs, tabs, line breaks and each CJK character are tokens; trailing spaces
  join the token before. it's, a-b, 3.14, 1,000 are one word; e.g., a_b and
  x@y.com are not. Grapheme clusters never split.
- Ctrl+Left/Right and Ctrl+Shift+Left/Right move by those stops, across
  paragraphs; with a selection they go on from its start/end. In a
  right-to-left paragraph Left and Right swap.
- Ctrl+Backspace deletes to the previous stop; Ctrl+Delete deletes the rest
  of the word, or spaces plus the next word, then applies PowerPoint's smart
  spacing. With a selection both work from its start.
- Ctrl+Up/Down by paragraph, Ctrl+Home/End to the text's ends.
- End on a wrapped line goes past the trailing space and the caret stays
  drawn at that line's end (TextSel::line_end); End again stays, Home
  returns to the same line.
- Ctrl+]/[ and Ctrl+Shift+>/< change font size (egui's Period/Comma/
  bracket key names now match registry shortcuts).
- Shift+F3 cycles lower -> Title -> UPPER; a caret changes its word.
- Double-click selects the word without trailing spaces; triple-click
  includes the paragraph mark.
- Mac keeps Option for words and Cmd for line/text ends (Session::mac_keys).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 22:05:53 -04:00
38 changed files with 339 additions and 4644 deletions
+3 -5
View File
@@ -129,13 +129,13 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
@@ -147,8 +147,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
-4
View File
@@ -37,7 +37,3 @@ sync_github_name = false
[people.Johannesgauss]
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+9 -72
View File
@@ -15,16 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(
query "file.info",
"Info",
["File"],
None,
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
has_doc,
info
),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
cmd!(
@@ -127,59 +118,26 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
}
}
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions> {
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
/// includeHidden, textLayer, frame}`.
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes,
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 },
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
o.slides = match p.get("slides") {
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
_ => None,
};
o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
o.include_hidden = bool_or(p, "includeHidden", false);
o.text_layer = bool_or(p, "textLayer", true);
o.frame_slides = bool_or(p, "frame", true);
Ok(o)
}
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![];
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
None => (part, part),
};
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
let (lo, hi) = (a.min(b), a.max(b));
if lo == 0 || hi > count {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
if !out.contains(&i) {
out.push(i);
}
}
}
if out.is_empty() {
return Err(invalid());
}
Ok(out)
o
}
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
}
pub fn format_for_path(path: &str) -> &'static str {
@@ -306,7 +264,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"files": written}))
}
"pdf" => {
let bytes = pdf_bytes(&st.doc, p)?;
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@@ -318,27 +276,6 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// File › Info: what the Info page shows about the active presentation.
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let doc = &st.doc;
#[cfg(not(target_arch = "wasm32"))]
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
#[cfg(target_arch = "wasm32")]
let bytes: Option<u64> = None;
Ok(json!({
"title": st.title(),
"path": st.path,
"format": st.path.as_deref().map(format_for_path),
"dirty": st.is_dirty(),
"slides": doc.slides.len(),
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
"sections": doc.sections.len(),
"bytes": bytes,
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
}))
}
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
if let Some(i) = s.active_index() {
s.close_document(i);
-61
View File
@@ -774,64 +774,3 @@ fn ime_composition_underlines_follow_the_text() {
s.commit_text("日本語").unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
}
/// File › Info (#76): the active presentation's name, location, counts and properties.
#[test]
fn file_info_reports_the_presentation() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.go", &json!({"index": 1})).unwrap();
s.execute("slide.hide", &json!({})).unwrap();
s.execute("file.properties", &json!({"title": "Quarterly", "author": "Ada"})).unwrap();
let v = s.execute("file.info", &json!({})).unwrap();
assert_eq!(v["slides"], 3);
assert_eq!(v["hiddenSlides"], 1);
assert_eq!(v["path"], serde_json::Value::Null);
assert_eq!(v["dirty"], true);
assert_eq!(v["properties"]["title"], "Quarterly");
assert_eq!(v["properties"]["author"], "Ada");
assert!(v["title"].as_str().is_some_and(|t| !t.is_empty()));
// A query: no undo step, and nothing changes.
let before = s.doc().unwrap().history.undo.len();
s.execute("file.info", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), before);
// Saved to disk, it reports where and how big.
let dir = std::env::temp_dir().join(format!("deckcraft-info-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Info test.pptx").to_string_lossy().to_string();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
let v = s.execute("file.info", &json!({})).unwrap();
assert_eq!(v["path"], path.as_str());
assert_eq!(v["format"], "pptx");
assert_eq!(v["dirty"], false);
assert!(v["bytes"].as_u64().is_some_and(|b| b > 0));
let _ = std::fs::remove_dir_all(&dir);
// Nothing open: an error, not a panic.
let mut empty = Session::new();
assert!(empty.execute("file.info", &json!({})).is_err());
}
/// The Print page's Slides box (#76): PowerPoint-style ranges, 1-based.
#[test]
fn slide_ranges_parse_like_powerpoints_print_box() {
use cmd::file::parse_slide_range as r;
assert_eq!(r("1-3, 5", 6).unwrap(), vec![0, 1, 2, 4]);
assert_eq!(r("3-1;2", 6).unwrap(), vec![0, 1, 2], "reversed ranges and repeats");
assert_eq!(r(" 6 ", 6).unwrap(), vec![5]);
assert!(r("7", 6).is_err(), "past the last slide");
assert!(r("0", 6).is_err());
assert!(r("a-b", 6).is_err());
assert!(r(" , ", 6).is_err());
assert!(r("1-99999999999999999999999", 6).is_err(), "hostile numbers are refused");
// file.export takes the same text for a PDF.
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let one = cmd::file::pdf_bytes(&doc, &json!({"slides": "2"})).unwrap();
let both = cmd::file::pdf_bytes(&doc, &json!({"slides": "1-2"})).unwrap();
assert!(one.len() < both.len());
assert!(cmd::file::pdf_bytes(&doc, &json!({"slides": "5"})).is_err());
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
+40 -359
View File
@@ -2,11 +2,8 @@
//! transform, pen and brush selection through an object table, the DC save stack and path brackets.
//! Output is device-space items; `lib.rs` maps them into the picture frame.
use std::sync::{Arc, OnceLock};
use crate::dib::Rgba;
use crate::geom::{Op, P};
use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, TA_UPDATECP, Text, align};
use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK};
pub(crate) const MM_TEXT: u32 = 1;
@@ -18,21 +15,9 @@ pub(crate) const PS_NULL: u32 = 5;
pub(crate) const PS_STYLE_MASK: u32 = 0x0F;
const BS_SOLID: u32 = 0;
pub(crate) const BS_NULL: u32 = 1;
pub(crate) const BS_HATCHED: u32 = 2;
/// BS_PATTERN, BS_DIBPATTERN and BS_DIBPATTERNPT: the brush paints a bitmap tile.
const BS_PATTERN: u32 = 3;
const BS_DIBPATTERN: u32 = 5;
const BS_DIBPATTERNPT: u32 = 6;
const WINDING: u32 = 2;
/// SETROP2 mix modes. R2_NOP leaves the destination alone: outlines and fills draw nothing.
/// R2_XORPEN and R2_MASKPEN draw the classic see-through shadow (XOR a colour, AND a pattern, XOR
/// the colour again); the others draw as R2_COPYPEN.
const R2_XORPEN: u32 = 7;
const R2_MASKPEN: u32 = 9;
/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing.
const R2_NOP: u32 = 11;
const R2_COPYPEN: u32 = 13;
/// SETBKMODE OPAQUE: text cells and hatch gaps are filled with the background colour.
const OPAQUE: u32 = 2;
const PATCOPY: u32 = 0x00F0_0021;
const BLACKNESS: u32 = 0x0000_0042;
const WHITENESS: u32 = 0x00FF_0062;
@@ -63,78 +48,20 @@ pub(crate) struct Pen {
pub color: [u8; 4],
}
#[derive(Clone, PartialEq, Debug)]
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Brush {
pub style: u32,
pub color: [u8; 4],
/// HS_* hatch style of a BS_HATCHED brush.
pub hatch: u32,
/// The tile of a pattern brush.
pub pattern: Option<Arc<Pattern>>,
}
impl Brush {
pub fn new(style: u32, color: [u8; 4]) -> Self {
Brush { style, color, hatch: 0, pattern: None }
}
/// A pattern brush painting `img`. A monochrome brush paints its dark pixels in the text
/// colour and its light ones in the background colour. None for an empty or oversized tile.
pub fn pattern(img: Rgba, mono: bool) -> Option<Self> {
if img.w == 0 || img.h == 0 || img.w > MAX_TILE_SIDE || img.h > MAX_TILE_SIDE || img.px.len() != img.w * img.h * 4 {
return None;
}
let pattern = Arc::new(Pattern { w: img.w, h: img.h, px: img.px, mono, plain: OnceLock::new() });
Some(Brush { style: BS_DIBPATTERNPT, color: [0, 0, 0, 255], hatch: 0, pattern: Some(pattern) })
}
}
/// A pattern brush's tile, top-down RGBA.
#[derive(PartialEq, Debug)]
pub(crate) struct Pattern {
pub w: usize,
pub h: usize,
pub px: Vec<u8>,
pub mono: bool,
/// The tile as drawn when nothing recolours it, shared by every fill with this brush.
pub plain: OnceLock<Arc<RawTile>>,
}
/// Largest pattern tile side kept (GDI brushes are typically 8×8).
pub(crate) const MAX_TILE_SIDE: usize = 256;
#[derive(Clone, Debug)]
#[derive(Clone, Copy, Debug)]
pub(crate) enum Obj {
Pen(Pen),
Brush(Brush),
Font(Arc<LogFont>),
/// Palettes and regions: they take a table slot but draw nothing.
/// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing.
Other,
}
/// A font object: the neutral [`Font`] and its escapement (tenths of a degree, counter-clockwise),
/// which the text transform takes.
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct LogFont {
pub font: Font,
pub escapement: f64,
}
/// A brush tile in device units: `cell` device units per tile pixel, laid from the device origin.
#[derive(Debug, PartialEq)]
pub(crate) struct RawTile {
pub w: usize,
pub h: usize,
pub px: Vec<u8>,
pub cell: f64,
}
/// What a fill paints.
enum Paint {
Solid([u8; 4]),
Tile(Arc<RawTile>),
}
/// Converts a COLORREF (0x00BBGGRR) to opaque RGBA.
pub(crate) fn colorref(c: u32) -> [u8; 4] {
[(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255]
@@ -239,34 +166,8 @@ fn ratio(num: f64, den: f64) -> f64 {
/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them.
pub(crate) enum Raw {
Path {
ops: Vec<Op>,
fill: Option<[u8; 4]>,
stroke: Option<([u8; 4], f64)>,
even_odd: bool,
},
/// A path filled with a repeating tile.
Pattern {
ops: Vec<Op>,
even_odd: bool,
tile: Arc<RawTile>,
},
Bitmap {
rect: [f64; 4],
img: Rgba,
},
/// Text whose transform and clip are in device units.
Text(Box<Text>),
}
/// Text drawing state: colours, background mode, alignment and the selected font.
#[derive(Clone)]
struct TextState {
color: [u8; 4],
bk_color: [u8; 4],
bk_opaque: bool,
align: u32,
font: Option<Arc<LogFont>>,
Path { ops: Vec<Op>, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
Bitmap { rect: [f64; 4], img: Rgba },
}
#[derive(Clone)]
@@ -275,8 +176,7 @@ struct Saved {
pen: Pen,
brush: Brush,
even_odd: bool,
rop2: u32,
text: TextState,
nop: bool,
}
pub(crate) struct Canvas {
@@ -293,13 +193,8 @@ pub(crate) struct Canvas {
pen: Pen,
brush: Brush,
even_odd: bool,
/// SETROP2 mix mode.
rop2: u32,
/// The colour of the last fill drawn with R2_XORPEN, for the see-through shadow idiom.
xor_color: Option<[u8; 4]>,
text: TextState,
/// Device units per pattern-brush pixel: brushes are laid out in 96-dpi pixels.
pub pattern_px: f64,
/// The mix mode is R2_NOP: shapes draw nothing.
nop: bool,
pos: P,
in_path: bool,
path: Vec<Op>,
@@ -317,12 +212,9 @@ impl Canvas {
used: 0,
objects: Vec::new(),
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
brush: Brush::new(BS_SOLID, [255, 255, 255, 255]),
brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] },
even_odd: true,
rop2: R2_COPYPEN,
xor_color: None,
text: TextState { color: [0, 0, 0, 255], bk_color: [255, 255, 255, 255], bk_opaque: true, align: 0, font: None },
pattern_px: 1.0,
nop: false,
pos: (0.0, 0.0),
in_path: false,
path: Vec::new(),
@@ -338,14 +230,7 @@ impl Canvas {
fn push(&mut self, raw: Raw) {
let cost = match &raw {
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
// A tile shared with its brush is counted once (by the brush); one made for this fill
// counts here.
Raw::Pattern { ops, tile, .. } => {
let own = if Arc::strong_count(tile) == 1 { tile.px.len() } else { 0 };
ops.len() * std::mem::size_of::<Op>() + 64 + own
}
Raw::Bitmap { img, .. } => img.px.len(),
Raw::Text(t) => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
};
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
self.full = true;
@@ -403,31 +288,9 @@ impl Canvas {
self.even_odd = mode != WINDING;
}
/// SETROP2. R2_NOP draws nothing; R2_XORPEN and R2_MASKPEN are read as the see-through shadow
/// idiom (see [`Canvas::fill_paint`]); the other mix modes draw as copy.
/// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy.
pub fn set_rop2(&mut self, mode: u32) {
self.rop2 = mode;
}
pub fn set_text_color(&mut self, c: u32) {
self.text.color = colorref(c);
}
pub fn set_bk_color(&mut self, c: u32) {
self.text.bk_color = colorref(c);
}
pub fn set_bk_mode(&mut self, mode: u32) {
self.text.bk_opaque = mode == OPAQUE;
}
pub fn set_text_align(&mut self, ta: u32) {
self.text.align = ta;
}
/// The character set of the selected font (ANSI without one).
pub fn font_charset(&self) -> u8 {
self.text.font.as_ref().map_or(0, |f| f.font.charset)
self.nop = mode == R2_NOP;
}
/// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set).
@@ -446,14 +309,7 @@ impl Canvas {
/// could no longer match the file.
pub fn save_dc(&mut self) {
if self.saved.len() < MAX_STACK {
self.saved.push(Saved {
map: self.map,
pen: self.pen,
brush: self.brush.clone(),
even_odd: self.even_odd,
rop2: self.rop2,
text: self.text.clone(),
});
self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop });
} else {
self.full = true;
}
@@ -469,21 +325,11 @@ impl Canvas {
self.pen = s.pen;
self.brush = s.brush;
self.even_odd = s.even_odd;
self.rop2 = s.rop2;
self.text = s.text;
self.nop = s.nop;
}
/// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table).
pub fn add_object(&mut self, slot: Option<usize>, obj: Obj) {
// A pattern brush's pixels count against the byte budget like drawn items.
if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj {
let cost = p.px.len();
if self.used.saturating_add(cost) > MAX_BYTES {
self.full = true;
return;
}
self.used += cost;
}
match slot {
Some(i) if i < MAX_OBJECTS => {
if self.objects.len() <= i {
@@ -517,10 +363,9 @@ impl Canvas {
if i & STOCK != 0 {
return self.select_stock(i & !STOCK);
}
match self.objects.get(i as usize).cloned().flatten() {
match self.objects.get(i as usize).copied().flatten() {
Some(Obj::Pen(p)) => self.pen = p,
Some(Obj::Brush(b)) => self.brush = b,
Some(Obj::Font(f)) => self.text.font = Some(f),
Some(Obj::Other) | None => {}
}
}
@@ -528,19 +373,17 @@ impl Canvas {
fn select_stock(&mut self, i: u32) {
let white = [255, 255, 255, 255];
let black = [0, 0, 0, 255];
let gray = |v: u8| Brush::new(BS_SOLID, [v, v, v, 255]);
let gray = |v: u8| Brush { style: BS_SOLID, color: [v, v, v, 255] };
match i {
0 | 18 => self.brush = Brush::new(BS_SOLID, white),
0 | 18 => self.brush = Brush { style: BS_SOLID, color: white },
1 => self.brush = gray(192),
2 => self.brush = gray(128),
3 => self.brush = gray(64),
4 => self.brush = Brush::new(BS_SOLID, black),
5 => self.brush = Brush::new(BS_NULL, black),
4 => self.brush = Brush { style: BS_SOLID, color: black },
5 => self.brush = Brush { style: BS_NULL, color: black },
6 => self.pen = Pen { style: 0, width: 0.0, color: white },
7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black },
8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black },
// The stock fonts (OEM_FIXED_FONT … DEFAULT_GUI_FONT): drawn in the default face.
10..=17 => self.text.font = None,
_ => {}
}
}
@@ -554,81 +397,8 @@ impl Canvas {
}
}
/// The current brush's paint. Hatch and monochrome brushes take the background colour (when
/// the background mode is opaque) and the text colour. Under R2_MASKPEN (destination AND
/// brush) a tile's white pixels keep what is below, and its black ones take the colour of the
/// XOR fill before it: XOR, AND, XOR again leaves that colour where the tile is black.
fn fill_paint(&self) -> Option<Paint> {
let b = &self.brush;
match b.style {
BS_SOLID => Some(Paint::Solid(b.color)),
BS_HATCHED => {
let bg = self.text.bk_opaque.then_some(self.text.bk_color);
let px = hatch_tile(b.hatch, b.color, bg)?;
Some(Paint::Tile(Arc::new(RawTile { w: 8, h: 8, px, cell: self.pattern_px })))
}
BS_PATTERN | BS_DIBPATTERN | BS_DIBPATTERNPT => {
let p = b.pattern.as_ref()?;
if !p.mono && self.rop2 != R2_MASKPEN {
let cell = self.pattern_px;
return Some(Paint::Tile(p.plain.get_or_init(|| Arc::new(RawTile { w: p.w, h: p.h, px: p.px.clone(), cell })).clone()));
}
let mut px = p.px.clone();
for c in px.as_chunks_mut::<4>().0 {
if p.mono {
let light = u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]) >= 384;
let v = if light { self.text.bk_color } else { self.text.color };
*c = [v[0], v[1], v[2], 255];
}
if self.rop2 == R2_MASKPEN {
if c[..3] == [255, 255, 255] {
c[3] = 0;
} else if c[..3] == [0, 0, 0]
&& let Some(x) = self.xor_color
{
*c = x;
}
}
}
Some(Paint::Tile(Arc::new(RawTile { w: p.w, h: p.h, px, cell: self.pattern_px })))
}
_ => None,
}
}
/// Pushes device ops filled with `fill` and stroked with `stroke`: a tile fill and its outline
/// are two items.
fn push_shape(&mut self, ops: Vec<Op>, fill: Option<Paint>, stroke: Option<([u8; 4], f64)>) {
let even_odd = self.even_odd;
match fill {
Some(Paint::Tile(tile)) => {
if stroke.is_some() {
self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile });
self.push(Raw::Path { ops, fill: None, stroke, even_odd });
} else {
self.push(Raw::Pattern { ops, even_odd, tile });
}
}
Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, even_odd }),
None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, even_odd }),
None => {}
}
}
/// Whether the mix mode suppresses drawing: R2_NOP draws nothing, and R2_XORPEN shapes only
/// remember their fill colour (an XOR pair cancels; the see-through shadow idiom draws its
/// pattern in that colour).
fn mix_skips(&mut self, fill: bool) -> bool {
match self.rop2 {
R2_NOP => true,
R2_XORPEN => {
if fill && self.brush.style == BS_SOLID {
self.xor_color = Some(self.brush.color);
}
true
}
_ => false,
}
fn fill_color(&self) -> Option<[u8; 4]> {
(self.brush.style == BS_SOLID).then_some(self.brush.color)
}
fn stroke_pen(&self) -> Option<([u8; 4], f64)> {
@@ -656,12 +426,16 @@ impl Canvas {
if self.in_path {
return self.push_path_ops(dev);
}
if self.mix_skips(fill) || dev.len() < 2 {
if self.nop {
return;
}
let fill = if fill { self.fill_paint() } else { None };
let fill = if fill { self.fill_color() } else { None };
let stroke = if stroke { self.stroke_pen() } else { None };
self.push_shape(dev, fill, stroke);
if (fill.is_none() && stroke.is_none()) || dev.len() < 2 {
return;
}
let even_odd = self.even_odd;
self.push(Raw::Path { ops: dev, fill, stroke, even_odd });
}
pub fn move_to(&mut self, p: P) {
@@ -734,22 +508,23 @@ impl Canvas {
/// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it.
pub fn paint_path(&mut self, fill: bool, stroke: bool) {
let ops = std::mem::take(&mut self.path);
if self.mix_skips(fill) || ops.len() < 2 {
if self.nop {
return;
}
let fill = if fill { self.fill_paint() } else { None };
let fill = if fill { self.fill_color() } else { None };
let stroke = if stroke { self.stroke_pen() } else { None };
self.push_shape(ops, fill, stroke);
if ops.len() < 2 || (fill.is_none() && stroke.is_none()) {
return;
}
let even_odd = self.even_odd;
self.push(Raw::Path { ops, fill, stroke, even_odd });
}
/// Fills a logical rectangle `[l, t, r, b]` with the current brush (PATBLT, PATCOPY blits).
pub fn fill_brush_rect(&mut self, [l, t, r, b]: [f64; 4]) {
if self.mix_skips(true) {
return;
/// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid).
pub fn fill_brush_rect(&mut self, rect: [f64; 4]) {
if let Some(color) = self.fill_color() {
self.fill_rect_with(rect, color);
}
let fill = self.fill_paint();
let dev: Vec<Op> = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect();
self.push_shape(dev, fill, None);
}
/// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS).
@@ -775,75 +550,6 @@ impl Canvas {
}
}
/// Draws `text` (one `dx` advance per character, or none) with the current font, colours and
/// alignment. The reference point is `at`, or the current position under TA_UPDATECP (which
/// then moves past the string). `rect` (logical `[l, t, r, b]`) is filled with the background
/// colour under ETO_OPAQUE and clips the text under ETO_CLIPPED. Text ignores the mix mode, as
/// GDI's does.
pub fn text(&mut self, at: P, text: String, dx: Vec<f32>, options: u32, rect: Option<[f64; 4]>) {
if let (true, Some(r)) = (options & ETO_OPAQUE != 0, rect) {
self.fill_rect_with(r, self.text.bk_color);
}
let lf = self.text.font.as_deref().cloned().unwrap_or_default();
let update = self.text.align & TA_UPDATECP != 0;
let origin = if update { self.pos } else { at };
let (h_align, v_align) = align(self.text.align);
let o = self.map.device(origin);
let ux = self.map.device((origin.0 + 1.0, origin.1));
let uy = self.map.device((origin.0, origin.1 + 1.0));
let (ux, uy) = ((ux.0 - o.0, ux.1 - o.1), (uy.0 - o.0, uy.1 - o.1));
if update {
// The current position moves along the baseline past the string (back past it when
// right-aligned). Without a dx array the advance is estimated from the height.
let adv: f64 = if dx.is_empty() {
f64::from(lf.font.height.abs().max(1.0)) * 0.5 * text.chars().count() as f64
} else {
dx.iter().map(|d| f64::from(*d)).sum()
};
let adv = match h_align {
crate::HAlign::Left => adv,
crate::HAlign::Right => -adv,
crate::HAlign::Center => 0.0,
};
let theta = (lf.escapement / 10.0).to_radians();
let y_sign = if uy.1 < 0.0 { 1.0 } else { -1.0 };
let p = (origin.0 + adv * theta.cos(), origin.1 + adv * theta.sin() * y_sign);
if p.0.is_finite() && p.1.is_finite() {
self.pos = p;
}
}
let (sx, sy) = (ux.0.hypot(ux.1), uy.0.hypot(uy.1));
let drawable = !text.is_empty() && [sx, sy, o.0, o.1].iter().all(|v| v.is_finite()) && sx > 0.0 && sy > 0.0;
if !drawable {
return;
}
// The baseline runs along the device direction of the logical x axis (rotated by a world
// transform; a mirroring mapping does not mirror glyphs), turned counter-clockwise by the
// escapement.
let det = ux.0 * uy.1 - ux.1 * uy.0;
let phi = if det > 0.0 { ux.1.atan2(ux.0) } else { ux.1.atan2(ux.0.abs()) };
let a = phi - (lf.escapement / 10.0).to_radians();
let (c, s) = (a.cos(), a.sin());
let clip = match (options & ETO_CLIPPED != 0, rect) {
(true, Some([l, t, r, b])) => {
let (p, q) = (self.map.device((l, t)), self.map.device((r, b)));
Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32])
}
_ => None,
};
self.push(Raw::Text(Box::new(Text {
transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1],
text,
dx,
font: lf.font,
color: self.text.color,
background: self.text.bk_opaque.then_some(self.text.bk_color),
h_align,
v_align,
clip,
})));
}
/// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the
/// bitmap, as the mapping does.
pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) {
@@ -856,31 +562,6 @@ impl Canvas {
}
}
/// An 8×8 hatch tile (HS_HORIZONTAL, HS_VERTICAL, HS_FDIAGONAL, HS_BDIAGONAL, HS_CROSS,
/// HS_DIAGCROSS): one-pixel lines in `fg` over `bg`, or over nothing when the background is
/// transparent. None for an unknown style.
pub(crate) fn hatch_tile(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>) -> Option<Vec<u8>> {
if style > 5 {
return None;
}
let mut px = Vec::with_capacity(8 * 8 * 4);
for y in 0..8u32 {
for x in 0..8u32 {
let (h, v, f, b) = (y == 7, x == 7, x == y, x + y == 7);
let on = match style {
0 => h,
1 => v,
2 => f,
3 => b,
4 => h || v,
_ => f || b,
};
px.extend(if on { fg } else { bg.unwrap_or([0, 0, 0, 0]) });
}
}
Some(px)
}
/// Cubic segments from groups of three points (control, control, end). A trailing partial group is
/// dropped.
fn cubics(pts: &[P]) -> Vec<Op> {
+2 -120
View File
@@ -1,20 +1,12 @@
//! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a
//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice.
use std::sync::Arc;
use crate::bytes::Bytes;
use crate::canvas::{BS_HATCHED, BS_NULL, Brush, Canvas, LogFont, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
use crate::dib::{self, Blend};
use crate::geom::{self, ArcKind, P};
use crate::text::{ETO_GLYPH_INDEX, ETO_PDY, Font, MAX_CHARS, decode_bytes, decode_utf16};
use crate::{Error, Frame, MAX_PATH_OPS};
/// ExtTextOut options: no rectangle in the record (EMR_SMALLTEXTOUT, EmrText), and 8-bit
/// characters (EMR_SMALLTEXTOUT).
const ETO_NO_RECT: u32 = 0x0100;
const ETO_SMALL_CHARS: u32 = 0x0200;
const EMF_SIGNATURE: u32 = 0x464D_4520;
/// Hundredths of a millimetre to points.
const HMM_TO_PT: f64 = 72.0 / 2540.0;
@@ -108,14 +100,6 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
38 => create_pen(cv, r),
95 => create_ext_pen(cv, r),
39 => create_brush(cv, r),
93 | 94 => create_dib_brush(cv, r, typ == 93),
82 => create_font(cv, r),
18 => r.u32(8).map(|m| cv.set_bk_mode(m)),
22 => r.u32(8).map(|a| cv.set_text_align(a)),
24 => r.u32(8).map(|c| cv.set_text_color(c)),
25 => r.u32(8).map(|c| cv.set_bk_color(c)),
83 | 84 => ext_text_out(cv, r, typ == 84),
108 => small_text_out(cv, r),
40 => r.u32(8).map(|i| cv.delete_object(i)),
27 => xy32(r, 8).map(|p| cv.move_to(p)),
54 => xy32(r, 8).map(|p| cv.line_to(p)),
@@ -262,109 +246,7 @@ fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let style = r.u32(12)?;
let color = r.u32(16)?;
let mut b = Brush::new(style, colorref(color));
if style == BS_HATCHED {
b.hatch = r.u32(20).unwrap_or(0);
}
cv.add_object(Some(slot), Obj::Brush(b));
Some(())
}
/// EMR_CREATEDIBPATTERNBRUSHPT (94) and EMR_CREATEMONOBRUSH (93): handle, colour usage, then the
/// bitmap info and bits as offset/size pairs. A mono brush (or one whose palette holds indices,
/// DIB_PAL_COLORS) takes the text and background colours. An undecodable bitmap leaves the slot
/// empty.
fn create_dib_brush(cv: &mut Canvas, r: Bytes, mono: bool) -> Option<()> {
let slot = r.u32(8)? as usize;
let usage = r.u32(12)?;
let (bo, bl) = (r.u32(16)? as usize, r.u32(20)? as usize);
let (po, pl) = (r.u32(24)? as usize, r.u32(28)? as usize);
let img = dib::decode(Bytes(r.slice(bo, bl)?), Bytes(r.slice(po, pl)?), false)?;
let b = Brush::pattern(img, mono || usage == 1)?;
cv.add_object(Some(slot), Obj::Brush(b));
Some(())
}
/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (MS-EMF 2.2.13): height, width, escapement,
/// orientation, weight (i32 each), italic, underline, strike-out, charset and four more bytes, then
/// the face name in 32 UTF-16 units (NUL-terminated).
fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let height = r.i32(12)?;
let width = r.i32(16).unwrap_or(0);
let escapement = r.i32(20).unwrap_or(0);
let weight = r.i32(28).unwrap_or(0);
let byte = |o: usize| r.0.get(o).copied().unwrap_or(0);
let units: Vec<u16> = (0..32).map_while(|i| r.u16(40 + 2 * i)).take_while(|u| *u != 0).collect();
let (family, _) = decode_utf16(&units, &[]);
let font = Font {
family: family.trim().to_string(),
height: height as f32,
width: width as f32,
weight: weight.clamp(0, 1000) as u16,
italic: byte(32) != 0,
underline: byte(33) != 0,
strike: byte(34) != 0,
charset: byte(35),
};
cv.add_object(Some(slot), Obj::Font(Arc::new(LogFont { font, escapement: f64::from(escapement) })));
Some(())
}
/// The dx array at `off` for `n` characters: i32 advances, or (dx, dy) pairs under ETO_PDY (only
/// dx is used). Empty when absent or short.
fn dx_array(r: Bytes, off: usize, n: usize, opts: u32) -> Vec<f32> {
let step = if opts & ETO_PDY != 0 { 8 } else { 4 };
if off == 0 {
return Vec::new();
}
let dx: Vec<f32> = (0..n).map_while(|i| r.i32(off.checked_add(i.checked_mul(step)?)?)).map(|v| v as f32).collect();
if dx.len() == n { dx } else { Vec::new() }
}
/// EMR_EXTTEXTOUTA (83) and EMR_EXTTEXTOUTW (84): bounds, graphics mode and scales, then an EmrText
/// at 36: reference point, character count, string offset, options, the rectangle (absent under
/// ETO_NO_RECT) and the dx offset. Offsets count from the record start. Glyph-index strings name
/// glyphs of a font the reader cannot match, so they are left out.
fn ext_text_out(cv: &mut Canvas, r: Bytes, wide: bool) -> Option<()> {
let at = xy32(r, 36)?;
let n = (r.u32(44)? as usize).min(MAX_CHARS);
let off = r.u32(48)? as usize;
let opts = r.u32(52)?;
let (rect, dx_off) = if opts & ETO_NO_RECT != 0 { (None, r.u32(56)?) } else { (rect32(r, 56), r.u32(72)?) };
if opts & ETO_GLYPH_INDEX != 0 {
return None;
}
let dx = dx_array(r, dx_off as usize, n, opts);
let (text, dx) = if wide {
let raw = r.slice(off, n.checked_mul(2)?)?;
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
decode_utf16(&units, &dx)
} else {
decode_bytes(r.slice(off, n)?, cv.font_charset(), &dx)
};
cv.text(at, text, dx, opts, rect);
Some(())
}
/// EMR_SMALLTEXTOUT (MS-EMF 2.3.5.37): x, y, character count, options, graphics mode, scales, the
/// bounds (absent under ETO_NO_RECT), then the characters: 8-bit under ETO_SMALL_CHARS, else UTF-16.
fn small_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
let at = xy32(r, 8)?;
let n = (r.u32(16)? as usize).min(MAX_CHARS);
let opts = r.u32(20)?;
if opts & ETO_GLYPH_INDEX != 0 {
return None;
}
let (rect, off) = if opts & ETO_NO_RECT != 0 { (None, 36) } else { (rect32(r, 36), 52) };
let (text, _) = if opts & ETO_SMALL_CHARS != 0 {
decode_bytes(r.slice(off, n)?, cv.font_charset(), &[])
} else {
let raw = r.slice(off, n.checked_mul(2)?)?;
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
decode_utf16(&units, &[])
};
cv.text(at, text, Vec::new(), opts, rect);
cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) }));
Some(())
}
+3 -70
View File
@@ -1,9 +1,6 @@
//! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF)
//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths, pattern
//! fills, RGBA bitmaps and text strings, in a picture frame with y down.
//!
//! Text stays font-agnostic: a [`Text`] item names the font the file asks for and carries the
//! string, its advances and alignment; the renderer resolves the font and draws the glyphs.
//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths and RGBA
//! bitmaps, in a picture frame with y down.
//!
//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is
//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`],
@@ -19,20 +16,15 @@ mod dib;
mod emf;
mod geom;
mod place;
mod text;
mod wmf;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::sync::Arc;
use bytes::Bytes;
use canvas::Raw;
use geom::{Op, P};
pub use place::{MAX_BITMAP_SIDE, PlacedItem};
pub use text::{Font, HAlign, SYMBOL_CHARSET, Text, VAlign};
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
pub(crate) const MAX_ITEMS: usize = 200_000;
@@ -69,22 +61,6 @@ pub enum Item {
},
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec<u8> },
/// A path filled with a repeating tile (pattern and hatch brushes). Items filled with the same
/// brush share its tile.
Pattern { segs: Vec<Seg>, even_odd: bool, tile: Arc<Tile> },
/// A text string.
Text(Box<Text>),
}
/// A brush tile: RGBA8 rows top-down (transparent where the brush leaves the background), each
/// pixel `cell` picture units, with a tile's top-left corner at `origin`.
#[derive(Debug, Clone, PartialEq)]
pub struct Tile {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
pub cell: [f32; 2],
pub origin: [f32; 2],
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -118,8 +94,6 @@ pub fn parse(bytes: &[u8]) -> Result<Picture, Error> {
let (cv, frame) = if emf::is_emf(b) {
emf::parse(b)?
} else if wmf::is_wmf(b) {
// A WMF converted from an EMF can carry the original in MFCOMMENT escapes; PowerPoint draws
// the WMF's own records (its dx rounding shows), so the copy is not used.
wmf::parse(b)?
} else {
return Err(Error::NotMetafile);
@@ -150,8 +124,6 @@ fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
let stroke_scale = (sx + sy) / 2.0;
// No stroke is wider than the picture's diagonal: anything wider is a mapping artefact.
let diag = size.0.hypot(size.1);
// Tiles shared by several fills stay shared.
let mut tiles: HashMap<*const canvas::RawTile, (Arc<canvas::RawTile>, Arc<Tile>)> = HashMap::new();
let items = raw.into_iter().filter_map(|r| match r {
Raw::Path { ops, fill, stroke, even_odd } => {
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
@@ -166,44 +138,6 @@ fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px })
}
Raw::Pattern { ops, even_odd, tile } => {
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
if segs.len() < 2 {
return None;
}
let key = Arc::as_ptr(&tile);
let tile = match tiles.get(&key) {
Some((_, t)) => t.clone(),
None => {
let (ox, oy) = tf((0.0, 0.0));
let t = Arc::new(Tile {
width: u32::try_from(tile.w).ok()?,
height: u32::try_from(tile.h).ok()?,
rgba: tile.px.clone(),
cell: [f(tile.cell * sx)?, f(tile.cell * sy)?],
origin: [f(ox)?, f(oy)?],
});
// The raw tile is kept with its key, so the address is never reused by another.
tiles.insert(key, (tile.clone(), t.clone()));
t
}
};
Some(Item::Pattern { segs, even_odd, tile })
}
Raw::Text(mut t) => {
// Device to picture: scale by (sx, sy) after moving the frame's corner to the origin.
let [a, b, c, d, e, g] = t.transform;
let (e, g) = tf((e, g));
t.transform = [a * sx, b * sy, c * sx, d * sy, e, g];
if !t.transform.iter().all(|v| v.is_finite()) {
return None;
}
if let Some([x0, y0, x1, y1]) = t.clip {
let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1))));
t.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]);
}
Some(Item::Text(t))
}
});
let items = items.collect();
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
@@ -251,7 +185,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
};
for r in raw {
match r {
Raw::Path { ops, .. } | Raw::Pattern { ops, .. } => {
Raw::Path { ops, .. } => {
for op in ops {
match *op {
Op::M(p) | Op::L(p) => grow(p),
@@ -268,7 +202,6 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
grow((rect[0], rect[1]));
grow((rect[0] + rect[2], rect[1] + rect[3]));
}
Raw::Text(t) => grow((t.transform[4], t.transform[5])),
}
}
b
-26
View File
@@ -19,12 +19,6 @@ pub enum PlacedItem {
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
Bitmap { index: usize, rect: Rect },
/// `Picture::items[index]`, a pattern fill: `path` (target units) filled with the item's tile
/// repeated; `tile` maps tile pixels to target units.
Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine },
/// `Picture::items[index]`, a text string: `transform` maps its text space to target units;
/// `clip` (target units) bounds what it draws.
Text { index: usize, transform: Affine, clip: Option<Rect> },
}
impl Picture {
@@ -62,26 +56,6 @@ impl Picture {
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
}
}
Item::Pattern { segs, even_odd, tile: t } => {
let path = tf * bez(segs);
let tile = tf
* Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1])))
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
let det = tile.determinant();
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile });
}
}
Item::Text(t) => {
let transform = tf * Affine::new(t.transform);
let clip =
t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1))));
let c = transform.as_coeffs();
let origin = Rect::new(c[4], c[5], c[4], c[5]);
if in_range(origin) && c.iter().all(|v| v.is_finite()) && clip.is_none_or(in_range) {
out.push(PlacedItem::Text { index, transform, clip });
}
}
Item::Bitmap { rect: [x, y, w, h], .. } => {
let (x0, y0) = (f64::from(*x), f64::from(*y));
let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h)));
+1 -503
View File
@@ -120,7 +120,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255
fn path_segs(item: &Item) -> (&Vec<Seg>, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) {
match item {
Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd),
other => panic!("expected a path: {other:?}"),
Item::Bitmap { .. } => panic!("expected a path"),
}
}
@@ -1016,505 +1016,3 @@ fn plain_wmf_target_comes_from_a_window_that_was_read() {
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}");
}
// ---- Text, pattern and hatch brushes, mix-mode idioms ----
use crate::{HAlign, Picture, Text, VAlign};
/// META_CREATEFONTINDIRECT: height, width, escapement (and orientation), weight, the italic,
/// underline, strike-out and charset bytes, four precision bytes, then the NUL-terminated face.
fn wfont(height: i32, weight: i32, flags: [u8; 3], charset: u8, esc: i32, face: &[u8]) -> Vec<u8> {
let mut v = cat(vec![h16(height), h16(0), h16(esc), h16(esc), h16(weight)]);
v.extend(flags);
v.extend([charset, 0, 0, 0, 0]);
v.extend(face);
v.push(0);
wrec(0x02FB, &v)
}
/// META_EXTTEXTOUT at (x, y): the rectangle when given, the string padded to a word, then `dx`.
fn wtext(x: i32, y: i32, s: &[u8], opts: i32, rect: Option<[i32; 4]>, dx: &[i32]) -> Vec<u8> {
let mut v = cat(vec![h16(y), h16(x), h16(s.len() as i32), h16(opts)]);
for c in rect.into_iter().flatten() {
v.extend(h16(c));
}
v.extend(s);
if s.len() % 2 == 1 {
v.push(0);
}
for d in dx {
v.extend(h16(*d));
}
wrec(0x0A32, &v)
}
/// A plain WMF whose 200 × 100 window maps one logical unit to one picture unit.
fn wmf200(records: &[Vec<u8>]) -> Picture {
let mut recs = vec![wrec(0x020C, &cat(vec![h16(100), h16(200)]))];
recs.extend(records.iter().cloned());
parse(&wmf(&recs, None)).unwrap()
}
fn texts(p: &Picture) -> Vec<&Text> {
p.items
.iter()
.filter_map(|i| match i {
Item::Text(t) => Some(t.as_ref()),
_ => None,
})
.collect()
}
fn close6(a: [f64; 6], b: [f64; 6]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
#[test]
fn wmf_text_reads_the_font_string_dx_colour_and_alignment() {
let recs = [
wfont(-20, 700, [1, 1, 1], 0, 0, b"Arial"),
wrec(0x012D, &h16(0)),
wrec(0x0209, &h32(0x0000_00FF)),
wrec(0x0102, &h16(1)),
wrec(0x012E, &h16(24 | 6)),
wtext(50, 60, b"Hi!", 0, None, &[10, 8, 6]),
];
let p = wmf200(&recs);
let t = texts(&p);
assert_eq!(t.len(), 1, "{:?}", p.items);
let t = t[0];
assert_eq!(t.text, "Hi!");
assert_eq!(t.dx, vec![10.0, 8.0, 6.0], "the dx array follows the word-padded odd string");
assert_eq!(t.font.family, "Arial");
assert_eq!((t.font.height, t.font.weight, t.font.italic, t.font.underline, t.font.strike), (-20.0, 700, true, true, true));
assert_eq!(t.color, [255, 0, 0, 255]);
assert_eq!(t.background, None, "TRANSPARENT background mode");
assert_eq!((t.h_align, t.v_align), (HAlign::Center, VAlign::Baseline));
assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 50.0, 60.0]), "{:?}", t.transform);
assert_eq!(t.clip, None);
}
#[test]
fn font_height_sign_and_default_state_are_kept() {
// A positive height is the cell height; it stays positive for the renderer to resolve. Without
// SETTEXTALIGN the reference point is the top left; without SETBKMODE the cell is filled white.
let recs = [wfont(30, 400, [0, 0, 0], 0, 0, b"Times New Roman"), wrec(0x012D, &h16(0)), wtext(5, 5, b"x", 0, None, &[])];
let p = wmf200(&recs);
let t = texts(&p)[0];
assert_eq!(t.font.height, 30.0);
assert_eq!(t.font.family, "Times New Roman");
assert_eq!((t.h_align, t.v_align), (HAlign::Left, VAlign::Top));
assert_eq!(t.background, Some([255, 255, 255, 255]));
assert!(t.dx.is_empty(), "no dx array in the record");
// Right and bottom.
let p = wmf200(&[wrec(0x012E, &h16(2 | 8)), wtext(5, 5, b"x", 0, None, &[])]);
let t = texts(&p)[0];
assert_eq!((t.h_align, t.v_align), (HAlign::Right, VAlign::Bottom));
assert_eq!(t.font, crate::Font::default(), "no font selected");
}
#[test]
fn escapement_turns_the_baseline_counter_clockwise() {
let recs = [wfont(-20, 400, [0, 0, 0], 0, 900, b"Arial"), wrec(0x012D, &h16(0)), wtext(100, 90, b"Up", 0, None, &[])];
let p = wmf200(&recs);
let t = texts(&p)[0];
// Text x runs up the picture (0, -1); text y (down the glyphs) runs right (1, 0).
assert!(close6(t.transform, [0.0, -1.0, 1.0, 0.0, 100.0, 90.0]), "{:?}", t.transform);
}
#[test]
fn a_y_up_mapping_moves_text_but_does_not_mirror_it() {
// Window 200 × -100 onto a 200 × 100 viewport: y goes up.
let recs = [
wrec(0x0103, &h16(8)),
wrec(0x020C, &cat(vec![h16(-100), h16(200)])),
wrec(0x020E, &cat(vec![h16(100), h16(200)])),
wtext(40, -30, b"A", 0, None, &[]),
];
let p = parse(&wmf(&recs, None)).unwrap();
let t = texts(&p)[0];
assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 40.0, 30.0]), "{:?}", t.transform);
}
#[test]
fn update_cp_starts_at_the_current_position_and_moves_past_the_string() {
let recs =
[wrec(0x012E, &h16(1)), wrec(0x0214, &cat(vec![h16(20), h16(10)])), wtext(0, 0, b"ab", 0, None, &[5, 7]), wtext(0, 0, b"c", 0, None, &[3])];
let p = wmf200(&recs);
let t = texts(&p);
assert_eq!(t.len(), 2);
assert!(close6(t[0].transform, [1.0, 0.0, 0.0, 1.0, 10.0, 20.0]), "{:?}", t[0].transform);
assert!(close6(t[1].transform, [1.0, 0.0, 0.0, 1.0, 22.0, 20.0]), "{:?}", t[1].transform);
}
#[test]
fn eto_opaque_fills_the_rectangle_and_eto_clipped_clips() {
let recs = [wrec(0x0201, &h32(0x0000_FF00)), wtext(12, 30, b"ok", 2 | 4, Some([10, 10, 60, 40]), &[])];
let p = wmf200(&recs);
assert_eq!(p.items.len(), 2, "{:?}", p.items);
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
assert!(close(x0, 10.0) && close(y0, 10.0) && close(x1, 60.0) && close(y1, 40.0));
assert_eq!(path_segs(&p.items[0]).1, Some([0, 255, 0, 255]));
let t = texts(&p)[0];
assert_eq!(t.clip, Some([10.0, 10.0, 60.0, 40.0]));
assert_eq!(t.background, Some([0, 255, 0, 255]));
}
#[test]
fn character_sets_decode_bytes() {
let one = |charset: u8, s: &[u8]| {
let p = wmf200(&[wfont(-10, 400, [0, 0, 0], charset, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, s, 0, None, &[])]);
texts(&p)[0].text.clone()
};
assert_eq!(one(0, &[b'A', 0x80, 0x93, 0x94, 0xE9]), "A€“”é");
assert_eq!(one(238, &[0xB9, 0xE8, 0x9E, 0xA3]), "ąčžŁ");
assert_eq!(one(204, &[0xC0, 0xFF, 0xA8]), "АяЁ");
assert_eq!(one(2, b"n\xA7"), "\u{F06E}\u{F0A7}", "symbol fonts keep their codes in the private-use area");
assert_eq!(one(0, &[0x81]), "\u{FFFD}", "an undefined byte");
// A double-byte character set: a lead and trail byte become one blank with both advances.
let p =
wmf200(&[wfont(-10, 400, [0, 0, 0], 128, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, &[0x41, 0x82, 0xA0, 0x42], 0, None, &[1, 2, 3, 4])]);
let t = texts(&p)[0];
assert_eq!((t.text.as_str(), t.dx.as_slice()), ("A B", [1.0, 5.0, 4.0].as_slice()));
}
#[test]
fn wmf_textout_reads_its_string_and_position() {
let mut v = cat(vec![h16(3)]);
v.extend(b"abc\0");
v.extend(cat(vec![h16(40), h16(30)]));
let p = wmf200(&[wrec(0x0521, &v)]);
let t = texts(&p)[0];
assert_eq!(t.text, "abc");
assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 30.0, 40.0]), "{:?}", t.transform);
}
#[test]
fn savedc_restores_text_colour_and_font() {
let recs = [
wfont(-10, 400, [0, 0, 0], 0, 0, b"One"),
wrec(0x012D, &h16(0)),
wrec(0x0209, &h32(0x0000_00FF)),
wrec(0x001E, &[]),
wfont(-10, 400, [0, 0, 0], 0, 0, b"Two"),
wrec(0x012D, &h16(1)),
wrec(0x0209, &h32(0x00FF_0000)),
wrec(0x0127, &h16(-1)),
wtext(0, 0, b"x", 0, None, &[]),
];
let p = wmf200(&recs);
let t = texts(&p)[0];
assert_eq!((t.font.family.as_str(), t.color), ("One", [255, 0, 0, 255]));
}
/// EMR_EXTCREATEFONTINDIRECTW in slot 1: LogFont at 12, face at 40.
fn efont(height: i32, esc: i32, weight: i32, italic: u8, charset: u8, face: &str) -> Vec<u8> {
let mut v = cat(vec![h32(1), e32(height), e32(0), e32(esc), e32(esc), e32(weight)]);
v.extend([italic, 0, 0, charset, 0, 0, 0, 0]);
let mut name: Vec<u8> = face.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
name.resize(64, 0);
v.extend(name);
erec(82, &v)
}
/// EMR_EXTTEXTOUTW (or A) at (x, y) with options, the rectangle (absent under ETO_NO_RECT), the
/// string at 76 and the dx array after it.
fn etext(wide: bool, x: i32, y: i32, units: &[u16], opts: u32, dx: &[i32]) -> Vec<u8> {
let no_rect = opts & 0x100 != 0;
let fixed = if no_rect { 60 } else { 76 };
let s: Vec<u8> = if wide { units.iter().flat_map(|u| u.to_le_bytes()).collect() } else { units.iter().map(|u| *u as u8).collect() };
let s_off = fixed;
let dx_off = (s_off + s.len()).next_multiple_of(4);
let len = dx_off + dx.len() * 4;
let mut f = vec![(24, 1u32), (36, x as u32), (40, y as u32), (44, units.len() as u32), (48, s_off as u32), (52, opts)];
f.push((if no_rect { 56 } else { 72 }, if dx.is_empty() { 0 } else { dx_off as u32 }));
let dxb: Vec<u8> = dx.iter().flat_map(|d| d.to_le_bytes()).collect();
erec_at(if wide { 84 } else { 83 }, len.next_multiple_of(4), &f, &[(s_off, &s), (dx_off, &dxb)])
}
#[test]
fn emf_exttextoutw_reads_utf16_dx_and_the_font() {
let recs = [
efont(24, 0, 400, 1, 0, "Segoe UI"),
erec(37, &h32(1)),
erec(22, &h32(0x18 | 2)),
erec(24, &h32(0x0000_FF00)),
erec(18, &h32(1)),
etext(true, 100, 50, &[0x41, 0xD83D, 0xDE00], 0, &[10, 6, 0]),
];
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
let t = texts(&p);
assert_eq!(t.len(), 1, "{:?}", p.items);
let t = t[0];
assert_eq!(t.text, "A\u{1F600}");
assert_eq!(t.dx, vec![10.0, 6.0], "a surrogate pair takes both advances");
assert_eq!((t.font.family.as_str(), t.font.height, t.font.italic), ("Segoe UI", 24.0, true));
assert_eq!((t.h_align, t.v_align, t.color, t.background), (HAlign::Right, VAlign::Baseline, [0, 255, 0, 255], None));
// Upright, uniformly scaled.
let [a, b, c, d, ..] = t.transform;
assert!(a > 0.0 && d > 0.0 && b.abs() < 1e-9 && c.abs() < 1e-9 && (a - d).abs() < 1e-9, "{:?}", t.transform);
}
#[test]
fn emf_text_options_pdy_no_rect_ansi_and_glyph_indices() {
// ETO_PDY: (dx, dy) pairs, of which dx is used. ETO_NO_RECT moves the dx offset to 56.
let recs = [etext(true, 0, 0, &[0x61, 0x62], 0x2000 | 0x100, &[7, 1, 9, 2])];
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
assert_eq!(texts(&p)[0].dx, vec![7.0, 9.0]);
// EXTTEXTOUTA decodes through the font's charset.
let recs = [efont(-10, 0, 400, 0, 238, "F"), erec(37, &h32(1)), etext(false, 0, 0, &[0xB9], 0, &[])];
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
assert_eq!(texts(&p)[0].text, "ą");
// Glyph indices name glyphs of a font we cannot match: left out.
let recs = [etext(true, 0, 0, &[5, 6], 0x10, &[])];
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
assert!(texts(&p).is_empty());
}
#[test]
fn emf_smalltextout_reads_8_bit_and_16_bit_strings() {
// x, y, count, options, mode, scales, (no bounds under ETO_NO_RECT), characters.
let small = |opts: u32, chars: &[u8]| {
let mut v = cat(vec![e32(3), e32(4), h32(2), h32(opts), h32(1), h32(0), h32(0)]);
v.extend(chars);
erec(108, &v)
};
let p = parse(&emf(&[small(0x100 | 0x200, b"hi")], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
assert_eq!(texts(&p)[0].text, "hi");
let p = parse(&emf(&[small(0x100, &[b'o', 0, b'k', 0])], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
assert_eq!(texts(&p)[0].text, "ok");
}
/// An 8 × 8 one-bit DIB (palette white, black; stored bottom-up): `rows` top to bottom.
fn mono_dib(rows: [u8; 8]) -> Vec<u8> {
let mut v = info_header(8, 8, 1, 32);
v.extend([255, 255, 255, 0, 0, 0, 0, 0]);
for r in rows.iter().rev() {
v.extend([*r, 0, 0, 0]);
}
v
}
fn pattern_of(item: &Item) -> &crate::Tile {
match item {
Item::Pattern { tile, .. } => tile,
other => panic!("expected a pattern fill: {other:?}"),
}
}
/// Pixel (x, y) of a tile.
fn tile_px(t: &crate::Tile, x: usize, y: usize) -> [u8; 4] {
let o = (y * t.width as usize + x) * 4;
t.rgba[o..o + 4].try_into().unwrap()
}
#[test]
fn wmf_dib_pattern_brush_paints_its_rows_top_down() {
let brush = wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0xFF, 0x80, 0, 0, 0, 0, 0, 0x01])]));
let recs = [
brush,
// A pen after the brush: the brush took handle 0.
wrec(0x02FA, &cat(vec![h16(0), h16(1), h16(0), h32(0x0000_00FF)])),
wrec(0x012D, &h16(0)),
wrec(0x012D, &h16(1)),
wrec(0x041B, &cat(vec![h16(720), h16(1440), h16(0), h16(0)])),
];
let p = parse(&wmf(&recs, Some([0, 0, 1440, 720]))).unwrap();
assert_eq!(p.items.len(), 2, "the tile fill and the outline: {:?}", p.items);
let t = pattern_of(&p.items[0]);
assert_eq!((t.width, t.height), (8, 8));
for x in 0..8 {
assert_eq!(tile_px(t, x, 0), [0, 0, 0, 255], "top row is set: black");
}
assert_eq!(tile_px(t, 0, 1), [0, 0, 0, 255]);
assert_eq!(tile_px(t, 1, 1), [255, 255, 255, 255]);
assert_eq!(tile_px(t, 7, 7), [0, 0, 0, 255]);
assert_eq!(tile_px(t, 0, 7), [255, 255, 255, 255]);
// 96-dpi pixels of a picture in points, laid from the picture's corner.
assert!(close(t.cell[0], 0.75) && close(t.cell[1], 0.75) && t.origin == [0.0, 0.0], "{:?} {:?}", t.cell, t.origin);
assert_eq!(path_segs(&p.items[1]).2.map(|s| s.0), Some([255, 0, 0, 255]));
}
#[test]
fn hatch_brushes_draw_lines_over_the_background_mode() {
let hatch = |style: i32| wrec(0x02FC, &cat(vec![h16(2), h32(0x00FF_0000), h16(style)]));
let rect = wrec(0x041B, &cat(vec![h16(50), h16(50), h16(0), h16(0)]));
// HS_CROSS over a transparent background.
let p = wmf200(&[hatch(4), wrec(0x012D, &h16(0)), wrec(0x0102, &h16(1)), rect.clone()]);
let t = pattern_of(&p.items[0]);
let blue = [0, 0, 255, 255];
assert_eq!((tile_px(t, 3, 7), tile_px(t, 7, 3)), (blue, blue));
assert_eq!(tile_px(t, 3, 3)[3], 0, "gaps are transparent");
// HS_BDIAGONAL over the opaque white background.
let p = wmf200(&[hatch(3), wrec(0x012D, &h16(0)), rect.clone()]);
let t = pattern_of(&p.items[0]);
assert_eq!((tile_px(t, 0, 7), tile_px(t, 7, 0)), (blue, blue));
assert_eq!(tile_px(t, 0, 0), [255, 255, 255, 255]);
// An unknown hatch style draws no fill.
let p = wmf200(&[hatch(9), wrec(0x012D, &h16(0)), rect]);
assert!(p.items.iter().all(|i| !matches!(i, Item::Pattern { .. })), "{:?}", p.items);
}
#[test]
fn xor_mask_xor_draws_the_pattern_in_the_xor_colour() {
// The see-through shadow: XOR a grey polygon, AND a checker pattern, XOR the polygon again.
let square = wrec(0x0324, &cat(vec![h16(4), h16(0), h16(0), h16(40), h16(0), h16(40), h16(40), h16(0), h16(40)]));
let recs = [
wrec(0x0104, &h16(7)),
wrec(0x02FC, &cat(vec![h16(0), h32(0x0080_8080), h16(0)])),
wrec(0x012D, &h16(0)),
square.clone(),
wrec(0x0104, &h16(9)),
wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA])])),
wrec(0x012D, &h16(1)),
wrec(0x041B, &cat(vec![h16(40), h16(40), h16(0), h16(0)])),
wrec(0x0104, &h16(7)),
wrec(0x012D, &h16(0)),
square,
];
let p = wmf200(&recs);
assert_eq!(p.items.len(), 2, "only the AND pattern (and its outline) draw: {:?}", p.items);
let t = pattern_of(&p.items[0]);
// 0x55 = 0101 0101: white, black, …
assert_eq!(tile_px(t, 0, 0)[3], 0, "white keeps what is below");
assert_eq!(tile_px(t, 1, 0), [128, 128, 128, 255], "black takes the XOR colour");
}
#[test]
fn patblt_fills_with_the_brush() {
let patblt = |rop: u32| wrec(0x061D, &cat(vec![h32(rop), h16(30), h16(40), h16(10), h16(20)]));
let recs = [wrec(0x02FC, &cat(vec![h16(0), h32(0x00FF_0000), h16(0)])), wrec(0x012D, &h16(0)), patblt(0x00F0_0021), patblt(0x0055_0009)];
let p = wmf200(&recs);
assert_eq!(p.items.len(), 1, "DSTINVERT draws nothing: {:?}", p.items);
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
assert!(close(x0, 20.0) && close(y0, 10.0) && close(x1, 60.0) && close(y1, 40.0), "{x0} {y0} {x1} {y1}");
assert_eq!(path_segs(&p.items[0]).1, Some([0, 0, 255, 255]));
}
#[test]
fn emf_dib_pattern_and_mono_brushes() {
let dib = mono_dib([0xF0; 8]);
let (info, bits) = dib.split_at(48);
let brush = |typ: u32| {
let f = [(8, 1u32), (12, 0), (16, 32), (20, info.len() as u32), (24, 32 + info.len() as u32), (28, bits.len() as u32)];
erec_at(typ, 32 + dib.len(), &f, &[(32, info), (32 + info.len(), bits)])
};
let rect = erec(43, &cat(vec![e32(0), e32(0), e32(40), e32(40)]));
let null_pen = erec(37, &h32(0x8000_0008));
let p = parse(&emf(&[brush(94), erec(37, &h32(1)), null_pen.clone(), rect.clone()], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
let t = pattern_of(&p.items[0]);
assert_eq!((tile_px(t, 0, 0), tile_px(t, 7, 0)), ([0, 0, 0, 255], [255, 255, 255, 255]));
// A mono brush paints in the text colour (red) and the background colour (green).
let recs = [erec(24, &h32(0xFF)), erec(25, &h32(0xFF00)), brush(93), erec(37, &h32(1)), null_pen, rect];
let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap();
let t = pattern_of(&p.items[0]);
assert_eq!((tile_px(t, 0, 0), tile_px(t, 7, 0)), ([255, 0, 0, 255], [0, 255, 0, 255]));
}
#[test]
fn hostile_text_and_brush_records_never_panic() {
// A string longer than its record is cut to what the record holds.
let mut v = cat(vec![h16(0), h16(0), h16(0x7FFF), h16(0)]);
v.extend(b"abcd");
let p = wmf200(&[wrec(0x0A32, &v)]);
assert_eq!(texts(&p)[0].text, "abcd");
// The rectangle flag without room for the rectangle draws nothing.
let p = wmf200(&[wrec(0x0A32, &cat(vec![h16(0), h16(0), h16(1), h16(6)]))]);
assert!(texts(&p).is_empty());
// A face name with no terminator stops at 32 bytes.
let p = wmf200(&[wfont(-10, 400, [0, 0, 0], 0, 0, &[b'A'; 40]), wrec(0x012D, &h16(0)), wtext(0, 0, b"x", 0, None, &[])]);
assert_eq!(texts(&p)[0].font.family.len(), 32);
// A pattern brush whose DIB is cut short, or too big for a tile, still takes its handle slot
// and draws no fill.
let short = wrec(0x0142, &cat(vec![h16(5), h16(0), info_header(8, 8, 1, 32)]));
let mut huge = info_header(300, 300, 1, 0);
huge.extend([255, 255, 255, 0, 0, 0, 0, 0]);
huge.extend(vec![0u8; 40 * 300]);
let huge = wrec(0x0142, &cat(vec![h16(5), h16(0), huge]));
for b in [short, huge] {
let recs = [
b,
wrec(0x02FA, &cat(vec![h16(0), h16(1), h16(0), h32(0xFF)])),
wrec(0x012D, &h16(0)),
wrec(0x012D, &h16(1)),
wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])),
];
let p = wmf200(&recs);
assert_eq!(p.items.len(), 1, "{:?}", p.items);
let (_, fill, stroke, _) = path_segs(&p.items[0]);
assert_eq!((fill, stroke.map(|s| s.0)), (Some([255, 255, 255, 255]), Some([255, 0, 0, 255])));
}
// EMF: the string offset past the record, a huge count, a dx offset past the record.
let bad = [
erec_at(84, 80, &[(44, 2), (48, 4000), (52, 0)], &[]),
erec_at(84, 84, &[(44, u32::MAX), (48, 76), (52, 0)], &[(76, &[b'a', 0, b'b', 0])]),
erec_at(84, 80, &[(44, 2), (48, 76), (52, 0), (72, 0xFFFF_FFF0)], &[(76, &[b'a', 0, b'b', 0])]),
erec_at(82, 20, &[(8, 1)], &[]),
erec_at(94, 32, &[(8, 1), (16, 0xFFFF_FFFF), (20, 40), (24, 0), (28, 0xFFFF_FFFF)], &[]),
erec_at(108, 40, &[(16, u32::MAX), (20, 0x300)], &[]),
];
for r in bad {
let _ = parse(&emf(&[r], [0, 0, 200, 100], [0, 0, 5000, 2500]));
}
let p = parse(&emf(
&[erec_at(84, 84, &[(44, 2), (48, 76), (52, 0), (72, 0xFFFF_FFF0)], &[(76, &[b'a', 0, b'b', 0])])],
[0, 0, 200, 100],
[0, 0, 5000, 2500],
))
.unwrap();
assert!(texts(&p)[0].dx.is_empty(), "a dx array past the record is dropped");
// Mutated text and brush files.
let wfile = wmf(
&[
wfont(-20, 700, [1, 1, 1], 238, 450, b"Arial"),
wrec(0x012D, &h16(0)),
wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0x55; 8])])),
wrec(0x012D, &h16(1)),
wrec(0x012E, &h16(1)),
wtext(5, 5, b"Hello", 6, Some([0, 0, 50, 50]), &[1, 2, 3, 4, 5]),
wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])),
],
Some([0, 0, 100, 100]),
);
let efile = emf(
&[efont(24, 900, 400, 1, 0, "Segoe UI"), erec(37, &h32(1)), etext(true, 1, 2, &[0x41, 0xD83D, 0xDE00], 0x2000, &[1, 2, 3, 4, 5, 6])],
[0, 0, 200, 100],
[0, 0, 5000, 2500],
);
let mut rng = Lcg(0x9E37_79B9_7F4A_7C15);
for file in [&wfile, &efile] {
for n in 0..=file.len() {
let _ = parse(&file[..n]);
}
for _ in 0..1500 {
let mut buf = file.clone();
for _ in 0..6 {
let at = rng.rnd() as usize % buf.len();
buf[at] = rng.rnd() as u8;
}
let _ = parse(&buf);
}
}
}
#[test]
fn text_items_are_capped_like_any_other() {
let t = wtext(0, 0, b"x", 0, None, &[]);
let recs: Vec<Vec<u8>> = (0..MAX_ITEMS + 5).map(|_| t.clone()).collect();
let p = wmf200(&recs);
assert_eq!(p.items.len(), MAX_ITEMS);
}
#[test]
fn pattern_brush_pixels_count_against_the_byte_budget() {
// 256 × 256 one-bit pattern brushes (256 KiB of RGBA each): far fewer than the object table
// holds fit the budget, and the parse stops there.
let mut dib = info_header(256, 256, 1, 0);
dib.extend([255, 255, 255, 0, 0, 0, 0, 0]);
dib.extend(vec![0x55u8; 32 * 256]);
let brush = wrec(0x0142, &cat(vec![h16(5), h16(0), dib]));
let mut recs: Vec<Vec<u8>> = (0..1100).map(|_| brush.clone()).collect();
recs.push(wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])));
let p = wmf200(&recs);
assert!(p.items.is_empty(), "the parse stopped at the budget: {:?}", p.items.len());
}
-200
View File
@@ -1,200 +0,0 @@
//! Text records: the font (LOGFONT), the text string decoded from its character set, and the
//! neutral [`Text`] item. The metafile crate knows no fonts, so measuring, alignment against the
//! font's metrics and glyph drawing are left to the renderer.
/// A font chosen by a CREATEFONTINDIRECT record (MS-WMF 2.2.1.2 Font, MS-EMF 2.2.13 LogFont).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Font {
/// The face name the file asks for (`"Arial"`, `"Wingdings"`); may be empty.
pub family: String,
/// LOGFONT height in text units: negative is the character (em) height, positive the cell
/// height (ascent plus descent), 0 the default size.
pub height: f32,
/// Average character width in text units; 0 keeps the font's own proportions.
pub width: f32,
/// 100..=900 (400 regular, 700 bold); 0 is the default weight.
pub weight: u16,
pub italic: bool,
pub underline: bool,
pub strike: bool,
/// LOGFONT character set (0 ANSI, 2 SYMBOL, 238 EASTEUROPE…).
pub charset: u8,
}
/// SYMBOL_CHARSET: byte codes are the font's own symbol codes.
pub const SYMBOL_CHARSET: u8 = 2;
/// Where the reference point sits on the string horizontally (TA_LEFT, TA_CENTER, TA_RIGHT).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HAlign {
#[default]
Left,
Center,
Right,
}
/// Where the reference point sits vertically (TA_TOP, TA_BASELINE, TA_BOTTOM): the top of the
/// character cell, the baseline or the bottom of the cell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VAlign {
#[default]
Top,
Baseline,
Bottom,
}
/// One string drawn by EXTTEXTOUT, TEXTOUT or SMALLTEXTOUT.
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
/// Text space to picture space as affine coefficients `[a, b, c, d, e, f]` (`x' = a·x + c·y + e`,
/// `y' = b·x + d·y + f`, kurbo's order). Text space has x along the baseline and y down, in the
/// metafile's logical units, with the reference point at the origin. Escapement and the
/// mapping are folded in; glyphs are never mirrored.
pub transform: [f64; 6],
pub text: String,
/// Advance after each character of `text`, in text units (the dx array). Empty when the file
/// gives none: the font's own advances apply.
pub dx: Vec<f32>,
pub font: Font,
pub color: [u8; 4],
/// Colour filled behind the character cells (OPAQUE background mode); None when transparent.
pub background: Option<[u8; 4]>,
pub h_align: HAlign,
pub v_align: VAlign,
/// Clip rectangle `[x0, y0, x1, y1]` in picture units (ETO_CLIPPED).
pub clip: Option<[f32; 4]>,
}
/// TA_UPDATECP: the string starts at the current position, which moves past it.
pub(crate) const TA_UPDATECP: u32 = 0x0001;
/// ETO_OPAQUE fills the rectangle with the background colour; ETO_CLIPPED clips the text to it.
pub(crate) const ETO_OPAQUE: u32 = 0x0002;
pub(crate) const ETO_CLIPPED: u32 = 0x0004;
/// The string holds glyph indices, not characters (drawn only when the font matches).
pub(crate) const ETO_GLYPH_INDEX: u32 = 0x0010;
/// The dx array holds (dx, dy) pairs.
pub(crate) const ETO_PDY: u32 = 0x2000;
/// Longest string kept from one record.
pub(crate) const MAX_CHARS: usize = 16_384;
/// Horizontal and vertical alignment from a TA_* word (the vertical bits are 0 top, 8 bottom,
/// 24 baseline; the horizontal ones 0 left, 2 right, 6 center).
pub(crate) fn align(ta: u32) -> (HAlign, VAlign) {
let h = match ta & 0x06 {
6 => HAlign::Center,
2 => HAlign::Right,
_ => HAlign::Left,
};
let v = match ta & 0x18 {
0x18 => VAlign::Baseline,
0x08 => VAlign::Bottom,
_ => VAlign::Top,
};
(h, v)
}
/// Decodes 8-bit text in `charset` to one character per byte, and merges `dx` to match: a
/// double-byte character set's lead and trail bytes become one blank (its glyphs are not
/// decoded) whose advance is the sum of both. `dx` may be empty.
pub(crate) fn decode_bytes(bytes: &[u8], charset: u8, dx: &[f32]) -> (String, Vec<f32>) {
let mut s = String::with_capacity(bytes.len());
let mut out_dx = Vec::with_capacity(dx.len().min(bytes.len()));
let dbcs = matches!(charset, 128 | 129 | 130 | 134 | 136);
let mut i = 0;
while let Some(&b) = bytes.get(i) {
let n = if dbcs && b >= 0x81 && i + 1 < bytes.len() { 2 } else { 1 };
let c = if n == 2 { ' ' } else { byte_char(b, charset) };
s.push(c);
if !dx.is_empty() {
out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum());
}
i += n;
}
(s, out_dx)
}
/// UTF-16 text to a string with one dx entry per character: a surrogate pair takes the sum of
/// its two advances. Unpaired surrogates become U+FFFD.
pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec<f32>) {
let mut s = String::with_capacity(units.len());
let mut out_dx = Vec::with_capacity(dx.len().min(units.len()));
let mut i = 0;
for r in char::decode_utf16(units.iter().copied()) {
let c = r.unwrap_or(char::REPLACEMENT_CHARACTER);
let n = c.len_utf16();
s.push(c);
if !dx.is_empty() {
out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum());
}
i += n;
}
(s, out_dx)
}
/// One byte in a single-byte Windows code page. Symbol fonts keep their codes in the private-use
/// range (U+F000 + code), where symbol fonts map them.
fn byte_char(b: u8, charset: u8) -> char {
if b < 0x80 && charset != SYMBOL_CHARSET {
return char::from(b);
}
let cp = match charset {
SYMBOL_CHARSET => 0xF000 | u32::from(b),
238 => high(b, &CP1250),
204 => high(b, &CP1251),
_ => high(b, &CP1252),
};
char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER)
}
/// The code point of byte `b` (0x80..=0xFF) from a table of the upper half; 0 entries are
/// undefined bytes, drawn as U+FFFD.
fn high(b: u8, table: &[u16; 128]) -> u32 {
match table.get(usize::from(b.wrapping_sub(0x80))) {
Some(0) | None => 0xFFFD,
Some(v) => u32::from(*v),
}
}
/// Windows-1252 (Western) 0x80..=0xFF: the upper half of Latin-1 with typographic marks in 0x80..=0x9F.
const CP1252: [u16; 128] = {
let mut t = [0u16; 128];
let low: [u16; 32] = [
0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C,
0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178,
];
let mut i = 0;
while i < 128 {
t[i] = if i < 32 { low[i] } else { 0x80 + i as u16 };
i += 1;
}
t
};
/// Windows-1250 (Central European) 0x80..=0xFF.
const CP1250: [u16; 128] = [
0x20AC, 0, 0x201A, 0, 0x201E, 0x2026, 0x2020, 0x2021, 0, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, //
0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, //
0x00A0, 0x02C7, 0x02D8, 0x0141, 0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, //
0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5, 0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, //
0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7, 0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, //
0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E, 0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, //
0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB, 0x011B, 0x00ED, 0x00EE, 0x010F, //
0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD, 0x0163, 0x02D9,
];
/// Windows-1251 (Cyrillic) 0x80..=0xFF; 0xC0..=0xFF are А..я in order.
const CP1251: [u16; 128] = {
let low: [u16; 64] = [
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, //
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, //
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, //
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
];
let mut t = [0u16; 128];
let mut i = 0;
while i < 128 {
t[i] = if i < 64 { low[i] } else { 0x0410 + (i as u16 - 64) };
i += 1;
}
t
};
+7 -114
View File
@@ -1,19 +1,14 @@
//! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then
//! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record.
use std::sync::Arc;
use crate::bytes::Bytes;
use crate::canvas::{BS_HATCHED, Brush, Canvas, LogFont, Obj, Pen, colorref};
use crate::canvas::{Brush, Canvas, Obj, Pen, colorref};
use crate::dib;
use crate::geom::{self, ArcKind, P};
use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, MAX_CHARS, decode_bytes};
use crate::{Error, Frame, MAX_PATH_OPS};
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
const DEFAULT_INCH: f64 = 1440.0;
/// A placeable WMF plays into a target sized in points; brushes are laid out in 96-dpi pixels.
const PT_PER_PX: f64 = 72.0 / 96.0;
pub(crate) fn is_wmf(b: Bytes) -> bool {
if b.u32(0) == Some(PLACEABLE_KEY) {
@@ -47,7 +42,6 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
let mut cv = Canvas::new();
if let Some((c, size)) = bounds {
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
cv.pattern_px = PT_PER_PX;
}
let mut player_viewport = bounds.is_some();
let mut parsed = 0usize;
@@ -134,31 +128,12 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) {
}),
0x02FA => create_pen(cv, r),
0x02FC => create_brush(cv, r),
0x02FB => {
let f = font(r).unwrap_or_default();
cv.add_object(None, Obj::Font(Arc::new(f)));
Some(())
}
0x0142 => {
// A pattern the reader cannot decode still takes its handle slot.
let b = dib_pattern_brush(r).map_or(Obj::Other, Obj::Brush);
cv.add_object(None, b);
Some(())
}
// Palettes, old-style pattern brushes and regions take a handle slot too, so later
// SELECTOBJECT handles count them; selecting one draws nothing.
0x00F7 | 0x01F9 | 0x06FF => {
// Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT
// handles count them; selecting one draws nothing.
0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => {
cv.add_object(None, Obj::Other);
Some(())
}
0x0209 => r.u32(6).map(|c| cv.set_text_color(c)),
0x0201 => r.u32(6).map(|c| cv.set_bk_color(c)),
0x0102 => r.u16(6).map(|m| cv.set_bk_mode(u32::from(m))),
0x012E => r.u16(6).map(|a| cv.set_text_align(u32::from(a))),
0x0A32 => ext_text_out(cv, r),
0x0521 => text_out(cv, r),
// PATBLT: rop(4), height, width, y, x.
0x061D => r.u32(6).map(|rop| cv.fill_rop([i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)], rop)),
0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))),
0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))),
// STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB.
@@ -193,93 +168,11 @@ fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
Some(())
}
/// CREATEBRUSHINDIRECT: style, colour, hatch.
/// CREATEBRUSHINDIRECT: style, colour, hatch (hatch is not drawn).
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
let style = u32::from(r.u16(6)?);
let style = r.u16(6)?;
let color = r.u32(8)?;
let mut b = Brush::new(style, colorref(color));
if style == BS_HATCHED {
b.hatch = u32::from(r.u16(12).unwrap_or(0));
}
cv.add_object(None, Obj::Brush(b));
Some(())
}
/// DIBCREATEPATTERNBRUSH: style, colour usage, then a DIB (MS-WMF 2.3.4.8). The DIB's own palette
/// gives the colours; with DIB_PAL_COLORS (indices into a palette the reader does not keep) a
/// monochrome DIB takes the text and background colours.
fn dib_pattern_brush(r: Bytes) -> Option<Brush> {
let usage = r.u16(8)?;
let dib = r.tail(10)?;
let n = dib::info_len(dib)?;
let img = dib::decode(Bytes(dib.slice(0, n)?), dib.tail(n)?, false)?;
Brush::pattern(img, usage == 1)
}
/// Font object (MS-WMF 2.2.1.2): height, width, escapement, orientation, weight (i16 each), italic,
/// underline, strike-out and charset bytes, three precision/quality bytes, pitch and family, then the
/// face name (up to 32 bytes, NUL-terminated, in the font's character set).
fn font(r: Bytes) -> Option<LogFont> {
let height = r.i16(6)?;
let width = r.i16(8).unwrap_or(0);
let escapement = r.i16(10).unwrap_or(0);
let weight = r.i16(14).unwrap_or(0);
let byte = |o: usize| r.0.get(o).copied().unwrap_or(0);
let charset = byte(19);
let name = r.0.get(24..).unwrap_or(&[]);
let name = name.get(..32).unwrap_or(name);
let name = name.split(|b| *b == 0).next().unwrap_or(&[]);
// Face names are plain ASCII in practice; other bytes go through the Western code page.
let (family, _) = decode_bytes(name, 0, &[]);
Some(LogFont {
font: Font {
family: family.trim().to_string(),
height: f32::from(height),
width: f32::from(width),
weight: weight.clamp(0, 1000) as u16,
italic: byte(16) != 0,
underline: byte(17) != 0,
strike: byte(18) != 0,
charset,
},
escapement: f64::from(escapement),
})
}
/// The charset of the selected font, for decoding 8-bit text.
fn charset(cv: &Canvas) -> u8 {
cv.font_charset()
}
/// EXTTEXTOUT (MS-WMF 2.3.3.5): y, x, string length, options, the rectangle (left, top, right,
/// bottom) when ETO_OPAQUE or ETO_CLIPPED is set, the string padded to a word, then an optional dx
/// array of i16 advances.
fn ext_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
let (y, x) = (r.i16(6)?, r.i16(8)?);
let n = usize::from(r.u16(10)?).min(MAX_CHARS);
let opts = u32::from(r.u16(12)?);
let has_rect = opts & (ETO_OPAQUE | ETO_CLIPPED) != 0;
let rect = if has_rect { Some([i16s(r, 14), i16s(r, 16), i16s(r, 18), i16s(r, 20)]) } else { None };
let at = if has_rect { 22 } else { 14 };
// A string running past the record is cut to what the record holds.
let avail = r.0.len().saturating_sub(at);
let n = n.min(avail);
let bytes = r.slice(at, n)?;
let dx_at = at + n.next_multiple_of(2);
let dx: Vec<f32> = if r.0.len() >= dx_at + 2 * n { (0..n).filter_map(|i| r.i16(dx_at + 2 * i)).map(f32::from).collect() } else { Vec::new() };
let (text, dx) = decode_bytes(bytes, charset(cv), &dx);
cv.text((f64::from(x), f64::from(y)), text, dx, opts, rect);
Some(())
}
/// TEXTOUT (MS-WMF 2.3.3.20): string length, the string padded to a word, then y and x.
fn text_out(cv: &mut Canvas, r: Bytes) -> Option<()> {
let n = usize::from(r.u16(6)?).min(MAX_CHARS);
let bytes = r.slice(8, n)?;
let at = 8 + n.next_multiple_of(2);
let (y, x) = (r.i16(at)?, r.i16(at + 2)?);
let (text, _) = decode_bytes(bytes, charset(cv), &[]);
cv.text((f64::from(x), f64::from(y)), text, Vec::new(), 0, None);
cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) }));
Some(())
}
-11
View File
@@ -387,16 +387,6 @@ pub struct Effects {
pub bevel: Option<String>,
/// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`).
pub raw3d: Option<String>,
/// `a:fillOverlay`: a fill blended over the shape's fill (`blend`: `over`, `mult`, `screen`,
/// `darken`, `lighten`). Only solid and gradient overlays are kept.
pub fill_overlay: Option<FillOverlay>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FillOverlay {
pub blend: String,
pub fill: Fill,
}
impl Effects {
@@ -407,7 +397,6 @@ impl Effects {
&& self.soft_edge.is_none()
&& self.reflection.is_none()
&& self.bevel.is_none()
&& self.fill_overlay.is_none()
}
}
-8
View File
@@ -257,14 +257,6 @@ pub fn effects(el: &El) -> Effects {
"innerShdw" => fx.inner_shadow = Some(shadow(e, true)),
"glow" => fx.glow = Some(Glow { color: color(e).unwrap_or(ColorRef::rgb(Rgba::BLACK)), radius: pt(e, "rad").unwrap_or(0.0).max(0.0) }),
"softEdge" => fx.soft_edge = Some(pt(e, "rad").unwrap_or(0.0).max(0.0)),
"fillOverlay" => {
let fill = e.elements().find_map(|f| match f.local() {
"solidFill" => color(f).map(|c| Fill::Solid { color: c }),
"gradFill" => Some(Fill::Gradient(gradient(f))),
_ => None,
});
fx.fill_overlay = fill.map(|fill| deckcraft_model::style::FillOverlay { blend: e.attr("blend").unwrap_or("over").to_string(), fill });
}
"reflection" => {
fx.reflection = Some(Reflection {
blur: pt(e, "blurRad").unwrap_or(0.0),
-4
View File
@@ -711,10 +711,6 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout
if let Some(tree) = csld.child("spTree") {
s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0);
}
if let Some(c) = csld.child("controls") {
let pics = shapes::controls(imp, part, &mut ctx, c);
s.shapes.extend(pics);
}
}
if let Some(t) = root.child("transition") {
s.transition = timing::transition(imp, part, t);
-16
View File
@@ -87,22 +87,6 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us
out
}
/// ActiveX controls (`p:controls`): each control's picture (`p:pic`), the image PowerPoint shows
/// for it, as a picture above the slide's shapes. Controls without one are left out.
pub fn controls(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, controls: &El) -> Vec<Shape> {
let mut out = vec![];
for c in controls.children_named("control") {
if ctx.count >= MAX_SHAPES {
break;
}
if let Some(p) = c.child("pic") {
ctx.count += 1;
out.push(pic(imp, part, ctx, p));
}
}
out
}
fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) {
if depth > MAX_TREE_DEPTH {
return;
+34 -48
View File
@@ -76,7 +76,40 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
match f {
Fill::None => w.empty0("a:noFill"),
Fill::Solid { color: c } => solid(w, c),
Fill::Gradient(g) => gradient(w, g),
Fill::Gradient(g) => {
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
let mut stops = g.stops.clone();
if stops.len() == 1 {
let mut s = stops[0].clone();
s.pos = 1.0;
stops.push(s);
}
if !stops.is_empty() {
w.open0("a:gsLst");
for s in &stops {
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
color(w, &s.color);
w.close("a:gs");
}
w.close("a:gsLst");
}
match &g.shape {
GradientShape::Linear { angle, scaled } => {
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
}
GradientShape::Path { path, focus } => {
let p = match path.as_str() {
"rect" | "shape" | "circle" => path.as_str(),
_ => "circle",
};
w.open("a:path", A::new().a("path", p));
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
w.close("a:path");
}
}
w.close("a:gradFill");
}
Fill::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf),
Fill::Pattern(p) => {
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
@@ -242,55 +275,8 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) {
w.close(tag);
}
fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
let mut stops = g.stops.clone();
if stops.len() == 1 {
let mut s = stops[0].clone();
s.pos = 1.0;
stops.push(s);
}
if !stops.is_empty() {
w.open0("a:gsLst");
for s in &stops {
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
color(w, &s.color);
w.close("a:gs");
}
w.close("a:gsLst");
}
match &g.shape {
GradientShape::Linear { angle, scaled } => w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })),
GradientShape::Path { path, focus } => {
let p = match path.as_str() {
"rect" | "shape" | "circle" => path.as_str(),
_ => "circle",
};
w.open("a:path", A::new().a("path", p));
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
w.close("a:path");
}
}
w.close("a:gradFill");
}
pub fn effects(w: &mut W, fx: &Effects) {
w.open0("a:effectLst");
// Schema order: blur, fillOverlay, glow, innerShdw, outerShdw, prstShdw, reflection, softEdge.
if let Some(ov) = &fx.fill_overlay {
let blend = match ov.blend.as_str() {
b @ ("over" | "mult" | "screen" | "darken" | "lighten") => b,
_ => "over",
};
w.open("a:fillOverlay", A::new().a("blend", blend));
match &ov.fill {
Fill::Solid { color: c } => solid(w, c),
Fill::Gradient(g) => gradient(w, g),
_ => w.empty0("a:noFill"),
}
w.close("a:fillOverlay");
}
if let Some(g) = &fx.glow {
w.open("a:glow", A::new().a("rad", emu(g.radius).max(0)));
color(w, &g.color);
-31
View File
@@ -102,34 +102,3 @@ fn a_gradient_without_stops_is_saved_without_stops() {
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
assert_eq!(s.fill, Some(Fill::Gradient(own)));
}
#[test]
fn a_fill_overlay_round_trips() {
use deckcraft_model::style::{FillOverlay, Gradient, GradientShape, GradientStop};
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
let overlay = FillOverlay {
blend: "mult".into(),
fill: Fill::Gradient(Gradient {
stops: vec![
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) },
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) },
],
shape: GradientShape::Linear { angle: 4.5, scaled: true },
rotate_with_shape: true,
}),
};
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Overlaid".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE))),
effects: Some(deckcraft_model::Effects { fill_overlay: Some(overlay.clone()), ..Default::default() }),
..Default::default()
});
let bytes = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&bytes).expect("import");
let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape");
assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay));
}
-84
View File
@@ -1,84 +0,0 @@
//! Legacy OLE objects whose preview picture lives in the slide's VML drawing, and ActiveX controls
//! shown by their pictures.
use std::io::{Cursor, Read, Write};
use deckcraft_model::ShapeKind;
use deckcraft_model::defaults;
const FRAME: &str = r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="90" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s2050" name="Slide" r:id="rIdOle" progId="Thing.1"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
const VML: &str = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><v:shape id="_x0000_s2049"><v:imagedata o:relid="rIdA"/></v:shape><v:shape id="_x0000_s2050" style='position:absolute;left:0;top:0;width:1in;height:1in'><v:imagedata o:relid="rIdB" o:title=""/></v:shape></xml>"#;
const VML_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdA" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/other.png"/><Relationship Id="rIdB" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/></Relationships>"#;
const SLIDE_RELS: &str = r#"<Relationship Id="rIdVml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preview.png"/>"#;
/// An ActiveX control as PowerPoint writes it: a VML-only choice, and a fallback with its picture.
const CONTROLS: &str = r#"<p:controls><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:v="urn:schemas-microsoft-com:vml" Requires="v"><p:control spid="1137" name="CheckBox1" r:id="rIdX" imgW="1933560" imgH="514440"/></mc:Choice><mc:Fallback><p:control name="CheckBox1" r:id="rIdX" imgW="1933560" imgH="514440"><p:pic><p:nvPicPr><p:cNvPr id="4" name="CheckBox1"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="12700" y="25400"/><a:ext cx="127000" cy="63500"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic></p:control></mc:Fallback></mc:AlternateContent><p:control name="NoPicture" r:id="rIdY"/></p:controls>"#;
fn png(v: u8) -> Vec<u8> {
let mut out = Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([v, v, v])))
.write_to(&mut out, image::ImageFormat::Png)
.expect("png");
out.into_inner()
}
/// A new deck whose first slide holds [`FRAME`], with the VML drawing, its rels and two pictures.
fn deck() -> Vec<u8> {
let bytes = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
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/slides/slide1.xml" {
let s = String::from_utf8(d).expect("utf8");
let at = s.rfind("</p:spTree>").expect("spTree");
let s = format!("{}{FRAME}{}", &s[..at], &s[at..]);
let at = s.rfind("</p:cSld>").expect("cSld");
d = format!("{}{CONTROLS}{}", &s[..at], &s[at..]).into_bytes();
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
let s = String::from_utf8(d).expect("utf8");
d = s.replace("</Relationships>", &format!("{SLIDE_RELS}</Relationships>")).into_bytes();
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
for (name, data) in [
("ppt/drawings/vmlDrawing1.vml", VML.as_bytes().to_vec()),
("ppt/drawings/_rels/vmlDrawing1.vml.rels", VML_RELS.as_bytes().to_vec()),
("ppt/media/other.png", png(10)),
("ppt/media/preview.png", png(200)),
] {
out.start_file(name, o).expect("start");
out.write_all(&data).expect("write");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn an_ole_object_without_a_picture_shows_its_vml_preview() {
let p = deckcraft_pptx::import(&deck()).expect("import");
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("frame");
let ShapeKind::Opaque { preview: Some(m), label, .. } = &s.kind else { panic!("{:?}", s.kind) };
assert_eq!(label, "Thing.1");
let media = p.media.iter().find(|x| x.id == *m).expect("media");
// The shape whose id is the object's spid, not the first one in the drawing.
assert_eq!(media.name, "preview.png");
}
#[test]
fn activex_controls_show_their_pictures() {
let p = deckcraft_pptx::import(&deck()).expect("import");
let s = p.slides[0].shapes.iter().find(|s| s.name == "CheckBox1").expect("control picture");
let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) };
assert_eq!(p.media.iter().find(|m| m.id == fill.media).map(|m| m.name.as_str()), Some("preview.png"));
let x = s.xfrm.expect("xfrm");
assert_eq!((x.x, x.y, x.w, x.h), (1.0, 2.0, 10.0, 5.0));
assert!(!p.slides[0].shapes.iter().any(|s| s.name == "NoPicture"), "a control without a picture draws nothing");
}
+10 -126
View File
@@ -238,14 +238,11 @@ pub struct Renderer {
/// What the frame being drawn has painted so far, in output pixels: the editor learns from it
/// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
drawn: Option<Rect>,
/// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
/// under it combined with the group's child-coordinate mapping, which must not scale lines.
view: Affine,
}
impl Default for Renderer {
fn default() -> Self {
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY }
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None }
}
}
@@ -663,43 +660,6 @@ impl Renderer {
}
fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
if depth == 0 {
let outer = std::mem::replace(&mut self.view, parent);
self.shape_at(ctx, f, rctx, s, parent, depth);
self.view = outer;
} else {
self.shape_at(ctx, f, rctx, s, parent, depth);
}
}
/// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the
/// one that takes the shape's outline into it. Inside a group they are the slide-level view and
/// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but
/// never the line width; outside a group, `m` itself and no change to the outline.
fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) {
let view = self.view;
if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) }
}
/// The size and transform a shape's text is set in: inside a group whose child coordinates
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) {
let (_, rel) = self.line_space(m, depth);
let c = rel.as_coeffs();
let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3]));
let ok = |k: f64| k.is_finite() && k > 1e-9;
if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) {
return (w, h, m);
}
let (tw, th) = (w * sx, h * sy);
if !tw.is_finite() || !th.is_finite() {
return (w, h, m);
}
(tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy))
}
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
return;
}
@@ -758,34 +718,20 @@ impl Renderer {
bp.line_to((*px, *py));
}
}
let (base, rel) = self.line_space(parent, depth);
bp.apply_affine(rel);
ctx.set_transform(base);
ctx.set_transform(parent);
ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 }));
ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&bp);
}
}
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth),
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st),
}
if layered {
ctx.pop_layer();
}
}
#[allow(clippy::too_many_arguments)]
fn geometry_shape(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
rctx: &Ctx,
s: &Shape,
m: Affine,
w: f64,
h: f64,
st: &ShapeState,
depth: usize,
) {
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
Some(pm) => morph_path::geometry(pm, s, w, h),
None => (shape_geometry(s, w, h), Vec::new()),
@@ -803,12 +749,7 @@ impl Renderer {
let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill };
// Effects below the shape.
if let Some(e) = &effects {
// A picture (or picture fill) casts its shadow from its pixels, not its frame.
let content = match (&s.kind, &fill) {
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
_ => None,
};
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h);
}
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
if let Some(r) = soft {
@@ -872,41 +813,14 @@ impl Renderer {
}
}
}
// `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply
// a gradient over a tiled picture), inside the filled outline, under the line.
if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref())
&& fill.as_ref().is_some_and(|f| !f.is_none())
{
let mix = match ov.blend.as_str() {
"mult" => peniko::Mix::Multiply,
"screen" => peniko::Mix::Screen,
"darken" => peniko::Mix::Darken,
"lighten" => peniko::Mix::Lighten,
_ => peniko::Mix::Normal,
};
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) {
ctx.set_transform(m);
ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None);
paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres);
ctx.pop_layer();
}
}
// Outline.
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
let lw = line.width.unwrap_or(0.75).max(0.0);
let (base, rel) = self.line_space(m, depth);
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) {
let path = if rel == Affine::IDENTITY {
std::borrow::Cow::Borrowed(&sp.path)
} else {
let mut moved = sp.path.clone();
moved.apply_affine(rel);
std::borrow::Cow::Owned(moved)
};
stroke(ctx, rctx, &line, line_ph, &path, base, lw, fade(i).stroke);
stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke);
}
if s.is_line() || geo.is_open() {
arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw);
arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw);
}
}
if let Some(r) = soft {
@@ -958,16 +872,6 @@ impl Renderer {
if let Some(body) = &s.text
&& !skip_text
{
// Text in a group is set at the shape's size on the slide, in points: a group's
// child-coordinate scale sizes and places the box, never the type (#233's twin).
let (tw, th, m) = self.text_space(m, depth, w, h);
let text_geo;
let geo = if (tw, th) == (w, h) {
&geo
} else {
text_geo = shape_geometry(s, tw, th);
&text_geo
};
let tr = geo.text_rect;
if !body.is_empty() {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
@@ -1011,7 +915,6 @@ impl Renderer {
ph: Option<Rgba>,
geo: &Geometry,
fill: &Option<Fill>,
content: Option<&deckcraft_model::style::PictureFill>,
m: Affine,
w: f64,
h: f64,
@@ -1039,24 +942,8 @@ impl Renderer {
// 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.set_transform(sm);
match content {
// As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a
// transparent background casts the logo's shadow): the picture, clipped to the
// shape, recoloured to the shadow colour.
Some(pf) => {
ctx.push_clip_layer(&silhouette);
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
ctx.pop_layer();
ctx.pop_layer();
}
None => {
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
}
}
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
ctx.pop_layer();
}
if let Some(g) = &e.glow {
@@ -1182,12 +1069,9 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
Some(((first, second), (last, prev)))
}
#[allow(clippy::too_many_arguments)]
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, rel: Affine, m: Affine, lw: f64) {
fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, geo: &Geometry, m: Affine, lw: f64) {
let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return };
let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return };
// The outline's ends in the space the arrowheads are sized and drawn in.
let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2);
let col = match &line.fill {
Some(Fill::Solid { color: c }) => rctx.color(c, ph),
_ => Rgba::BLACK,
+2 -257
View File
@@ -1,30 +1,17 @@
//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by
//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go
//! through the same crop, tile and recolour path as any other picture.
//!
//! Text items name the font the file asks for; they are drawn with the face slide text would use
//! for that family (`FontDb::document_face`, so a missing font falls back the same way), symbol
//! fonts mapped as bullets map them, and the file's own advances (the dx array) placing each
//! character.
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_fonts::{FontDb, FontFace};
use deckcraft_metafile::{HAlign, Item, PlacedItem, Text, Tile, VAlign};
use kurbo::{Affine, BezPath, Rect, Shape as _};
use skrifa::MetadataProvider as _;
use deckcraft_metafile::PlacedItem;
use kurbo::{Affine, Rect, Shape as _};
use vello_cpu::{Pixmap, RenderContext, Resources, peniko};
/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture).
pub const DEFAULT_SIDE: u32 = 1024;
/// Largest side drawn.
const MAX_SIDE: u32 = 8192;
/// Pattern cells smaller than this (output pixels) are drawn as the tile's average colour: a
/// sub-pixel pattern would only alias.
const MIN_CELL_PX: f64 = 1.5;
/// Size of text whose font gives no height (LOGFONT height 0), in text units.
const DEFAULT_EM: f64 = 12.0;
/// Is `bytes` a WMF or EMF file?
pub fn is_metafile(bytes: &[u8]) -> bool {
@@ -44,8 +31,6 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
let pixmaps: Vec<Option<Arc<Pixmap>>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect();
// Pattern tiles shared by several fills are converted once.
let mut tiles: HashMap<*const Tile, (Option<Arc<Pixmap>>, peniko::Color)> = HashMap::new();
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let r = Rect::new(0.0, 0.0, w as f64, h as f64);
ctx.push_clip_layer(&r.to_path(0.1));
@@ -75,43 +60,6 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
PlacedItem::Pattern { index, path, even_odd, tile } => {
let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { continue };
let key = Arc::as_ptr(t);
let (pm, avg) = tiles.entry(key).or_insert_with(|| (premul(t.width, t.height, &t.rgba), average(&t.rgba))).clone();
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
let cell = tile.determinant().abs().sqrt();
match pm {
Some(pm) if cell >= MIN_CELL_PX => {
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,
},
});
ctx.set_paint_transform(tile);
ctx.fill_path(&path);
ctx.reset_paint_transform();
}
_ => {
ctx.set_paint(avg);
ctx.fill_path(&path);
}
}
}
PlacedItem::Text { index, transform, clip } => {
let Some(Item::Text(t)) = pic.items.get(index) else { continue };
if let Some(c) = clip {
ctx.push_clip_layer(&c.to_path(0.1));
}
draw_text(&mut ctx, t, transform);
if clip.is_some() {
ctx.pop_layer();
}
}
}
}
ctx.set_fill_rule(peniko::Fill::NonZero);
@@ -133,213 +81,10 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
image::RgbaImage::from_raw(w as u32, h as u32, px)
}
/// One glyph placed in text space (x along the baseline from the string's start, font units at
/// `scale` text units each).
struct Glyph {
face: Arc<FontFace>,
gid: u32,
x: f64,
y: f64,
}
/// Draws a text item; `m` maps its text space to the output.
fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) {
let db = FontDb::global();
let font = &t.font;
let family = if font.family.trim().is_empty() { "Arial" } else { font.family.trim() };
let (bold, italic) = (font.weight >= 600, font.italic);
let style = match (bold, italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
(false, false) => "Regular",
};
let face = db.document_face(family, style);
let upem = face.upem.max(1.0);
let (asc, desc) = win_metrics(&face);
// LOGFONT height: negative is the em, positive the cell (ascent plus descent).
let h = f64::from(font.height);
let em = if h < 0.0 {
-h
} else if h > 0.0 {
h * upem / (asc + desc).max(1.0)
} else {
DEFAULT_EM
};
let k = em / upem;
if !k.is_finite() || k <= 0.0 {
return;
}
// Characters as drawn: symbol fonts (Wingdings…) map like bullets; each character takes the
// face that has it.
let chars: Vec<char> = t.text.chars().map(|c| deckcraft_text::symbol_char(&face, family, c).unwrap_or(c)).collect();
let faces: Vec<Arc<FontFace>> = chars
.iter()
.map(|c| {
if c.is_whitespace() || c.is_control() || face.covers(*c) { face.clone() } else { db.cascade(&[*c], std::slice::from_ref(&face), style) }
})
.collect();
// Shape runs of one face; record each glyph's pen position and its character.
let mut shaped: Vec<(Glyph, usize)> = Vec::with_capacity(chars.len());
let mut char_pen: Vec<f64> = vec![0.0; chars.len() + 1];
let mut pen = 0.0;
let mut i = 0;
while i < chars.len() {
let Some(f) = faces.get(i).cloned() else { break };
let mut j = i + 1;
while faces.get(j).is_some_and(|g| g.id() == f.id()) {
j += 1;
}
let run: String = chars.get(i..j).unwrap_or(&[]).iter().collect();
// Byte offset in the run → character index in the string.
let byte_char: Vec<usize> = run.char_indices().map(|(b, _)| b).collect();
let ku = f.upem.max(1.0);
for g in deckcraft_fonts::shape(&f, &run, &[], |c| c) {
let ci = i + byte_char.partition_point(|b| *b < g.cluster).min(j - i - 1);
let s = em / ku;
shaped.push((Glyph { face: f.clone(), gid: g.gid, x: pen + f64::from(g.x_offset) * s, y: -f64::from(g.y_offset) * s }, ci));
pen += f64::from(g.x_advance) * s;
if let Some(p) = char_pen.get_mut(ci + 1) {
*p = pen;
}
}
i = j;
}
// Character start pens: a character's start is the previous one's end.
for ci in 1..char_pen.len() {
let prev = char_pen.get(ci - 1).copied().unwrap_or(0.0);
if let Some(p) = char_pen.get_mut(ci)
&& *p < prev
{
*p = prev;
}
}
// The file's advances place each character; glyphs keep their offset within their character.
let use_dx = !t.dx.is_empty() && t.dx.len() == chars.len();
let mut width = pen;
let mut x_scale = 1.0;
if use_dx {
let mut starts = Vec::with_capacity(chars.len() + 1);
let mut x = 0.0;
for d in &t.dx {
starts.push(x);
x += f64::from(*d);
}
width = x;
for (g, ci) in &mut shaped {
let (start, pen_start) = (starts.get(*ci).copied().unwrap_or(0.0), char_pen.get(*ci).copied().unwrap_or(0.0));
g.x = start + (g.x - pen_start);
}
} else if font.width > 0.0 {
// A LOGFONT width narrows or widens the font against its average character width.
let avg = avg_char_width(&face) * k;
if avg > 0.0 {
x_scale = (f64::from(font.width) / avg).clamp(0.05, 20.0);
width *= x_scale;
for (g, _) in &mut shaped {
g.x *= x_scale;
}
}
}
let x0 = match t.h_align {
HAlign::Left => 0.0,
HAlign::Center => -width / 2.0,
HAlign::Right => -width,
};
let (a, d) = (asc * k, desc * k);
let base = match t.v_align {
VAlign::Baseline => 0.0,
VAlign::Top => a,
VAlign::Bottom => -d,
};
let at = m * Affine::translate((x0, base));
ctx.set_fill_rule(peniko::Fill::NonZero);
if let Some(bg) = t.background
&& width > 0.0
{
ctx.set_paint(rgba(bg));
ctx.fill_path(&(at * Rect::new(0.0, -a, width, d).to_path(0.1)));
}
let color = rgba(t.color);
ctx.set_paint(color);
for (g, _) in &shaped {
let path = db.outline(&g.face, g.gid);
if path.elements().is_empty() {
continue;
}
let s = em / g.face.upem.max(1.0);
let skew = if italic && !g.face.italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let gm = at * Affine::translate((g.x, g.y)) * Affine::scale_non_uniform(x_scale, 1.0) * skew * Affine::scale(s);
let p: BezPath = gm * path.as_ref().clone();
ctx.fill_path(&p);
if bold && g.face.weight < 600.0 {
ctx.set_stroke(kurbo::Stroke::new(em * 0.025 * scale_of(at)).with_join(kurbo::Join::Round));
ctx.stroke_path(&p);
}
}
// Underline and strike-out: the font's own positions and thickness.
let metrics = face.skrifa().map(|f| f.metrics(skrifa::instance::Size::unscaled(), face.location()));
let deco = |on: bool, d: Option<skrifa::metrics::Decoration>, fallback: (f64, f64)| -> Option<Rect> {
if !on || width <= 0.0 {
return None;
}
let (off, th) = d.map_or(fallback, |d| (f64::from(d.offset) * k, f64::from(d.thickness) * k));
let th = if th > 0.0 { th } else { em / 20.0 };
// Offsets are y-up from the baseline; text space is y-down.
Some(Rect::new(0.0, -off - th / 2.0, width, -off + th / 2.0))
};
let lines = [
deco(font.underline, metrics.as_ref().and_then(|m| m.underline), (-em * 0.1, em / 20.0)),
deco(font.strike, metrics.as_ref().and_then(|m| m.strikeout), (em * 0.26, em / 20.0)),
];
for r in lines.into_iter().flatten() {
ctx.fill_path(&(at * r.to_path(0.1)));
}
}
/// Mean scale of an affine transform's linear part.
fn scale_of(m: Affine) -> f64 {
m.determinant().abs().sqrt()
}
/// The face's Windows ascent and descent (OS/2 `usWinAscent`/`usWinDescent`, as GDI measures the
/// cell), in font units; the typographic ones when the font has no OS/2 table.
fn win_metrics(face: &FontFace) -> (f64, f64) {
use skrifa::raw::TableProvider as _;
let os2 = face.skrifa().and_then(|f| f.os2().ok()).map(|o| (f64::from(o.us_win_ascent()), f64::from(o.us_win_descent())));
match os2 {
Some((a, d)) if a + d > 0.0 => (a, d),
_ => (face.ascent.max(0.0), face.descent.max(0.0)),
}
}
/// OS/2 `xAvgCharWidth` in font units (half an em without it).
fn avg_char_width(face: &FontFace) -> f64 {
use skrifa::raw::TableProvider as _;
face.skrifa().and_then(|f| f.os2().ok()).map(|o| f64::from(o.x_avg_char_width())).filter(|w| *w > 0.0).unwrap_or(face.upem * 0.5)
}
fn rgba(c: [u8; 4]) -> peniko::Color {
peniko::Color::from_rgba8(c[0], c[1], c[2], c[3])
}
/// The average colour of straight RGBA pixels (alpha-weighted).
fn average(px: &[u8]) -> peniko::Color {
let (mut r, mut g, mut b, mut a, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64);
for p in px.as_chunks::<4>().0 {
let w = u64::from(p[3]);
r += u64::from(p[0]) * w;
g += u64::from(p[1]) * w;
b += u64::from(p[2]) * w;
a += w;
n += 1;
}
if a == 0 || n == 0 {
return peniko::Color::from_rgba8(0, 0, 0, 0);
}
peniko::Color::from_rgba8((r / a) as u8, (g / a) as u8, (b / a) as u8, (a / n) as u8)
}
/// Straight RGBA rows to a premultiplied pixmap.
fn premul(w: u32, h: u32, px: &[u8]) -> Option<Arc<Pixmap>> {
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 {
-346
View File
@@ -1268,349 +1268,3 @@ fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
}
/// A placeable WMF (1440 units per inch, bounds `w` × `h`) holding `records` (function, parameter
/// bytes).
fn wmf_with(w: i16, h: i16, records: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut body = Vec::new();
for (func, params) in records {
let mut p = params.clone();
if p.len() % 2 == 1 {
p.push(0);
}
body.extend(((6 + p.len()) as u32 / 2).to_le_bytes());
body.extend(func.to_le_bytes());
body.extend(p);
}
body.extend([3, 0, 0, 0, 0, 0]);
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
f.extend(0u16.to_le_bytes());
for v in [0i16, 0, w, h] {
f.extend(v.to_le_bytes());
}
f.extend(1440u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
let total = (f.len() + 18 + body.len()) / 2;
for v in [1u16, 9, 0x300] {
f.extend(v.to_le_bytes());
}
f.extend((total as u32).to_le_bytes());
f.extend(16u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
f.extend(body);
f
}
fn i16s(v: &[i32]) -> Vec<u8> {
v.iter().flat_map(|x| (*x as i16).to_le_bytes()).collect()
}
/// CREATEFONTINDIRECT (height, escapement, face) then SELECTOBJECT of it (handle 0), transparent
/// background, and the alignment `ta`.
fn font_records(height: i32, esc: i32, face: &str, ta: i32) -> Vec<(u16, Vec<u8>)> {
let mut f = i16s(&[height, 0, esc, esc, 400]);
f.extend([0, 0, 0, if face.contains("dings") { 2 } else { 0 }, 0, 0, 0, 0]);
f.extend(face.as_bytes());
f.push(0);
vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[ta]))]
}
/// EXTTEXTOUT at (x, y) with an optional dx array.
fn text_record(x: i32, y: i32, s: &str, dx: &[i32]) -> (u16, Vec<u8>) {
let mut v = i16s(&[y, x, s.len() as i32, 0]);
v.extend(s.as_bytes());
if s.len() % 2 == 1 {
v.push(0);
}
v.extend(i16s(dx));
(0x0A32, v)
}
/// Bounding box (x0, y0, x1, y1) of the pixels at least half opaque and darker than mid-grey.
fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> {
let mut b: Option<(u32, u32, u32, u32)> = None;
for (x, y, p) in img.enumerate_pixels() {
if p[3] > 128 && (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 {
b = Some(match b {
None => (x, y, x, y),
Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)),
});
}
}
b
}
#[test]
fn metafile_text_ink_lands_at_its_reference_point() {
// 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left
// aligned at (72, 18) pt.
let mut recs = font_records(-720, 0, "Arial", 0);
recs.push(text_record(1440, 360, "HH", &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let (x0, y0, x1, y1) = mf_ink(&img).expect("text ink");
assert!((144..156).contains(&x0), "left edge at the reference point: {x0}");
assert!(x1 > x0 + 40 && x1 < 288, "two capitals wide: {x1}");
// The cell's top is at 36 px; a capital starts below the cell top and sits on the baseline.
assert!((38..72).contains(&y0), "cap top below the cell top: {y0}");
assert!((88..112).contains(&y1), "baseline one ascent below the top: {y1}");
}
#[test]
fn metafile_text_centre_baseline_and_escapement() {
// TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px.
let mut recs = font_records(-720, 0, "Arial", 24 | 6);
recs.push(text_record(1440, 720, "HH", &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let (x0, _, x1, y1) = mf_ink(&img).expect("ink");
assert!(((x0 + x1) as i32 / 2 - 144).abs() <= 4, "centred: {x0}..{x1}");
assert!((69..=74).contains(&y1), "on the baseline: {y1}");
// Escapement 90°: the string runs up the picture.
let mut recs = font_records(-360, 900, "Arial", 24);
recs.push(text_record(1440, 1300, "HHHH", &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
assert!(y1 - y0 > 2 * (x1 - x0), "a vertical run: {x0} {y0} {x1} {y1}");
assert!(x1 <= 146 && y1 <= 132, "drawn up and left of the baseline origin (144, 130): {x1} {y1}");
}
#[test]
fn metafile_text_follows_its_dx_array() {
// "HH" with the second H a full inch (144 px) after the first: two clumps of ink, a gap between.
let mut recs = font_records(-360, 0, "Arial", 24);
recs.push(text_record(360, 720, "HH", &[1440, 100]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let col_has_ink = |x: u32| (0..img.height()).any(|y| img.get_pixel(x, y)[3] > 128);
assert!((36..56).any(col_has_ink), "first H at 36 px");
assert!((180..200).any(col_has_ink), "second H one inch later");
assert!(!(70..170).any(col_has_ink), "nothing between them");
}
#[test]
fn metafile_symbol_text_draws_a_bullet_not_a_box() {
// Webdings "n" is a filled circle (installed, or mapped to a black circle where it is not), as
// is Wingdings "l" in a symbol font that is not installed anywhere.
for (face, s) in [("Webdings", "n"), ("Wingdings Not Installed", "l")] {
let mut recs = font_records(-1080, 0, face, 0);
recs.push(text_record(360, 0, s, &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
assert!(x1 - x0 > 10 && y1 - y0 > 10, "{face}: a sizeable mark: {x0} {y0} {x1} {y1}");
let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
assert!(img.get_pixel(cx, cy)[3] > 200, "{face}: filled in the middle");
for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {
assert!(img.get_pixel(x, y)[3] < 128, "{face}: round, not a box: corner ({x}, {y})");
}
}
}
#[test]
fn metafile_opaque_text_fills_its_cell() {
// OPAQUE background in red behind a space.
let mut recs = font_records(-720, 0, "Arial", 0);
recs.push((0x0102, i16s(&[2])));
recs.push((0x0201, 0x0000_00FFu32.to_le_bytes().to_vec()));
recs.push(text_record(1440, 360, " ", &[]));
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
let p = img.get_pixel(148, 60);
assert!(p[0] > 200 && p[1] < 60 && p[3] > 200, "cell filled red: {p:?}");
assert!(img.get_pixel(140, 60)[3] < 50, "nothing left of the cell");
}
#[test]
fn metafile_pattern_brush_tiles_and_averages_when_tiny() {
// A 1-bit 8 × 8 checker (palette white, black), a null pen and a one-inch square.
let mut dib: Vec<u8> = [40u32, 8, 8].iter().flat_map(|v| v.to_le_bytes()).collect();
dib.extend(1u16.to_le_bytes());
dib.extend(1u16.to_le_bytes());
dib.extend([0u8; 24]);
dib.extend([255, 255, 255, 0, 0, 0, 0, 0]);
for r in 0..8 {
dib.extend([if r % 2 == 0 { 0x55 } else { 0xAA }, 0, 0, 0]);
}
let mut brush = i16s(&[5, 0]);
brush.extend(dib);
let recs = vec![(0x0142, brush), (0x012D, i16s(&[0])), (0x012D, i16s(&[0x8008])), (0x041B, i16s(&[1440, 1440, 0, 0]))];
let bytes = wmf_with(1440, 1440, &recs);
// 4 px per point: a 0.75 pt cell is 3 px.
let img = crate::metafile::rasterize(&bytes, 288).expect("raster");
let (a, b) = (img.get_pixel(1, 1), img.get_pixel(4, 1));
assert!(a[3] > 200 && b[3] > 200);
assert!((a[0] as i32 - b[0] as i32).abs() > 200, "neighbouring cells alternate: {a:?} {b:?}");
// Far below one pixel per cell: the average grey.
let img = crate::metafile::rasterize(&bytes, 32).expect("raster");
let p = img.get_pixel(16, 16);
assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}");
}
/// A 400 x 200 group at (100, 100) holding a dashed 4 pt rectangle and a 3 pt line with an
/// arrowhead, placed in the group's child coordinates by `to_child` (slide point to child point).
fn outlined_group(child: Xfrm, to_child: impl Fn(f64, f64) -> (f64, f64)) -> Image {
use deckcraft_model::style::{Dash, LineEnd};
let ink = |w: f64| deckcraft_model::style::Line { dash: None, ..deckcraft_model::style::Line::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)), w) };
let place = |x0: f64, y0: f64, x1: f64, y1: f64| {
let ((a, b), (c, d)) = (to_child(x0, y0), to_child(x1, y1));
Xfrm::new(a, b, c - a, d - b)
};
let rect = Shape {
id: ShapeId(11),
xfrm: Some(place(140.0, 120.0, 300.0, 200.0)),
geom: Geom::preset("rect"),
line: Some(deckcraft_model::style::Line { dash: Some(Dash::Preset { name: "dash".into() }), ..ink(4.0) }),
..Default::default()
};
let end = LineEnd { kind: "triangle".into(), w: "lg".into(), len: "lg".into() };
let line = Shape {
id: ShapeId(12),
xfrm: Some(Xfrm { flip_v: true, ..place(150.0, 170.0, 450.0, 260.0) }),
geom: Geom::preset("line"),
line: Some(deckcraft_model::style::Line { tail: Some(end), ..ink(3.0) }),
..Default::default()
};
let pts: Vec<(f64, f64, f32)> = [(160.0, 240.0), (260.0, 285.0)].iter().map(|(x, y)| to_child(*x, *y)).map(|(x, y)| (x, y, 1.0)).collect();
let ink = Shape {
id: ShapeId(13),
xfrm: Some(place(100.0, 100.0, 500.0, 300.0)),
kind: ShapeKind::Ink {
strokes: vec![deckcraft_model::InkStroke { points: pts, color: Rgba::rgb(200, 0, 0), width: 5.0, highlighter: false }],
},
..Default::default()
};
let group = Shape {
id: ShapeId(10),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Group { children: vec![rect, line, ink], child },
..Default::default()
};
render_slide(&deck_with(vec![group]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
fn inked(img: &Image) -> usize {
img.pixels.as_chunks::<4>().0.iter().filter(|px| px[1] < 200).count()
}
#[test]
fn group_child_scale_does_not_scale_line_widths() {
// Children laid out 1:1 with the slide are the reference.
let reference = outlined_group(Xfrm::new(100.0, 100.0, 400.0, 200.0), |x, y| (x, y));
let drawn = inked(&reference);
assert!((500..20_000).contains(&drawn), "the reference draws thin lines: {drawn}");
// The same drawing in a small legacy child space (scale 100), and one scaled unevenly
// (100 across, 50 down): same outlines, same line widths, dashes and arrowhead.
let uniform = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 2.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 100.0));
let uneven = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 4.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 50.0));
for (name, img) in [("uniform", &uniform), ("uneven", &uneven)] {
let (n, d) = (inked(img), differing(&reference, img));
assert!(n.abs_diff(drawn) * 20 < drawn, "{name}: {n} inked pixels against {drawn}");
assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels");
}
}
#[test]
fn a_picture_casts_its_shadow_from_its_pixels() {
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
// logo's shadow, not its frame's. Here the left half of the image is transparent.
let png = {
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 160, 20, 255]) });
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
let shadow = deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
blur: 0.0,
dist: 20.0,
dir: 90.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
};
for as_fill in [false, true] {
let mut p = Presentation::default();
let media = p.add_media("logo.png", "image/png", png.clone());
let pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() };
if as_fill {
sh.fill = Some(Fill::Picture(pf));
sh.line = Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
} else {
sh.kind = ShapeKind::Picture { fill: pf };
}
sh.effects = Some(deckcraft_model::Effects { outer_shadow: Some(shadow.clone()), ..Default::default() });
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Below the picture, 10 pt down: shadow under the opaque right half only.
let left = img.pixel(200, 310);
let right = img.pixel(400, 310);
assert!(left[0] > 240, "no shadow under the transparent half ({as_fill}): {left:?}");
assert!(right[0] < 40, "shadow under the opaque half ({as_fill}): {right:?}");
// The transparent half shows the slide, not a shadow behind it.
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
}
}
#[test]
fn text_in_a_scaled_group_keeps_its_point_size() {
// A group's child coordinates scale its shapes' boxes, never their type: a 24 pt word in a
// group whose child space is 1/1600 of its size (as old decks write them) or 4 times it is
// 24 pt on the slide, and inside its box (it used to grow until the slide turned black).
let draw = |scale: f64| {
let mut p = Presentation::default();
let mut body = TextBody::from_text("Hello");
body.paragraphs[0].runs[0].props.size = Some(24.0);
body.paragraphs[0].runs[0].props.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK)));
let child = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale)), text: Some(body), ..Default::default() };
let g = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale) },
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![g];
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
};
let ((_, y0, _, y1), _) = ink(&draw(1.0));
let plain = y1 - y0;
assert!(plain > 10 && plain < 30, "24 pt word ink height {plain}");
for scale in [1600.0, 4.0, 0.25] {
let ((x0, sy0, x1, sy1), _) = ink(&draw(scale));
assert!((sy1 - sy0).abs_diff(plain) <= 1, "scale {scale}: ink height {} vs {plain}", sy1 - sy0);
assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box");
}
}
#[test]
fn a_fill_overlay_blends_over_the_fill() {
// `a:fillOverlay blend="mult"`: a 50% grey multiplied over a light fill darkens it; "screen"
// lightens a dark one; the overlay stays inside the shape.
use deckcraft_model::style::FillOverlay;
let draw = |base: [u8; 3], blend: &str| {
let mut p = Presentation::default();
let mut sh = Shape {
id: ShapeId(600),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(base[0], base[1], base[2])))),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
sh.effects = Some(deckcraft_model::Effects {
fill_overlay: Some(FillOverlay { blend: blend.into(), fill: Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128))) }),
..Default::default()
});
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
(img.pixel(200, 150), img.pixel(50, 50))
};
let (mult, outside) = draw([200, 100, 50], "mult");
assert!((mult[0] as i32 - 100).abs() <= 3 && (mult[1] as i32 - 50).abs() <= 3 && (mult[2] as i32 - 25).abs() <= 3, "{mult:?}");
assert_eq!(&outside[..3], &[255, 255, 255], "nothing outside the shape");
let (screen, _) = draw([0, 0, 0], "screen");
assert!((screen[0] as i32 - 128).abs() <= 3, "{screen:?}");
let (over, _) = draw([200, 100, 50], "over");
assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}");
}
+3 -44
View File
@@ -201,9 +201,6 @@ struct Style {
/// The `a:ea` and `a:cs` families (theme references resolved), if the document names one.
ea: Option<String>,
cs: Option<String>,
/// The `a:sym` family: private-use characters (U+F000–U+F0FF, as PowerPoint stores symbol-font
/// characters) are drawn in it.
sym: Option<String>,
/// Heading font (`+mj-…`): the theme's major font set applies.
major: bool,
/// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
@@ -318,7 +315,6 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
lang: r.lang.clone().filter(|l| !l.is_empty()),
ea,
cs,
sym: r.font_sym.as_deref().map(|f| ctx.master.theme.font(f)).filter(|f| !f.trim().is_empty()),
major,
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
}
@@ -592,18 +588,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
// 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}');
// A symbol-font character (U+F0xx) with an `a:sym` font draws in that font when it's there.
let sym_face = st
.sym
.as_deref()
.filter(|_| cluster.first().is_some_and(|c| ('\u{F000}'..='\u{F0FF}').contains(c)))
.filter(|f| db.has_family(f))
.map(|f| db.face(f, st.style_name))
.filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
let face = match (sym_face, prev.as_ref()) {
(Some(f), _) => f,
(None, Some(p)) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
(None, _) => {
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(),
_ => {
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
db.cascade(cluster, chain, st.style_name)
}
@@ -786,37 +773,9 @@ fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char
}
}
/// The character that draws `c`, a character of text set in the symbol font `font` (Wingdings,
/// Symbol, Webdings…: its code, or U+F000 + its code), with `face`: the installed symbol font's own
/// glyph when `face` is that font, else a Unicode look-alike as bullets use. None when `font` is not
/// a symbol font or `c` is not a symbol code. Metafile text takes this path.
pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> {
let code = c as u32;
if !is_symbol_font(font) || !(code < 0x100 || (0xF000..=0xF0FF).contains(&code)) {
return None;
}
let plain = char::from_u32(code & 0xFF)?;
let s = plain.to_string();
Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•')))
}
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.starts_with("webdings") {
let m = match (first as u32) & 0xff {
0x6e => '●',
0x67 => '■',
0x61 => '✓',
0x72 => '✕',
0x33 => '◀',
0x34 => '▶',
0x35 => '▲',
0x36 => '▼',
_ => '•',
};
return m.to_string();
}
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.
-33
View File
@@ -748,36 +748,3 @@ fn an_installed_symbol_font_draws_its_own_bullet() {
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);
}
#[test]
fn symbol_char_maps_symbol_font_codes() {
// Inter is not a symbol font, so the codes map to look-alikes.
let face = FontDb::global().face("Inter", "Regular");
assert_eq!(symbol_char(&face, "Arial", 'n'), None, "not a symbol font");
assert_eq!(symbol_char(&face, "Webdings", 'n'), Some('\u{25CF}'));
assert_eq!(symbol_char(&face, "Webdings", '\u{F06E}'), Some('\u{25CF}'), "the private-use form of the code");
assert_eq!(symbol_char(&face, "Wingdings", '\u{F06E}'), Some('\u{25A0}'));
assert_eq!(symbol_char(&face, "Wingdings", '\u{100}'), None, "not a symbol code");
}
#[test]
fn symbol_font_characters_draw_in_the_sym_font() {
// PowerPoint stores a Symbol-font character as U+F0xx with `<a:sym typeface="Symbol"/>`:
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
let symbol = "Symbol";
if !FontDb::global().has_family(symbol) {
eprintln!("skipped: no Symbol font installed");
return;
}
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.font = Some("Arial".into());
para.runs[0].props.font_sym = Some(symbol.into());
let l = lay(&p, &sh, 600.0, 300.0);
let families: Vec<(String, (usize, usize))> = l.runs.iter().map(|r| (r.face.family.clone(), r.chars)).collect();
assert!(families.iter().any(|(f, c)| f.eq_ignore_ascii_case(symbol) && c.0 == 1), "{families:?}");
// The letters around it keep the run's font.
assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}");
}
File diff suppressed because it is too large Load Diff
-438
View File
@@ -1,438 +0,0 @@
//! The File tab (Backstage, #76) through real egui input: the File tab, Alt+F and Esc, every page,
//! and the commands its pages run.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::cell::RefCell;
use std::rc::Rc;
use deckcraft_engine::Session;
use egui::accesskit::Role;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use super::*;
use crate::{Services, SlideApp};
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
// The app keeps asking for frames, so step instead of run().
h.run_steps(3);
h
}
fn new_app() -> SlideApp {
SlideApp::new(Session::with_new(), Services::default())
}
/// A folder of its own for one test's files.
fn temp_dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("deckcraft-backstage-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Desktop services whose Save dialog answers with `dir`/<suggested name> and records the names.
fn saving_into(dir: &std::path::Path) -> (Services, Rc<RefCell<Vec<String>>>) {
let asked: Rc<RefCell<Vec<String>>> = Default::default();
let log = asked.clone();
let dir = dir.to_path_buf();
let services = Services {
pick_save: Some(Box::new(move |suggested: &str| {
log.borrow_mut().push(suggested.to_string());
Some(dir.join(suggested).to_string_lossy().to_string())
})),
..Default::default()
};
(services, asked)
}
/// Is `r` in the side menu (the harness insets the window by its default margin)?
fn in_nav(r: egui::Rect) -> bool {
r.left() < 20.0 && r.right() <= NAV_W + 20.0
}
/// Clicks the button called `label` on the page (not the side menu's entry of the same name).
fn click_on_page(h: &Harness<'_, SlideApp>, label: &str) {
let node = h
.query_all_by_role_and_label(Role::Button, label)
.find(|n| !in_nav(n.rect()))
.or_else(|| h.query_all_by_label(label).find(|n| !in_nav(n.rect())));
node.unwrap_or_else(|| panic!("no “{label}” button on the page")).click();
}
/// Clicks the side menu's entry called `label`.
fn click_nav(h: &Harness<'_, SlideApp>, label: &str) {
let node = h.query_all_by_label(label).find(|n| in_nav(n.rect()));
node.unwrap_or_else(|| panic!("no “{label}” in the side menu")).click();
}
fn page(h: &Harness<'_, SlideApp>) -> Option<Page> {
h.state().backstage.as_ref().map(|b| b.page)
}
#[test]
fn page_ids_round_trip_and_unknown_ids_are_refused() {
for p in Page::ALL {
assert_eq!(Page::parse(p.id()), Some(p));
}
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
assert_eq!(Page::parse("share"), None);
assert_eq!(Page::parse(""), None);
let (top, bottom) = entries(false);
assert_eq!(top, vec![Nav::Page(Page::Home), Nav::Page(Page::New), Nav::Page(Page::Open)], "the start screen has no presentation pages");
assert_eq!(bottom, vec![Nav::Page(Page::Account), Nav::Options]);
assert_eq!(entries(true).0.len(), 9);
assert_eq!(split_path("/home/me/Talk.pptx"), ("Talk.pptx".into(), "/home/me".into()));
assert_eq!(format_bytes(512), "512 bytes");
assert_eq!(format_bytes(33_485), "32.7 KB");
}
/// The File tab opens Home in place of the ribbon; Esc and the back arrow return.
#[test]
fn the_file_tab_opens_the_backstage_and_esc_or_back_returns() {
let mut h = harness(new_app());
assert!(h.query_by_label("Blank Presentation").is_none(), "the slide shows first");
h.get_by_label("File").click();
h.run_steps(3);
assert_eq!(page(&h), Some(Page::Home));
assert!(h.state().backstage_open());
assert!(h.query_by_label("Blank Presentation").is_some(), "Home offers new presentations");
assert!(h.query_by_label("File").is_none(), "the ribbon is hidden");
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!h.state().backstage_open(), "Esc goes back to the slide");
assert!(h.query_by_label("File").is_some());
h.get_by_label("File").click();
h.run_steps(3);
h.get_by_label("Back").click();
h.run_steps(3);
assert!(!h.state().backstage_open(), "so does the back arrow");
assert_eq!(h.state().session.documents().len(), 1, "nothing was created or closed");
}
/// Alt+F opens it with the keyboard in the side menu; arrows walk it (pages show as they get the
/// focus); Enter runs an action; Esc goes back.
#[test]
fn alt_f_arrows_enter_and_esc_drive_the_side_menu() {
let mut h = harness(new_app());
h.key_press_modifiers(Modifiers::ALT, Key::F);
h.run_steps(3);
assert_eq!(page(&h), Some(Page::Home));
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), Some(0));
for want in [Page::New, Page::Open, Page::Info] {
h.key_press(Key::ArrowDown);
h.run_steps(2);
assert_eq!(page(&h), Some(want));
}
// Save has the focus now; the page stays Info until it is run.
h.key_press(Key::ArrowDown);
h.run_steps(2);
assert_eq!(page(&h), Some(Page::Info));
// A presentation that was never saved goes to Save As, as in PowerPoint.
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(page(&h), Some(Page::SaveAs));
// Up from the top wraps to Options at the bottom.
for _ in 0..20 {
h.key_press(Key::ArrowUp);
}
h.run_steps(2);
h.key_press(Key::Escape);
h.run_steps(3);
assert!(!h.state().backstage_open());
assert!(!h.state().session.active().unwrap().is_dirty(), "the keys never reached the slide");
}
/// Every page draws its own content.
#[test]
fn every_page_renders() {
let mut h = harness(new_app());
let last_theme = deckcraft_model::theme::builtin_themes().last().map(|t| t.name.clone()).unwrap();
let checks: [(Page, &str); 8] = [
(Page::Home, "Recent"),
(Page::New, &last_theme),
(Page::Open, "Browse"),
(Page::Info, "Hidden slides"),
(Page::SaveAs, "Not saved yet"),
(Page::Print, "Print Hidden Slides"),
(Page::Export, "Create PDF Document"),
(Page::Account, "About DeckCraft"),
];
for (p, marker) in checks {
h.state_mut().run("app.backstage", json!({"page": p.id()})).unwrap();
h.run_steps(3);
assert_eq!(page(&h), Some(p));
assert!(h.query_all_by_label_contains(marker).next().is_some(), "{} shows “{marker}”", p.label());
let selected = h.query_all_by_label(p.label()).any(|n| in_nav(n.rect()));
assert!(selected, "{} is in the side menu", p.label());
}
assert!(h.state_mut().run("app.backstage", json!({"page": "share"})).is_err(), "Share (cloud) isn't offered");
}
/// New and Home create presentations, which leaves the File tab.
#[test]
fn new_creates_a_presentation() {
let mut h = harness(new_app());
h.state_mut().run("app.backstage", json!({"page": "new"})).unwrap();
h.run_steps(3);
h.get_by_label("Blank Presentation").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 2);
assert!(h.state().backstage.is_none(), "the new presentation shows");
let theme = deckcraft_model::theme::builtin_themes().into_iter().nth(1).unwrap();
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
h.get_by_label(&theme.name).click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 3);
assert_eq!(h.state().session.active().unwrap().doc.masters[0].theme.name, theme.name);
assert!(h.state().backstage.is_none());
}
/// Open lists recent presentations by name (filtered by the search box) and opens one.
#[test]
fn open_lists_recent_files_and_opens_one() {
let dir = temp_dir("open");
let path = dir.join("Quarterly review.pptx").to_string_lossy().to_string();
let mut s = Session::with_new();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
let mut app = SlideApp::new(Session::with_new(), Services::default());
app.ui.recent = vec![path.clone(), dir.join("Other.pptx").to_string_lossy().to_string()];
let mut h = harness(app);
h.state_mut().run("app.backstage", json!({"page": "open"})).unwrap();
h.run_steps(3);
assert!(h.query_by_label("Other.pptx").is_some());
h.state_mut().backstage.as_mut().unwrap().query = "quarter".into();
h.run_steps(2);
assert!(h.query_by_label("Other.pptx").is_none(), "the search box filters by name");
h.get_by_label("Quarterly review.pptx").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 2);
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(path.as_str()));
assert!(h.state().backstage.is_none(), "the opened presentation shows");
assert_eq!(h.state().ui.recent.first(), Some(&path));
let _ = std::fs::remove_dir_all(&dir);
}
/// Save As saves through the Save dialog and the presentation takes the new file; Export writes
/// copies (PDF, another file type) and leaves the presentation's own file alone.
#[test]
fn save_as_and_export_run_the_file_commands() {
let dir = temp_dir("saveas");
let (services, asked) = saving_into(&dir);
let mut h = harness(SlideApp::new(Session::with_new(), services));
let title = h.state().session.active().unwrap().title();
// Save on a presentation without a file goes to Save As.
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
click_nav(&h, "Save");
h.run_steps(3);
assert_eq!(page(&h), Some(Page::SaveAs));
click_on_page(&h, "Save");
h.run_steps(3);
let pptx = dir.join(format!("{title}.pptx")).to_string_lossy().to_string();
assert!(std::path::Path::new(&pptx).is_file(), "saved as PowerPoint by default");
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(pptx.as_str()));
assert!(h.state().backstage.is_none(), "a save returns to the slide");
// Export › Create PDF Document.
h.state_mut().run("app.backstage", json!({"page": "export"})).unwrap();
h.run_steps(3);
click_on_page(&h, "Create PDF");
h.run_steps(3);
let pdf = std::fs::read(dir.join(format!("{title}.pdf"))).unwrap();
assert!(pdf.starts_with(b"%PDF"));
// Export › Change File Type › Outline.
click_on_page(&h, "Change File Type");
h.run_steps(3);
click_on_page(&h, "Outline (*.txt)");
h.run_steps(2);
click_on_page(&h, "Save As");
h.run_steps(3);
assert!(dir.join(format!("{title}.txt")).is_file());
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(pptx.as_str()), "exports are copies");
assert_eq!(*asked.borrow(), vec![format!("{title}.pptx"), format!("{title}.pdf"), format!("{title}.txt")]);
// Save with a file saves in place and goes back.
h.state_mut().run("slide.new", json!({})).unwrap();
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
click_nav(&h, "Save");
h.run_steps(3);
assert!(!h.state().session.active().unwrap().is_dirty());
assert!(h.state().backstage.is_none());
assert_eq!(asked.borrow().len(), 3, "no Save dialog for a presentation with a file");
let _ = std::fs::remove_dir_all(&dir);
}
/// Close closes a saved presentation at once and asks about unsaved changes first.
#[test]
fn close_closes_and_asks_about_unsaved_changes() {
let mut h = harness(new_app());
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
click_nav(&h, "Close");
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 0);
assert_eq!(page(&h), Some(Page::Home), "the start screen shows");
assert!(h.query_by_label("Back").is_none() && h.query_by_label("Close").is_none(), "nothing to go back to or close");
// Unsaved: Cancel keeps it, Don't Save closes it.
h.get_by_label("Blank Presentation").click();
h.run_steps(3);
h.state_mut().run("slide.new", json!({})).unwrap();
h.state_mut().run("app.backstage", json!({})).unwrap();
h.run_steps(3);
click_nav(&h, "Close");
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 1);
assert!(h.state().backstage_open(), "Cancel leaves the File tab as it was");
click_nav(&h, "Close");
h.run_steps(3);
h.get_by_label("Don't Save").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 0);
}
/// Ctrl+W asks too (it used to close unsaved presentations without a word).
#[test]
fn ctrl_w_asks_before_closing_unsaved_changes() {
let mut h = harness(new_app());
h.state_mut().run("slide.new", json!({})).unwrap();
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 1);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
}
/// Info shows the presentation's counts and properties, and edits them through file.properties.
#[test]
fn info_shows_and_edits_the_properties() {
let mut app = new_app();
app.run("file.properties", json!({"title": "Quarterly", "keywords": "sales"})).unwrap();
app.run("slide.new", json!({})).unwrap();
app.run("slide.hide", json!({})).unwrap();
let mut h = harness(app);
h.state_mut().run("app.backstage", json!({"page": "info"})).unwrap();
h.run_steps(3);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Title").value().as_deref(), Some("Quarterly"));
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Tags").value().as_deref(), Some("sales"));
assert!(h.query_all_by_label("Not saved yet").next().is_some());
let author = h.get_by_role_and_label(Role::TextInput, "Author");
author.click();
h.run_steps(2);
h.get_by_role_and_label(Role::TextInput, "Author").type_text("Ada");
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().doc.props.author, "Ada");
// Esc in a field leaves the field; the next one goes back.
assert!(h.state().backstage_open());
}
/// With nothing open the File tab is the start screen: Home, New and Open, no back arrow, and Esc
/// has nowhere to go.
#[test]
fn the_start_screen_is_file_home() {
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
assert_eq!(page(&h), Some(Page::Home));
assert!(h.query_by_label("Back").is_none());
assert!(h.query_all_by_label("Info").next().is_none());
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(page(&h), Some(Page::Home));
h.key_press(Key::ArrowDown);
h.run_steps(2);
assert_eq!(page(&h), Some(Page::New));
assert!(h.state_mut().run("app.backstage", json!({"page": "print"})).is_err(), "nothing to print");
h.get_by_label("Blank Presentation").click();
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 1);
assert!(h.state().backstage.is_none());
}
/// Print previews the pages its settings make and prints through a PDF opened in the system
/// viewer.
#[test]
fn print_previews_pages_and_prints_a_pdf() {
let dir = temp_dir("print");
let (services, _) = saving_into(&dir);
let mut app = SlideApp::new(Session::with_new(), services);
for _ in 0..3 {
app.run("slide.new", json!({})).unwrap();
}
app.run("slide.hide", json!({"index": 1})).unwrap();
let mut h = harness(app);
h.key_press_modifiers(Modifiers::COMMAND, Key::P);
h.run_steps(3);
assert_eq!(page(&h), Some(Page::Print), "Ctrl+P opens File › Print");
assert!(h.query_by_label("1 of 3").is_some(), "hidden slides are left out");
h.state_mut().backstage.as_mut().unwrap().print_layout = "h4";
h.run_steps(2);
assert!(h.query_by_label("1 of 1").is_some(), "four slides a page");
h.state_mut().backstage.as_mut().unwrap().print_layout = "slides";
h.state_mut().backstage.as_mut().unwrap().print_hidden = true;
h.run_steps(2);
assert!(h.query_by_label("1 of 4").is_some());
h.get_by_label("Next Page").click();
h.run_steps(2);
assert!(h.query_by_label("2 of 4").is_some());
{
let bs = h.state_mut().backstage.as_mut().unwrap();
bs.print_scope = PrintScope::Custom;
bs.print_range = "9".into();
}
h.run_steps(2);
assert!(h.query_by_label_contains("outside them").is_some(), "a bad range says why");
h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into();
h.run_steps(2);
click_on_page(&h, "Print");
h.run_steps(2);
let url = h.state().pending_urls.first().cloned().unwrap_or_default();
// The URL was handed to egui at the end of the frame; check what it pointed at.
let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title()));
assert!(printed.is_file(), "the PDF to print was written: {url}");
assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF"));
let _ = std::fs::remove_file(printed);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn print_slides_follow_the_settings() {
let mut s = Session::with_new();
for _ in 0..4 {
s.execute("slide.new", &json!({})).unwrap();
}
s.execute("slide.hide", &json!({"index": 2})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let mut bs = Backstage::new(Page::Print, (1, Some(0)));
assert_eq!(print_slides(&doc, &bs, 0).unwrap(), vec![0, 1, 3, 4]);
bs.print_hidden = true;
assert_eq!(print_slides(&doc, &bs, 0).unwrap().len(), 5);
bs.print_scope = PrintScope::Current;
assert_eq!(print_slides(&doc, &bs, 2).unwrap(), vec![2], "the current slide prints even when hidden");
bs.print_scope = PrintScope::Custom;
bs.print_range = "2-4".into();
bs.print_hidden = false;
assert_eq!(print_slides(&doc, &bs, 0).unwrap(), vec![1, 3]);
bs.print_layout = "h6";
assert_eq!(print_params(&bs, &[1, 3])["perPage"], 6);
assert_eq!(print_params(&bs, &[1, 3])["layout"], "handouts");
}
+1 -1
View File
@@ -216,7 +216,7 @@ pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
let ctx = ui.ctx();
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() {
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
return;
}
let Some(r) = ime_area(app, xf) else { return };
+1 -2
View File
@@ -73,8 +73,7 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"canvasScale": app.canvas_scale,
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
"home": app.backstage_open(),
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
"home": app.home_open(),
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
"status": app.status.as_ref().map(|s| s.0.clone()),
+168 -36
View File
@@ -1,11 +1,11 @@
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the
//! About window. The start screen is File › Home (`backstage.rs`).
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …), the start
//! screen and the About window.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::SlideApp;
use crate::theme::{self, Tokens};
use crate::{SlideApp, ribbon};
#[derive(Clone, Debug, serde::Serialize)]
pub struct Dialog {
@@ -290,37 +290,6 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
done
}
"close" => {
// File › Close and Ctrl+W on a presentation with unsaved changes.
let Some(name) = app.session.active().map(|d| d.title()) else { return true };
ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong());
ui.label("Your changes will be lost if you don't save them.");
ui.add_space(8.0);
let mut done = false;
ui.horizontal(|ui| {
let t = Tokens::get(ui.ctx());
if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() {
run(app, "file.close", json!({}));
done = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
app.save();
// In the browser a save is a download: the presentation stays "unsaved".
let web = app.services.pick_save.is_none() && app.services.download.is_some();
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
run(app, "file.close", json!({}));
}
done = true;
}
if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
done = true;
}
});
});
done
}
"zoom" => {
for pct in [400, 200, 150, 100, 75, 66, 50, 33] {
if ui.radio(false, format!("{pct}%")).clicked() {
@@ -461,8 +430,45 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
};
let (ok, cancel) = buttons(ui, "Export");
if ok {
let extra = if fmt == "pdf" { pdf_params } else { json!({}) };
app.export_as(&fmt, extra);
let name = app.session.active().map(|s| s.title()).unwrap_or_else(|| "Presentation".into());
let (ext, all) = match fmt.as_str() {
"pdf" => ("pdf", false),
"pngAll" => ("png", true),
"jpeg" => ("jpg", false),
"pptx" => ("pptx", false),
"outline" => ("txt", false),
_ => ("png", false),
};
let suggested = format!("{name}.{ext}");
if let Some(pick) = app.services.pick_save.as_mut() {
if let Some(path) = pick(&suggested) {
let mut params = json!({"path": path, "all": all});
if ext == "pdf"
&& let (Some(o), Some(extra)) = (params.as_object_mut(), pdf_params.as_object())
{
o.extend(extra.clone());
}
let r = app.session.execute("file.export", &params);
match r {
Ok(_) => app.set_status(format!("Exported {path}")),
Err(e) => app.set_status(e.to_string()),
}
}
} else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) {
let bytes = match ext {
"png" | "jpg" => {
let img = deckcraft_render::render_slide(
&s.doc,
s.selection.slide,
&deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() },
);
if ext == "png" { img.to_png() } else { img.to_jpeg(92) }
}
"pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params),
other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(),
};
dl(&suggested, &bytes);
}
}
ok || cancel
}
@@ -829,3 +835,129 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) {
Stroke::NONE,
));
}
/// The start screen (no presentation open): new from themes, open, recent files.
pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::ScrollArea::vertical().show(ui, |ui| {
ui.add_space(24.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
});
if app.home_open() {
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
let _ = app.run("app.home", json!({"on": false}));
}
});
}
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
});
ui.add_space(8.0);
inset_wrapped(ui, |ui| {
for th in deckcraft_model::theme::builtin_themes() {
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
ribbon::paint_theme_tile(ui.painter(), tile, &th);
if resp.hovered() {
ui.painter().rect_stroke(tile, CornerRadius::same(2), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);
}
ui.painter().text(pos2(r.min.x, r.max.y - 10.0), Align2::LEFT_CENTER, &th.name, theme::font(12.0), t.text);
if resp.clicked() {
let _ = app.run("file.new", json!({"theme": th.name}));
}
}
});
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button("Open…").clicked() {
let _ = app.run("app.openDialog", json!({}));
}
if ui.button("Open the sample deck").clicked()
&& let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session)
{
app.set_status(e.to_string());
}
});
if !app.ui.recent.is_empty() {
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("Recent").font(theme::bold(16.0)));
});
for p in app.ui.recent.clone() {
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.link(&p).clicked()
&& let Err(e) = app.open_path(&p)
{
app.set_status(e);
}
});
}
}
ui.add_space(30.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD);
});
});
}
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
/// would indent only the first row.
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
ui.horizontal_wrapped(add_contents);
});
}
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
#[test]
fn inset_wrapped_indents_every_row() {
let left = Cell::new(0.0_f32);
let tiles = RefCell::new(Vec::<Rect>::new());
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
left.set(ui.max_rect().left());
tiles.borrow_mut().clear();
inset_wrapped(ui, |ui| {
for _ in 0..4 {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
tiles.borrow_mut().push(r);
}
});
});
harness.run();
let tiles = tiles.borrow().clone();
let first_top = tiles.first().map_or(0.0, |r| r.top());
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
let mut row_top = f32::NEG_INFINITY;
for r in &tiles {
if r.top() > row_top {
row_top = r.top();
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
}
}
}
}
-34
View File
@@ -306,13 +306,6 @@ icons! {
Collapse => "collapse",
Expand => "expand",
Pin => "pin",
// File tab (Backstage)
BackCircle => "back-circle",
SaveAs => "save-as",
Account => "account",
Recent => "recent",
NewFile => "new-file",
OpenFolder => "open-folder",
// Status bar (small, monochrome)
NotesSmall => "notes-small",
CommentsSmall => "comments-small",
@@ -2368,33 +2361,6 @@ fn draw(p: &Pen<'_>, icon: Icon) {
p.rshape(&m(&[(-4.2, -10.0), (4.2, -10.0), (4.2, -7.0), (-4.2, -7.0)]), 0.6, R, N);
}
// ----- File tab (Backstage) navigation: outlines in the ink colour -----
I::BackCircle => {
p.circle((12.0, 12.0), 9.8, N, K);
p.arrow((17.0, 12.0), (6.5, 12.0), K);
}
I::SaveAs => {
p.rline(&[(11.0, 20.5), (2.5, 20.5), (2.5, 3.0), (15.0, 3.0), (18.0, 6.0), (18.0, 10.5)], 1.2, K);
p.rect(6.0, 3.0, 13.0, 7.5, 0.6, N, K);
p.seg_w((13.5, 21.0), (21.5, 13.0), O, 1.4);
p.seg_w((12.5, 22.0), (13.5, 21.0), K, 1.0);
}
I::Account => {
p.circle((12.0, 8.0), 4.5, N, K);
p.line(&arc_pts((12.0, 22.0), 8.5, 7.5, 180.0, 360.0), K);
}
I::Recent => {
p.circle((12.0, 12.0), 9.8, N, K);
p.line(&[(12.0, 6.0), (12.0, 12.0), (16.0, 14.5)], K);
}
I::NewFile => {
p.rline(&[(14.0, 2.5), (5.0, 2.5), (5.0, 21.5), (19.0, 21.5), (19.0, 7.5), (14.0, 2.5), (14.0, 7.5), (19.0, 7.5)], 0.8, K);
}
I::OpenFolder => {
p.rline(&[(18.5, 9.0), (18.5, 6.5), (10.0, 6.5), (8.0, 4.0), (2.5, 4.0), (2.5, 19.5), (18.0, 19.5)], 1.0, K);
p.rline(&[(2.5, 19.5), (6.0, 10.0), (22.0, 10.0), (18.0, 19.5)], 1.0, K);
}
// ----- Status bar (monochrome) -----
I::NotesSmall => {
p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K);
+29 -209
View File
@@ -6,7 +6,6 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod backstage;
pub mod canvas;
pub mod control;
pub mod credits;
@@ -155,9 +154,6 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("show.presenter", "Presenter View", None, "{on?: bool}"),
("show.end", "End Show", None, "{}"),
("app.home", "Home", None, "{on?}"),
("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"),
("app.closePresentation", "Close", Some("Cmd+W"), "{}"),
("app.print", "Print", Some("Cmd+P"), "{layout?, perPage?, slides?, includeHidden?, frame?}"),
("app.about", "About DeckCraft", None, "{}"),
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
@@ -204,10 +200,9 @@ pub struct SlideApp {
pub palette: Option<(String, usize)>,
/// Integrated title bar (macOS traffic lights over our chrome).
pub integrated_titlebar: bool,
/// The File tab's full-window view (Backstage). Over a presentation it replaces the ribbon,
/// slides and status bar until Esc or Back; with nothing open it is the start screen. Opening,
/// creating or closing a presentation leaves it.
pub backstage: Option<backstage::Backstage>,
/// The start screen is shown over the open presentations (Home), with the document count
/// and active index when it was opened: opening or creating a presentation leaves it.
pub home: Option<(usize, Option<usize>)>,
pending_urls: Vec<String>,
shot_token: u64,
queued_shots: Vec<(u64, f64, u32)>,
@@ -252,7 +247,7 @@ impl SlideApp {
canvas_region: None,
palette: None,
integrated_titlebar: false,
backstage: None,
home: None,
pending_urls: vec![],
shot_token: 0,
queued_shots: vec![],
@@ -294,153 +289,13 @@ impl SlideApp {
r
}
pub(crate) fn docs_key(&self) -> (usize, Option<usize>) {
fn docs_key(&self) -> (usize, Option<usize>) {
(self.session.documents().len(), self.session.active_index())
}
/// The File tab's view is open over a presentation (not just the start screen with nothing
/// open).
pub fn backstage_open(&self) -> bool {
self.backstage.is_some() && self.session.active().is_some()
}
/// Opens the File tab's view on `page` (keeping its state when it is already open).
pub fn open_backstage(&mut self, page: backstage::Page) {
let key = self.docs_key();
match &mut self.backstage {
Some(b) => b.go(page),
None => self.backstage = Some(backstage::Backstage::new(page, key)),
}
}
/// Back to the presentation (no effect on the start screen, where nothing is open).
pub fn close_backstage(&mut self) {
if self.session.active().is_some() {
self.backstage = None;
}
}
/// Keeps the File tab's view in step with the open presentations: it closes when one is
/// opened, created or closed (the result shows), and it is the start screen when none is open.
fn sync_backstage(&mut self) {
let key = self.docs_key();
let has_doc = self.session.active().is_some();
let stale = self.backstage.as_ref().is_some_and(|b| b.docs != key);
if has_doc && stale {
self.backstage = None;
} else if !has_doc && (stale || self.backstage.is_none()) {
self.backstage = Some(backstage::Backstage::new(backstage::Page::Home, key));
}
}
/// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved
/// changes (Save, Don't Save, Cancel), as PowerPoint does.
pub fn close_presentation(&mut self) {
match self.session.active() {
None => {}
Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")),
Some(_) => {
let _ = self.run("file.close", json!({}));
}
}
}
/// The file name a save or export suggests: the presentation's title with `ext`.
fn suggested_name(&self, ext: &str) -> String {
let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into());
format!("{name}.{ext}")
}
/// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`,
/// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download
/// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The
/// presentation keeps its own file. Returns whether a file was written or handed over.
pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool {
let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false };
let (ext, format, all) = match fmt {
"pdf" => ("pdf", "pdf", false),
"png" => ("png", "png", false),
"pngAll" => ("png", "png", true),
"jpeg" | "jpg" => ("jpg", "jpeg", false),
"pptx" => ("pptx", "pptx", false),
"outline" | "txt" => ("txt", "outline", false),
"deckcraft" => ("deckcraft", "deckcraft", false),
other => {
self.set_status(format!("DeckCraft can't export “{other}”"));
return false;
}
};
let suggested = self.suggested_name(ext);
if let Some(pick) = self.services.pick_save.as_mut() {
let Some(path) = pick(&suggested) else { return false };
let mut params = json!({"path": path, "format": format, "all": all});
if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) {
o.extend(x.clone());
}
return match self.run("file.export", params) {
Ok(_) => {
self.set_status(format!("Exported {path}"));
true
}
Err(_) => false,
};
}
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false };
let bytes = match format {
"png" | "jpeg" => {
let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() });
Ok(if format == "png" { img.to_png() } else { img.to_jpeg(92) })
}
"pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra),
other => deckcraft_engine::cmd::file::save_bytes(&doc, other),
};
self.download(&suggested, bytes)
}
/// Hands `bytes` to the browser as a download (web); false (with the reason in the status
/// bar) when there are no bytes or no way to download.
fn download(&mut self, name: &str, bytes: deckcraft_engine::Result<Vec<u8>>) -> bool {
match (bytes, self.services.download.as_mut()) {
(Ok(b), Some(dl)) => {
dl(name, &b);
true
}
(Err(e), _) => {
self.set_status(e.to_string());
false
}
(Ok(_), None) => {
self.set_status("Saving a copy needs a file picker, which this window doesn't have.");
false
}
}
}
/// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's
/// layout, slides and options) is written to the temporary folder and opened in the system's
/// PDF viewer, which has the printers; on the web it downloads.
pub fn print_pdf(&mut self, params: Value) -> bool {
let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false };
let mut params = if params.is_object() { params } else { json!({}) };
if self.services.pick_save.is_none() {
// Web: the browser hands the PDF to the user.
let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, &params);
return self.download(&format!("{title}.pdf"), bytes);
}
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect();
let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string();
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(path));
o.insert("format".into(), json!("pdf"));
}
match self.run("file.export", params) {
Ok(_) => {
self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/')));
self.set_status("Opened the presentation as a PDF to print");
true
}
Err(_) => false,
}
/// Home is open over a presentation (not just the start screen with nothing open).
pub fn home_open(&self) -> bool {
self.home.is_some() && self.session.active().is_some()
}
fn flag(&mut self, params: &Value, cur: bool) -> bool {
@@ -503,34 +358,8 @@ impl SlideApp {
self.media.stop_owned(media::Owner::Show);
}
"app.home" => {
let on = self.flag(p, self.backstage_open());
if on {
self.open_backstage(backstage::Page::Home);
} else {
self.close_backstage();
}
}
"app.backstage" => {
if !p.get("on").and_then(Value::as_bool).unwrap_or(true) {
self.close_backstage();
return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())}));
}
let page = match p.get("page").and_then(Value::as_str) {
Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?,
None => backstage::Page::Home,
};
if page.needs_doc() && self.session.active().is_none() {
return Err(format!("File › {} needs an open presentation", page.label()));
}
self.open_backstage(page);
return Ok(json!({"page": page.id()}));
}
"app.closePresentation" => self.close_presentation(),
"app.print" => {
if self.session.active().is_none() {
return Err("nothing to print: no presentation is open".into());
}
return Ok(json!({"printed": self.print_pdf(p.clone())}));
let on = self.flag(p, self.home.is_some());
self.home = (on && self.session.active().is_some()).then(|| self.docs_key());
}
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
"app.palette" => self.palette = Some((String::new(), 0)),
@@ -849,22 +678,16 @@ impl SlideApp {
return;
}
let events = ctx.input(|i| i.events.clone());
if self.backstage.is_some() {
// The File tab: its own keys (Esc goes back, arrows walk the side menu) and app
// shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the
// hidden presentation.
if self.home_open() {
// Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the
// presentation: typing, pasting or deleting would edit the hidden presentation.
for e in events {
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e {
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
if backstage::key(self, key, mods) || !(mods.cmd || mods.alt) || menus::ui_shortcut(self, key, mods) {
continue;
}
// Engine shortcuts that don't touch the hidden slide (Ctrl+N).
if mods.cmd
&& matches!(key, egui::Key::N)
&& let Err(e) = self.session.key(key_name(key), mods)
{
self.set_status(e.to_string());
if key == egui::Key::Escape {
self.home = None;
} else if mods.cmd || mods.alt {
menus::ui_shortcut(self, key, mods);
}
}
}
@@ -940,21 +763,18 @@ impl SlideApp {
self.perf.frame_ms = now_ms() - t0;
return;
}
self.sync_backstage();
if self.home.is_some_and(|k| k != self.docs_key()) {
self.home = None;
}
self.keyboard(&ctx);
self.sync_backstage();
ribbon::title_bar(self, ui);
if self.backstage.is_none() && self.session.active().is_some() {
if self.session.active().is_some() && !self.home_open() {
ribbon::show(self, ui);
status::status_bar(self, ui);
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
if self.backstage.is_some() {
// File tab: the Backstage view replaces everything below the title bar.
backstage::show(self, ui);
return;
}
if self.session.active().is_none() {
if self.session.active().is_none() || self.home_open() {
dialogs::start_screen(self, ui);
return;
}
match self.ui.view {
@@ -1190,7 +1010,7 @@ mod tests {
harness.state_mut().run("app.home", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.state().backstage_open());
assert!(harness.state().home_open());
assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation");
// Typing on the start screen doesn't reach the presentation behind it; Escape goes back.
@@ -1201,18 +1021,18 @@ mod tests {
assert!(!harness.state().session.active().unwrap().is_dirty());
harness.key_press(egui::Key::Escape);
harness.run_steps(2);
assert!(!harness.state().backstage_open());
assert!(!harness.state().home_open());
// Home again toggles; picking a theme on the start screen (a new presentation) leaves it.
harness.state_mut().run("app.home", json!({})).unwrap();
harness.run_steps(2);
assert!(harness.state().backstage_open());
assert!(harness.state().home_open());
harness.state_mut().run("app.home", json!({})).unwrap();
assert!(!harness.state().backstage_open());
assert!(!harness.state().home_open());
harness.state_mut().run("app.home", json!({"on": true})).unwrap();
harness.state_mut().run("file.new", json!({})).unwrap();
harness.run_steps(2);
assert!(!harness.state().backstage_open(), "a new presentation leaves Home");
assert!(!harness.state().home_open(), "a new presentation leaves Home");
assert_eq!(harness.state().session.documents().len(), 2);
}
@@ -1303,7 +1123,7 @@ mod tests {
fn home_needs_an_open_presentation() {
let mut app = SlideApp::new(Session::new(), Services::default());
app.run("app.home", json!({})).unwrap();
assert!(!app.backstage_open(), "with nothing open the start screen shows anyway");
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
}
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
+1 -21
View File
@@ -29,25 +29,6 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool {
return true;
}
(S, true, true, false) => "app.saveAsDialog",
// File tab: Alt+F opens it on Home with the keyboard in its side menu; Ctrl+P on Print.
(F, false, false, true) => {
app.open_backstage(crate::backstage::Page::Home);
if let Some(b) = app.backstage.as_mut() {
b.focus = Some(0);
}
return true;
}
(P, true, false, false) => {
if app.session.active().is_none() {
return false;
}
app.open_backstage(crate::backstage::Page::Print);
return true;
}
(W, true, false, false) => {
app.close_presentation();
return true;
}
(O, true, false, false) => "app.openDialog",
(P, true, true, false) => "app.palette",
(Comma, true, false, false) => "app.preferences",
@@ -152,11 +133,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
("New Presentation", "file.new"),
("Open…", "app.openDialog"),
("-", ""),
("Close", "app.closePresentation"),
("Close", "file.close"),
("Save", "file.save"),
("Save As…", "app.saveAsDialog"),
("Export…", "app.exportDialog"),
("Print…", "app.print"),
("-", ""),
("Properties", "file.properties"),
],
+2 -17
View File
@@ -47,7 +47,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
child.spacing_mut().item_spacing.x = 2.0;
let has_doc = app.session.active().is_some();
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
if icon_toggle(&mut child, Icon::Home, "Home", true, app.backstage_open()).clicked() {
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
let _ = app.run("app.home", json!({}));
}
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
@@ -143,24 +143,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let h = 28.0 + if app.ui.ribbon_collapsed { 4.0 } else { RIBBON_H + 20.0 };
egui::Panel::top("ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
let rect = ui.max_rect();
// Tabs. File comes first and opens the File tab's full-window view (Backstage), as in
// PowerPoint.
// Tabs.
let mut x = rect.min.x + 12.0;
let y = rect.min.y + 2.0;
{
let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0;
let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
let resp = ui.interact(r, ui.id().with("tab_file"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "File"));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
}
ui.painter().text(r.center(), Align2::CENTER_CENTER, "File", theme::font(13.0), t.text);
if resp.on_hover_text("File (Alt+F)").clicked() {
app.open_backstage(crate::backstage::Page::Home);
}
x += w + 2.0;
}
let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect();
for (name, contextual) in all {
let font = theme::font(13.0);
-45
View File
@@ -191,16 +191,6 @@ pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) ->
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
defs.families.insert(FontFamily::Name("bold".into()), bold);
add_fallback_faces(&mut defs, extra);
// The Japanese interface face (the first one when Inter isn't embedded) hints its en and em
// dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text;
// unhinted, they draw.
for (name, d) in defs.font_data.iter_mut() {
if name.contains("BIZ UDPGothic") {
let mut data = (**d).clone();
data.tweak.hinting = Some(false);
*d = std::sync::Arc::new(data);
}
}
defs
}
@@ -231,38 +221,3 @@ pub fn font(size: f32) -> egui::FontId {
pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 {
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
}
#[cfg(test)]
mod tests {
/// Each character of `text` laid out in the interface fonts at interface sizes, and whether it
/// has ink (a glyph image).
fn drawn(text: &str) -> Vec<(char, f32, bool)> {
let ctx = egui::Context::default();
ctx.set_fonts(super::font_definitions(&[]));
let mut o = ctx.run_ui(Default::default(), |_| {});
o.textures_delta.clear();
let mut out = vec![];
ctx.fonts_mut(|f| {
for size in [11.0, 12.0, 13.0, 16.0] {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] {
let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE);
for gl in g.rows.iter().flat_map(|r| &r.glyphs) {
out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0));
}
}
}
});
let mut o = ctx.run_ui(Default::default(), |_| {});
o.textures_delta.clear();
out
}
#[test]
fn thin_punctuation_is_drawn_in_interface_text() {
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
// so notes and the outline showed gaps where they were.
for (c, size, ink) in drawn("\u{2013}\u{2014}-_\u{2026}\u{2022}\u{00b7}\u{2018}\u{2019}\u{201c}\u{201d}=~") {
assert!(ink, "{c:?} ({:04X}) draws nothing at {size} px", c as u32);
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) |
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, Home (start screen over the open presentations), slide show state, documents, perf (fps, render ms) |
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
+4 -6
View File
@@ -1,6 +1,6 @@
# File-format parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
@@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only).
|---|---|---|---|
| PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 |
| SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted |
| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions and EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures |
| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, bitmaps); text records inside are skipped |
| HEIC | yes (macOS) | no | |
| Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` |
| WMA | yes (Windows) | embedded, not playable | |
@@ -63,7 +63,7 @@ the round trip even though DeckCraft doesn't model it.
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
| Notes slides and notes master | yes | yes | No notes-master editing view |
| Handout master | no | no | |
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`) |
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
@@ -93,13 +93,11 @@ the round trip even though DeckCraft doesn't model it.
embedded fonts, every transition and animation) in a corpus repo, with import → render → export
→ re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint).
2. The round-trip losses above (#70, #74, #77).
3. SVG rendering, and clip regions inside EMF/WMF.
3. SVG rendering, and text inside EMF/WMF.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
+9 -10
View File
@@ -20,7 +20,7 @@ revision history.
| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) |
| 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file |
| 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) |
| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 6–12 | [file-format-parity.md](file-format-parity.md) |
| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) |
| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |
| 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) |
@@ -77,12 +77,12 @@ Detail in [file-format-parity.md](file-format-parity.md).
### SVG pictures don't render
- **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF,
TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens,
pattern and hatch brushes, bitmaps and text; clip regions and EMF+ records are not drawn).
Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback.
- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. An
EMF/WMF that clips its drawing (EXTSELECTCLIPRGN, INTERSECTCLIPRECT) draws past its clip.
- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF/WMF clip regions 2–4 h.
TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens
and bitmaps; text records are skipped). Nothing reads SVG; PowerPoint's Insert Icons stores SVG
with a PNG fallback.
- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. Text
drawn inside an EMF/WMF is missing.
- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF text records 2–4 h.
### Template, slide-show and macro variants are saved as plain `.pptx`
- **Evidence:** `crates/pptx/src/write/mod.rs` always writes the
@@ -197,8 +197,8 @@ Detail in [ui-parity.md](ui-parity.md).
blocker.**
- **Format Shape pane thinner than PowerPoint's** (Fill/Line/Effects/Size/Text Box pages, picture
and texture options, 3-D). 6–10 h. **Beta blocker.**
- **File tab depth** (#76 landed the Backstage): no online templates, cloud places, printer
choice, video/GIF/CD exports or document protection. 4–8 h for the parts that need no cloud.
- **First-run and start screen** (#17, #76: missing File overlay, broken layout; recent files and
templates thin). 3–5 h.
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
2–4 h.
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
@@ -256,7 +256,6 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
+4 -4
View File
@@ -7,13 +7,13 @@ The summary between `<!-- SUMMARY -->
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 72 | 61 | 11 | 0 | 92% |
| P0 | 72 | 60 | 12 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 14 | 27 | 37% |
| Area | Parity |
|---|---|
| Application shell | 77% |
| Application shell | 71% |
| Files | 71% |
| Slides | 90% |
| Views | 90% |
@@ -36,13 +36,13 @@ The summary between `<!-- SUMMARY -->
| Automation (✱) | 100% |
| Platforms (✱) | 67% |
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
## Rows
| Area | Feature | Priority | Status |
|---|---|---|---|
| Application shell | Start screen (new, templates/themes, recent) | P0 | D |
| Application shell | Start screen (new, templates/themes, recent) | P0 | P |
| Application shell | Ribbon with Home/Insert/Draw/Design/Transitions/Animations/Slide Show/Review/View tabs | P0 | D |
| Application shell | Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design) | P0 | P |
| Application shell | Quick Access toolbar / title bar commands | P1 | P |
+5 -33
View File
@@ -1,6 +1,6 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -18,42 +18,13 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
| Menu-bar items with a DeckCraft equivalent | 217 app-specific items | 128 matched by name (59%, heuristic lower bound) | See [target-app-parity.md](target-app-parity.md) |
| Ribbon tabs: Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View | 10 | 10 | Record tab is a placeholder |
| Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner |
| File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes |
| Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, Account and Options (#76) | |
| Start screen: new, themes, templates, recent, open | yes | yes, thin (#17 broken layout, #76 missing overlay) | 3–5 h |
| Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | |
| Command search (Tell Me) | yes | command palette | #79 default entry fails |
| Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h |
| One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h |
| Quick Access Toolbar customization | yes | no | P1 partial |
## File tab (Backstage)
Observed black-box in PowerPoint for Windows (Microsoft 365, Version 2609, October 2026) on a
synthetic presentation. Clicking **File** (or Alt+F) replaces the ribbon, slides and status bar with
a full-window view; the title bar stays. A side menu 160 points wide, a step darker than the page,
lists Back (a circled arrow), Home, New, Open, Share, a rule, Info, Save, Save As, Print, Export,
Close and, at the bottom under a rule, Account and Options. Entries are 40 points tall with an
outline icon; the current page is drawn in the accent colour and outlined. Esc goes back. Alt+F opens
on Home with the keyboard in the side menu; Up and Down move through it and a page shows as soon as
its entry has the focus. PowerPoint's start screen is the same Home page, with only Home, New and
Open at the top. DeckCraft follows all of this (`crates/ui-egui/src/backstage.rs`), with the page and
menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Page | PowerPoint | DeckCraft |
|---|---|---|
| Home | Prompt box (Copilot), New row of themes with More themes, Recent / Favorites / Shared with Me with a file search, More presentations | DeckCraft's mark, Blank Presentation and the first themes with More themes →, Recent with a file search and More presentations → (to Open). No Copilot prompt, Favorites or Shared with Me |
| New | Blank Presentation, template search with suggested searches, gallery of online templates and themes | Blank Presentation, search box, the built-in themes and the sample deck (`file.new {theme}`). No online templates |
| Open | Recent, Shared with Me, OneDrive, This PC, Add a Place, Browse; Presentations / Folders; Recover Unsaved Presentations | Recent and Browse (`app.openDialog`), recent presentations with a search box (`file.open`), Recover Unsaved Presentations (`file.recovery.open`). No cloud places or Folders tab |
| Info | Name and path, Upload / Share / Copy path / Open file location; Protect, Inspect, Version History, Manage Presentation; Properties, Related Dates, Related People | Name and path, Copy path, Open file location; Inspect Presentation (Accessibility Checker), Manage Presentation (Revert); Size, Slides, Hidden slides and editable Title, Subject, Tags, Categories, Company, Comments, Author (`file.info`, `file.properties`). No Protect, Version History or dates |
| Save | Saves; a presentation never saved goes to Save As | Same (`file.save`) |
| Save As | Places, folder, file name, type, Save, More options | Type (PowerPoint, DeckCraft, PDF, Outline, PNG, JPEG) and Save, which opens the system Save dialog (web: Download). .pptx/.deckcraft take the new file; the others are copies (`file.saveAs`, `file.export`) |
| Print | Print and Copies, Printer, Print All / Selection / Current / Custom Range, Slides box, layout (Full Page Slides, Notes Pages, Outline, Handouts 1–9), sides, collation, colour, Edit Header & Footer; page preview with pager and zoom | Print, Print All Slides / Current Slide / Custom Range, Slides box (`1-3, 5`), Full Page Slides / Notes Pages / Handouts 1–9, Print Hidden Slides, Frame Slides, Edit Header & Footer; preview with pager. DeckCraft prints through a PDF (`file.export`) opened in the system viewer, which has the printers (web: downloaded). No printer list, copies, sides or colour |
| Share | Cloud sharing | Not offered |
| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type. No video, GIF or CD package |
| Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too |
| Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to |
| Options | Opens the PowerPoint Options dialog | Opens Preferences |
## Panes and dialogs
| Pane / dialog | PowerPoint | DeckCraft |
@@ -138,7 +109,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 |
| Format Shape pane depth | 6–10 |
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 |
| Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 |
| Start screen, window chrome, system appearance, palette fixes (#17, #41, #71, #76, #79) | 4–7 |
| Grid Options, edit points UI, crop depth | 4–7 |
| Multiple windows | 3–5 |
| A screenshot comparison pass on the most-used tabs | 2–4 |
@@ -148,8 +119,9 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
| 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) |
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |