mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e90c955ca | ||
|
|
6a0ca98550 | ||
|
|
4e780d52cc |
+3
-5
@@ -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 |
|
||||
|
||||
@@ -37,7 +37,3 @@ sync_github_name = false
|
||||
|
||||
[people.Johannesgauss]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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", ¶ms);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¶ms);
|
||||
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(
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user