diff --git a/Cargo.lock b/Cargo.lock index 01a92e4a5..0bc88a0ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -983,7 +983,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.11.1" +version = "0.12.0" dependencies = [ "arboard", "chrono", @@ -1005,7 +1005,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1023,14 +1023,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-cli" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -1040,14 +1040,14 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "deckcraft-anim", @@ -1073,7 +1073,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.11.1" +version = "0.12.0" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -1085,7 +1085,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-model", "serde", @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.11.1" +version = "0.12.0" dependencies = [ "kurbo", "serde", @@ -1104,7 +1104,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1113,7 +1113,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1122,21 +1122,21 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-matroska" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-mcp" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1146,7 +1146,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1164,7 +1164,7 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.11.1" +version = "0.12.0" dependencies = [ "image", "kurbo", @@ -1173,7 +1173,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1185,17 +1185,18 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.11.1" +version = "0.12.0" [[package]] name = "deckcraft-opus" -version = "0.11.1" +version = "0.12.0" [[package]] name = "deckcraft-pdf" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-pdf", + "deckcraft-color", "deckcraft-fonts", "deckcraft-model", "deckcraft-render", @@ -1211,7 +1212,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1227,7 +1228,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1246,7 +1247,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "deckcraft-color", @@ -1260,7 +1261,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-ui", "craft-update", @@ -1285,7 +1286,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1294,7 +1295,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -6176,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 8d46cd437..079604959 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] [workspace.package] -version = "0.11.1" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69f..beac16be1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md). and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records, MicroType Express font subsets and SVG. 36–64 h. 3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h. -4. **Output:** vector PDF, printing. 10–16 h. +4. **Output:** printing (vector PDF landed). 5–8 h. 5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline, presenter view on the right display, native macOS menu bar, Format Shape pane. 33–56 h. @@ -270,6 +270,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) | | 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) | | 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items | +| 2026-10-15 | Vector PDF export: shapes, gradients, transparency, tables and charts as PDF paths, real text in font subsets, pictures embedded as they are; effects and patterns rasterize per element (`vector: false` keeps the raster mode) | | 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes | | 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) | | 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f21f2a3ae..798374aef 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { 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|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), vector? (pdf; true), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export), cmd!( query "file.info", "Info", @@ -352,7 +352,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { /// Paper sizes for notes and handouts, for the UI's Print page. pub use deckcraft_pdf::{PAPERS, paper_size}; -/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides, +/// PDF options from command params: `{layout: slides|notes|handouts, perPage, vector, dpi, slides, /// includeHidden, textLayer, frame, paper: letter|a4|legal|a3, orientation: portrait|landscape}` 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 { @@ -362,6 +362,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result "handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 }, _ => deckcraft_pdf::PageLayout::Slides, }; + o.vector = bool_or(p, "vector", true); 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()), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index f34ee8674..4ebeec4ce 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -186,7 +186,7 @@ fn export_tool_lists_pdf_and_its_options() { } assert_eq!(props["layout"]["enum"], json!(["slides", "notes", "handouts"])); assert_eq!(props["perPage"]["enum"], json!([1, 2, 3, 4, 6, 9])); - for p in ["dpi", "slides", "includeHidden", "textLayer", "frame", "paper", "orientation"] { + for p in ["vector", "dpi", "slides", "includeHidden", "textLayer", "frame", "paper", "orientation"] { assert!(props.get(p).is_some(), "{p}: {export}"); } // What the schema describes is accepted. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c4f7e85da..0b2c4dc80 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -125,10 +125,11 @@ pub fn tool_definitions() -> Vec { "scale": num("Pixels per point for images (default 2)"), "layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"}, "perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"}, - "dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"), + "vector": boolean("PDF: vector slides with real text (default true); false: each slide one raster image"), + "dpi": num("PDF: resolution of raster slides (vector: false) and of effects rasterized in place, 36-600 (default 200)"), "slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF and GIF: slide indices to export, in order (default all)"}, "includeHidden": boolean("PDF and GIF: include hidden slides (default false)"), - "textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"), + "textLayer": boolean("PDF raster slides: invisible text layer for search, select and copy (default true)"), "frame": boolean("PDF notes/handouts: thin frame around each slide (default true)"), "paper": {"type": "string", "enum": ["letter", "a4", "legal", "a3"], "description": "PDF notes/handouts: paper size (default letter)"}, "orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"}, diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 604fd511d..862f0ff34 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -3,12 +3,13 @@ name = "deckcraft-pdf" version.workspace = true edition.workspace = true license.workspace = true -description = "DeckCraft PDF export (craft-pdf/Krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks" +description = "DeckCraft PDF export (craft-pdf/Krilla): vector slides, notes pages and handouts with real text, links and bookmarks" [lints] workspace = true [dependencies] +deckcraft-color = { workspace = true } deckcraft-model = { workspace = true } deckcraft-fonts = { workspace = true } deckcraft-text = { workspace = true } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 32da17da9..1f215e295 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1,16 +1,28 @@ //! PDF export. //! -//! Each slide is drawn by the DeckCraft renderer at print resolution (so the PDF looks exactly like -//! the slide, effects included) and overlaid with an invisible layer of real text from the text -//! layout engine with embedded font subsets, so the text can be searched, selected and copied. -//! Hyperlinks and shape actions become link annotations, slide titles become bookmarks. +//! Slides are vector by default: the DeckCraft renderer draws each slide through a krilla-backed +//! [`Canvas`](deckcraft_render::Canvas) (see `vector.rs`), so shapes, outlines, gradients and +//! transparency become PDF paths and paints, text is real text in embedded font subsets +//! (searchable, selectable, sharp at any zoom), and pictures are embedded: an unchanged JPEG or PNG +//! file as it is, others (GIF, BMP, TIFF, WebP, recoloured or corrected pictures) as lossless +//! pixels at `dpi`. Only what a PDF can't express is rasterized, element by element at `dpi` and in +//! place: shapes with shadows, glow, soft edges or reflections (their geometry and effects; their +//! text stays vector) and pattern fills. Pictures the renderer can't decode (EMF/WMF, SVG) show the +//! same placeholder as on screen; glyphs of a variable font's non-default instance (a Bold or +//! Light instance) are outlines over invisible real text, since the font subset is the default +//! instance. //! -//! Layouts: one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3, -//! 4, 6 or 9 slides per page). - +//! `vector: false` keeps the earlier mode: each slide one raster image at `dpi`, under an invisible +//! real-text layer. +//! +//! Hyperlinks and shape actions become link annotations, slide titles become bookmarks. Layouts: +//! one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3, 4, 6 or 9 +//! slides per page). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod vector; + use std::collections::HashMap; use craft_pdf::write::{self, Metadata, Writer, backend as krilla}; @@ -18,7 +30,7 @@ use craft_pdf::write::{self, Metadata, Writer, backend as krilla}; use deckcraft_fonts::FontDb; use deckcraft_model::Presentation; use deckcraft_model::text::{Action as ClickAction, safe_url}; -use deckcraft_render::{PlacedText, RenderOpts}; +use deckcraft_render::{Fallback, PlacedText, RenderOpts}; use krilla::action::{Action, LinkAction}; use krilla::annotation::{Annotation, LinkAnnotation, Target}; use krilla::destination::XyzDestination; @@ -27,7 +39,7 @@ use krilla::image::Image; use krilla::num::NormalizedF32; use krilla::outline::{Outline, OutlineNode}; use krilla::page::PageSettings; -use krilla::paint::Fill; +use krilla::paint::{Fill, FillRule}; use krilla::surface::Surface; use krilla::text::{Font, GlyphId, KrillaGlyph, TextDirection}; use kurbo::{Affine, Rect}; @@ -66,12 +78,17 @@ pub enum PageLayout { #[serde(default, rename_all = "camelCase")] pub struct PdfOptions { pub layout: PageLayout, - /// Raster resolution of slide artwork (dots per inch), lowered for a slide whose raster would pass 64M pixels. + /// Vector slides (paths, real text, embedded pictures). `false`: each slide is one raster + /// image at `dpi` under an invisible text layer. + pub vector: bool, + /// Raster resolution (dots per inch) of slides in raster mode, lowered for a slide whose raster would pass 64M + /// pixels, and of the elements rasterized in place in vector mode (effects, patterns). pub dpi: f64, /// Slide indices to export (0-based); `None` = all. pub slides: Option>, pub include_hidden: bool, - /// Invisible real-text layer (search, select, copy). + /// Raster mode: invisible real-text layer (search, select, copy). Vector slides always have + /// real text. pub text_layer: bool, /// Thin frame around each slide (notes/handouts). pub frame_slides: bool, @@ -85,6 +102,7 @@ impl Default for PdfOptions { fn default() -> Self { PdfOptions { layout: PageLayout::Slides, + vector: true, dpi: 200.0, slides: None, include_hidden: false, @@ -128,7 +146,13 @@ pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect { /// Export `pres` as PDF bytes. pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { - write::guard(|| export_inner(pres, opts)) + export_with(pres, opts, true) +} + +/// [`export`], with page contents and text maps left uncompressed when `compress` is false (tests +/// read them). +fn export_with(pres: &Presentation, opts: &PdfOptions, compress: bool) -> Result> { + write::guard(|| export_inner(pres, opts, compress)) } fn plan_pages(pres: &Presentation, opts: &PdfOptions, slides: &[usize], sw: f64, sh: f64) -> Vec> { @@ -158,7 +182,7 @@ fn build_outline(pres: &Presentation, pages: &[Vec<(usize, Rect)>]) -> Outline { outline } -fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { +fn export_inner(pres: &Presentation, opts: &PdfOptions, compress: bool) -> Result> { let slides: Vec = match &opts.slides { Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(), None => (0..pres.slides.len()).filter(|i| opts.include_hidden || !pres.slides[*i].hidden).collect(), @@ -174,11 +198,11 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { authors: if pres.props.author.trim().is_empty() { Vec::new() } else { vec![pres.props.author.clone()] }, ..Default::default() }; - let mut writer = Writer::new(Default::default(), metadata); + let mut writer = Writer::new(krilla::SerializeSettings { compress_content_streams: compress, ..Default::default() }, metadata); let pdf = writer.document_mut(); let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); - let mut ex = Exporter { pres, opts, fonts: HashMap::new(), backdrops: Backdrops::default() }; + let mut ex = Exporter { pres, opts, cache: vector::Cache::default(), backdrops: Backdrops::default() }; // Page plan: per page, the slides on it with their boxes (page points). let pages = plan_pages(pres, opts, &slides, sw, sh); // Bookmarks and slide → page lookup for internal links. @@ -390,7 +414,7 @@ pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec struct Exporter<'a> { pres: &'a Presentation, opts: &'a PdfOptions, - fonts: HashMap, + cache: vector::Cache, backdrops: Backdrops, } @@ -496,20 +520,42 @@ fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 { impl Exporter<'_> { fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result { - if let Some(font) = self.fonts.get(&face.id()) { - return Ok(font.clone()); - } - let font = write::font(face.data(), face.index(), None)?; - self.fonts.insert(face.id(), font.clone()); - Ok(font) + Ok(self.cache.font(face)?) } /// Slide `i` drawn into `b` (page points). fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> { + if self.opts.vector { + self.vector_slide(s, i, b)?; + } else { + self.raster_slide(s, i, b)?; + } + self.frame(s, b); + Ok(()) + } + + /// Slide `i` as vector artwork and real text, clipped to its box. + fn vector_slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> { + let pres = self.pres; + let k = b.width() / pres.slide_size.width.max(1.0); + let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); + let Some(clip) = to_path(&kurbo::Shape::to_path(&b, 0.1)) else { return Ok(()) }; + s.push_clip_path(&clip, &FillRule::NonZero); + let fallback = Fallback { scale: self.opts.dpi.clamp(36.0, 600.0) / 72.0, clip: b }; + let mut canvas = vector::PdfCanvas::new(s, &mut self.cache, fallback); + deckcraft_render::draw_slide(&mut canvas, pres, i, &RenderOpts::default(), view); + let done = canvas.finish(); + s.pop(); + Ok(done?) + } + + /// Slide `i` as one raster image, with the invisible text layer when asked. + fn raster_slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> { let pres = self.pres; - let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); + let sw = pres.slide_size.width.max(1.0); let k = b.width() / sw; // Raster at the requested dpi relative to the box size on paper. + let sh = pres.slide_size.height.max(1.0); let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi); let ropts = RenderOpts { scale, threads: 4, ..Default::default() }; // Two rasters per slide: the backdrop (background, master and layout graphics) is the same @@ -532,6 +578,17 @@ impl Exporter<'_> { } s.pop(); } + if self.opts.text_layer { + let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); + for pt in deckcraft_render::place_slide(pres, i).texts { + self.text_layer(s, &pt, view, false)?; + } + } + Ok(()) + } + + /// Thin frame around a slide on notes and handout pages. + fn frame(&self, s: &mut Surface, b: Rect) { if self.opts.frame_slides && self.opts.layout != PageLayout::Slides { let p = kurbo::Shape::to_path(&b, 0.1); if let Some(path) = to_path(&p) { @@ -545,14 +602,6 @@ impl Exporter<'_> { s.set_stroke(None); } } - if self.opts.text_layer { - let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); - for pt in deckcraft_render::place_slide(pres, i).texts { - self.text_layer(s, &pt, view, false)?; - } - let _ = sh; - } - Ok(()) } /// Invisible glyphs exactly over the rasterised text. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index dde7a9b76..ebee49d9b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1,17 +1,16 @@ use std::sync::Arc; +use super::*; +use deckcraft_color::Rgba; +use deckcraft_model::style::{ColorRef, Effects, Fill as ShapeFill, Gradient, GradientShape, GradientStop, PictureFill, Shadow}; +use deckcraft_model::text::{Hyperlink, TextBody}; +use deckcraft_model::{Shape, ShapeId, ShapeKind, Xfrm}; use std::collections::HashMap; use deckcraft_fonts::FontDb; use deckcraft_render::RenderOpts; use kurbo::Rect; -use super::{ - MAX_RASTER_PIXELS, PAPERS, PageLayout, PdfError, PdfOptions, Presentation, export, glyph_text_ranges, handout_boxes, master_text_pieces, - master_texts, notes_frames, notes_slide_box, opaque, paper_size, raster_scale, straight, -}; -use deckcraft_model::text::TextBody; - fn deck(n: usize) -> Presentation { let mut p = deckcraft_model::defaults::new_presentation(None); let layout = p.slides[0].layout; @@ -33,23 +32,86 @@ fn opts(layout: PageLayout) -> PdfOptions { PdfOptions { layout, dpi: 48.0, ..Default::default() } } +fn add(p: &mut Presentation, slide: usize, sh: Shape) { + Arc::make_mut(&mut p.slides[slide]).shapes.push(sh); +} + +fn rect(id: u32, x: f64, y: f64, fill: ShapeFill) -> Shape { + Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, 200.0, 100.0)), fill: Some(fill), ..Default::default() } +} + +fn shadow() -> Effects { + Effects { + outer_shadow: Some(Shadow { + color: ColorRef::rgb(Rgba::BLACK), + blur: 8.0, + dist: 6.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }), + ..Default::default() + } +} + +/// A PDF with readable (uncompressed) page contents and text maps. +fn plain(p: &Presentation, o: &PdfOptions) -> Vec { + export_with(p, o, false).expect("export") +} + +fn count(hay: &[u8], needle: &[u8]) -> usize { + hay.windows(needle.len()).filter(|w| *w == needle).count() +} + +fn pages(pdf: &[u8]) -> usize { + count(pdf, b"/Type/Page") - count(pdf, b"/Type/Pages") +} + +/// (width, height) of every image XObject, soft masks included. +fn images(pdf: &[u8]) -> Vec<(u32, u32)> { + let num = |d: &[u8], key: &[u8]| -> u32 { + let Some(at) = d.windows(key.len()).position(|w| w == key) else { return 0 }; + d[at + key.len()..] + .iter() + .skip_while(|c| c.is_ascii_whitespace()) + .take_while(|c| c.is_ascii_digit()) + .fold(0, |n, c| n * 10 + (c - b'0') as u32) + }; + let key = b"/Subtype/Image"; + let mut out = vec![]; + for (at, _) in pdf.windows(key.len()).enumerate().filter(|(_, w)| *w == key) { + let start = pdf[..at].windows(3).rposition(|w| w == b"obj").unwrap_or(0); + let end = at + pdf[at..].windows(6).position(|w| w == b"stream").unwrap_or(0); + let d = &pdf[start..end]; + out.push((num(d, b"/Width"), num(d, b"/Height"))); + } + out +} + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn exports_slides_notes_and_handouts() { let p = deck(5); - for layout in [PageLayout::Slides, PageLayout::Notes, PageLayout::Handouts { per_page: 3 }, PageLayout::Handouts { per_page: 6 }] { - let bytes = export(&p, &opts(layout)).expect("export"); - assert!(bytes.starts_with(b"%PDF-"), "{layout:?}"); - assert!(bytes.len() > 1000); - let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); - let (pages, size) = match layout { - PageLayout::Slides => (5, (960.0, 540.0)), - PageLayout::Notes => (5, (612.0, 792.0)), - PageLayout::Handouts { per_page: 3 } => (2, (612.0, 792.0)), - _ => (1, (612.0, 792.0)), - }; - assert_eq!(pdf.pages().len(), pages); - assert!(pdf.pages().iter().all(|page| page.render_dimensions() == size)); + for vector in [true, false] { + for layout in [PageLayout::Slides, PageLayout::Notes, PageLayout::Handouts { per_page: 3 }, PageLayout::Handouts { per_page: 6 }] { + let bytes = export(&p, &PdfOptions { vector, ..opts(layout) }).expect("export"); + assert!(bytes.starts_with(b"%PDF-"), "{layout:?}"); + assert!(bytes.len() > 1000); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let (pages, size) = match layout { + PageLayout::Slides => (5, (960.0, 540.0)), + PageLayout::Notes => (5, (612.0, 792.0)), + PageLayout::Handouts { per_page: 3 } => (2, (612.0, 792.0)), + _ => (1, (612.0, 792.0)), + }; + assert_eq!(pdf.pages().len(), pages); + assert!(pdf.pages().iter().all(|page| page.render_dimensions() == size)); + } } } @@ -66,12 +128,168 @@ fn empty_range_is_an_error() { fn hidden_slides_skipped_unless_asked() { let mut p = deck(2); Arc::make_mut(&mut p.slides[1]).hidden = true; - let a = export(&p, &opts(PageLayout::Slides)).unwrap(); - let b = export(&p, &PdfOptions { include_hidden: true, ..opts(PageLayout::Slides) }).unwrap(); - assert_eq!(hayro_syntax::Pdf::new(a.clone()).unwrap().pages().len(), 1); - assert_eq!(hayro_syntax::Pdf::new(b.clone()).unwrap().pages().len(), 2); + for vector in [true, false] { + let a = plain(&p, &PdfOptions { vector, ..opts(PageLayout::Slides) }); + let b = plain(&p, &PdfOptions { vector, include_hidden: true, ..opts(PageLayout::Slides) }); + assert_eq!(pages(&a), 1, "vector {vector}"); + assert_eq!(pages(&b), 2, "vector {vector}"); + } let ranged = export(&p, &PdfOptions { slides: Some(vec![1, 0, 9]), ..opts(PageLayout::Slides) }).unwrap(); - assert_eq!(hayro_syntax::Pdf::new(ranged.clone()).unwrap().pages().len(), 2); + assert_eq!(hayro_syntax::Pdf::new(ranged).unwrap().pages().len(), 2); +} + +#[test] +fn vector_slides_are_paths_and_text_without_a_page_image() { + let mut p = deck(1); + add(&mut p, 0, rect(900, 40.0, 300.0, ShapeFill::solid(ColorRef::rgb(Rgba::rgb(0x20, 0x60, 0xC0))))); + let grad = Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: ColorRef::rgb(Rgba::rgb(255, 0, 0)) }, + GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgba(0, 0, 255, 128)) }, + ], + shape: GradientShape::Linear { angle: 30.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + }; + add(&mut p, 0, rect(901, 300.0, 300.0, ShapeFill::Gradient(grad))); + let pdf = plain(&p, &opts(PageLayout::Slides)); + assert!(images(&pdf).is_empty(), "{:?}", images(&pdf)); + // Paths (filled), real text and a shading for the gradient. + assert!(count(&pdf, b"\nf\n") + count(&pdf, b" f\n") > 0); + assert!(count(&pdf, b"BT") > 0 && count(&pdf, b" Tf") > 0); + assert!(count(&pdf, b"/ShadingType 2") > 0); + // The raster mode draws the slide as one image the size of the page. + let raster = plain(&p, &PdfOptions { vector: false, ..opts(PageLayout::Slides) }); + assert!(images(&raster).iter().any(|(w, _)| *w >= 600), "{:?}", images(&raster)); +} + +#[test] +fn vector_text_is_extractable() { + let p = deck(1); + let pdf = plain(&p, &opts(PageLayout::Slides)); + // The title's characters are mapped back to Unicode (UTF-16 hex in the ToUnicode map). + assert!(count(&pdf, b"begincmap") > 0); + for c in "Slide title".chars().filter(|c| !c.is_whitespace()) { + let hex = format!("<{:04X}>", c as u32); + assert!(count(&pdf, hex.as_bytes()) > 0, "{c}"); + } + // No invisible layer: the text is drawn once. + assert_eq!(count(&pdf, b"3 Tr"), 0); +} + +#[test] +fn vector_pdf_is_much_smaller_than_raster() { + let mut p = deck(5); + add(&mut p, 1, rect(900, 40.0, 300.0, ShapeFill::solid(ColorRef::rgb(Rgba::rgb(0x20, 0x60, 0xC0))))); + let o = PdfOptions { dpi: 200.0, ..Default::default() }; + let vector = export(&p, &o).expect("vector"); + let raster = export(&p, &PdfOptions { vector: false, ..o }).expect("raster"); + assert!(vector.len() * 5 < raster.len(), "vector {} raster {}", vector.len(), raster.len()); +} + +#[test] +fn a_shadow_rasterizes_that_shape_only() { + let mut p = deck(1); + let mut sh = rect(900, 100.0, 300.0, ShapeFill::solid(ColorRef::rgb(Rgba::rgb(0xF0, 0x80, 0x20)))); + sh.effects = Some(shadow()); + sh.text = Some(TextBody::from_text("Shadowed")); + add(&mut p, 0, sh); + let pdf = plain(&p, &PdfOptions { dpi: 72.0, ..opts(PageLayout::Slides) }); + let imgs = images(&pdf); + // One local raster (and its soft mask), far smaller than the 960 × 540 page. + assert!(!imgs.is_empty() && imgs.len() <= 2, "{imgs:?}"); + assert!(imgs.iter().all(|(w, h)| *w > 200 && *w < 400 && *h < 250), "{imgs:?}"); + // The shape's text stays text. + assert!(count(&pdf, format!("<{:04X}>", 'S' as u32).as_bytes()) > 0); + assert!(count(&pdf, format!("<{:04X}>", 'w' as u32).as_bytes()) > 0); +} + +#[test] +fn pattern_fills_rasterize_their_area_only() { + let mut p = deck(1); + let pat = deckcraft_model::style::PatternFill { + preset: "dkHorz".into(), + fg: ColorRef::rgb(Rgba::rgb(0x20, 0x20, 0x20)), + bg: ColorRef::rgb(Rgba::rgb(0xF0, 0xF0, 0xF0)), + }; + add(&mut p, 0, rect(900, 100.0, 300.0, ShapeFill::Pattern(pat))); + let pdf = plain(&p, &PdfOptions { dpi: 72.0, ..opts(PageLayout::Slides) }); + let imgs = images(&pdf); + assert!(!imgs.is_empty() && imgs.iter().all(|(w, h)| *w <= 201 && *h <= 101), "{imgs:?}"); +} + +#[test] +fn jpeg_pictures_are_embedded_as_they_are() { + let mut p = deck(1); + let px: Vec = (0..64 * 48).flat_map(|i| [(i % 64 * 4) as u8, (i / 64 * 5) as u8, 128, 255]).collect(); + let jpeg = deckcraft_render::Image { width: 64, height: 48, pixels: px }.to_jpeg(90); + assert!(jpeg.starts_with(&[0xFF, 0xD8])); + let media = p.add_media("photo.jpg", "image/jpeg", jpeg.clone()).expect("media"); + let pic = Shape { + id: ShapeId(900), + xfrm: Some(Xfrm::new(100.0, 100.0, 320.0, 240.0)), + kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.1, 0.0, 0.0, 0.1], ..Default::default() } }, + ..Default::default() + }; + add(&mut p, 0, pic); + let pdf = plain(&p, &opts(PageLayout::Slides)); + assert_eq!(count(&pdf, b"/DCTDecode"), 1); + assert_eq!(images(&pdf), vec![(64, 48)]); + // The file itself, byte for byte. + assert_eq!(count(&pdf, &jpeg[..64]), 1); + // Cropped: drawn under a clip. + assert!(count(&pdf, b"W n") + count(&pdf, b"W\nn") > 0); +} + +/// A PNG whose header reads but whose data doesn't shows the missing-picture placeholder, not a hole. +#[test] +fn a_picture_that_cannot_be_decoded_draws_the_placeholder() { + let mut p = deck(1); + let empty = plain(&p, &opts(PageLayout::Slides)); + let png = deckcraft_render::Image { width: 8, height: 8, pixels: vec![255; 8 * 8 * 4] }.to_png(); + let media = p.add_media("broken.png", "image/png", png[..33].to_vec()).expect("media"); + let pic = Shape { + id: ShapeId(900), + xfrm: Some(Xfrm::new(100.0, 100.0, 320.0, 240.0)), + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + ..Default::default() + }; + add(&mut p, 0, pic); + let pdf = plain(&p, &opts(PageLayout::Slides)); + assert!(images(&pdf).is_empty()); + assert!(pdf.len() > empty.len(), "the placeholder is drawn"); +} + +#[test] +fn links_and_bookmarks_survive_vector_export() { + let mut p = deck(3); + let mut sh = rect(900, 40.0, 300.0, ShapeFill::solid(ColorRef::rgb(Rgba::rgb(0x20, 0x60, 0xC0)))); + sh.click = Some(Hyperlink { + action: deckcraft_model::text::Action::Url { url: "https://example.org/deck".into() }, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }); + add(&mut p, 0, sh); + let mut next = rect(901, 300.0, 300.0, ShapeFill::solid(ColorRef::rgb(Rgba::rgb(0x20, 0xA0, 0x60)))); + next.click = Some(Hyperlink { + action: deckcraft_model::text::Action::LastSlide, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }); + add(&mut p, 0, next); + for layout in [PageLayout::Slides, PageLayout::Handouts { per_page: 6 }] { + let pdf = plain(&p, &opts(layout)); + assert_eq!(count(&pdf, b"https://example.org/deck"), 1, "{layout:?}"); + assert!(count(&pdf, b"/Subtype/Link") >= 2, "{layout:?}"); + assert!(count(&pdf, b"/Outlines") > 0, "{layout:?}"); + assert!(count(&pdf, b"Slide title 2") > 0, "{layout:?}"); + } } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] @@ -123,15 +341,16 @@ fn text_layer_links_and_bookmarks_survive_serialization() { for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) { shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }); } - for text_layer in [true, false] { - let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap(); + // Vector slides always carry real text; raster slides only with the text layer. + for (vector, text_layer) in [(false, true), (false, false), (true, false)] { + let bytes = export(&p, &PdfOptions { vector, text_layer, ..opts(PageLayout::Slides) }).unwrap(); let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); let page = &pdf.pages()[0]; let annotations = page.raw().get::>(b"Annots").unwrap(); assert_eq!(annotations.iter::>().count(), 2); let resources = page.raw().get::>(b"Resources").unwrap(); let fonts = resources.get::>(b"Font"); - assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), text_layer); + assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), vector || text_layer); let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); let outlines = catalog.get::>(b"Outlines").unwrap(); let first = outlines.get::>(b"First").unwrap(); @@ -241,9 +460,12 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() { } } slide.shapes = vec![Shape { id: ShapeId(9000), xfrm: Some(Xfrm::new(20.0, 20.0, 500.0, 200.0)), text: Some(body), ..Default::default() }]; - let error = export(&p, &opts(PageLayout::Slides)).unwrap_err(); - assert!(matches!(error, PdfError::Write(_))); - assert!(error.to_string().contains("font could not be read for embedding")); + // Vector slides and the raster mode's text layer both report it. + for vector in [true, false] { + let error = export(&p, &PdfOptions { vector, ..opts(PageLayout::Slides) }).unwrap_err(); + assert!(matches!(error, PdfError::Write(_))); + assert!(error.to_string().contains("font could not be read for embedding"), "vector {vector}: {error}"); + } // Master text remains visible even when the optional slide text layer is off. // It must propagate the same embedding error through notes and handout pages. let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, ""); @@ -511,7 +733,7 @@ fn slides_share_one_backdrop_raster_and_keep_their_pixels() { /// The image XObjects the export stores, one entry per distinct object: the pages that draw /// it, its filters, color channel, alpha channel (the SMask) and pixel size. fn images(p: &Presentation) -> Vec<(Vec, Vec, Vec, Option>, (u32, u32))> { - let bytes = export(p, &opts(PageLayout::Slides)).expect("export"); + let bytes = export(p, &PdfOptions { vector: false, ..opts(PageLayout::Slides) }).expect("export"); let pdf = hayro_syntax::Pdf::new(bytes).expect("pdf"); let mut out: Vec<(String, Vec, Vec, Vec, Option>, (u32, u32))> = Vec::new(); for (n, page) in pdf.pages().iter().enumerate() { @@ -644,6 +866,108 @@ fn glyph_ranges_follow_shaping_clusters() { assert_eq!(glyph_text_ranges("αβγ", (3, 6), &[(5, 'γ'), (4, 'β'), (3, 'α')], 3), vec![4..6, 2..4, 0..2]); } +/// A placeable WMF, 2 x 1 inch: a red rectangle over the left half. +fn half_red_wmf() -> Vec { + fn rec(func: u16, params: &[i16]) -> Vec { + let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec(); + v.extend(func.to_le_bytes()); + for p in params { + v.extend(p.to_le_bytes()); + } + v + } + let body = [rec(0x02FC, &[0, 0xFF, 0, 0]), rec(0x012D, &[0]), rec(0x041B, &[1440, 1440, 0, 0]), rec(0, &[])].concat(); + let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec(); + f.extend(0u16.to_le_bytes()); + for v in [0i16, 0, 2880, 1440] { + 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 +} + +/// What the renderer draws only as pixels (pattern fills, rectangle path gradients, text shadows, +/// WMF pictures, colour emoji) exports as local images in a vector PDF: nothing panics, nothing is +/// dropped, and the text around stays text. +#[test] +fn pixel_only_drawing_exports_as_local_images() { + use deckcraft_model::style::{PatternFill, Shadow}; + let mut p = deck(1); + let pat = PatternFill { preset: "dkVert".into(), fg: ColorRef::rgb(Rgba::rgb(0x20, 0x20, 0x20)), bg: ColorRef::rgb(Rgba::rgb(0xF0, 0xF0, 0xF0)) }; + add(&mut p, 0, rect(900, 40.0, 300.0, ShapeFill::Pattern(pat))); + let stops = vec![ + GradientStop { pos: 0.0, color: ColorRef::rgb(Rgba::rgb(255, 0, 0)) }, + GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }, + ]; + let path = + Gradient { stops, shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] }, rotate_with_shape: true, tile: [0.0; 4] }; + add(&mut p, 0, rect(901, 260.0, 300.0, ShapeFill::Gradient(path))); + let mut text = Shape { id: ShapeId(902), xfrm: Some(Xfrm::new(480.0, 280.0, 440.0, 120.0)), text_box: true, ..Default::default() }; + let mut body = TextBody::from_text("Shade \u{1F600} me"); + body.paragraphs[0].runs[0].props.size = Some(40.0); + body.paragraphs[0].runs[0].props.shadow = + Some(Shadow { color: ColorRef::rgb(Rgba { r: 0, g: 0, b: 0, a: 160 }), blur: 4.0, dist: 4.0, ..shadow_of() }); + text.text = Some(body); + add(&mut p, 0, text); + let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf()).expect("media"); + let pic = Shape { + id: ShapeId(903), + xfrm: Some(Xfrm::new(480.0, 420.0, 200.0, 100.0)), + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + ..Default::default() + }; + add(&mut p, 0, pic); + let pdf = plain(&p, &PdfOptions { dpi: 72.0, ..opts(PageLayout::Slides) }); + let imgs = images(&pdf); + // Pattern, path gradient, text shadow, metafile (emoji when a colour font is installed), + // each a local image well under the page size, soft masks included. + assert!(imgs.len() >= 4, "{imgs:?}"); + assert!(imgs.iter().all(|(w, h)| *w < 600 && *h < 300), "{imgs:?}"); + for c in "Shademe".chars() { + assert!(count(&pdf, format!("<{:04X}>", c as u32).as_bytes()) > 0, "{c} is text"); + } +} + +fn shadow_of() -> Shadow { + shadow().outer_shadow.expect("shadow") +} + +/// Upright glyphs in East Asian vertical text (`eaVert`) each turn about their own em box, which a +/// PDF text run can't express: they are drawn as outlines over the same text, invisible, so the +/// characters still search, select and copy. +#[test] +fn upright_vertical_text_is_outlines_over_invisible_text() { + let mut p = deck(1); + let mut sh = Shape { id: ShapeId(904), xfrm: Some(Xfrm::new(100.0, 40.0, 120.0, 400.0)), text_box: true, ..Default::default() }; + let mut body = TextBody::from_text("一二三"); + body.body.vert = Some(deckcraft_model::text::TextDir::EaVertical); + body.paragraphs[0].runs[0].props.size = Some(40.0); + sh.text = Some(body); + add(&mut p, 0, sh); + let pdf = plain(&p, &opts(PageLayout::Slides)); + // The run's text, drawn fully transparent (the outlines carry the ink). + assert!(count(&pdf, b"/Type/ExtGState/ca 0>>") > 0, "an invisible text layer for the upright run"); + for c in "一二三".chars() { + assert!(count(&pdf, format!("<{:04X}>", c as u32).as_bytes()) > 0, "{c} is text"); + } + let mut q = deck(1); + let mut sh = Shape { id: ShapeId(905), xfrm: Some(Xfrm::new(100.0, 40.0, 400.0, 100.0)), text_box: true, ..Default::default() }; + sh.text = Some(TextBody::from_text("一二三")); + add(&mut q, 0, sh); + assert_eq!(count(&plain(&q, &opts(PageLayout::Slides)), b"/Type/ExtGState/ca 0>>"), 0, "horizontal text is drawn as text"); +} + #[test] fn raster_scale_caps_total_pixels_not_each_side() { // A huge square slide at 600 dpi would be 16,000 x 16,000 px (~1 GB RGBA) without the cap. diff --git a/crates/pdf/src/vector.rs b/crates/pdf/src/vector.rs new file mode 100644 index 000000000..5ee4daae9 --- /dev/null +++ b/crates/pdf/src/vector.rs @@ -0,0 +1,400 @@ +//! Vector drawing: the renderer's [`Canvas`] on a krilla surface. +//! +//! Paths become PDF paths, solid colours and linear/radial gradients PDF paints (with their +//! transparency), glyph runs real text in the embedded font subsets, pictures image XObjects (an +//! unchanged JPEG embedded as it is, other pictures losslessly). Whatever the renderer can only +//! draw as pixels (blur-based effects, pattern fills) arrives through [`Canvas::draw_pixels`], +//! rasterized for that element alone. + +use std::collections::HashMap; + +use craft_pdf::write::{self, WriteError, backend as krilla}; +use deckcraft_render::canvas::{Canvas, CanvasImage, Fallback, FillRule as Rule, Paint}; +use deckcraft_text::GlyphRun; +use krilla::color::rgb; +use krilla::geom::{Point, Size, Transform}; +use krilla::image::Image; +use krilla::num::NormalizedF32; +use krilla::paint::{Fill, FillRule, LineCap, LineJoin, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; +use krilla::surface::Surface; +use krilla::text::{Font, GlyphId, KrillaGlyph}; +use kurbo::{Affine, BezPath, Rect}; + +use crate::{tf, to_path}; + +/// Fonts and pictures already handed to krilla, shared by every page. +#[derive(Default)] +pub(crate) struct Cache { + fonts: HashMap, + images: HashMap>, + /// The pixmaps `images` is keyed by, kept alive so a freed one's address is never reused as a key. + held: Vec>, +} + +impl Cache { + /// The embedded font for `face`. A face that can't be embedded is an error: the export + /// reports it rather than dropping or substituting text. + pub fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result { + if let Some(font) = self.fonts.get(&face.id()) { + return Ok(font.clone()); + } + let font = write::font(face.data(), face.index(), None)?; + self.fonts.insert(face.id(), font.clone()); + Ok(font) + } + + fn image(&mut self, img: &CanvasImage) -> Option { + let key = img.key(); + if key == 0 { + return None; + } + if let Some(pm) = &img.pixmap + && !self.images.contains_key(&key) + { + self.held.push(Box::new(pm.clone())); + } + self.images + .entry(key) + .or_insert_with(|| { + if let Some(data) = img.encoded { + let bytes = data.as_slice(); + // krilla reads a JPEG's headers now (and copies its data as it is), but decodes + // a PNG only when the document is written: a broken one would fail the whole + // export, so PNGs are checked first. + let embedded = if bytes.starts_with(&[0xFF, 0xD8]) { + Image::from_jpeg(data.clone().into(), true).ok() + } else if deckcraft_render::decode_image(bytes).is_some() { + Image::from_png(data.clone().into(), true).ok() + } else { + None + }; + // A file krilla can't read is decoded and embedded as pixels. + return embedded.or_else(|| { + let px = deckcraft_render::decode_image(bytes)?; + let (w, h) = px.dimensions(); + Some(Image::from_rgba8(px.into_raw(), w, h)) + }); + } + let (px, w, h) = img.rgba()?; + Some(Image::from_rgba8(px, w, h)) + }) + .clone() + } +} + +fn norm(v: f64) -> NormalizedF32 { + NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 1.0 }).unwrap_or(NormalizedF32::ONE) +} + +fn rgb_of(c: &deckcraft_color::Rgba) -> rgb::Color { + rgb::Color::new(c.r, c.g, c.b) +} + +fn stops(s: &[(f32, deckcraft_color::Rgba)]) -> Vec { + s.iter().map(|(o, c)| Stop { offset: norm(*o as f64), color: rgb_of(c).into(), opacity: norm(c.a as f64 / 255.0) }).collect() +} + +/// A krilla paint and its opacity for `p`. +fn paint(p: &Paint) -> (krilla::paint::Paint, NormalizedF32) { + match p { + Paint::Solid(c) => (rgb_of(c).into(), norm(c.a as f64 / 255.0)), + Paint::Linear { stops: s, .. } | Paint::Radial { stops: s, .. } if s.len() < 2 => match s.first() { + Some((_, c)) => (rgb_of(c).into(), norm(c.a as f64 / 255.0)), + None => (rgb::Color::black().into(), NormalizedF32::ZERO), + }, + Paint::Linear { start, end, stops: s } => ( + LinearGradient { + x1: start.x as f32, + y1: start.y as f32, + x2: end.x as f32, + y2: end.y as f32, + transform: Transform::identity(), + spread_method: SpreadMethod::Pad, + stops: stops(s), + anti_alias: false, + } + .into(), + NormalizedF32::ONE, + ), + Paint::Radial { focus, center, radius, stops: s } => ( + RadialGradient { + fx: focus.x as f32, + fy: focus.y as f32, + fr: 0.0, + cx: center.x as f32, + cy: center.y as f32, + cr: radius.max(1e-3) as f32, + transform: Transform::identity(), + spread_method: SpreadMethod::Pad, + stops: stops(s), + anti_alias: false, + } + .into(), + NormalizedF32::ONE, + ), + } +} + +fn stroke(s: &kurbo::Stroke, p: &Paint) -> Stroke { + let (paint, opacity) = paint(p); + let dash: Vec = s.dash_pattern.iter().map(|v| *v as f32).collect(); + Stroke { + paint, + width: s.width.max(0.0) as f32, + miter_limit: s.miter_limit.max(1.0) as f32, + line_cap: match s.start_cap { + kurbo::Cap::Butt => LineCap::Butt, + kurbo::Cap::Round => LineCap::Round, + kurbo::Cap::Square => LineCap::Square, + }, + line_join: match s.join { + kurbo::Join::Bevel => LineJoin::Bevel, + kurbo::Join::Miter => LineJoin::Miter, + kurbo::Join::Round => LineJoin::Round, + }, + opacity, + dash: (!dash.is_empty() && dash.iter().all(|v| v.is_finite() && *v >= 0.0) && dash.iter().any(|v| *v > 0.0)) + .then_some(StrokeDash { array: dash, offset: s.dash_offset as f32 }), + } +} + +fn finite(t: Affine) -> bool { + t.as_coeffs().iter().all(|v| v.is_finite()) +} + +/// The renderer's drawing calls as PDF content on one page. +pub(crate) struct PdfCanvas<'a, 'b> { + s: &'a mut Surface<'b>, + cache: &'a mut Cache, + t: Affine, + paint: Paint, + stroke: kurbo::Stroke, + even_odd: bool, + /// Per open layer: how many krilla pushes it made. + layers: Vec, + fallback: Fallback, + /// The first font that could not be embedded. + error: Option, +} + +impl<'a, 'b> PdfCanvas<'a, 'b> { + pub fn new(s: &'a mut Surface<'b>, cache: &'a mut Cache, fallback: Fallback) -> Self { + PdfCanvas { + s, + cache, + t: Affine::IDENTITY, + paint: Paint::Solid(deckcraft_color::Rgba::BLACK), + stroke: kurbo::Stroke::new(1.0), + even_odd: false, + layers: Vec::new(), + fallback, + error: None, + } + } + + /// Close the layers left open (a drawing that stopped half-way), so pushes and pops balance. + /// An error when a font could not be embedded. + pub fn finish(mut self) -> Result<(), WriteError> { + while !self.layers.is_empty() { + self.pop_layer(); + } + self.error.map_or(Ok(()), Err) + } + + fn draw(&mut self, p: &BezPath, fill: Option, stroke: Option) { + if !finite(self.t) || p.elements().is_empty() { + return; + } + let Some(path) = to_path(p) else { return }; + self.s.push_transform(&tf(self.t)); + self.s.set_fill(fill); + self.s.set_stroke(stroke); + self.s.draw_path(&path); + self.s.set_fill(None); + self.s.set_stroke(None); + self.s.pop(); + } +} + +impl Canvas for PdfCanvas<'_, '_> { + fn set_transform(&mut self, t: Affine) { + self.t = t; + } + fn set_paint_to(&mut self, p: Paint) { + self.paint = p; + } + fn set_stroke(&mut self, s: kurbo::Stroke) { + self.stroke = s; + } + fn set_fill_rule(&mut self, r: Rule) { + self.even_odd = matches!(r, Rule::EvenOdd); + } + fn fill_path(&mut self, p: &BezPath) { + let (paint, opacity) = paint(&self.paint); + if opacity == NormalizedF32::ZERO { + return; + } + let rule = if self.even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; + self.draw(p, Some(Fill { paint, opacity, rule }), None); + } + fn stroke_path(&mut self, p: &BezPath) { + let sk = stroke(&self.stroke, &self.paint); + if sk.opacity == NormalizedF32::ZERO { + return; + } + self.draw(p, None, Some(sk)); + } + fn push_layer(&mut self, clip: Option<&BezPath>, opacity: Option) { + let mut n = 0; + if let Some(c) = clip { + let mut c = c.clone(); + let path = if finite(self.t) { + c.apply_affine(self.t); + to_path(&c) + } else { + None + }; + match path { + Some(path) => { + let rule = if self.even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; + self.s.push_clip_path(&path, &rule) + } + // Nothing can show through an empty clip. + None => self.s.push_opacity(NormalizedF32::ZERO), + } + n += 1; + } + if let Some(o) = opacity.filter(|o| *o < 0.999) { + self.s.push_opacity(norm(o as f64)); + n += 1; + } + self.layers.push(n); + } + fn pop_layer(&mut self) { + for _ in 0..self.layers.pop().unwrap_or(0) { + self.s.pop(); + } + } + fn draw_image(&mut self, img: &CanvasImage, alpha: f64) { + let (Some(size), true) = (Size::from_wh(img.width as f32, img.height as f32), finite(self.t)) else { return }; + // A file that can't be embedded (a header that reads but data that doesn't) shows the + // missing-picture placeholder, as the pixel canvas does. + let Some(image) = self.cache.image(img) else { + deckcraft_render::missing_picture(self, Rect::new(0.0, 0.0, img.width, img.height)); + return; + }; + self.s.push_transform(&tf(self.t)); + let faded = alpha < 0.999; + if faded { + self.s.push_opacity(norm(alpha)); + } + self.s.draw_image(image, size); + if faded { + self.s.pop(); + } + self.s.pop(); + } + fn glyph_run(&mut self, run: &GlyphRun, text: &str, at: Affine, opacity: f64) -> bool { + let (Some(first), true) = (run.glyphs.first(), run.size > 0.0 && run.size.is_finite()) else { return true }; + // Bullets (glyphs without characters) stay outlines: they aren't text to copy. + if run.cells.len() != run.glyphs.len() { + return false; + } + let font = match self.cache.font(&run.face) { + Ok(font) => font, + Err(e) => { + // The export fails with this error; nothing more to draw for the run. + self.error.get_or_insert(e); + return true; + } + }; + let (x0, y0) = (first.1, first.2); + // The characters the run covers, and each glyph's cluster within them. + let chars: Vec = text.chars().collect(); + let lo = run.chars.0.min(chars.len()); + let hi = run.chars.1.clamp(lo, chars.len()); + let sub: String = chars.get(lo..hi).unwrap_or_default().iter().collect(); + let mut offs: Vec = sub.char_indices().map(|(b, _)| b).collect(); + offs.push(sub.len()); + let mut heads: Vec = run.cells.iter().map(|c| c.0.clamp(lo, hi)).collect(); + heads.sort_unstable(); + heads.dedup(); + let range = |gi: usize| { + let Some(&(h, _)) = run.cells.get(gi) else { return 0..0 }; + let h = h.clamp(lo, hi); + let end = heads.iter().copied().find(|x| *x > h).unwrap_or(hi); + let (a, b) = (offs.get(h - lo).copied().unwrap_or(0), offs.get(end - lo).copied().unwrap_or(sub.len())); + a.min(b)..b + }; + let upem = run.face.upem.max(1.0); + let glyphs: Vec = run + .glyphs + .iter() + .enumerate() + .map(|(gi, g)| { + let adv = match run.glyphs.get(gi + 1) { + Some(n) => (n.1 - g.1) / run.size, + None => run.face.advance(g.0) / upem, + }; + KrillaGlyph::new(GlyphId::new(g.0), adv as f32, 0.0, (-(g.2 - y0) / run.size) as f32, 0.0, range(gi), None) + }) + .collect(); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let t = at * Affine::translate((x0, y0)) * skew; + if !finite(t) { + return true; + } + // A named instance of a variable font (Bold, Light…) other than the default one: krilla + // embeds the default instance only; and upright glyphs in vertical text (East Asian + // `eaVert`, stacked WordArt), each turned about its own em box, which a PDF text run + // can't express. The renderer draws those glyphs as outlines and the text goes + // underneath, invisible, for search, selection and copy. + if run.upright || run.face.location().coords().iter().any(|c| c.to_f32() != 0.0) { + self.s.push_transform(&tf(t)); + self.s.set_fill(Some(Fill { opacity: NormalizedF32::ZERO, ..Default::default() })); + self.s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &sub, run.size as f32, false); + self.s.set_fill(None); + self.s.pop(); + return false; + } + let alpha = run.alpha * opacity; + let fill = (alpha > 0.0 && run.color.a > 0).then(|| Fill { + paint: rgb_of(&run.color).into(), + opacity: norm(alpha * run.color.a as f64 / 255.0), + ..Default::default() + }); + let line = |c: &deckcraft_color::Rgba, w: f64, o: f64| Stroke { + paint: rgb_of(c).into(), + width: w.max(0.0) as f32, + line_join: LineJoin::Round, + opacity: norm(o * c.a as f64 / 255.0), + ..Default::default() + }; + let outline = match (&run.outline, &fill) { + (Some((oc, ow)), _) => Some(line(oc, *ow, opacity)), + (None, Some(_)) if run.fake_bold => Some(line(&run.color, run.size * 0.025, alpha)), + _ => None, + }; + if fill.is_none() && outline.is_none() { + return true; + } + self.s.push_transform(&tf(t)); + self.s.set_fill(fill); + self.s.set_stroke(outline); + self.s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &sub, run.size as f32, false); + self.s.set_fill(None); + self.s.set_stroke(None); + self.s.pop(); + true + } + fn fallback(&self) -> Option { + Some(self.fallback) + } + fn draw_pixels(&mut self, img: &deckcraft_render::Image, dest: Rect) { + let Some(size) = Size::from_wh(dest.width() as f32, dest.height() as f32) else { return }; + let image = Image::from_rgba8(img.to_straight(), img.width, img.height); + self.s.push_transform(&Transform::from_translate(dest.x0 as f32, dest.y0 as f32)); + self.s.draw_image(image, size); + self.s.pop(); + } +} diff --git a/crates/render/src/canvas.rs b/crates/render/src/canvas.rs new file mode 100644 index 000000000..069771c37 --- /dev/null +++ b/crates/render/src/canvas.rs @@ -0,0 +1,260 @@ +//! Drawing surfaces. +//! +//! The renderer draws through [`Canvas`]: `vello_cpu`'s `RenderContext` for pixels, or a vector +//! backend (the PDF export) that turns paths, paints, pictures and glyph runs into its own +//! primitives. What a vector canvas can't express (blur-based effects, pattern fills) is drawn by +//! `vello_cpu` into a pixmap covering just that element and placed as an image +//! ([`Canvas::fallback`]). + +use std::sync::Arc; + +use deckcraft_color::Rgba; +use deckcraft_text::GlyphRun; +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use vello_cpu::{Pixmap, RenderContext, Resources, peniko}; + +use crate::{Image, MAX_SIDE}; + +/// Fill rule for [`Canvas::set_fill_rule`]. +pub use vello_cpu::peniko::Fill as FillRule; + +/// A paint for fills and strokes, in the coordinates of the path it paints. +#[derive(Clone, Debug, PartialEq)] +pub enum Paint { + /// Straight RGBA (alpha included). + Solid(Rgba), + /// Colour stops (offset 0–1, sorted) along `start` → `end`, padded beyond them. + Linear { start: Point, end: Point, stops: Vec<(f32, Rgba)> }, + /// Colour stops from the point `focus` (0) to the circle of `radius` around `center` (1), with + /// circles in between moving from one to the other; padded beyond. `focus == center` is a + /// plain radial gradient. + Radial { focus: Point, center: Point, radius: f64, stops: Vec<(f32, Rgba)> }, +} + +impl From for Paint { + fn from(c: peniko::Color) -> Self { + let p = c.to_rgba8(); + Paint::Solid(Rgba::rgba(p.r, p.g, p.b, p.a)) + } +} + +impl From for Paint { + fn from(c: Rgba) -> Self { + Paint::Solid(c) + } +} + +impl Paint { + fn to_vello(&self) -> vello_cpu::PaintType { + let col = |c: &Rgba| peniko::Color::from_rgba8(c.r, c.g, c.b, c.a); + let stops = |s: &[(f32, Rgba)]| s.iter().map(|(o, c)| peniko::ColorStop { offset: *o, color: col(c).into() }).collect::>(); + match self { + Paint::Solid(c) => col(c).into(), + Paint::Linear { start, end, stops: s } => peniko::Gradient::new_linear(*start, *end).with_stops(stops(s).as_slice()).into(), + Paint::Radial { focus, center, radius, stops: s } => { + let g = if focus == center { + peniko::Gradient::new_radial(*center, *radius as f32) + } else { + peniko::Gradient::new_two_point_radial(*focus, 0.0, *center, *radius as f32) + }; + g.with_stops(stops(s).as_slice()).into() + } + } + } +} + +/// How a vector canvas wants what it can't draw itself: rasterized at `scale` pixels per canvas +/// unit, within `clip` (canvas units; nothing outside it is visible). +#[derive(Clone, Copy, Debug)] +pub struct Fallback { + pub scale: f64, + pub clip: Rect, +} + +/// A picture for [`Canvas::draw_image`]: its pixel grid is `width` × `height` units of the current +/// transform. +pub struct CanvasImage<'a> { + pub width: f64, + pub height: f64, + /// Decoded (and adjusted) pixels; `None` when `encoded` is to be embedded instead. + pub pixmap: Option>, + /// The original JPEG or PNG file, unmodified, for canvases that embed files as they are. + pub encoded: Option<&'a Arc>>, +} + +impl CanvasImage<'_> { + /// The decoded pixels as straight RGBA, with their size. + pub fn rgba(&self) -> Option<(Vec, u32, u32)> { + let pm = self.pixmap.as_ref()?; + let mut out = Vec::with_capacity(pm.data().len() * 4); + for p in pm.data() { + let a = p.a as u32; + let un = |c: u8| (c as u32 * 255 + a / 2).checked_div(a).map_or(0, |v| v.min(255) as u8); + out.extend_from_slice(&[un(p.r), un(p.g), un(p.b), p.a]); + } + Some((out, pm.width() as u32, pm.height() as u32)) + } + + /// Identity of the image data (for caches): the file or the pixmap it comes from. + pub fn key(&self) -> usize { + match (&self.encoded, &self.pixmap) { + (Some(e), _) => Arc::as_ptr(e) as usize, + (None, Some(p)) => Arc::as_ptr(p) as usize, + (None, None) => 0, + } + } +} + +/// Where the renderer draws. Transforms are absolute (shape space → canvas units), as in +/// `vello_cpu`; layers nest. +pub trait Canvas { + fn set_transform(&mut self, t: Affine); + /// Use [`dyn Canvas::set_paint`], which takes anything that converts to a [`Paint`]. + fn set_paint_to(&mut self, p: Paint); + fn set_stroke(&mut self, s: kurbo::Stroke); + fn set_fill_rule(&mut self, r: FillRule); + fn fill_path(&mut self, p: &BezPath); + fn stroke_path(&mut self, p: &BezPath); + fn fill_rect(&mut self, r: &Rect) { + self.fill_path(&r.to_path(0.1)); + } + /// A group clipped to `clip` (current transform) and faded by `opacity`, until [`Self::pop_layer`]. + fn push_layer(&mut self, clip: Option<&BezPath>, opacity: Option); + fn push_clip_layer(&mut self, clip: &BezPath) { + self.push_layer(Some(clip), None); + } + fn pop_layer(&mut self); + /// Draw a picture on its pixel grid under the current transform, faded by `alpha`. + fn draw_image(&mut self, img: &CanvasImage, alpha: f64); + /// Draw a whole glyph run as text, its origin mapped by `at` (paragraph `text` gives the + /// characters the run's cells index). `false`: draw it glyph by glyph as outlines instead. + fn glyph_run(&mut self, _run: &GlyphRun, _text: &str, _at: Affine, _opacity: f64) -> bool { + false + } + /// The pixel context, for effects only pixels can show. + fn raster(&mut self) -> Option<&mut RenderContext> { + None + } + /// Vector canvases: how to rasterize what they can't draw. + fn fallback(&self) -> Option { + None + } + /// Place premultiplied pixels over `dest` (canvas units, untransformed). + fn draw_pixels(&mut self, _img: &Image, _dest: Rect) {} +} + +impl dyn Canvas + '_ { + pub fn set_paint(&mut self, p: impl Into) { + self.set_paint_to(p.into()); + } +} + +impl Canvas for RenderContext { + fn set_transform(&mut self, t: Affine) { + RenderContext::set_transform(self, t); + } + fn set_paint_to(&mut self, p: Paint) { + RenderContext::set_paint(self, p.to_vello()); + } + fn set_stroke(&mut self, s: kurbo::Stroke) { + RenderContext::set_stroke(self, s); + } + fn set_fill_rule(&mut self, r: FillRule) { + RenderContext::set_fill_rule(self, r); + } + fn fill_path(&mut self, p: &BezPath) { + RenderContext::fill_path(self, p); + } + fn stroke_path(&mut self, p: &BezPath) { + RenderContext::stroke_path(self, p); + } + fn fill_rect(&mut self, r: &Rect) { + RenderContext::fill_rect(self, r); + } + fn push_layer(&mut self, clip: Option<&BezPath>, opacity: Option) { + RenderContext::push_layer(self, clip, None, opacity, None, None); + } + fn push_clip_layer(&mut self, clip: &BezPath) { + RenderContext::push_clip_layer(self, clip); + } + fn pop_layer(&mut self) { + RenderContext::pop_layer(self); + } + fn draw_image(&mut self, img: &CanvasImage, alpha: f64) { + let Some(pm) = &img.pixmap else { return }; + let image = vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm.clone()), + sampler: peniko::ImageSampler { quality: peniko::ImageQuality::High, ..Default::default() }, + }; + let faded = alpha < 0.999; + if faded { + RenderContext::push_layer(self, None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None); + } + RenderContext::set_paint(self, image); + RenderContext::fill_rect(self, &Rect::new(0.0, 0.0, pm.width() as f64, pm.height() as f64)); + if faded { + RenderContext::pop_layer(self); + } + } + fn raster(&mut self) -> Option<&mut RenderContext> { + Some(self) + } +} + +/// Largest local raster, in pixels. +const MAX_PIXELS: f64 = 40.0e6; + +/// On a vector canvas, run `draw` on a transparent pixmap covering `bounds` (canvas units, cut to +/// the fallback's clip) and place the result. `draw` gets the canvas → pixmap transform to prepend +/// to its own. +pub(crate) fn rasterize(ctx: &mut dyn Canvas, fb: Fallback, bounds: Rect, draw: &mut dyn FnMut(&mut RenderContext, Affine)) { + let b = bounds.intersect(fb.clip); + if !(b.is_finite() && b.width() > 0.0 && b.height() > 0.0) { + return; + } + let mut k = if fb.scale.is_finite() { fb.scale.clamp(0.05, 20.0) } else { 1.0 }; + if b.area() * k * k > MAX_PIXELS { + k = (MAX_PIXELS / b.area()).sqrt(); + } + k = k.min(MAX_SIDE as f64 / b.width()).min(MAX_SIDE as f64 / b.height()); + let w = (b.width() * k).ceil().clamp(1.0, MAX_SIDE as f64) as u16; + let h = (b.height() * k).ceil().clamp(1.0, MAX_SIDE as f64) as u16; + let mut v = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + draw(&mut v, Affine::scale(k) * Affine::translate(-b.origin().to_vec2())); + v.flush(); + let mut pixels = vec![0u8; w as usize * h as usize * 4]; + if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) { + v.render(pm, &mut Resources::new()); + } + if pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 0) { + return; + } + let dest = Rect::new(b.x0, b.y0, b.x0 + w as f64 / k, b.y0 + h as f64 / k); + ctx.draw_pixels(&Image { width: w as u32, height: h as u32, pixels }, dest); +} + +/// Draw what only the pixel context can draw (blurs, blend modes, pixel paints): directly on a +/// pixel canvas; on a vector canvas rasterized over `bounds` (canvas units) and placed, so it is +/// never dropped. `draw` gets the pixel context and the transform to put before its own (the +/// identity on a pixel canvas). +pub(crate) fn pixels(ctx: &mut dyn Canvas, bounds: Rect, draw: &mut dyn FnMut(&mut RenderContext, Affine)) { + if let Some(px) = ctx.raster() { + draw(px, Affine::IDENTITY); + } else if let Some(fb) = ctx.fallback() { + rasterize(ctx, fb, bounds, draw); + } +} + +/// The placeholder drawn for a picture that can't be shown: a grey box with a cross. +pub fn missing_picture(ctx: &mut dyn Canvas, b: Rect) { + ctx.set_paint(peniko::Color::from_rgba8(230, 230, 230, 255)); + ctx.fill_rect(&b); + ctx.set_paint(peniko::Color::from_rgba8(160, 160, 160, 255)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + let mut p = BezPath::new(); + p.move_to((b.x0, b.y0)); + p.line_to((b.x1, b.y1)); + p.move_to((b.x1, b.y0)); + p.line_to((b.x0, b.y1)); + ctx.stroke_path(&p); +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index cb96dba9f..1a6122bf6 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -7,8 +7,8 @@ use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; use deckcraft_model::text::{Align, Anchor, Bullet, TextBody}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; -use vello_cpu::RenderContext; +use crate::canvas::Canvas; use crate::color; const SLOTS: [SchemeSlot; 6] = @@ -61,14 +61,16 @@ fn paint_of(ctx: &Ctx, f: Option<&Fill>, fallback: Rgba) -> Paint { } /// Fill `r` (in the space `m` maps to the output) with `p`. -fn fill_with(ctx: &mut RenderContext, p: &Paint, r: Rect, m: Affine) { +fn fill_with(ctx: &mut dyn Canvas, p: &Paint, r: Rect, m: Affine) { match p { Paint::Color(c) => { ctx.set_transform(m); ctx.set_paint(color(*c, 1.0)); ctx.fill_rect(&r); } - Paint::Pattern(name, fg, bg) => crate::pattern::fill(ctx, name, *fg, *bg, 1.0, &r.to_path(0.1), m), + Paint::Pattern(name, fg, bg) => { + crate::canvas::pixels(ctx, m.transform_rect_bbox(r), &mut |v, pre| crate::pattern::fill(v, name, *fg, *bg, 1.0, &r.to_path(0.1), pre * m)) + } Paint::Nothing => {} } } @@ -155,8 +157,12 @@ pub(crate) struct LabelStyle { pub(crate) bold: bool, } -pub(crate) fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, style: LabelStyle) { - let LabelStyle { size, align, anchor, col, bold } = style; +pub(crate) fn label(ctx: &mut dyn Canvas, rctx: &Ctx, m: Affine, text: &str, r: Rect, style: LabelStyle) { + label_custom(ctx, rctx, m, text, r, style.size, style.align, style.anchor, style.col, style.bold); +} + +#[allow(clippy::too_many_arguments)] +fn label_custom(ctx: &mut dyn Canvas, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) { let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); body.body.inset_r = Some(0.0); @@ -176,14 +182,7 @@ pub(crate) fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, } } let tmp = Shape { text: Some(body.clone()), ..Default::default() }; - crate::draw_text( - ctx, - rctx, - &tmp, - &body, - m, - &deckcraft_text::Opts { rect: r, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - ); + crate::draw_text(ctx, rctx, &tmp, &body, r, m, &deckcraft_text::NoFields, None); } fn fmt_num(v: f64) -> String { @@ -248,27 +247,18 @@ fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator { (0..=n).map(move |k| lo + k as f64 * st) } -pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { +pub fn draw(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { match c.kind { // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. - ChartType::Histogram | ChartType::Pareto => { - draw_inner(ctx, rctx, &c.histogram_view(), ChartFrame { transform: m, width: w, height: h, boxes: None }) - } + ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None), ChartType::BoxWhisker => { let (view, boxes) = crate::chartex::box_view(c); - draw_inner(ctx, rctx, &view, ChartFrame { transform: m, width: w, height: h, boxes: Some(&boxes) }); + draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes)); } - _ => draw_inner(ctx, rctx, c, ChartFrame { transform: m, width: w, height: h, boxes: None }), + _ => draw_inner(ctx, rctx, c, m, w, h, None), } } -struct ChartFrame<'a> { - transform: Affine, - width: f64, - height: f64, - boxes: Option<&'a crate::chartex::Boxes>, -} - /// PowerPoint's default chart text colour: `tx1` with `lumMod` 65000 and `lumOff` 35000, which /// is 595959 on a light theme and a light grey on a dark one. pub fn default_text_color() -> ColorRef { @@ -276,8 +266,8 @@ pub fn default_text_color() -> ColorRef { ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000)) } -fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, frame: ChartFrame<'_>) { - let ChartFrame { transform: m, width: w, height: h, boxes } = frame; +#[allow(clippy::cognitive_complexity, clippy::too_many_arguments)] +fn draw_inner(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { // Text without a colour of its own takes PowerPoint's default, the theme's text colour at // lumMod 65 % / lumOff 35 % (595959 on a light theme, light grey on a dark one), so a chart // on a dark theme is readable (a fixed grey used to vanish there). @@ -303,13 +293,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, frame: ChartFrame< let title_size = c.title_size.unwrap_or(base * 1.4); let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96); if let Some(t) = &c.title { - label( + label_custom( ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), - LabelStyle { size: title_size, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, + title_size, + Align::Center, + Anchor::Top, + text_col, + false, ); area.y0 += title_size * 1.7; } @@ -317,10 +311,132 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, frame: ChartFrame< // Legend placed by `c:legendPos`. let waterfall = c.kind == ChartType::Waterfall; // Each legend entry: its name, its key's paint and the key's outline. - let legend_items = build_legend_items(rctx, c, pie, waterfall); - area = draw_legend(ctx, &LegendDraw { rctx, chart: c, transform: m, items: &legend_items, base, text_color: text_col, width: w }, area); + let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall { + ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + // A hierarchy's legend names its top-level groups. + c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { + // A map draws its own colour scale. + vec![] + } else if pie { + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else { + c.series + .iter() + .enumerate() + .map(|(i, s)| { + let k = c.series_kind(s); + let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + if line { + (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) + } else if k.is_bar_like() { + let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { + Paint::Color(series_color(rctx, c, i)) + } else { + paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) + }; + (s.name.clone(), paint, bar_outline(rctx, s)) + } else { + (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) + } + }) + .collect() + }; + if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) { + let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); + let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); + let size = if vertical { + (item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5) + } else { + (item_ws.iter().sum::().min(w * 0.9), base * 1.4) + }; + let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); + area = plot_area; + let (mut x, mut y) = (origin.x, origin.y); + for (name, paint, outline) in &legend_items { + let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); + fill_with(ctx, paint, key, m); + if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { + ctx.set_transform(m); + ctx.set_paint(color(oc, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); + ctx.stroke_path(&key.to_path(0.1)); + } + let tw = name.chars().count() as f64 * base * 0.55 + base; + label_custom( + ctx, + rctx, + m, + name, + Rect::new(x + base, y, x + base + tw, y + base * 1.3), + base, + Align::Left, + Anchor::Middle, + text_col, + false, + ); + if vertical { + y += base * 1.5; + } else { + x += tw + base * 0.6; + } + } + } if pie { - draw_pie(ctx, rctx, c, m, area, base, text_col); + let Some(s) = c.series.first() else { return }; + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if total <= 0.0 { + return; + } + let r = (area.width().min(area.height()) / 2.0) * 0.95; + let cen = area.center(); + let mut a0 = -std::f64::consts::FRAC_PI_2; + for (i, v) in vals.iter().enumerate() { + let sweep = v / total * std::f64::consts::TAU; + let mut p = BezPath::new(); + p.move_to(cen); + let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0); + p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); + p.extend(arc.append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + let col = s + .point_fills + .iter() + .find(|(k, _)| *k == i) + .and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None }) + .unwrap_or_else(|| series_color(rctx, c, i)); + ctx.set_paint(color(col, 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + ctx.stroke_path(&p); + if c.series_data_labels(s) { + let mid = a0 + sweep / 2.0; + let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); + label_custom( + ctx, + rctx, + m, + &c.data_label(s, *v), + Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), + base, + Align::Center, + Anchor::Middle, + // On the slices themselves: white, unless the chart's text was given a colour. + if c.text_color.is_some() { text_col } else { Rgba::WHITE }, + false, + ); + } + a0 += sweep; + } + if c.kind == ChartType::Doughnut { + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1)); + } return; } if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { @@ -335,7 +451,23 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, frame: ChartFrame< // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() }; - area = draw_axis_titles(ctx, rctx, &AxisTitleDrawing { chart: c, transform: m, base, text_color: text_col, horiz }, area); + // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. + // On a horizontal bar chart the category axis is the left one. + let (left_title, bottom_title) = + if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; + let band_h = base * 1.6; + if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { + let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); + label_custom(ctx, rctx, m, t, r, base, Align::Center, Anchor::Bottom, text_col, true); + area.y1 -= band_h + base * 0.3; + } + if let Some(t) = left_title.filter(|t| !t.is_empty()) { + let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); + let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); + let half = area.height() / 2.0; + label_custom(ctx, rctx, turned, t, Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), base, Align::Center, Anchor::Middle, text_col, true); + area.x0 += band_h + base * 0.3; + } // As many major units as the value axis has room for labels (PowerPoint's automatic unit). let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; @@ -386,265 +518,6 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, frame: ChartFrame< (true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / positive_span(max2 - min2) * plot.height(), _ => to_v(v), }; - draw_gridlines( - ctx, - &GridlineDrawing { - rctx, - chart: c, - transform: m, - area, - plot, - horiz, - base, - text_color: text_col, - grid_color: grid_col, - min, - max, - step, - log, - primary_ticks: &primary_ticks, - second, - to_v: &to_v, - to_axis: &to_axis, - }, - ); - let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); - // Forecast reserves empty category slots so the line can run past the data. Charts with no - // forecast keep the old band. - let (back, fwd) = forecast_pad(c); - let nspan = ncat as f64 + back + fwd; - let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; - // Where category `i`'s band starts along its axis: left to right, or bottom to top on a - // horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on - // their own side of the data. - let backwards = horiz != c.cat_reversed; - let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; - // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its - // own tick labels in place of category names. - let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; - let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); - draw_category_labels( - ctx, - &CategoryLabelDrawing { rctx, chart: c, transform: m, area, plot, horiz, band, slot: &slot, xscale, base, text_color: text_col }, - ); - if let Some(b) = boxes { - crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); - return; - } - let zero = match log { - Some((b, _)) => to_v(b.powf(min)), - None => to_v(clamp_soft(0.0, min, max)), - }; - // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). - let mut err_marks = draw_bars( - ctx, - &BarDrawing { - rctx, - chart: c, - transform: m, - plot, - band, - horiz, - ncat, - bar_kind, - waterfall, - slot: &slot, - to_v: &to_v, - to_axis: &to_axis, - base, - text_color: text_col, - }, - ); - // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. - if c.kind == ChartType::Stock { - for i in 0..ncat { - crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); - } - } - draw_lines( - ctx, - &mut LineDrawing { - chart: c, - rctx, - transform: m, - plot, - category_count: ncat, - band, - slot: &slot, - to_x: &to_x, - to_axis: &to_axis, - zero, - base, - error_marks: &mut err_marks, - }, - ); - // A category position (0-based, fractional) as a slot, the way `slot` places the bars. - let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; - draw_trendlines(ctx, rctx, c, m, TrendlineLayout { plot, band, slot: &slot_at, xscale, ncat, horiz, base, text_col, to_axis: &to_axis }); - if !err_marks.is_empty() { - let mut p = BezPath::new(); - for &(mid, a, b, cap, end_cap) in &err_marks { - // A horizontal bar chart's error bars run across, its value axis being horizontal. - let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; - p.move_to(pt(a, mid)); - p.line_to(pt(b, mid)); - if end_cap { - for e in [a, b] { - p.move_to(pt(e, mid - cap)); - p.line_to(pt(e, mid + cap)); - } - } - } - ctx.set_transform(m); - ctx.set_paint(color(text_col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(0.75)); - ctx.stroke_path(&p); - } - // Axis line. - let mut ax = BezPath::new(); - if horiz { - ax.move_to((zero, plot.y0)); - ax.line_to((zero, plot.y1)); - } else { - ax.move_to((plot.x0, zero)); - ax.line_to((plot.x1, zero)); - } - ctx.set_transform(m); - ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(0.75)); - ctx.stroke_path(&ax); -} - -struct LegendDraw<'a, 'ctx> { - rctx: &'a Ctx<'ctx>, - chart: &'a Chart, - transform: Affine, - items: &'a [(String, Paint, Option<(Rgba, f64)>)], - base: f64, - text_color: Rgba, - width: f64, -} - -fn draw_legend(ctx: &mut RenderContext, draw: &LegendDraw<'_, '_>, mut area: Rect) -> Rect { - let rctx = draw.rctx; - let c = draw.chart; - let m = draw.transform; - let legend_items = draw.items; - let base = draw.base; - let text_col = draw.text_color; - let width = draw.width; - let Some(pos) = c.legend.as_ref().filter(|_| !legend_items.is_empty()) else { return area }; - let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); - let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); - let size = if vertical { - (item_ws.iter().copied().fold(0.0, f64::max).min(width * 0.4), legend_items.len() as f64 * base * 1.5) - } else { - (item_ws.iter().sum::().min(width * 0.9), base * 1.4) - }; - let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); - area = plot_area; - let (mut x, mut y) = (origin.x, origin.y); - for (name, paint, outline) in legend_items { - let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); - fill_with(ctx, paint, key, m); - if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { - ctx.set_transform(m); - ctx.set_paint(color(oc, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); - ctx.stroke_path(&key.to_path(0.1)); - } - let tw = name.chars().count() as f64 * base * 0.55 + base; - label( - ctx, - rctx, - m, - name, - Rect::new(x + base, y, x + base + tw, y + base * 1.3), - LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, - ); - if vertical { - y += base * 1.5; - } else { - x += tw + base * 0.6; - } - } - area -} - -fn build_legend_items(rctx: &Ctx, c: &Chart, pie: bool, waterfall: bool) -> Vec<(String, Paint, Option<(Rgba, f64)>)> { - if waterfall { - ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { - // A hierarchy's legend names its top-level groups. - c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { - // A map draws its own colour scale. - vec![] - } else if pie { - c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else { - c.series - .iter() - .enumerate() - .map(|(i, s)| { - let k = c.series_kind(s); - let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - if line { - (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) - } else if k.is_bar_like() { - let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { - Paint::Color(series_color(rctx, c, i)) - } else { - paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) - }; - (s.name.clone(), paint, bar_outline(rctx, s)) - } else { - (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) - } - }) - .collect() - } -} - -struct GridlineDrawing<'a, 'ctx> { - rctx: &'a Ctx<'ctx>, - chart: &'a Chart, - transform: Affine, - area: Rect, - plot: Rect, - horiz: bool, - base: f64, - text_color: Rgba, - grid_color: Rgba, - min: f64, - max: f64, - step: f64, - log: Option<(f64, (f64, f64, f64))>, - primary_ticks: &'a [f64], - second: Option<(f64, f64, f64)>, - to_v: &'a dyn Fn(f64) -> f64, - to_axis: &'a dyn Fn(bool, f64) -> f64, -} - -fn draw_gridlines(ctx: &mut RenderContext, draw: &GridlineDrawing<'_, '_>) { - let rctx = draw.rctx; - let c = draw.chart; - let m = draw.transform; - let area = draw.area; - let plot = draw.plot; - let horiz = draw.horiz; - let base = draw.base; - let text_col = draw.text_color; - let grid_col = draw.grid_color; - let min = draw.min; - let max = draw.max; - let step = draw.step; - let log = draw.log; - let primary_ticks = draw.primary_ticks; - let second = draw.second; - let to_v = draw.to_v; - let to_axis = draw.to_axis; - let ticks = axis_ticks; // Minor gridlines, under the major ones: five to a major unit. if c.minor_gridlines { let minor = step / 5.0; @@ -668,31 +541,39 @@ fn draw_gridlines(ctx: &mut RenderContext, draw: &GridlineDrawing<'_, '_>) { ctx.stroke_path(&g); } // Gridlines and value labels. - for &v in primary_ticks { + for &v in &primary_ticks { let pos = to_v(v); let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); if horiz { g.move_to((pos, plot.y0)); g.line_to((pos, plot.y1)); - label( + label_custom( ctx, rctx, m, &text, Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5), - LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, + base, + Align::Center, + Anchor::Top, + text_col, + false, ); } else { g.move_to((plot.x0, pos)); g.line_to((plot.x1, pos)); - label( + label_custom( ctx, rctx, m, &text, Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base), - LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, + base, + Align::Right, + Anchor::Middle, + text_col, + false, ); } if c.gridlines { @@ -706,55 +587,49 @@ fn draw_gridlines(ctx: &mut RenderContext, draw: &GridlineDrawing<'_, '_>) { if let Some(sc) = second.filter(|_| !c.y2_hidden) { for v in ticks(sc) { let pos = to_axis(true, v); - label( + label_custom( ctx, rctx, m, &c.axis_label(true, v), Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base), - LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, + base, + Align::Left, + Anchor::Middle, + text_col, + false, ); } } -} - -struct CategoryLabelDrawing<'a, 'ctx> { - rctx: &'a Ctx<'ctx>, - chart: &'a Chart, - transform: Affine, - area: Rect, - plot: Rect, - horiz: bool, - band: f64, - slot: &'a dyn Fn(usize) -> f64, - xscale: Option<(f64, f64, f64)>, - base: f64, - text_color: Rgba, -} - -fn draw_category_labels(ctx: &mut RenderContext, draw: &CategoryLabelDrawing<'_, '_>) { - let rctx = draw.rctx; - let c = draw.chart; - let m = draw.transform; - let area = draw.area; - let plot = draw.plot; - let horiz = draw.horiz; - let band = draw.band; - let slot = draw.slot; - let xscale = draw.xscale; - let base = draw.base; - let text_col = draw.text_color; - let ticks = axis_ticks; + let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); + // Forecast reserves empty category slots so the line can run past the data. Charts with no + // forecast keep the old band. + let (back, fwd) = forecast_pad(c); + let nspan = ncat as f64 + back + fwd; + let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; + // Where category `i`'s band starts along its axis: left to right, or bottom to top on a + // horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on + // their own side of the data. + let backwards = horiz != c.cat_reversed; + let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; + // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its + // own tick labels in place of category names. + let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; + let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); if let Some(sc) = xscale { for v in ticks(sc) { let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width(); - label( + label_custom( ctx, rctx, m, &fmt_num(v), Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6), - LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, + base, + Align::Center, + Anchor::Top, + text_col, + false, ); } } @@ -763,197 +638,43 @@ fn draw_category_labels(ctx: &mut RenderContext, draw: &CategoryLabelDrawing<'_, for (i, name) in c.categories.iter().enumerate().filter(|_| xscale.is_none()) { let mid = if horiz { plot.y0 + band * (slot(i) + 0.5) } else { plot.x0 + band * (slot(i) + 0.5) }; if horiz { - label( + label_custom( ctx, rctx, m, name, Rect::new(area.x0, mid - base, plot.x0 - base * 0.4, mid + base), - LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, + base, + Align::Right, + Anchor::Middle, + text_col, + false, ); } else { let text = if wraps { split_label(name) } else { name.clone() }; - label( + label_custom( ctx, rctx, m, &text, Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }), - LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, + base, + Align::Center, + Anchor::Top, + text_col, + false, ); } } -} - -struct AxisTitleDrawing<'a> { - chart: &'a Chart, - transform: Affine, - base: f64, - text_color: Rgba, - horiz: bool, -} - -fn draw_axis_titles(ctx: &mut RenderContext, rctx: &Ctx, draw: &AxisTitleDrawing<'_>, mut area: Rect) -> Rect { - let c = draw.chart; - let m = draw.transform; - let base = draw.base; - let text_col = draw.text_color; - let horiz = draw.horiz; - // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. - // On a horizontal bar chart the category axis is the left one. - let (left_title, bottom_title) = - if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; - let band_h = base * 1.6; - if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { - let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); - label(ctx, rctx, m, t, r, LabelStyle { size: base, align: Align::Center, anchor: Anchor::Bottom, col: text_col, bold: true }); - area.y1 -= band_h + base * 0.3; - } - if let Some(t) = left_title.filter(|t| !t.is_empty()) { - let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); - let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); - let half = area.height() / 2.0; - label( - ctx, - rctx, - turned, - t, - Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), - LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: text_col, bold: true }, - ); - area.x0 += band_h + base * 0.3; - } - area -} - -struct BarDrawing<'a, 'ctx> { - rctx: &'a Ctx<'ctx>, - chart: &'a Chart, - transform: Affine, - plot: Rect, - band: f64, - horiz: bool, - ncat: usize, - bar_kind: Option, - waterfall: bool, - slot: &'a dyn Fn(usize) -> f64, - to_v: &'a dyn Fn(f64) -> f64, - to_axis: &'a dyn Fn(bool, f64) -> f64, - base: f64, - text_color: Rgba, -} - -struct LineDrawing<'a, 'ctx> { - chart: &'a Chart, - rctx: &'a Ctx<'ctx>, - transform: Affine, - plot: Rect, - category_count: usize, - band: f64, - slot: &'a dyn Fn(usize) -> f64, - to_x: &'a dyn Fn(f64) -> Option, - to_axis: &'a dyn Fn(bool, f64) -> f64, - zero: f64, - base: f64, - error_marks: &'a mut Vec<(f64, f64, f64, f64, bool)>, -} - -fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) { - let c = draw.chart; - let rctx = draw.rctx; - let m = draw.transform; - let plot = draw.plot; - let ncat = draw.category_count; - let band = draw.band; - let slot = draw.slot; - let to_x = draw.to_x; - let to_axis = draw.to_axis; - let zero = draw.zero; - let base = draw.base; - let err_marks = &mut draw.error_marks; - let mut acc = vec![0.0; ncat]; - for (si, s) in c.series.iter().enumerate() { - let kind = c.series_kind(s); - if kind.is_bar_like() || kind == ChartType::Stock { - continue; - } - let sec = c.on_secondary(s); - let stacked = kind.stacked(); - let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; - let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); - let mut pts = vec![]; - for i in 0..ncat { - let Some(v) = s.values.get(i).copied().flatten() else { continue }; - let v = if stacked { - let base_v = acc.get(i).copied().unwrap_or(0.0); - if let Some(a) = acc.get_mut(i) { - *a += v; - } - base_v + v - } else { - v - }; - let x = match kind { - ChartType::Scatter | ChartType::Bubble => { - match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { - (Some(x), _) => x, - // No x values at all: the points are numbered 1, 2, 3... - (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), - (None, Some(_)) => continue, - } - } - _ => plot.x0 + band * (slot(i) + 0.5), - }; - if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) { - err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); - } - pts.push(Point::new(x, to_axis(sec, v))); - } - if pts.is_empty() { - continue; - } - let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; - ctx.set_transform(m); - if area_kind { - let mut a = p.clone(); - if let (Some(l), Some(f)) = (pts.last(), pts.first()) { - a.line_to((l.x, zero)); - a.line_to((f.x, zero)); - a.close_path(); - } - ctx.set_paint(color(col, 0.85)); - ctx.fill_path(&a); - } else if kind != ChartType::Scatter { - let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); - ctx.set_paint(color(col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); - ctx.stroke_path(&p); - } - if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { - ctx.set_paint(color(col, 1.0)); - for q in &pts { - ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); - } - } + if let Some(b) = boxes { + crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); + return; } -} - -fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f64, f64, f64, bool)> { - let c = draw.chart; - let rctx = draw.rctx; - let m = draw.transform; - let plot = draw.plot; - let band = draw.band; - let horiz = draw.horiz; - let ncat = draw.ncat; - let bar_kind = draw.bar_kind; - let waterfall = draw.waterfall; - let slot = draw.slot; - let to_v = draw.to_v; - let to_axis = draw.to_axis; - let base = draw.base; - let text_col = draw.text_color; + let zero = match log { + Some((b, _)) => to_v(b.powf(min)), + None => to_v(clamp_soft(0.0, min, max)), + }; + // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![]; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = @@ -1047,83 +768,130 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6 } else { Rect::new(r.x0 - 10.0, r.y0 - base * 1.4, r.x1 + 10.0, r.y0) }; - label( + label_custom( ctx, rctx, m, &t, lr, - LabelStyle { - size: base * 0.9, - align: if horiz { Align::Left } else { Align::Center }, - anchor: if horiz { Anchor::Middle } else { Anchor::Bottom }, - col: text_col, - bold: false, - }, + base * 0.9, + if horiz { Align::Left } else { Align::Center }, + if horiz { Anchor::Middle } else { Anchor::Bottom }, + text_col, + false, ); } } } } - err_marks -} - -fn draw_pie(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { - let Some(s) = c.series.first() else { return }; - let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); - let total: f64 = vals.iter().sum(); - if total <= 0.0 { - return; + // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. + if c.kind == ChartType::Stock { + for i in 0..ncat { + crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); + } } - let r = (area.width().min(area.height()) / 2.0) * 0.95; - let cen = area.center(); - let mut a0 = -std::f64::consts::FRAC_PI_2; - for (i, v) in vals.iter().enumerate() { - let sweep = v / total * std::f64::consts::TAU; - let mut p = BezPath::new(); - p.move_to(cen); - let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0); - p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); - p.extend(arc.append_iter(0.1)); - p.close_path(); + // Lines, areas, scatter: the series that aren't bars. + let mut acc = vec![0.0; ncat]; + for (si, s) in c.series.iter().enumerate() { + let kind = c.series_kind(s); + if kind.is_bar_like() || kind == ChartType::Stock { + continue; + } + let sec = c.on_secondary(s); + let stacked = kind.stacked(); + let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; + let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); + let mut pts = vec![]; + for i in 0..ncat { + let Some(v) = s.values.get(i).copied().flatten() else { continue }; + let v = if stacked { + let base_v = acc.get(i).copied().unwrap_or(0.0); + if let Some(a) = acc.get_mut(i) { + *a += v; + } + base_v + v + } else { + v + }; + let x = match kind { + ChartType::Scatter | ChartType::Bubble => { + match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { + (Some(x), _) => x, + // No x values at all: the points are numbered 1, 2, 3... + (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), + (None, Some(_)) => continue, + } + } + _ => plot.x0 + band * (slot(i) + 0.5), + }; + if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) { + err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); + } + pts.push(Point::new(x, to_axis(sec, v))); + } + if pts.is_empty() { + continue; + } + let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; ctx.set_transform(m); - let col = s - .point_fills - .iter() - .find(|(k, _)| *k == i) - .and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None }) - .unwrap_or_else(|| series_color(rctx, c, i)); - ctx.set_paint(color(col, 1.0)); - ctx.fill_path(&p); - ctx.set_paint(color(Rgba::WHITE, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(1.0)); - ctx.stroke_path(&p); - if c.series_data_labels(s) { - let mid = a0 + sweep / 2.0; - let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); - label( - ctx, - rctx, - m, - &c.data_label(s, *v), - Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), - LabelStyle { - size: base, - align: Align::Center, - anchor: Anchor::Middle, - // On the slices themselves: white, unless the chart's text was given a colour. - col: if c.text_color.is_some() { text_col } else { Rgba::WHITE }, - bold: false, - }, - ); + if area_kind { + let mut a = p.clone(); + if let (Some(l), Some(f)) = (pts.last(), pts.first()) { + a.line_to((l.x, zero)); + a.line_to((f.x, zero)); + a.close_path(); + } + ctx.set_paint(color(col, 0.85)); + ctx.fill_path(&a); + } else if kind != ChartType::Scatter { + let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&p); + } + if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { + ctx.set_paint(color(col, 1.0)); + for q in &pts { + ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); + } } - a0 += sweep; } - if c.kind == ChartType::Doughnut { + // A category position (0-based, fractional) as a slot, the way `slot` places the bars. + let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; + draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); + if !err_marks.is_empty() { + let mut p = BezPath::new(); + for &(mid, a, b, cap, end_cap) in &err_marks { + // A horizontal bar chart's error bars run across, its value axis being horizontal. + let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; + p.move_to(pt(a, mid)); + p.line_to(pt(b, mid)); + if end_cap { + for e in [a, b] { + p.move_to(pt(e, mid - cap)); + p.line_to(pt(e, mid + cap)); + } + } + } ctx.set_transform(m); - ctx.set_paint(color(Rgba::WHITE, 1.0)); - ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1)); + ctx.set_paint(color(text_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&p); + } + // Axis line. + let mut ax = BezPath::new(); + if horiz { + ax.move_to((zero, plot.y0)); + ax.line_to((zero, plot.y1)); + } else { + ax.move_to((plot.x0, zero)); + ax.line_to((plot.x1, zero)); } + ctx.set_transform(m); + ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&ax); } /// `v` kept within `lo..=hi` without `f64::clamp`'s panic when `lo > hi` (a chart smaller than @@ -1201,35 +969,37 @@ fn forecast_pad(c: &Chart) -> (f64, f64) { const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)]; -struct XYMapper<'a> { +#[allow(clippy::too_many_arguments)] +fn map_xy( plot: Rect, band: f64, - slot: &'a dyn Fn(f64) -> f64, + slot: &impl Fn(f64) -> f64, horiz: bool, scatter: bool, - scatter_x: (f64, f64), - secondary: bool, - to_axis: &'a dyn Fn(bool, f64) -> f64, -} - -fn map_xy(mapper: &XYMapper<'_>, x: f64, y: f64) -> Option { + sx0: f64, + sx1: f64, + sec: bool, + x: f64, + y: f64, + to_axis: &impl Fn(bool, f64) -> f64, +) -> Option { if !x.is_finite() || !y.is_finite() { return None; } - let value = (mapper.to_axis)(mapper.secondary, y); + let value = to_axis(sec, y); if !value.is_finite() { return None; } - let along = if mapper.scatter && mapper.scatter_x.1 > mapper.scatter_x.0 { - mapper.plot.x0 + (x - mapper.scatter_x.0) / (mapper.scatter_x.1 - mapper.scatter_x.0) * mapper.plot.width() + let along = if scatter && sx1 > sx0 { + plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() } else { - let origin = if mapper.horiz { mapper.plot.y0 } else { mapper.plot.x0 }; - origin + mapper.band * ((mapper.slot)(x - 1.0) + 0.5) + let origin = if horiz { plot.y0 } else { plot.x0 }; + origin + band * (slot(x - 1.0) + 0.5) }; if !along.is_finite() { return None; } - Some(if mapper.horiz && !mapper.scatter { Point::new(value, along) } else { Point::new(along, value) }) + Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) }) } /// The part of segment `a`–`b` that lies inside `r`. @@ -1263,20 +1033,22 @@ fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy))) } -struct TrendlineLayout<'a> { +#[allow(clippy::too_many_arguments)] +fn draw_trendlines( + ctx: &mut dyn Canvas, + rctx: &Ctx, + c: &Chart, + m: Affine, plot: Rect, band: f64, - slot: &'a dyn Fn(f64) -> f64, + slot: &impl Fn(f64) -> f64, xscale: Option<(f64, f64, f64)>, ncat: usize, horiz: bool, base: f64, text_col: Rgba, - to_axis: &'a dyn Fn(bool, f64) -> f64, -} - -fn draw_trendlines(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, layout: TrendlineLayout<'_>) { - let TrendlineLayout { plot, band, slot, xscale, ncat, horiz, base, text_col, to_axis } = layout; + to_axis: &impl Fn(bool, f64) -> f64, +) { let mut label_n = 0usize; for s in &c.series { if s.trendlines.is_empty() { @@ -1313,7 +1085,6 @@ fn draw_trendlines(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, la } else { (0.0, 0.0) }; - let mapper = XYMapper { plot, band, slot, horiz, scatter, scatter_x: (sx0, sx1), secondary: sec, to_axis }; for (ti, t) in s.trendlines.iter().take(8).enumerate() { let Some(fit) = t.fit(&xs, &ys) else { continue }; let (x0, x1) = fit.span(t.forward, t.backward); @@ -1323,7 +1094,10 @@ fn draw_trendlines(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, la let mut ink_pts = Vec::new(); let mut any = false; for w in samples.windows(2) { - let (Some(a), Some(b)) = (map_xy(&mapper, w[0].0, w[0].1), map_xy(&mapper, w[1].0, w[1].1)) else { + let (Some(a), Some(b)) = ( + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), + ) else { prev = None; continue; }; @@ -1365,13 +1139,17 @@ fn draw_trendlines(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, la ctx.set_transform(m); ctx.set_paint(color(Rgba::WHITE, 1.0)); ctx.fill_rect(&Rect::new(x, y, x + box_w, y + box_h)); - label( + label_custom( ctx, rctx, m, &caption, Rect::new(x, y, x + box_w, y + box_h), - LabelStyle { size: (base * 0.9).clamp(8.0, 13.0), align: Align::Left, anchor: Anchor::Top, col: text_col, bold: false }, + (base * 0.9).clamp(8.0, 13.0), + Align::Left, + Anchor::Top, + text_col, + false, ); label_n += 1; } diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index 9a9a66b8e..7f98a9ebe 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -2,18 +2,18 @@ //! boxes of a box and whisker chart (waterfall bars are drawn by [`crate::chart`], histograms as //! the column chart of their bins). +use crate::canvas::Canvas; use deckcraft_color::Rgba; use deckcraft_model::chart::{BoxStats, Chart, ChartType, TreeNode}; use deckcraft_model::resolve::Ctx; use deckcraft_model::text::{Align, Anchor}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; -use vello_cpu::RenderContext; use crate::chart::{label, series_color}; use crate::color; /// Draw `c` inside `area` (the space left by the title and legend). -pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { +pub fn draw(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { let Some(s) = c.series.first() else { return }; if c.kind == ChartType::RegionMap { region_tiles(ctx, rctx, c, m, area, base, text_col); @@ -38,7 +38,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec /// the left, values inside. #[allow(clippy::too_many_arguments)] fn funnel( - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, @@ -93,7 +93,7 @@ fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba { /// map service in an undocumented format and DeckCraft ships no map data, so each region is a /// tile in a grid, coloured on the map's value scale, with the scale below and a note saying /// the map itself is not drawn. -fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { +fn region_tiles(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { let Some(s) = c.series.first() else { return }; let regions: Vec<(String, Option)> = c.categories.iter().enumerate().map(|(i, n)| (n.clone(), s.values.get(i).copied().flatten())).filter(|(n, _)| !n.is_empty()).collect(); @@ -265,16 +265,7 @@ pub fn squarify(vals: &[f64], r: Rect) -> Vec { } #[allow(clippy::too_many_arguments)] -fn treemap( - ctx: &mut RenderContext, - rctx: &Ctx, - c: &Chart, - m: Affine, - area: Rect, - base: f64, - vals: &[f64], - value_label: Option<&dyn Fn(f64) -> String>, -) { +fn treemap(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, vals: &[f64], value_label: Option<&dyn Fn(f64) -> String>) { for (i, t) in squarify(vals, area).into_iter().enumerate() { if t.area() <= 0.0 { continue; @@ -304,7 +295,7 @@ fn treemap( } /// One ring of the innermost level, with the centre left open. -fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, vals: &[f64]) { +fn sunburst(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, vals: &[f64]) { let total: f64 = vals.iter().sum(); let r = area.width().min(area.height()) / 2.0 * 0.95; let hole = r * 0.35; @@ -357,11 +348,11 @@ fn lighten(col: Rgba, k: f64) -> Rgba { /// a banner along the top, and their children squarified inside, down to the leaves. A group /// and everything in it take the group's colour. #[allow(clippy::too_many_arguments)] -fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, value_label: Option<&dyn Fn(f64) -> String>) { +fn tree_treemap(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, value_label: Option<&dyn Fn(f64) -> String>) { let tree = c.category_tree(); #[allow(clippy::too_many_arguments)] fn place( - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, rctx: &Ctx, m: Affine, nodes: &[TreeNode], @@ -431,7 +422,7 @@ fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: /// A multi-level sunburst: the top level as the inner ring, each deeper level in the next ring /// out within its parent's angle, coloured by top-level group and lighter further out. -fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64) { +fn tree_sunburst(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64) { let tree = c.category_tree(); fn depth(n: &[TreeNode]) -> usize { n.iter().map(|c| 1 + depth(&c.children)).max().unwrap_or(0) @@ -447,7 +438,7 @@ fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area let cen = area.center(); #[allow(clippy::too_many_arguments)] fn ring_of( - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, rctx: &Ctx, m: Affine, nodes: &[TreeNode], @@ -546,17 +537,7 @@ pub fn box_view(c: &Chart) -> (Chart, Boxes) { /// to the furthest points within 1.5 IQR, and outliers as dots; series side by side in each /// group's band. #[allow(clippy::too_many_arguments)] -pub fn draw_boxes( - ctx: &mut RenderContext, - rctx: &Ctx, - c: &Chart, - b: &Boxes, - m: Affine, - plot: Rect, - band: f64, - horiz: bool, - to_v: &dyn Fn(f64) -> f64, -) { +pub fn draw_boxes(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, b: &Boxes, m: Affine, plot: Rect, band: f64, horiz: bool, to_v: &dyn Fn(f64) -> f64) { let ns = b.stats.len().max(1); let group = band / (1.0 + c.gap_width.clamp(0.0, 5.0)); let w = group / ns as f64; diff --git a/crates/render/src/colr.rs b/crates/render/src/colr.rs index 97294aa81..6d7464c20 100644 --- a/crates/render/src/colr.rs +++ b/crates/render/src/colr.rs @@ -12,6 +12,11 @@ use vello_cpu::{RenderContext, peniko}; /// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts). const MAX_DEPTH: usize = 64; +/// Does `gid` of `face` have a colour version? +pub fn has_color(face: &FontFace, gid: u32) -> bool { + face.skrifa().is_some_and(|font| font.color_glyphs().get(GlyphId::new(gid)).is_some()) +} + /// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale` /// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all. /// False when the glyph has no colour version, so the caller draws the plain outline. diff --git a/crates/render/src/extrude.rs b/crates/render/src/extrude.rs index 08369f48c..214af7933 100644 --- a/crates/render/src/extrude.rs +++ b/crates/render/src/extrude.rs @@ -6,11 +6,11 @@ //! rig and material, and the sides are drawn farthest first. Without a turned camera an extrusion //! points at the viewer and nothing of it shows. +use crate::canvas::Canvas; use deckcraft_color::{Rgba, SchemeSlot}; use deckcraft_model::ColorRef; use deckcraft_model::resolve::Ctx; use kurbo::{Affine, BezPath, Vec2}; -use vello_cpu::RenderContext; use crate::camera::{self, Camera}; use crate::light::Lighting; @@ -86,7 +86,7 @@ pub fn contour_from_raw(raw: &str) -> Option { } /// Draw `c` around `outline` (shape space, mapped by `m`), lit as a surface facing the viewer. -pub fn draw_contour(ctx: &mut RenderContext, outline: &BezPath, m: Affine, width: f64, color: Rgba, lit: &Lighting) { +pub fn draw_contour(ctx: &mut dyn Canvas, outline: &BezPath, m: Affine, width: f64, color: Rgba, lit: &Lighting) { ctx.set_transform(m); ctx.set_paint(crate::color(lit.shade(color, [0.0, 0.0, 1.0]), 1.0)); ctx.set_stroke(kurbo::Stroke::new(2.0 * width).with_join(kurbo::Join::Round)); @@ -110,7 +110,7 @@ pub fn side_color(rctx: &Ctx, e: &Extrusion, face: Option) -> Rgba { /// `pre` maps the turned shape's space to the output and `turn` is the camera's turn. #[allow(clippy::too_many_arguments)] pub fn draw( - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, outline: &BezPath, w: f64, h: f64, diff --git a/crates/render/src/geometry.rs b/crates/render/src/geometry.rs index 12b227961..1864d969c 100644 --- a/crates/render/src/geometry.rs +++ b/crates/render/src/geometry.rs @@ -188,6 +188,12 @@ pub(crate) fn shadow_local(sh: &deckcraft_model::style::Shadow, w: f64, h: f64) Affine::translate(a) * k * Affine::translate(-a) } +/// Does this shape show blur-based effects (shadows, glow, soft edges, reflection)? Bevels and +/// 3-D face lighting are drawn on their own (local pixels on vector canvases). +pub(crate) fn draws_effects(e: &Effects) -> bool { + e.outer_shadow.is_some() || e.inner_shadow.is_some() || e.glow.is_some() || e.soft_edge.is_some_and(|r| r > 0.0) || e.reflection.is_some() +} + pub(crate) fn pre_translate(m: Affine, d: Vec2) -> Affine { Affine::translate(d) * m } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 0e86be8ce..6e220ed68 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -318,6 +318,15 @@ fn png_crc(data: &[u8]) -> u32 { !c } +/// Pixel size of a JPEG or PNG file (other formats: `None`), without decoding it. +pub fn encoded_size(bytes: &[u8]) -> Option<(u32, u32)> { + if !(bytes.starts_with(&[0xFF, 0xD8, 0xFF]) || bytes.starts_with(&[0x89, b'P', b'N', b'G'])) { + return None; + } + let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; + (w > 0 && h > 0).then_some((w, h)) +} + /// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px. pub fn decode_for(bytes: &Arc>, want: f64, adj: &PictureAdjust) -> Option> { let addr = Arc::as_ptr(bytes) as usize; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5fd011411..00f611367 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,7 @@ mod bevel; mod camera; +pub mod canvas; mod chart; mod chartex; mod colr; @@ -48,6 +49,7 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::{RenderContext, Resources, peniko}; +pub use canvas::{Canvas, CanvasImage, Fallback, Paint, missing_picture}; pub use deckcraft_anim::{transition_frame, transition_layers, uses_slide_parts}; pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; @@ -78,7 +80,7 @@ pub use raster::{Image, MAX_SIDE, dpi_for_scale, image_dpi}; use renderer::RENDERER; pub use renderer::Renderer; pub use slide::{ - SlidePart, needs_filters, panics_caught, render_blend, render_layout, render_shape, render_shapes, render_slide, render_slide_drawn, + SlidePart, draw_slide, needs_filters, panics_caught, render_blend, render_layout, render_shape, render_shapes, render_slide, render_slide_drawn, render_slide_part, }; pub(crate) use stroke::stroke; @@ -94,6 +96,9 @@ mod tests; #[cfg(test)] mod tests_wild; +#[cfg(test)] +mod tests_vector; + #[cfg(test)] mod mt_tests { use super::*; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index ee572fd98..cccb27916 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -3,16 +3,17 @@ use deckcraft_color::Rgba; use deckcraft_geom::preset::FillMode; use deckcraft_model::resolve::Ctx; -use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureFill, PictureMode}; +use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureAdjust, PictureFill, PictureMode}; use deckcraft_model::{PhType, Presentation}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; -use vello_cpu::{RenderContext, peniko}; +use vello_cpu::peniko; -use crate::{color, images, shade}; +use crate::canvas::{self, Canvas, CanvasImage, Paint}; +use crate::{images, shade}; #[allow(clippy::too_many_arguments)] pub fn fill_path( - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, rctx: &Ctx, fill: &Fill, ph: Option, @@ -28,7 +29,7 @@ pub fn fill_path( Fill::None | Fill::Group | Fill::Background => {} Fill::Solid { color: c } => { let col = shade(rctx.color(c, ph), mode); - ctx.set_paint(color(col, alpha)); + ctx.set_paint(fade(col, alpha)); ctx.fill_path(path); } Fill::Gradient(g) => { @@ -51,34 +52,18 @@ pub fn fill_path( // its edges); a radial one ends on the circle around it, from a focus placed in the // shape's own box (PowerPoint's "from corner" presets centre the tile on the corner). let tile = tile_box(bounds, g.tile); - let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect(); - ctx.set_paint(gradient(&g.shape, stops, bounds, alpha)); - let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect(); stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - // Rectangle and outline paths: rasterised (see `pathgrad`). + // Rectangle and outline paths: rasterised (see `pathgrad`), on vector canvases too. if let GradientShape::Path { path: kind, focus } = &g.shape && matches!(kind.as_str(), "rect" | "shape") { - crate::pathgrad::fill(ctx, kind, *focus, &crate::ramp::expand(&stops), bounds, path, m, alpha); + let ramp = crate::ramp::expand(&stops); + let area = m.transform_rect_bbox(path.bounding_box()); + canvas::pixels(ctx, area, &mut |v, pre| crate::pathgrad::fill(v, kind, *focus, &ramp, bounds, path, pre * m, alpha)); return; } - // PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`. - let stops: Vec = - crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect(); - let grad = match &g.shape { - GradientShape::Linear { angle, .. } => { - // Angle measured clockwise from left→right; span the bounds along that direction. - let a = angle.to_radians(); - let (dx, dy) = (a.cos(), a.sin()); - let c = tile.center(); - let half = (tile.width() * dx.abs() + tile.height() * dy.abs()) / 2.0; - peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) - .with_stops(stops.as_slice()) - } - GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, tile).with_stops(stops.as_slice()), - }; - ctx.set_paint(grad); + ctx.set_paint(gradient_in(&g.shape, stops, bounds, tile, alpha)); ctx.fill_path(path); } Fill::Picture(pf) => { @@ -102,7 +87,9 @@ pub fn fill_path( Fill::Pattern(p) => { let fg = rctx.color(&p.fg, ph); let bg = rctx.color(&p.bg, ph); - crate::pattern::fill(ctx, &p.preset, fg, bg, alpha, path, m); + // Pixels (an 8 x 8 tile); vector canvases get this path's area rasterized. + let area = m.transform_rect_bbox(path.bounding_box()); + canvas::pixels(ctx, area, &mut |v, pre| crate::pattern::fill(v, &p.preset, fg, bg, alpha, path, pre * m)); } } } @@ -133,26 +120,36 @@ pub fn tile_box(bounds: Rect, tile: [f64; 4]) -> Rect { if x1 - x0 <= 1e-9 || y1 - y0 <= 1e-9 { bounds } else { Rect::new(x0, y0, x1, y1) } } +/// `c` with its alpha multiplied by `alpha`. +pub(crate) fn fade(c: Rgba, alpha: f64) -> Rgba { + c.with_alpha((c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8) +} + /// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded /// by `alpha`, ramping between stops as PowerPoint does (see `ramp`). -pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient { +pub fn gradient(shape: &GradientShape, stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> Paint { + gradient_in(shape, stops, bounds, bounds, alpha) +} + +/// [`gradient`] laid out over the tile rectangle `tile` (`a:tileRect`; `bounds` when there is +/// none): a linear gradient spans the tile, a circle one ends on the circle around it, from a +/// focus placed in `bounds`. +pub fn gradient_in(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, tile: Rect, alpha: f64) -> Paint { for s in &mut stops { s.0 = s.0.clamp(0.0, 1.0); } stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - let stops: Vec = - crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect(); + let stops: Vec<(f32, Rgba)> = crate::ramp::expand(&stops).into_iter().map(|(o, c)| (o as f32, fade(c, alpha))).collect(); match shape { GradientShape::Linear { angle, .. } => { - // Angle measured clockwise from left→right; span the bounds along that direction. + // Angle measured clockwise from left→right; span the tile along that direction. let a = angle.to_radians(); let (dx, dy) = (a.cos(), a.sin()); - let c = bounds.center(); - let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0; - peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) - .with_stops(stops.as_slice()) + let c = tile.center(); + let half = (tile.width() * dx.abs() + tile.height() * dy.abs()) / 2.0; + Paint::Linear { start: Point::new(c.x - dx * half, c.y - dy * half), end: Point::new(c.x + dx * half, c.y + dy * half), stops } } - GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, bounds).with_stops(stops.as_slice()), + GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, tile, stops), } } @@ -165,29 +162,30 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect /// /// With a tile rectangle (`tile`, otherwise `bounds` itself) the end circle is the one around /// the tile, while the focus stays placed in `bounds`. -pub fn circle_gradient(focus: [f64; 4], bounds: Rect, tile: Rect) -> peniko::Gradient { +pub fn circle_gradient(focus: [f64; 4], bounds: Rect, tile: Rect, stops: Vec<(f32, Rgba)>) -> Paint { let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; let c = tile.center(); let r = (tile.width().hypot(tile.height()) / 2.0).max(1e-6); let f = Point::new(fx, fy); if !(f.is_finite() && c.is_finite() && r.is_finite()) { - return peniko::Gradient::new_radial(c, r as f32); + return Paint::Radial { focus: c, center: c, radius: r, stops }; } // A focus on or outside the end circle has no circle through it: keep it just inside. let d = f - c; let f = if d.hypot() > r * 0.999 { c + d * (r * 0.999 / d.hypot()) } else { f }; - peniko::Gradient::new_two_point_radial(f, 0.0, c, r as f32) + Paint::Radial { focus: f, center: c, radius: r, stops } } /// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated /// GIF shows its frame at `gif_time` seconds. -pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option) { +pub fn picture(ctx: &mut dyn Canvas, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option) { let Some(item) = pres.media(pf.media) else { - missing(ctx, bounds); + canvas::missing_picture(ctx, bounds); return; }; - let px_scale = crate::scale_of(m); + let fallback = ctx.fallback(); + let px_scale = crate::scale_of(m) * fallback.map_or(1.0, |f| f.scale); let dest = match &pf.mode { PictureMode::Stretch { fill_rect } => { let [l, t, r, b] = *fill_rect; @@ -201,23 +199,31 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b } PictureMode::Tile { .. } => bounds, }; - // Decode for the whole image's size on screen, not the box's: a cropped picture shows only part - // of it, magnified (#762). - let want = (dest.width().abs().max(dest.height().abs()) * px_scale).max(1.0); - let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t)); - let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else { - missing(ctx, bounds); - return; + // Vector canvases embed an unchanged JPEG or PNG file as it is. + let encoded = fallback + .filter(|_| gif_time.is_none() && pf.adjust == PictureAdjust::default()) + .and_then(|_| images::encoded_size(&item.data)) + .map(|(w, h)| CanvasImage { width: w as f64, height: h as f64, pixmap: None, encoded: Some(&item.data) }); + let image = match encoded { + Some(img) => img, + None => { + // Decode for the whole image's size on the canvas (print resolution on vector + // canvases), not the box's: a cropped picture shows only part of it, magnified (#762). + // Metafiles (WMF, EMF) are drawn to pixels at that size too. + let want = (dest.width().abs().max(dest.height().abs()) * px_scale).max(1.0); + let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t)); + let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else { + canvas::missing_picture(ctx, bounds); + return; + }; + CanvasImage { width: pm.width() as f64, height: pm.height() as f64, pixmap: Some(pm), encoded: None } + } }; - let (iw, ih) = (pm.width() as f64, pm.height() as f64); + let (iw, ih) = (image.width, image.height); if iw < 1.0 || ih < 1.0 { return; } let alpha = pf.alpha.unwrap_or(1.0).clamp(0.0, 1.0); - let image = vello_cpu::Image { - image: vello_cpu::ImageSource::Pixmap(pm.clone()), - sampler: peniko::ImageSampler { quality: peniko::ImageQuality::High, ..Default::default() }, - }; if let PictureMode::Tile { sx, sy, tx, ty, flip, align } = &pf.mode { // The tile's size: the picture's own size (96 dpi) scaled. A scale of zero or less draws // nothing, as in PowerPoint. @@ -233,27 +239,72 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b return; } let o = tile_origin(bounds, align, (tile_w, tile_h), (*tx, *ty)); - // Every other tile is mirrored across the flip axes: a reflected repeat. - let ext = |mirror: bool| if mirror { peniko::Extend::Reflect } else { peniko::Extend::Repeat }; - let tiled = vello_cpu::Image { - image: vello_cpu::ImageSource::Pixmap(pm.clone()), - sampler: peniko::ImageSampler { - x_extend: ext(matches!(flip.as_str(), "x" | "xy")), - y_extend: ext(matches!(flip.as_str(), "y" | "xy")), - quality: peniko::ImageQuality::High, - alpha: 1.0, - }, - }; - ctx.set_transform(m); - if alpha < 0.999 { - ctx.push_layer(None, None, Some(alpha as f32), None, None); + let (mirror_x, mirror_y) = (matches!(flip.as_str(), "x" | "xy"), matches!(flip.as_str(), "y" | "xy")); + if let Some(px) = ctx.raster() + && let Some(pm) = &image.pixmap + { + // Every other tile is mirrored across the flip axes: a reflected repeat. + let ext = |mirror: bool| if mirror { peniko::Extend::Reflect } else { peniko::Extend::Repeat }; + let tiled = vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm.clone()), + sampler: peniko::ImageSampler { x_extend: ext(mirror_x), y_extend: ext(mirror_y), quality: peniko::ImageQuality::High, alpha: 1.0 }, + }; + px.set_transform(m); + if alpha < 0.999 { + px.push_layer(None, None, Some(alpha as f32), None, None); + } + px.set_paint(tiled); + px.set_paint_transform(Affine::translate(o.to_vec2()) * Affine::scale_non_uniform(tile_w / iw, tile_h / ih)); + px.fill_rect(&bounds); + px.reset_paint_transform(); + if alpha < 0.999 { + px.pop_layer(); + } + return; } - ctx.set_paint(tiled); - ctx.set_paint_transform(Affine::translate(o.to_vec2()) * Affine::scale_non_uniform(tile_w / iw, tile_h / ih)); - ctx.fill_rect(&bounds); - ctx.reset_paint_transform(); - if alpha < 0.999 { - ctx.pop_layer(); + // Vector canvases: one picture per tile covering `bounds` (the caller clips), mirrored + // tiles drawn flipped; past a few thousand tiles the fill is rasterized instead. + let first = |o: f64, t: f64, lo: f64| ((lo - o) / t).floor() as i64; + let (i0, j0) = (first(o.x, tile_w, bounds.x0), first(o.y, tile_h, bounds.y0)); + let (i1, j1) = (first(o.x, tile_w, bounds.x1), first(o.y, tile_h, bounds.y1)); + let count = i1.saturating_sub(i0).saturating_add(1).saturating_mul(j1.saturating_sub(j0).saturating_add(1)); + if !(0..=4000).contains(&count) { + let area = m.transform_rect_bbox(bounds); + let tile = |v: &mut vello_cpu::RenderContext, pre: Affine| { + let Some(pm) = images::decode_for(&item.data, (tile_w.max(tile_h) * px_scale).max(1.0), &pf.adjust) else { return }; + let (pw, ph) = (pm.width() as f64, pm.height() as f64); + let ext = |mirror: bool| if mirror { peniko::Extend::Reflect } else { peniko::Extend::Repeat }; + v.set_transform(pre * m); + v.push_layer(Some(&bounds.to_path(0.1)), None, Some(alpha as f32), None, None); + v.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { + x_extend: ext(mirror_x), + y_extend: ext(mirror_y), + quality: peniko::ImageQuality::High, + alpha: 1.0, + }, + }); + v.set_paint_transform(Affine::translate(o.to_vec2()) * Affine::scale_non_uniform(tile_w / pw, tile_h / ph)); + v.fill_rect(&bounds); + v.reset_paint_transform(); + v.pop_layer(); + }; + canvas::pixels(ctx, area, &mut { tile }); + return; + } + for j in j0..=j1 { + for i in i0..=i1 { + let r = Rect::new(o.x + i as f64 * tile_w, o.y + j as f64 * tile_h, 0.0, 0.0); + let r = Rect::new(r.x0, r.y0, r.x0 + tile_w, r.y0 + tile_h); + let fx = mirror_x && i.rem_euclid(2) == 1; + let fy = mirror_y && j.rem_euclid(2) == 1; + // A mirrored tile: the same rectangle drawn flipped about its centre. + let flip = Affine::translate(r.center().to_vec2()) + * Affine::scale_non_uniform(if fx { -1.0 } else { 1.0 }, if fy { -1.0 } else { 1.0 }) + * Affine::translate(-r.center().to_vec2()); + draw_image(ctx, &image, iw, ih, r, m * flip, alpha); + } } return; } @@ -279,34 +330,14 @@ pub fn tile_origin(bounds: Rect, align: &str, tile: (f64, f64), offset: (f64, f6 Point::new(bounds.x0 + ax * (bounds.width() - tile.0) + ox, bounds.y0 + ay * (bounds.height() - tile.1) + oy) } -fn draw_image(ctx: &mut RenderContext, image: &vello_cpu::Image, iw: f64, ih: f64, dest: Rect, m: Affine, alpha: f64) { +fn draw_image(ctx: &mut dyn Canvas, image: &CanvasImage, iw: f64, ih: f64, dest: Rect, m: Affine, alpha: f64) { let t = m * Affine::translate(dest.origin().to_vec2()) * Affine::scale_non_uniform(dest.width() / iw, dest.height() / ih); ctx.set_transform(t); - if alpha < 0.999 { - ctx.push_layer(None, None, Some(alpha as f32), None, None); - } - ctx.set_paint(image.clone()); - ctx.fill_rect(&Rect::new(0.0, 0.0, iw, ih)); - if alpha < 0.999 { - ctx.pop_layer(); - } -} - -fn missing(ctx: &mut RenderContext, b: Rect) { - ctx.set_paint(peniko::Color::from_rgba8(230, 230, 230, 255)); - ctx.fill_rect(&b); - ctx.set_paint(peniko::Color::from_rgba8(160, 160, 160, 255)); - ctx.set_stroke(kurbo::Stroke::new(1.0)); - let mut p = BezPath::new(); - p.move_to((b.x0, b.y0)); - p.line_to((b.x1, b.y1)); - p.move_to((b.x1, b.y0)); - p.line_to((b.x0, b.y1)); - ctx.stroke_path(&p); + ctx.draw_image(image, alpha); } /// The speaker / film glyph on audio and video objects without a poster. -pub fn media_glyph(ctx: &mut RenderContext, video: bool, b: Rect) { +pub fn media_glyph(ctx: &mut dyn Canvas, video: bool, b: Rect) { let s = b.width().min(b.height()); let c = b.center(); if video { @@ -343,7 +374,7 @@ pub fn media_glyph(ctx: &mut RenderContext, video: bool, b: Rect) { } /// A content-type icon drawn in empty picture/chart/table placeholders. -pub fn placeholder_icon(ctx: &mut RenderContext, kind: PhType, b: Rect) { +pub fn placeholder_icon(ctx: &mut dyn Canvas, kind: PhType, b: Rect) { let s = (b.width().min(b.height()) * 0.18).clamp(10.0, 48.0); let c = Point::new(b.center().x, b.center().y - s * 0.3); let r = Rect::new(c.x - s / 2.0, c.y - s / 2.0, c.x + s / 2.0, c.y + s / 2.0); diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs index 9e8df451d..d51e67d26 100644 --- a/crates/render/src/radar.rs +++ b/crates/render/src/radar.rs @@ -1,12 +1,12 @@ //! Radar charts: one spoke per category, the value axis running up the first spoke, a polygon //! of gridlines per major unit and each series a closed polygon (filled for a filled radar). +use crate::canvas::Canvas; use deckcraft_color::Rgba; use deckcraft_model::chart::{Chart, ChartType}; use deckcraft_model::resolve::Ctx; use deckcraft_model::text::{Align, Anchor}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; -use vello_cpu::RenderContext; use crate::chart::{label, series_fill, series_line}; use crate::color; @@ -17,7 +17,7 @@ fn spoke(cen: Point, r: f64, i: usize, n: usize, frac: f64) -> Point { Point::new(cen.x + r * frac * a.cos(), cen.y + r * frac * a.sin()) } -pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { +pub fn draw(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { let n = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); if n < 3 { // Fewer than three spokes make no polygon; PowerPoint draws a line, we draw nothing. @@ -144,7 +144,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec /// A stock chart's marks at one category: the high-low line, then the up/down bar between the /// first (open) and last (close) series, else a dash at the close. #[allow(clippy::too_many_arguments)] -pub fn stock_marks(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, x: f64, band: f64, i: usize, to_y: &dyn Fn(f64) -> f64) { +pub fn stock_marks(ctx: &mut dyn Canvas, rctx: &Ctx, c: &Chart, m: Affine, x: f64, band: f64, i: usize, to_y: &dyn Fn(f64) -> f64) { let vals: Vec = c.series.iter().filter_map(|s| s.values.get(i).copied().flatten()).collect(); if vals.len() < 2 { return; diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index fdabb2136..d7c0a3df9 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -1,4 +1,6 @@ -use super::geometry::{Caster, blur, border_outside, camera_turn, color, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry}; +use super::geometry::{ + Caster, blur, border_outside, camera_turn, color, draws_effects, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry, +}; use super::opts::{RenderOpts, ShapeState, SlideFields}; use super::raster::Image; use super::slide::{SlidePart, needs_filters, output_size}; @@ -60,50 +62,6 @@ pub(crate) struct Frame<'a> { pub(crate) fields: SlideFields, } -/// A shape being drawn: its resolved geometry, fill, line and effects, in shape space `m` at size -/// `w` x `h`, shared by the stages of `Renderer::geometry_shape`. -struct ShapeDraw<'a, 'ctx> { - f: &'a Frame<'a>, - rctx: &'a Ctx<'ctx>, - s: &'a Shape, - st: &'a ShapeState, - m: Affine, - w: f64, - h: f64, - depth: usize, - geo: Geometry, - fades: Vec, - fill: Option, - fill_ph: Option, - line: Line, - line_ph: Option, - effects: Option, - fx_ph: Option, - outline: BezPath, - is_pic: bool, - empty_ph: bool, -} - -impl ShapeDraw<'_, '_> { - /// How much of the fill and stroke of path `i` shows (morphing outlines fade). - fn fade(&self, i: usize) -> deckcraft_geom::morph::Fade { - self.fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }) - } -} - -/// What casts a shape's shadow, glow and reflection: its geometry, fill and outline, in shape -/// space `m` at size `w` x `h`. -struct FxSource<'a> { - geo: &'a Geometry, - fill: &'a Option, - fill_alpha: Option, - outline: Option>, - content: Option<&'a deckcraft_model::style::PictureFill>, - m: Affine, - w: f64, - h: f64, -} - impl Renderer { fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) { let (w, h) = output_size(pres, opts); @@ -133,7 +91,7 @@ impl Renderer { } /// Background plus the master and layout graphics the slide shows. - fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) { + fn backdrop(&mut self, ctx: &mut dyn Canvas, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) { self.background(ctx, f, rctx, Some(slide), view); let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); if show_master { @@ -155,18 +113,23 @@ impl Renderer { } pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); + self.slide_on(&mut ctx, pres, slide, index, opts, view); + self.finish(ctx, w, h) + } + + /// The slide drawn onto any canvas, `view` mapping slide points to canvas units. + pub(crate) fn slide_on(&mut self, ctx: &mut dyn Canvas, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts, view: Affine) { self.render_work = 0; self.shadow_work = 0; self.in_shadow_pass = false; - let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; if let Some(rctx) = Ctx::for_slide(pres, slide) { - self.backdrop(&mut ctx, &f, &rctx, slide, view); + self.backdrop(ctx, &f, &rctx, slide, view); for s in &slide.shapes { - self.shape(&mut ctx, &f, &rctx, s, view, 0); + self.shape(ctx, &f, &rctx, s, view, 0); } } - self.finish(ctx, w, h) } pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { @@ -297,7 +260,7 @@ impl Renderer { Image { width: w as u32, height: h as u32, pixels } } - fn background(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) { + fn background(&mut self, ctx: &mut dyn Canvas, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) { let (fill, ph) = resolve::background(rctx, slide); if f.opts.transparent_background { self.bg = Some((fill, ph, view)); @@ -314,7 +277,7 @@ impl Renderer { self.bg = Some((fill, ph, view)); } - pub(crate) fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { + pub(crate) fn shape(&mut self, ctx: &mut dyn Canvas, 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); @@ -381,7 +344,7 @@ impl Renderer { geo } - fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { + fn shape_at(&mut self, ctx: &mut dyn Canvas, 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; } @@ -415,7 +378,7 @@ impl Renderer { if layered { ctx.set_transform(m); let clip = st.clip.map(|[l, t, r, b]| Rect::new(l * x.w, t * x.h, r * x.w, b * x.h).to_path(0.1)); - ctx.push_layer(clip.as_ref(), None, Some(st.opacity.clamp(0.0, 1.0) as f32), None, None); + ctx.push_layer(clip.as_ref(), Some(st.opacity.clamp(0.0, 1.0) as f32)); } match &s.kind { ShapeKind::Group { children, child } => { @@ -442,20 +405,42 @@ impl Renderer { let det = m.determinant(); if det.is_finite() && det.abs() > 1e-12 { let shift = pre * m * own * m.inverse(); - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); - self.in_shadow_pass = true; - for ch in children { - self.shape(ctx, f, rctx, ch, shift * gm, depth + 1); - } - self.in_shadow_pass = false; - ctx.set_transform(Affine::IDENTITY); - 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)); - let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height); - ctx.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4)); - ctx.pop_layer(); - ctx.pop_layer(); + // Pixels (a blurred, recoloured layer); vector canvases get the shadow + // rasterized over the area it can reach. + let reach = sh.blur.max(0.0) * 2.0 + 2.0; + let area = (shift * m).transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)).inflate(reach * k, reach * k); + let area = area.union(m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h))); + canvas::pixels(ctx, area, &mut |v, vpre| { + // A local raster: what is placed through the slide-level view moves + // with it, and the editor's drawn extent stays as it was. + let saved = (self.view, self.bg.clone(), self.group_fill.clone(), self.drawn); + self.view = vpre * self.view; + if let Some(bg) = self.bg.as_mut() { + bg.2 = vpre * bg.2; + } + if let Some(gf) = self.group_fill.as_mut() { + gf.1 = vpre * gf.1; + } + v.set_transform(Affine::IDENTITY); + v.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k * scale_of(vpre)))); + self.in_shadow_pass = true; + for ch in children { + self.shape(v, f, rctx, ch, vpre * shift * gm, depth + 1); + } + self.in_shadow_pass = false; + v.set_transform(Affine::IDENTITY); + v.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); + v.set_paint(color(c.with_alpha(255), 1.0)); + let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height); + v.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4)); + v.pop_layer(); + v.pop_layer(); + let drawn = self.drawn; + (self.view, self.bg, self.group_fill, self.drawn) = saved; + if vpre == Affine::IDENTITY { + self.drawn = drawn; + } + }); } } for c in children { @@ -529,7 +514,7 @@ impl Renderer { #[allow(clippy::too_many_arguments)] fn fill_geometry( &self, - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, f: &Frame, rctx: &Ctx, fl: &Fill, @@ -569,9 +554,48 @@ impl Renderer { } #[allow(clippy::too_many_arguments)] - fn geometry_shape( + fn geometry_shape(&mut self, ctx: &mut dyn Canvas, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState, depth: usize) { + let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { + Some(pm) => self.morphs.geometry(pm, s, w, h), + None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()), + }; + let (line, _) = resolve::line(rctx, s); + let (effects, _) = resolve::effects(rctx, s); + let reach = extent::spill(Some(&line), effects.as_ref(), w, h); + let area = Rect::new(0.0, 0.0, w, h).inflate(reach, reach); + self.track(m, area); + // Vector canvases get the art of a shape with blur-based effects or a blended fill overlay + // as pixels covering it and its effects; its text stays vector (effects follow the + // geometry only). + let pixel_only = effects.as_ref().is_some_and(|e| draws_effects(e) || e.fill_overlay.is_some()); + match ctx.fallback().filter(|_| pixel_only) { + Some(fb) => { + canvas::rasterize(ctx, fb, m.transform_rect_bbox(area), &mut |v, pre| { + // Lines and `useBgFill` backgrounds are placed through the slide-level view. + let saved = (self.view, self.bg.clone(), self.group_fill.clone()); + self.view = pre * self.view; + if let Some(bg) = self.bg.as_mut() { + bg.2 = pre * bg.2; + } + // `grpFill`: the group's fill is laid out through the same view. + if let Some(gf) = self.group_fill.as_mut() { + gf.1 = pre * gf.1; + } + self.geometry_art(v, f, rctx, s, pre * m, w, h, st, depth, &geo, &fades); + (self.view, self.bg, self.group_fill) = saved; + }); + } + None => self.geometry_art(ctx, f, rctx, s, m, w, h, st, depth, &geo, &fades), + } + self.geometry_text(ctx, f, rctx, s, m, w, h, st, depth, &geo); + } + + /// A shape's effects, fill and outline. + #[allow(clippy::too_many_arguments)] + #[allow(clippy::cognitive_complexity)] + fn geometry_art( &mut self, - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, f: &Frame, rctx: &Ctx, s: &Shape, @@ -580,80 +604,29 @@ impl Renderer { h: f64, st: &ShapeState, depth: usize, + geo: &Geometry, + fades: &[deckcraft_geom::morph::Fade], ) { - let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { - Some(pm) => self.morphs.geometry(pm, s, w, h), - None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()), - }; + let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); let (fill, fill_ph) = resolve::fill(rctx, s); let (line, line_ph) = resolve::line(rctx, s); let (effects, fx_ph) = resolve::effects(rctx, s); - let reach = extent::spill(Some(&line), effects.as_ref(), w, h); - self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach)); let is_pic = matches!(s.kind, ShapeKind::Picture { .. }); let outline = geo.outline(); // Placeholders in the editor: dashed outline and prompt, nothing else when empty. let empty_ph = s.ph.is_some() && s.text.as_ref().is_none_or(TextBody::is_empty) && !is_pic && !matches!(s.kind, ShapeKind::Media(_)); let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill }; - let d = ShapeDraw { f, rctx, s, st, m, w, h, depth, geo, fades, fill, fill_ph, line, line_ph, effects, fx_ph, outline, is_pic, empty_ph }; - self.cast_effects(ctx, &d); - let soft = d.effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); - if let Some(r) = soft { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, None, None, None); - let _ = r; - } - let pt = self.point_scale(&d); - self.draw_extrusion(ctx, &d, pt); - self.draw_contour(ctx, &d, pt); - self.draw_fill(ctx, &d); - self.draw_fill_overlay(ctx, &d); - self.draw_bevel(ctx, &d, pt); - self.draw_outline(ctx, &d); - if let Some(r) = soft { - self.draw_feather(ctx, &d, r); - } - self.draw_inner_shadow(ctx, &d); - self.draw_text_body(ctx, &d); - } - - /// Shape-space units per point for the 3-D sizes, measured without the camera's turn (which - /// `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. - fn point_scale(&self, d: &ShapeDraw) -> f64 { - let turn = d - .effects - .as_ref() - .and_then(|e| e.raw3d.as_deref()) - .and_then(camera::from_raw) - .and_then(|c| camera::affine(&c, d.w, d.h)) - .filter(|t| t.determinant().abs() > 1e-9); - self.per_point(turn.map_or(d.m, |t| d.m * t.inverse()), d.depth) - } - - fn cast_effects(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { - let m = d.m; - let w = d.w; - let h = d.h; - let depth = d.depth; - let fill_ph = d.fill_ph; - let line_ph = d.line_ph; - let fx_ph = d.fx_ph; - let is_pic = d.is_pic; - let s = d.s; - let rctx = d.rctx; - let geo = &d.geo; - let fill = &d.fill; - let effects = &d.effects; - let line = &d.line; - // Effects below the shape. - if let Some(e) = &effects { + // Effects below the shape (pixels only: vector canvases draw such shapes as pixels). + if let Some(e) = &effects + && let Some(px) = ctx.raster() + { // 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, }; // What casts the shadow: the fill as see-through as it is, and the outline. - let fill_alpha = fill_opacity(rctx, fill, fill_ph); + let fill_alpha = fill_opacity(rctx, &fill, fill_ph); let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| { let a = match lf { Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0, @@ -661,22 +634,30 @@ impl Renderer { }; let (base, rel) = self.line_space(m, depth); let lw = line.width.unwrap_or(0.75).max(0.0); - let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(line, lw) } else { None }; - Caster { line: outside.map_or(std::borrow::Cow::Borrowed(line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } + let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(&line, lw) } else { None }; + Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } }); - self.effects_below(ctx, rctx, e, fx_ph, FxSource { geo, fill, fill_alpha, outline, content: content.as_ref(), m, w, h }); + self.effects_below(px, rctx, e, fx_ph, geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); } - } - - fn draw_extrusion(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { - let m = d.m; - let w = d.w; - let h = d.h; - let fill_ph = d.fill_ph; - let rctx = d.rctx; - let fill = &d.fill; - let effects = &d.effects; - let outline = &d.outline; + let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); + let soft = soft.filter(|_| ctx.raster().is_some()); + if soft.is_some() + && let Some(px) = ctx.raster() + { + px.set_transform(Affine::IDENTITY); + px.push_layer(None, None, None, None, None); + } + // Shape-space units per point for the 3-D sizes, measured without the camera's turn (which + // `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. + let pt = { + let turn = effects + .as_ref() + .and_then(|e| e.raw3d.as_deref()) + .and_then(camera::from_raw) + .and_then(|c| camera::affine(&c, w, h)) + .filter(|t| t.determinant().abs() > 1e-9); + self.per_point(turn.map_or(m, |t| m * t.inverse()), depth) + }; // A 3-D extrusion under a turned camera: the sides, swept behind the face. if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) && let Some(ex) = extrude::from_raw(raw) @@ -691,17 +672,8 @@ impl Renderer { let pre = m * turn.inverse(); let lit = light::Lighting::from_raw(raw); let d = ex.depth * pt; - extrude::draw(ctx, outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); + extrude::draw(ctx, &outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); } - } - - fn draw_contour(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { - let m = d.m; - let fill_ph = d.fill_ph; - let rctx = d.rctx; - let fill = &d.fill; - let effects = &d.effects; - let outline = &d.outline; // A 3-D contour: a band around the outline, behind the face (and in front of any sides). if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) && let Some(c) = extrude::contour_from_raw(raw) @@ -711,23 +683,8 @@ impl Renderer { _ => None, }; let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff)); - extrude::draw_contour(ctx, outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); + extrude::draw_contour(ctx, &outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); } - } - - fn draw_fill(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { - let m = d.m; - let w = d.w; - let h = d.h; - let fill_ph = d.fill_ph; - let empty_ph = d.empty_ph; - let s = d.s; - let f = d.f; - let rctx = d.rctx; - let geo = &d.geo; - let fill = &d.fill; - let outline = &d.outline; - let fade = |i: usize| d.fade(i); // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { @@ -740,7 +697,7 @@ impl Renderer { } ctx.set_transform(m); let bounds = Rect::new(0.0, 0.0, w, h); - ctx.push_clip_layer(outline); + ctx.push_clip_layer(&outline); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); ctx.pop_layer(); @@ -764,7 +721,7 @@ impl Renderer { let bounds = Rect::new(0.0, 0.0, w, h); if let Some(p) = mc.poster { let pf = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() }; - ctx.push_clip_layer(outline); + ctx.push_clip_layer(&outline); paint::picture(ctx, f.pres, &pf, bounds, m, None); ctx.pop_layer(); } else if mc.video { @@ -783,23 +740,11 @@ impl Renderer { } } } - } - - fn draw_fill_overlay(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { - let m = d.m; - let w = d.w; - let h = d.h; - let fx_ph = d.fx_ph; - let f = d.f; - let rctx = d.rctx; - let geo = &d.geo; - let fill = &d.fill; - let effects = &d.effects; - let fade = |i: usize| d.fade(i); // `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 Some(px) = ctx.raster() { let mix = match ov.blend.as_str() { "mult" => peniko::Mix::Multiply, @@ -809,25 +754,19 @@ impl Renderer { _ => 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(); + px.set_transform(m); + px.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None); + paint::fill_path(px, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres); + px.pop_layer(); } } - } - - fn draw_bevel(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { - let m = d.m; - let w = d.w; - let h = d.h; - let fill_ph = d.fill_ph; - let is_pic = d.is_pic; - let rctx = d.rctx; - let geo = &d.geo; - let fill = &d.fill; - let effects = &d.effects; - let outline = &d.outline; + // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). + let line = match (&s.geom, line.join) { + (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { + Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } + } + _ => line, + }; // A 3-D bevel: the face and a band inside the outline lit as surfaces. if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { let bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv }; @@ -844,32 +783,12 @@ impl Renderer { if is_pic || area.elements().is_empty() { area = outline.clone(); } - bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0); + // Pixels (a lit image); vector canvases get them rasterized over the shape. + let bounds = Rect::new(0.0, 0.0, w, h); + let fill = if is_pic { None } else { solid }; + canvas::pixels(ctx, m.transform_rect_bbox(bounds), &mut |v, pre| bevel::draw(v, &bv, fill, &area, bounds, pre * m, 1.0)); } } - } - - fn draw_outline(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { - let m = d.m; - let w = d.w; - let h = d.h; - let depth = d.depth; - let line_ph = d.line_ph; - let is_pic = d.is_pic; - let s = d.s; - let rctx = d.rctx; - let geo = &d.geo; - let effects = &d.effects; - let outline = &d.outline; - let line = d.line.clone(); - let fade = |i: usize| d.fade(i); - // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). - let line = match (&s.geom, line.join) { - (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { - Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } - } - _ => line, - }; // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); @@ -891,7 +810,7 @@ impl Renderer { let line = match see_through { Some(c) => { ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some(c.a as f32 / 255.0), None, None); + ctx.push_layer(None, Some(c.a as f32 / 255.0)); std::borrow::Cow::Owned(Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..line.clone() @@ -941,46 +860,36 @@ impl Renderer { ctx.pop_layer(); } } - } - - fn draw_feather(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, r: f64) { - let m = d.m; - let outline = &d.outline; - // Feather: the shape (fill and line) fades to nothing at its outline. Measured on - // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of - // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. - ctx.set_transform(m); - ctx.push_layer( - None, - Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), - None, - None, - Some(blur(r / 3.0 * scale_of(m))), - ); - ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); - ctx.fill_path(outline); - ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); - ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); - ctx.stroke_path(outline); - ctx.pop_layer(); - ctx.pop_layer(); - ctx.pop_layer(); - } - - fn draw_inner_shadow(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { - let m = d.m; - let w = d.w; - let h = d.h; - let fx_ph = d.fx_ph; - let rctx = d.rctx; - let effects = &d.effects; - let outline = &d.outline; + if let Some(r) = soft + && let Some(px) = ctx.raster() + { + // Feather: the shape (fill and line) fades to nothing at its outline. Measured on + // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of + // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. + px.set_transform(m); + px.push_layer( + None, + Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), + None, + None, + Some(blur(r / 3.0 * scale_of(m))), + ); + px.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + px.fill_path(&outline); + px.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); + px.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); + px.stroke_path(&outline); + px.pop_layer(); + px.pop_layer(); + px.pop_layer(); + } // Inner shadow. if let Some(e) = &effects && let Some(sh) = &e.inner_shadow + && let Some(ctx) = ctx.raster() { ctx.set_transform(m); - ctx.push_clip_layer(outline); + ctx.push_clip_layer(&outline); let c = rctx.color(&sh.color, fx_ph); let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; // As PowerPoint draws it (measured on its export): the shadow falls inside along the @@ -1000,18 +909,23 @@ impl Renderer { } } - fn draw_text_body(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { - let m = d.m; - let w = d.w; - let h = d.h; - let depth = d.depth; - let empty_ph = d.empty_ph; - let s = d.s; - let f = d.f; - let rctx = d.rctx; - let st = d.st; - let geo = &d.geo; - // Text (or the placeholder prompt in the editor). + /// A shape's text (or the placeholder prompt in the editor) and the editor's placeholder outline. + #[allow(clippy::too_many_arguments)] + fn geometry_text( + &mut self, + ctx: &mut dyn Canvas, + f: &Frame, + rctx: &Ctx, + s: &Shape, + m: Affine, + w: f64, + h: f64, + st: &ShapeState, + depth: usize, + geo: &Geometry, + ) { + let is_pic = matches!(s.kind, ShapeKind::Picture { .. }); + let empty_ph = s.ph.is_some() && s.text.as_ref().is_none_or(TextBody::is_empty) && !is_pic && !matches!(s.kind, ShapeKind::Media(_)); let skip_text = f.opts.skip_text == Some(s.id); if let Some(body) = &s.text && !skip_text @@ -1057,7 +971,7 @@ impl Renderer { let prompt_body = prompt_body(rctx, s, kind); if !prompt_body.is_empty() { let grey = Rgba::rgb(0x59, 0x59, 0x59); - draw_text(ctx, rctx, s, &prompt_body, m, &Opts { rect: tr, fields: &f.fields, prompt_color: Some(grey), no_shrink: false }); + draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey)); } } } @@ -1074,8 +988,22 @@ impl Renderer { } } - fn effects_below(&mut self, ctx: &mut RenderContext, rctx: &Ctx, e: &Effects, ph: Option, src: FxSource) { - let FxSource { geo, fill, fill_alpha, outline, content, m, w, h } = src; + #[allow(clippy::too_many_arguments)] + fn effects_below( + &mut self, + ctx: &mut RenderContext, + rctx: &Ctx, + e: &Effects, + ph: Option, + geo: &Geometry, + fill: &Option, + fill_alpha: Option, + outline: Option, + content: Option<&deckcraft_model::style::PictureFill>, + m: Affine, + w: f64, + h: f64, + ) { let silhouette = { let mut p = BezPath::new(); for sp in &geo.paths { diff --git a/crates/render/src/slide.rs b/crates/render/src/slide.rs index b3e6e5dec..581fcfdee 100644 --- a/crates/render/src/slide.rs +++ b/crates/render/src/slide.rs @@ -2,6 +2,7 @@ use super::opts::{RenderOpts, SlideFields}; use super::raster::{Image, MAX_SIDE}; use super::renderer::{Frame, RENDERER, Renderer}; use super::{Affine, Ctx, PathMorph, Presentation, Rect, RenderContext, Shape, ShapeId, Slide, TextMorph, resolve}; +use crate::{Canvas, canvas, colr}; static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -116,6 +117,20 @@ pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opt } } +/// Draw slide `index` onto any [`Canvas`] (the PDF export's vector pages), `view` mapping slide +/// points to canvas units: what [`render_slide`] draws. A panic inside the renderer stops the +/// drawing (logged), never crashes. +pub fn draw_slide(canvas: &mut dyn Canvas, pres: &Presentation, index: usize, opts: &RenderOpts, view: Affine) { + let Some(slide) = pres.slides.get(index) else { return }; + let drawn = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RENDERER.with(|r| r.borrow_mut().slide_on(canvas, pres, slide, index, opts, view)); + })); + if drawn.is_err() { + log::error!("drawing slide {index} panicked; the page stops there"); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + } +} + /// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the /// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by /// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`: @@ -209,6 +224,25 @@ pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: }) } +/// A colour glyph (emoji): painted on the pixel context; on vector canvases rasterized over the +/// glyph's em box. False when the glyph has no colour version. +pub(crate) fn color_glyph(ctx: &mut dyn Canvas, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, k: f64, alpha: f64) -> bool { + if let Some(px) = ctx.raster() { + return colr::paint(px, &run.face, gid, at, k, run.color, alpha); + } + let Some(fb) = ctx.fallback() else { return false }; + if !colr::has_color(&run.face, gid) { + return false; + } + let em = run.size.abs(); + let adv = run.face.advance(gid).abs() * k; + let adv = if adv.is_finite() { adv.min(em * 4.0) } else { em }; + let area = at.transform_rect_bbox(Rect::new(-0.25 * em, -1.3 * em, adv + 0.25 * em, 0.5 * em)); + let mut drawn = false; + canvas::rasterize(ctx, fb, area, &mut |v, pre| drawn = colr::paint(v, &run.face, gid, pre * at, k, run.color, alpha)); + drawn +} + /// Render one shape alone onto a transparent canvas (drag previews, copy as picture). pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { let rctx = Ctx::for_slide(pres, slide)?; diff --git a/crates/render/src/stroke.rs b/crates/render/src/stroke.rs index 8310cf0c9..a04c92f58 100644 --- a/crates/render/src/stroke.rs +++ b/crates/render/src/stroke.rs @@ -5,7 +5,7 @@ use super::*; /// line belongs to; `path` itself when `None`). #[allow(clippy::too_many_arguments)] pub(crate) fn stroke( - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, rctx: &Ctx, line: &Line, ph: Option, @@ -155,7 +155,7 @@ fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath { /// A gradient line's paint: laid out, as PowerPoint lays it, over the box the stroke covers (the /// path's bounds grown by half the line width), so a horizontal line's vertical gradient runs /// across its width. -fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option, path: &BezPath, lw: f64, alpha: f64) -> peniko::Gradient { +fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option, path: &BezPath, lw: f64, alpha: f64) -> Paint { let bounds = path.bounding_box().inflate(lw / 2.0, lw / 2.0); let bounds = paint::tile_box(bounds, g.tile); let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, rctx.color(&s.color, ph))).collect(); @@ -358,7 +358,7 @@ pub(crate) fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath { } #[allow(clippy::too_many_arguments)] -pub(crate) fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, m: Affine, lw: f64) { +pub(crate) fn arrowheads(ctx: &mut dyn Canvas, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, 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. @@ -376,7 +376,7 @@ pub(crate) fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: O } _ => None, }; - let set_paint = |ctx: &mut RenderContext| match &grad { + let set_paint = |ctx: &mut dyn Canvas| match &grad { Some(g) => ctx.set_paint(g.clone()), None => ctx.set_paint(color(col, 1.0)), }; diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 5be9900df..d146333a0 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -8,7 +8,9 @@ use deckcraft_model::text::{Anchor, TextBody, TextDir}; use deckcraft_model::{Presentation, Shape}; use deckcraft_text::Fields; use kurbo::{Affine, BezPath, Rect, Shape as _}; -use vello_cpu::{RenderContext, peniko}; +use vello_cpu::peniko; + +use crate::canvas::Canvas; use crate::color; @@ -353,19 +355,28 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> { out } -/// Can a cell's text be left undrawn because it lands wholly outside the `target` (pixels)? Only +/// Can a cell's text be left undrawn because it lands wholly outside `view` (the pixels of a pixel +/// canvas, the slide's box on a vector one)? Only /// for cells whose text stays inside the cell: its row grew to fit it (one row, horizontal text, /// not at the height cap), so nothing overflows. A margin covers italic overhang and the like. /// A table of thousands of rows otherwise lays out every cell's text off the slide's bottom. -fn text_off_target(cell: &Cell, rect: Rect, m: Affine, target: (u16, u16)) -> bool { +fn text_off_target(cell: &Cell, rect: Rect, m: Affine, view: Option) -> bool { + let Some(view) = view else { return false }; if cell.row_span > 1 || !matches!(cell.vertical, None | Some(TextDir::Horizontal)) || rect.height() >= 20_000.0 { return false; } let b = m.transform_rect_bbox(rect).inflate(64.0, 64.0); - let view = Rect::new(0.0, 0.0, f64::from(target.0), f64::from(target.1)); b.is_finite() && (b.x1 < view.x0 || b.y1 < view.y0 || b.x0 > view.x1 || b.y0 > view.y1) } +/// What a canvas shows: its pixels, or on a vector canvas the slide's box. +fn target_view(ctx: &mut dyn Canvas) -> Option { + match ctx.raster() { + Some(px) => Some(Rect::new(0.0, 0.0, f64::from(px.width()), f64::from(px.height()))), + None => ctx.fallback().map(|f| f.clip), + } +} + #[cfg(test)] thread_local! { /// Cells whose text `draw` laid out on this thread (tests check off-slide cells are skipped). @@ -375,7 +386,7 @@ thread_local! { /// Draws the table; returns the box its cells cover (table space), which grows with their text. #[allow(clippy::too_many_arguments)] pub fn draw( - ctx: &mut RenderContext, + ctx: &mut dyn Canvas, pres: &Presentation, rctx: &Ctx, t: &Table, @@ -469,7 +480,7 @@ pub fn draw( // Text. for (r, c, rect) in &rects { let Some(cell) = t.cell(*r, *c) else { continue }; - if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) { + if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, target_view(ctx)) { continue; } let body = cell_body_with_def(rctx, style_def, t, *r, *c, cell); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index cddf1e373..61e7fbcb2 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,7 +1,8 @@ use super::{ - Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph, - crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide, - render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform, + Affine, BezPath, Canvas, CanvasImage, Ctx, Fallback, Fields, Fill, Image, Opts, Paint, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, + Renderer, Rgba, ShapeState, SlideFields, TextMorph, canvas, crop_dest, dpi_for_scale, draw_slide, gif_frame, image_dpi, images, morph_path, + morph_text, needs_filters, parse_path, pasteboard_region, place_slide, render_blend, render_shapes, render_slide, render_slide_drawn, + slide_extent, text_transform, }; use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef; @@ -2894,6 +2895,226 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() { assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); } +#[test] +fn draw_slide_on_a_pixel_canvas_matches_render_slide() { + let p = deckcraft_model::defaults::new_presentation(None); + let opts = RenderOpts { scale: 0.25, ..Default::default() }; + let a = render_slide(&p, 0, &opts); + let (w, h) = (a.width as u16, a.height as u16); + let mut ctx = vello_cpu::RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + draw_slide(&mut ctx, &p, 0, &opts, Affine::scale(0.25)); + let b = RENDERER.with(|r| r.borrow_mut().finish(ctx, w, h)); + assert_eq!(diff_px(&a, &b), 0); +} + +/// Records what reaches a vector canvas. +#[derive(Default)] +struct Recorder { + fills: usize, + strokes: usize, + runs: usize, + /// Pictures: whether each came as its original file (else as decoded pixels). + images: Vec, + pixels: Vec, + depth: i32, +} + +impl Canvas for Recorder { + fn set_transform(&mut self, _t: Affine) {} + fn set_paint_to(&mut self, _p: Paint) {} + fn set_stroke(&mut self, _s: kurbo::Stroke) {} + fn set_fill_rule(&mut self, _r: canvas::FillRule) {} + fn fill_path(&mut self, _p: &BezPath) { + self.fills += 1; + } + fn stroke_path(&mut self, _p: &BezPath) { + self.strokes += 1; + } + fn push_layer(&mut self, _clip: Option<&BezPath>, _opacity: Option) { + self.depth += 1; + } + fn pop_layer(&mut self) { + self.depth -= 1; + } + fn draw_image(&mut self, img: &CanvasImage, _alpha: f64) { + assert!(img.encoded.is_some() || img.pixmap.is_some()); + self.images.push(img.encoded.is_some()); + } + fn glyph_run(&mut self, _run: &deckcraft_text::GlyphRun, text: &str, _at: Affine, _opacity: f64) -> bool { + assert!(!text.is_empty()); + self.runs += 1; + true + } + fn fallback(&self) -> Option { + Some(Fallback { scale: 1.0, clip: Rect::new(0.0, 0.0, 960.0, 540.0) }) + } + fn draw_pixels(&mut self, img: &Image, dest: Rect) { + assert!(img.width > 0 && img.height > 0); + self.pixels.push(dest); + } +} + +#[test] +fn vector_canvases_get_paths_and_text_and_effects_as_local_pixels() { + let mut sh = Shape { + id: ShapeId(600), + xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 100.0)), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + text: Some(TextBody::from_text("Glowing")), + ..Default::default() + }; + sh.effects = Some(deckcraft_model::Effects { + glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 200, 0)), radius: 6.0 }), + ..Default::default() + }); + let plain = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(400.0, 50.0, 200.0, 100.0)), ..sh.clone() }; + let plain = Shape { effects: None, ..plain }; + let p = deck_with(vec![sh, plain]); + let mut rec = Recorder::default(); + draw_slide(&mut rec, &p, 0, &RenderOpts::default(), Affine::IDENTITY); + assert_eq!(rec.depth, 0); + // Background and the plain shape are paths; both texts are glyph runs. + assert!(rec.fills >= 2, "{}", rec.fills); + assert_eq!(rec.runs, 2); + // Only the glowing shape is pixels, around it. + assert_eq!(rec.pixels.len(), 1); + let r = rec.pixels[0]; + assert!(r.x0 < 50.0 && r.x1 > 250.0 && r.x1 < 300.0 && r.y1 < 220.0, "{r:?}"); +} + +/// What `p`'s first slide sends a vector canvas (layers balanced). +fn record(p: &Presentation) -> Recorder { + let mut rec = Recorder::default(); + draw_slide(&mut rec, p, 0, &RenderOpts::default(), Affine::IDENTITY); + assert_eq!(rec.depth, 0); + rec +} + +/// A 200 x 100 shape at (100, 100) with `fill` and no outline. +fn filled(fill: deckcraft_model::style::Fill) -> Shape { + use deckcraft_model::style::{Fill, Line}; + Shape { + id: ShapeId(610), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + fill: Some(fill), + line: Some(Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + } +} + +fn assert_over(r: Rect, around: Rect) { + let a = around.inflate(4.0, 4.0); + assert!(r.x0 >= a.x0 && r.y0 >= a.y0 && r.x1 <= a.x1 && r.y1 <= a.y1 && r.area() > 0.0, "{r:?} not over {around:?}"); +} + +/// Drawing that only the pixel context does (pattern tiles, rectangle and outline path +/// gradients, blended fill overlays) reaches a vector canvas as pixels over that shape alone, +/// never dropped; compound outlines stay vector strokes. +#[test] +fn vector_canvases_get_pixel_only_fills_as_local_pixels() { + use deckcraft_model::style::{Compound, Fill, FillOverlay, Gradient, GradientShape, GradientStop, Line, PatternFill}; + let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)); + let area = Rect::new(100.0, 100.0, 300.0, 200.0); + let pattern = filled(Fill::Pattern(PatternFill { preset: "dkVert".into(), fg: rgb(20, 20, 20), bg: rgb(240, 240, 240) })); + let rect_gradient = filled(Fill::Gradient(Gradient { + stops: vec![GradientStop { pos: 0.0, color: rgb(255, 0, 0) }, GradientStop { pos: 1.0, color: rgb(0, 0, 255) }], + shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] }, + rotate_with_shape: true, + tile: [0.0; 4], + })); + let mut overlay = filled(Fill::solid(rgb(200, 220, 240))); + overlay.effects = Some(deckcraft_model::Effects { + fill_overlay: Some(FillOverlay { blend: "mult".into(), fill: Fill::solid(rgb(255, 128, 0)) }), + ..Default::default() + }); + for (name, sh) in [("pattern", pattern), ("rect gradient", rect_gradient), ("fill overlay", overlay)] { + let rec = record(&deck_with(vec![sh])); + assert_eq!(rec.pixels.len(), 1, "{name}"); + assert_over(rec.pixels[0], area); + } + // A linear gradient is a vector paint. + let linear = filled(Fill::Gradient(Gradient { + stops: vec![GradientStop { pos: 0.0, color: rgb(255, 0, 0) }, GradientStop { pos: 1.0, color: rgb(0, 0, 255) }], + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + })); + let rec = record(&deck_with(vec![linear])); + assert!(rec.pixels.is_empty() && rec.fills >= 2); + // Compound outlines: one stroke per band, no pixels. + let mut double = filled(Fill::solid(rgb(255, 255, 255))); + double.line = Some(Line { compound: Some(Compound::Triple), ..Line::solid(rgb(0, 0, 0), 9.0) }); + let rec = record(&deck_with(vec![double])); + assert!(rec.pixels.is_empty()); + assert_eq!(rec.strokes, 3); +} + +/// Text shadows and glows need a blur: on a vector canvas the text stays a real glyph run and the +/// effect comes as pixels around it. Gradient-filled text is drawn glyph by glyph as vector paths. +#[test] +fn vector_canvases_keep_text_and_rasterize_its_effects() { + use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop}; + let text = Rect::new(100.0, 100.0, 700.0, 400.0); + let glow = + effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 })); + let shadow = effect_text(|p| p.shadow = Some(shadow(2.0, 12.0, 45.0))); + for (name, p) in [("glow", glow), ("shadow", shadow)] { + let rec = record(&p); + assert_eq!(rec.runs, 1, "{name}: the text is a glyph run"); + assert_eq!(rec.pixels.len(), 1, "{name}"); + let r = rec.pixels[0]; + assert!(r.x0 >= text.x0 - 30.0 && r.x1 <= text.x1 + 30.0 && r.y0 >= text.y0 - 30.0 && r.y1 <= text.y1 + 30.0, "{name}: {r:?}"); + assert!(r.width() > 50.0 && r.height() > 50.0, "{name}: {r:?}"); + } + let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)); + let gradient = effect_text(|p| { + p.fill = Some(Fill::Gradient(Gradient { + stops: vec![GradientStop { pos: 0.0, color: rgb(255, 0, 0) }, GradientStop { pos: 1.0, color: rgb(0, 0, 255) }], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + })) + }); + let plain = record(&effect_text(|_| {})); + let rec = record(&gradient); + assert_eq!(rec.runs, 0); + assert!(rec.pixels.is_empty()); + assert_eq!(rec.fills, plain.fills + 4, "one path per glyph of IIII"); +} + +/// A WMF picture reaches a vector canvas as decoded pixels (the metafile drawn by the renderer), +/// not as a file it can't embed. +#[test] +fn vector_canvases_get_metafile_pictures_as_pixels() { + let mut p = Presentation::default(); + let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf()).expect("media"); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let rec = record(&p); + assert_eq!(rec.images, vec![false]); +} + +/// Colour emoji (COLR glyphs) reach a vector canvas as pixels over the emoji; the letters around +/// them stay text. Skipped without an installed colour emoji font. +#[test] +fn vector_canvases_get_colour_emoji_as_pixels() { + let probe = deckcraft_fonts::FontDb::global().cascade(&['\u{1F600}'], &[], "Regular"); + if !probe.is_color() || !crate::colr::has_color(&probe, probe.glyph_for('\u{1F600}')) { + return; + } + let mut sh = Shape { id: ShapeId(620), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 200.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("ab \u{1F600} cd"); + t.paragraphs[0].runs[0].props.size = Some(60.0); + sh.text = Some(t); + let rec = record(&deck_with(vec![sh])); + assert!(rec.runs >= 1, "the letters are text"); + assert_eq!(rec.pixels.len(), 1, "the emoji is pixels"); + let r = rec.pixels[0]; + // The emoji em box (with room for its ascent), past the second letter, about 60 pt wide. + assert!(r.x0 >= 150.0 && r.x1 <= 710.0 && r.y0 >= 50.0 && r.y1 <= 310.0 && r.width() < 150.0, "{r:?}"); +} + /// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour /// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible /// there), 595959 grey on Harbor's light ones. diff --git a/crates/render/src/tests_vector.rs b/crates/render/src/tests_vector.rs new file mode 100644 index 000000000..35e3dbde5 --- /dev/null +++ b/crates/render/src/tests_vector.rs @@ -0,0 +1,313 @@ +//! The vector canvas path (what the PDF export draws) against the pixel path, on the newer +//! shape, picture and effect cases: a test canvas takes the vector route (no pixel context, a +//! raster fallback) but replays every call into pixels, so both routes can be compared pixel for +//! pixel, and counts what reached it as local pixels. + +use super::*; +use deckcraft_color::Rgba; +use deckcraft_model::style::{ColorRef, Gradient, GradientShape, GradientStop, PictureFill, PictureMode}; +use deckcraft_model::{Presentation, Shape, ShapeId, Xfrm}; +use vello_cpu::Pixmap; + +/// A vector canvas that draws into pixels: no `raster()`, a fallback at 1 px per point. +struct Replay { + px: RenderContext, + /// Local rasters placed (what a PDF gets as images of one element). + locals: Vec, + /// Pictures placed: whether each came as its original file. + images: Vec, +} + +fn premul(rgba: &[u8], w: u32, h: u32) -> Option> { + let data = rgba + .as_chunks::<4>() + .0 + .iter() + .map(|p| { + let a = p[3] as u32; + let m = |c: u8| ((c as u32 * a + 127) / 255) as u8; + vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] } + }) + .collect(); + Some(Arc::new(Pixmap::from_parts(data, u16::try_from(w).ok()?, u16::try_from(h).ok()?))) +} + +impl Replay { + fn place(&mut self, pm: Arc, t: Affine, alpha: f64) { + Canvas::set_transform(&mut self.px, t); + let img = CanvasImage { width: pm.width() as f64, height: pm.height() as f64, pixmap: Some(pm), encoded: None }; + Canvas::draw_image(&mut self.px, &img, alpha); + } +} + +impl Canvas for Replay { + fn set_transform(&mut self, t: Affine) { + Canvas::set_transform(&mut self.px, t); + } + fn set_paint_to(&mut self, p: Paint) { + self.px.set_paint_to(p); + } + fn set_stroke(&mut self, s: kurbo::Stroke) { + Canvas::set_stroke(&mut self.px, s); + } + fn set_fill_rule(&mut self, r: canvas::FillRule) { + Canvas::set_fill_rule(&mut self.px, r); + } + fn fill_path(&mut self, p: &BezPath) { + Canvas::fill_path(&mut self.px, p); + } + fn stroke_path(&mut self, p: &BezPath) { + Canvas::stroke_path(&mut self.px, p); + } + fn push_layer(&mut self, clip: Option<&BezPath>, opacity: Option) { + Canvas::push_layer(&mut self.px, clip, opacity); + } + fn pop_layer(&mut self) { + Canvas::pop_layer(&mut self.px); + } + fn draw_image(&mut self, img: &CanvasImage, alpha: f64) { + self.images.push(img.encoded.is_some()); + let t = *self.px.transform(); + let pm = match (img.encoded, &img.pixmap) { + (Some(file), _) => { + let Ok(d) = image::load_from_memory(file) else { return }; + let d = d.to_rgba8(); + premul(d.as_raw(), d.width(), d.height()) + } + (None, Some(pm)) => Some(pm.clone()), + (None, None) => None, + }; + if let Some(pm) = pm { + // The picture's grid is `width` x `height` units of the current transform. + let k = Affine::scale_non_uniform(img.width / pm.width() as f64, img.height / pm.height() as f64); + self.place(pm, t * k, alpha); + } + } + fn fallback(&self) -> Option { + Some(Fallback { scale: 1.0, clip: Rect::new(0.0, 0.0, 960.0, 540.0) }) + } + fn draw_pixels(&mut self, img: &Image, dest: Rect) { + self.locals.push(dest); + let data = img.pixels.as_chunks::<4>().0.iter().map(|p| vello_cpu::color::PremulRgba8 { r: p[0], g: p[1], b: p[2], a: p[3] }).collect(); + let pm = Arc::new(Pixmap::from_parts(data, img.width as u16, img.height as u16)); + let t = Affine::translate(dest.origin().to_vec2()) + * Affine::scale_non_uniform(dest.width() / img.width as f64, dest.height() / img.height as f64); + self.place(pm, t, 1.0); + } +} + +/// Slide 0 of `p` at 1 px per point: drawn the pixel way, and the vector way through [`Replay`]. +fn both(p: &Presentation) -> (Image, Image, Replay) { + let opts = RenderOpts { scale: 1.0, ..Default::default() }; + let a = render_slide(p, 0, &opts); + let (w, h) = (a.width as u16, a.height as u16); + let px = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let mut rec = Replay { px, locals: Vec::new(), images: Vec::new() }; + draw_slide(&mut rec, p, 0, &opts, Affine::IDENTITY); + let px = std::mem::replace(&mut rec.px, RenderContext::new(1, 1)); + let b = RENDERER.with(|r| r.borrow_mut().finish(px, w, h)); + (a, b, rec) +} + +/// Pixels whose channels differ by more than `tol` between the two routes. +fn differing(a: &Image, b: &Image, tol: u8) -> usize { + a.pixels + .as_chunks::<4>() + .0 + .iter() + .zip(b.pixels.as_chunks::<4>().0) + .filter(|(x, y)| x.iter().zip(y.iter()).any(|(p, q)| p.abs_diff(*q) > tol)) + .count() +} + +/// The vector route draws what the pixel route draws, up to anti-aliasing at a few edge pixels. +fn assert_same(name: &str, p: &Presentation, locals: usize) -> Replay { + assert_close(name, p, locals, 400).2 +} + +fn assert_close(name: &str, p: &Presentation, locals: usize, most: usize) -> (Image, Image, Replay) { + let (a, b, rec) = both(p); + let n = differing(&a, &b, 40); + assert!(n <= most, "{name}: {n} pixels differ between the pixel and the vector route"); + assert_eq!(rec.locals.len(), locals, "{name}: local rasters {:?}", rec.locals); + (a, b, rec) +} + +fn deck_with(shapes: Vec) -> Presentation { + let mut p = Presentation::default(); + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = shapes; + p +} + +fn rgb(r: u8, g: u8, b: u8) -> ColorRef { + ColorRef::rgb(Rgba::rgb(r, g, b)) +} + +fn no_line() -> Option { + Some(Line { fill: Some(Fill::None), ..Default::default() }) +} + +fn box_shape(id: u32, fill: Fill) -> Shape { + Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), fill: Some(fill), line: no_line(), ..Default::default() } +} + +fn two_stops() -> Vec { + vec![GradientStop { pos: 0.0, color: rgb(255, 255, 255) }, GradientStop { pos: 1.0, color: rgb(0, 0, 160) }] +} + +/// Circle gradients from a focus off the centre (PowerPoint's two-circle gradient) and through a +/// tile rectangle are vector paints, the same as on screen. +#[test] +fn off_centre_and_tiled_radial_gradients_stay_vector() { + for (name, focus, tile) in [ + ("off centre", [0.2, 0.2, 0.8, 0.8], [0.0; 4]), + ("centred", [0.5, 0.5, 0.5, 0.5], [0.0; 4]), + ("corner through its tile", [0.0, 0.0, 1.0, 1.0], [-1.0, -1.0, 0.0, 0.0]), + ] { + let g = Gradient { stops: two_stops(), shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true, tile }; + assert_same(name, &deck_with(vec![box_shape(40, Fill::Gradient(g))]), 0); + } + // A linear one through a tile rectangle, and one that keeps to the slide in a turned shape. + let g = Gradient { + stops: two_stops(), + shape: GradientShape::Linear { angle: 30.0, scaled: false }, + rotate_with_shape: true, + tile: [0.25, 0.0, 0.25, 0.0], + }; + assert_same("linear tile", &deck_with(vec![box_shape(41, Fill::Gradient(g))]), 0); + let g = Gradient { stops: two_stops(), shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: false, tile: [0.0; 4] }; + let mut sh = box_shape(42, Fill::Gradient(g)); + sh.xfrm = Some(Xfrm { rot: 60.0, ..Xfrm::new(200.0, 150.0, 300.0, 120.0) }); + assert_same("rotWithShape=0", &deck_with(vec![sh]), 0); +} + +#[allow(clippy::unwrap_used)] +fn half_red_half_blue() -> Vec { + let img = image::RgbImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) }); + let mut out = std::io::Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +/// Tiled picture fills (tile size from the picture's resolution, alignment, mirrored tiles) reach +/// a vector canvas as one embedded picture per tile, mirrored ones drawn flipped: the same tiling +/// as the pixel route's repeating image paint. (Where two tiles meet, the repeating paint blends +/// across the seam while separate pictures don't: only there may the routes differ.) +#[test] +fn tiled_pictures_tile_flip_and_align_on_vector_canvases() { + let bounds = Rect::new(100.0, 100.0, 340.0, 170.0); + for (flip, align) in [("none", "tl"), ("x", "tl"), ("y", "ctr"), ("xy", "br")] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 3.0, ty: -2.0, sx: 5.0, sy: 5.0, flip: flip.into(), align: align.into() }; + let mut sh = box_shape(43, Fill::Picture(PictureFill { media, mode, ..Default::default() })); + sh.xfrm = Some(Xfrm::new(bounds.x0, bounds.y0, bounds.width(), bounds.height())); + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let (a, b, rec) = assert_close(&format!("tiles {flip} {align}"), &p, 0, 3000); + assert!(rec.images.len() > 8 && rec.images.iter().all(|e| *e), "{flip}: {:?}", rec.images); + // 8 x 4 px at 96 dpi scaled five times: 30 x 15 pt tiles. The middle of each half of + // each tile is the same colour both ways. + let o = paint::tile_origin(Rect::new(0.0, 0.0, bounds.width(), bounds.height()), align, (30.0, 15.0), (3.0, -2.0)); + for j in -1..6 { + for i in -1..9 { + for half in [7.5, 22.5] { + let (x, y) = (bounds.x0 + o.x + 30.0 * i as f64 + half, bounds.y0 + o.y + 15.0 * j as f64 + 7.5); + if !(x > bounds.x0 + 1.0 && x < bounds.x1 - 1.0 && y > bounds.y0 + 1.0 && y < bounds.y1 - 1.0) { + continue; + } + let (pa, pb) = (a.pixel(x as u32, y as u32), b.pixel(x as u32, y as u32)); + assert!(pa.iter().zip(pb.iter()).all(|(u, v)| u.abs_diff(*v) <= 40), "{flip} {align} at ({x}, {y}): {pa:?} vs {pb:?}"); + } + } + } + } +} + +/// 3-D bevels and lit faces are pixels: a vector canvas gets them as a local raster over the +/// shape, never dropped. +#[test] +fn bevels_and_lit_faces_reach_vector_canvases_as_local_pixels() { + let scene = r#""#; + for (name, sp3d) in [("bevel", r#""#), ("face", "")] { + let mut sh = box_shape(44, Fill::solid(rgb(91, 155, 213))); + sh.effects = Some(deckcraft_model::Effects { raw3d: Some(format!("{scene}{sp3d}")), ..Default::default() }); + let rec = assert_same(name, &deck_with(vec![sh]), 1); + let r = rec.locals[0]; + assert!(r.x0 >= 99.0 && r.y0 >= 99.0 && r.x1 <= 501.0 && r.y1 <= 301.0, "{name}: {r:?}"); + } +} + +fn offset_shadow() -> deckcraft_model::Effects { + let sh = deckcraft_model::style::Shadow { + color: ColorRef::rgb(Rgba::BLACK), + blur: 4.0, + dist: 20.0, + dir: 0.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: "b".into(), + rotate_with_shape: false, + }; + deckcraft_model::Effects { outer_shadow: Some(sh), ..Default::default() } +} + +/// A group's shadow is cast by its children: on a vector canvas it is a local raster under the +/// group, the children themselves stay vector. Children filled with the group's fill keep it +/// laid out across the group, inside a local raster too. +#[test] +fn group_shadows_and_group_fills_on_vector_canvases() { + let child = |id: u32, x: f64, fill: Fill| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 0.0, 80.0, 100.0)), + fill: Some(fill), + line: no_line(), + ..Default::default() + }; + let group = |children: Vec, fill: Option, effects| Shape { + id: ShapeId(45), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + fill, + effects, + kind: ShapeKind::Group { children, child: Xfrm::new(0.0, 0.0, 200.0, 100.0) }, + ..Default::default() + }; + let red = Fill::solid(rgb(255, 0, 0)); + let g = group(vec![child(46, 0.0, red.clone()), child(47, 120.0, red)], None, Some(offset_shadow())); + let rec = assert_same("group shadow", &deck_with(vec![g]), 1); + let r = rec.locals[0]; + assert!(r.x0 <= 100.0 && r.x1 >= 320.0 && r.y1 <= 230.0, "{r:?}"); + // grpFill children, one with a shadow of its own (a local raster laid out through the view). + let grad = Fill::Gradient(Gradient { + stops: two_stops(), + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + }); + let mut shadowed = child(49, 120.0, Fill::Group); + shadowed.effects = Some(offset_shadow()); + let g = group(vec![child(48, 0.0, Fill::Group), shadowed], Some(grad), None); + assert_same("group fill", &deck_with(vec![g]), 1); +} + +/// Gradient lines and their arrowheads are vector strokes and fills with the gradient paint. +#[test] +fn gradient_lines_and_heads_stay_vector() { + let grad = Fill::Gradient(Gradient { + stops: vec![GradientStop { pos: 0.0, color: rgb(255, 0, 0) }, GradientStop { pos: 1.0, color: rgb(0, 0, 255) }], + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + }); + let head = deckcraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() }; + let sh = Shape { + id: ShapeId(50), + xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 0.0)), + geom: deckcraft_model::Geom::preset("line"), + line: Some(Line { fill: Some(grad), width: Some(12.0), tail: Some(head), ..Default::default() }), + ..Default::default() + }; + assert_same("gradient line", &deck_with(vec![sh]), 0); +} diff --git a/crates/render/src/text_draw.rs b/crates/render/src/text_draw.rs index 3fe96d694..554f5e513 100644 --- a/crates/render/src/text_draw.rs +++ b/crates/render/src/text_draw.rs @@ -1,8 +1,7 @@ use super::geometry::{blur, color, pre_translate, scale_of}; use super::opts::ParaState; -use super::{ - Affine, Arc, BezPath, Ctx, FontDb, HashMap, Opts, PhType, Rect, RenderContext, Rgba, Shape, TextBody, Vec2, colr, paint, peniko, resolve, -}; +use super::slide::color_glyph; +use super::*; /// The prompt shown in an empty placeholder, formatted like its content would be. pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { @@ -55,10 +54,11 @@ pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc p } -/// Draw a text body into shape space `m` within the text rect `opts.rect`. -pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, m: Affine, opts: &Opts) { - let l = deckcraft_text::layout(rctx, s, body, opts); - draw_layout(ctx, &l, opts.rect, m); +/// Draw a text body into shape space `m` within the text rect. +#[allow(clippy::too_many_arguments)] +pub fn draw_text(ctx: &mut dyn Canvas, rctx: &Ctx, s: &Shape, body: &TextBody, tr: Rect, m: Affine, fields: &dyn Fields, prompt: Option) { + let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: prompt, no_shrink: false }); + draw_layout(ctx, &l, tr, m); } /// Add transient list markers to an editor layout, using the document's own bullet glyphs. @@ -97,15 +97,15 @@ pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option = Vec::new(); + let mut grads: Vec<(usize, Paint)> = Vec::new(); for (gid, x, y) in &run.glyphs { let at = m * glyph_place(run, *gid, *x, *y); let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else { @@ -172,7 +177,7 @@ pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f /// Fill an underline, strikethrough or highlight with the current paint and transform: its box, /// its dashes, or its zigzag stroked. -pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) { +pub(crate) fn fill_deco(ctx: &mut dyn Canvas, d: &deckcraft_text::Deco) { if let Some((pts, w)) = d.wave() { let mut path = BezPath::new(); for (i, p) in pts.iter().enumerate() { @@ -229,8 +234,36 @@ enum TextEffect { Glow, } -/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`. -fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { +/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`. Blurs need pixels: +/// vector canvases get the effect rasterized over the run's area. +fn text_effect(ctx: &mut dyn Canvas, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { + let Some(fx) = run.effects else { return }; + let reach = match (which, fx.shadow, fx.glow) { + (TextEffect::Shadow, Some(s), _) => s.dx.abs() + s.dy.abs() + s.blur * 1.5, + (TextEffect::Glow, _, Some((_, r))) => r * 1.5, + _ => return, + }; + if let Some(px) = ctx.raster() { + text_effect_px(px, run, which, m, opacity); + return; + } + let Some(fb) = ctx.fallback() else { return }; + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let mut area: Option = None; + for (gid, x, y) in &run.glyphs { + let b = (m * glyph_place(run, *gid, *x, *y) * skew * Affine::scale(k)).transform_rect_bbox(glyph(&run.face, *gid).bounding_box()); + if b.is_finite() { + area = Some(area.map_or(b, |a| a.union(b))); + } + } + let Some(area) = area else { return }; + let pad = (if reach.is_finite() { reach.clamp(0.0, 1.0e4) } else { 0.0 } + 2.0) * scale_of(m); + canvas::rasterize(ctx, fb, area.inflate(pad, pad), &mut |v, pre| text_effect_px(v, run, which, pre * m, opacity)); +} + +/// [`text_effect`] on the pixel context. +fn text_effect_px(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { let Some(fx) = run.effects else { return }; let k = run.size / run.face.upem.max(1.0); let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; @@ -267,20 +300,13 @@ fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: T } /// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`. -pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { +pub(crate) fn draw_glyph(ctx: &mut dyn Canvas, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { draw_glyph_with(ctx, run, gid, at, opacity, None); } /// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from /// the glyph's origin to that space) instead of the run's colour. -fn draw_glyph_with( - ctx: &mut RenderContext, - run: &deckcraft_text::GlyphRun, - gid: u32, - at: Affine, - opacity: f64, - fill: Option<(&peniko::Gradient, Affine)>, -) { +fn draw_glyph_with(ctx: &mut dyn Canvas, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64, fill: Option<(&Paint, Affine)>) { let alpha = run.alpha * opacity; if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { return; @@ -288,7 +314,7 @@ fn draw_glyph_with( let k = run.size / run.face.upem.max(1.0); // Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still // strokes the plain glyph below. - if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) { + if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && color_glyph(ctx, run, gid, at, k, alpha) { return; } let path = glyph(&run.face, gid); @@ -300,10 +326,14 @@ fn draw_glyph_with( if let Some((g, to_text)) = fill && alpha > 0.0 { + // The glyph moved into the text's space, where the gradient is laid out (no paint + // transform: vector canvases have none). + let mut in_text = (*path).clone(); + in_text.apply_affine(to_text.inverse() * skew * Affine::scale(k)); + ctx.set_transform(at * to_text); ctx.set_paint(g.clone()); - ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text); - ctx.fill_path(&path); - ctx.reset_paint_transform(); + ctx.fill_path(&in_text); + ctx.set_transform(at * skew * Affine::scale(k)); } else if alpha > 0.0 && run.color.a > 0 { ctx.set_paint(color(run.color, alpha)); ctx.fill_path(&path); diff --git a/crates/render/src/warp.rs b/crates/render/src/warp.rs index 58caa2ead..2e65f5749 100644 --- a/crates/render/src/warp.rs +++ b/crates/render/src/warp.rs @@ -7,7 +7,9 @@ use deckcraft_geom::warp::{Warp, WarpLine}; use deckcraft_text::{GlyphRun, TextLayout}; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _}; -use vello_cpu::{RenderContext, peniko}; +use vello_cpu::peniko; + +use crate::canvas::Canvas; use crate::{ParaState, color, glyph, scale_of, text_transform}; @@ -106,7 +108,7 @@ struct Item<'a> { } /// Draw `l` (laid out in text rect `tr`, shape transform `m`) through `warp`. -pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)], warp: &Warp) { +pub(crate) fn draw(ctx: &mut dyn Canvas, l: &TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)], warp: &Warp) { let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); let m = text_transform(m, tr, l.rotation); let n = l.lines.len(); @@ -150,7 +152,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, let px = scale_of(m).max(1e-6) * mapper.zoom(); let tol = (0.1 / px).clamp(1e-4, 2.0); let seg = (2.0 / px).clamp(1e-3, 50.0); - let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { + let deco = |ctx: &mut dyn Canvas, d: &deckcraft_text::Deco| { let ps = para(d.para); if !ps.visible || ps.opacity <= 0.001 { return; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f764cf52a..47f598f01 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -249,6 +249,7 @@ pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &Body }) .collect(); // Merge adjacent decorations of the same colour on the same line. + out.texts = shaped_paras.iter().map(|p| p.cells.iter().map(|c| c.ch).collect()).collect(); out.decos = merge_decos(std::mem::take(&mut out.decos)); out } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9e03a2b4d..370ac5860 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -117,6 +117,9 @@ pub struct TextLayout { /// Per paragraph: whether a caret may stand at each character offset 0..=len (grapheme /// boundaries), for logical caret movement. pub boundaries: Vec>, + /// Per paragraph: its characters as laid out (fields filled in), indexed like + /// [`GlyphRun::cells`] and [`GlyphRun::chars`] (text extraction of exports with real text). + pub texts: Vec, } /// Which side of a boundary a caret belongs to: `Downstream` sticks to the character after it diff --git a/docs/architecture.md b/docs/architecture.md index 2a600e57f..4a6ce136d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,8 +56,13 @@ textures stay straight; soft edges are per-corner opacities. The show renders th transition and only composites cached textures each frame; `render::composite_transition` is the CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape comes from `anim::timeline` each frame. Animations ▸ Preview plays that same state on the editor -canvas for the current slide, then draws the slide at rest. PDF export renders each slide at print resolution and -overlays an invisible real-text layer with embedded font subsets. +canvas for the current slide, then draws the slide at rest. The renderer draws through a small `Canvas` trait +(`crates/render/src/canvas.rs`): `vello_cpu` for pixels, and a krilla canvas in `crates/pdf` that +writes PDF paths, paints, pictures and real text. What the renderer only draws in pixels (shapes +with blur-based effects or a fill overlay, pattern fills, rectangle and shape path gradients, text +shadows and glows, colour emoji, WMF/EMF pictures) reaches a vector canvas as a pixmap of that +element alone (`canvas::pixels`); `vector: false` keeps the older whole-slide raster with an +invisible text layer. ## File I/O @@ -96,6 +101,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | 2026-10-14 | minor | Paragraph and Tabs dialogs; command registry 229; 29 dialogs | | 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | +| 2026-10-12 | minor | Renderer draws through a `Canvas` trait; the PDF export is a vector canvas | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | The PDF writer dependency enables only `write`. Krilla handles serialization and simple-text diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d68596e22..4aae0530e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -24,7 +24,7 @@ PowerPoint opens and saves; Viewer = opens only). | `.odp` OpenDocument | Editor | no | no | | none | | `.thmx` Office theme | Viewer | theme only (Design › Browse for Themes…) | no | Browse for Themes takes a `.thmx`'s colours, fonts and effects; a `.pptx` or `.potx` brings its slide master and layouts as well, and slides move to the matching layouts (#807). A `.thmx`'s own master and layouts aren't read yet | `crates/pptx/src/lib.rs` (`import_theme_tests`), `crates/engine/src/cmd/design/browse_theme_tests.rs` | | `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | -| PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | +| PDF | export | no | yes | Slides, notes pages, handouts (1–9), links, bookmarks. Vector: shapes, outlines, gradients, transparency, tables and charts as PDF paths; real text in embedded font subsets; JPEG/PNG pictures embedded as they are. Shapes with shadow, glow, soft edges, reflection or a fill overlay, pattern fills, rectangle and outline path gradients, text shadows and glows, and colour emoji rasterize alone at `dpi`; WMF/EMF pictures embed as pixels; `vector: false` keeps whole-slide rasters | `crates/pdf` tests | | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` and `.rtf` outline; `.md` as Markdown (headings are slides, list items bullets) (open, drop, `slide.fromOutline`) | `.txt` and `.rtf` outline | — | format + engine tests | | PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size. PNG can leave the slide background out (`transparentBackground`, Export… “Transparent background”, `deckcraft-cli render --transparent`), so only the slide's content is opaque (#784) | render, engine, UI and CLI tests | | TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none | @@ -129,6 +129,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-15 | minor | `.thmx` row: Browse for Themes reads a theme's colours, fonts and effects; a `.pptx`/`.potx` brings its slide master and layouts (#807) | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | +| 2026-10-15 | minor | PDF export draws slides as vectors and real text through the renderer's canvas; effects and patterns rasterize per element | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | | 2026-10-13 | minor | Embedded fonts read and written; TrueType, OpenType and Embedded OpenType faces drawn | | 2026-10-13 | minor | Owned corpus round-trip (parts and text). Notes-master header, footer, date and slide-number text is written back | diff --git a/docs/gaps.md b/docs/gaps.md index c4a875f1c..b2dddce2a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -14,7 +14,7 @@ tests (calibration in [target-app-parity.md](target-app-parity.md#calibration-ho When a gap closes, delete its entry, update the parity doc it points to, and add a line to the revision history. -## Ranked: the top 15 +## Ranked: the top 14 | # | Gap | Category | Beta | Hours | Doc | |---|---|---|:-:|---|---| @@ -31,8 +31,7 @@ revision history. | 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) | | 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) | | 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) | -| 14 | PDF export rasterizes slide artwork | File format | B | 5–8 | [file-format-parity.md](file-format-parity.md) | -| 15 | Slides rasterize on the CPU; no performance baseline | Performance / hardware | | 15–30 | [hardware-parity.md](hardware-parity.md) | +| 14 | Slides rasterize on the CPU; no performance baseline | Performance / hardware | | 15–30 | [hardware-parity.md](hardware-parity.md) | ## Stability @@ -132,12 +131,6 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** LibreOffice users and public-sector exchange. - **Estimate:** 12–20 h (read and write the common subset). -### PDF export rasterizes slide artwork -- **Evidence:** `crates/pdf` places each slide as an image under a selectable text layer, links and - bookmarks. -- **Impact:** large PDFs; blurry when zoomed or printed. -- **Estimate:** 5–8 h (vector paths, fills and text through the PDF writer). **Beta blocker.** - ### No printing - **Evidence:** checklist row "Print" is missing; PR #66 (browser print) is open. - **Impact:** handouts and speaker notes are printed in many workplaces. @@ -439,6 +432,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-14 | minor | Pen pressure scales ink width when the pointer reports it; tilt, pinch-zoom and other trackpad gestures remain | | 2026-10-13 | trivial | Chart workbook round-trip (#335) noted under the charts gap; editing chart data is still open | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | +| 2026-10-15 | minor | Closed "PDF export rasterizes slide artwork": slides export as vector paths, real text and embedded pictures; only blur effects and patterns rasterize, per element | | 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 710191a53..b9afc7b11 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -7,14 +7,14 @@ The summary between ` | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| -| P0 | 72 | 61 | 11 | 0 | 92% | +| P0 | 72 | 62 | 10 | 0 | 93% | | P1 | 65 | 45 | 16 | 4 | 82% | | P2 | 54 | 18 | 24 | 12 | 56% | | Area | Parity | |---|---| | Application shell | 81% | -| Files | 76% | +| Files | 81% | | Slides | 92% | | Views | 98% | | Masters & themes | 92% | @@ -36,7 +36,7 @@ The summary between ` | 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: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), 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 @@ -62,7 +62,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Save as template | P1 | D | | Files | Autosave and crash recovery | P0 | D | | Files | Document properties | P2 | D | -| Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P | +| Files | Export to PDF (slides, notes pages, handouts; vector artwork and real text, links, bookmarks) | P0 | D | | Files | Export slides as PNG/JPEG | P0 | D | | Files | Export outline / RTF | P2 | D | | Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | diff --git a/docs/roadmap.md b/docs/roadmap.md index 205d96070..b2af185fc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,7 +19,7 @@ points and lead the beta plan. | Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; text inside EMF/WMF pictures is missing; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 | | Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window, See All Slides and zoom. The presenter timer pauses and resets, and notes have a size control. A custom show plays only its slides. Typing a slide number during the show shows it, and Enter goes there. Presenter view isn't placed on the second display automatically (drag it there; #433) | 2–4 | | Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 | -| Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 | +| Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF: vector artwork, real text, links, bookmarks, handouts 1–9; PNG/JPEG. No direct print (print the PDF) | 5–8 | | Review: comments with replies, resolve, spelling | partial (not blocking) | Comments, replies and resolve work and survive `.deckcraft` and `.pptx` (#70); Previous and Next walk every slide and the pane lists them; spelling uses installed Hunspell dictionaries (only a short built-in list where none is installed) | 4–8 | ## Current focus (2026-10-10) @@ -47,7 +47,7 @@ Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` | 2 | PPTX real-deck corpus and fixes | 20–35 | File formats 58 → 75% | | 3 | PPTX round-trip losses (#70, #74, #77), `.potx`/`.ppsx`/`.pptm` content types, MicroType embedded-font subsets | 8–15 | File formats → 80% | | 4 | EMF/WMF and SVG pictures | 8–14 | File formats, Pictures | -| 5 | Vector PDF, printing | 10–16 | File formats, Files | +| 5 | Printing (vector PDF is in) | 5–8 | File formats, Files | | 6 | Localization infrastructure + zh-Hans, es, fr, de, ja (with IME) | 27–46 | Localization 8 → ~40% | | 7 | Font weights (#69), image paste (#53), Tab in text (#78) | 6–11 | Features | | 8 | Charts: data grid, elements | 6–10 | Features | @@ -119,6 +119,7 @@ plan above. | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native save/reopen retains imported chart workbooks and related parts; headless command tests cover a PPTX → native → PPTX cycle and stale-workbook rejection. Milestone scope and scores unchanged | | 2026-10-15 | trivial | PPTX trigger click steps survive save, including by-paragraph builds. Interactive-deck gaps remain media actions, audio triggers and click-box geometry; milestone scope and feature scores unchanged | +| 2026-10-15 | minor | Vector PDF landed: the share row and beta item 5 now cover printing only | | 2026-10-14 | trivial | M13 reliability: remote MCP validates IDs and envelopes, caps replies and rejects incomplete frames; #718 | | 2026-10-14 | minor | Community PR wave folded: one Review gate row, one Current focus item 2, one beta row each for items 9 and 10 (timeline and timer controls are in), one after-beta paragraph, one M9 and M10 row, one corpus row in the 2026-10-07 history | | 2026-10-13 | trivial | Beta item 3: embedded fonts are in, except MicroType Express subsets | @@ -144,5 +145,5 @@ plan above. | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | -PDF writer consolidation preserves the current raster-artwork export and its selectable -text layer, notes and handouts. It does not advance vector-PDF or printing parity. +PDF writer consolidation preserves the vector-artwork export and its selectable text layer, +notes and handouts. It does not advance printing parity. diff --git a/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml index 6245147cf..230c22896 100644 --- a/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml @@ -31,9 +31,9 @@ finish-args: # document portal, so picked files work anywhere without broad filesystem access. Documents is # granted so decks passed by path (the command line, Open Recent) work in the usual place. - --filesystem=xdg-documents - # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.deckcraft` exposes it to - # host agents) and home/host filesystem access. + # Network: update checks reach GitHub. The optional --control server still listens on 127.0.0.1 + # only. Home/host filesystem access is deliberately not granted. + - --share=network modules: - name: deckcraft buildsystem: simple diff --git a/packaging/linux/flatpak/ai.storyteller.deckcraft.yml b/packaging/linux/flatpak/ai.storyteller.deckcraft.yml index 574dbc868..b99365c28 100644 --- a/packaging/linux/flatpak/ai.storyteller.deckcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.deckcraft.yml @@ -32,9 +32,9 @@ finish-args: # document portal, so picked files work anywhere without broad filesystem access. Documents is # granted so decks passed by path (the command line, Open Recent) work in the usual place. - --filesystem=xdg-documents - # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.deckcraft` exposes it to - # host agents) and home/host filesystem access. + # Network: update checks reach GitHub. The optional --control server still listens on 127.0.0.1 + # only. Home/host filesystem access is deliberately not granted. + - --share=network build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index ef7f99c5b..2fba0d144 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -80,6 +80,21 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped +# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked. +# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try. +stapler_retry() { + local try + for try in 1 2 3 4; do + xcrun stapler "$1" "$2" && return 0 + [[ "$try" = 4 ]] && break + echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2 + sleep $((try * 20)) + done + echo "error: stapler $1 $(basename "$2") failed 4 times" >&2 + exit 1 +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -121,8 +136,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/DeckCraft-notarize.zip" notarize "$WORK/DeckCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -147,8 +162,8 @@ sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi