From 2a90e827364d56051ad5ffa9e218097ae9fe1937 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:00 +0000 Subject: [PATCH] refactor(engine): move cmd/fileio/affinity.rs items to cmd/fileio/affinity/{document,import,layout,master_copies,nodes,paint,text}.rs --- crates/engine/src/cmd/fileio/affinity.rs | 1468 +---------------- .../src/cmd/fileio/affinity/document.rs | 211 +++ .../engine/src/cmd/fileio/affinity/import.rs | 48 + .../engine/src/cmd/fileio/affinity/layout.rs | 223 +++ .../src/cmd/fileio/affinity/master_copies.rs | 47 + .../engine/src/cmd/fileio/affinity/nodes.rs | 249 +++ .../engine/src/cmd/fileio/affinity/paint.rs | 387 +++++ crates/engine/src/cmd/fileio/affinity/text.rs | 326 ++++ 8 files changed, 1500 insertions(+), 1459 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/affinity/document.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/import.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/layout.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/master_copies.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/nodes.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/paint.rs create mode 100644 crates/engine/src/cmd/fileio/affinity/text.rs diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index ddb6905e9..671bc04b6 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -20,52 +20,16 @@ use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKin use super::load::{err, raster_doc}; use crate::Result; -/// What an Affinity file opened as. -pub(super) struct Imported { - pub doc: Document, - pub warnings: Vec, - /// Only the embedded preview: never placed, used as a template or mined for styles. - pub preview_only: bool, -} +mod document; +mod import; +mod layout; +mod master_copies; +mod nodes; +mod paint; +mod text; -pub(super) fn import(name: &str, bytes: &[u8]) -> Result { - match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) { - Ok(d) => { - let mut b = Builder::new(72.0 / d.dpi); - b.document(&d)?; - b.doc.limit_depth_noting(&mut b.warnings); - let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); - warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}."))); - Ok(Imported { doc: b.doc, warnings, preview_only: false }) - } - Err(native) => { - let (doc, w) = preview(name, bytes, &native)?; - Ok(Imported { doc, warnings: vec![w], preview_only: true }) - } - } -} - -/// The embedded preview, for files whose native document can't be read. -fn preview(name: &str, bytes: &[u8], native: &vectorcraft_affinity::Error) -> Result<(Document, String)> { - let p = vectorcraft_affinity::preview(bytes).map_err(|e| err(format!("{native}; its embedded preview can't be read either ({e})")))?; - // `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this - // untrusted preview's complete pixels first, so corrupt data cannot create a blank image. - let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(4096); - limits.max_image_height = Some(4096); - limits.max_alloc = Some(128 << 20); - reader.limits(limits); - let _pixels = reader.decode().map_err(err)?; - let d = raster_doc(name, p.png)?; - Ok(( - d, - format!( - "The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.", - p.width, p.height - ), - )) -} +pub(super) use import::import; +use master_copies::{CopyCost, MasterPart}; struct Builder { doc: Document, @@ -92,1417 +56,3 @@ struct Builder { /// Each master spread's objects as its pages show them, made the first time one does. master_parts: std::collections::HashMap>, } - -/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. -#[derive(Clone, Copy, Debug, Default)] -struct CopyCost { - work: u64, - pixels: u64, -} - -impl CopyCost { - fn plus(self, o: CopyCost) -> CopyCost { - CopyCost { work: self.work.saturating_add(o.work), pixels: self.pixels.saturating_add(o.pixels) } - } - fn minus(self, o: CopyCost) -> Option { - Some(CopyCost { work: self.work.checked_sub(o.work)?, pixels: self.pixels.checked_sub(o.pixels)? }) - } -} - -/// Master page copies of one import together: as many objects, characters and pixels as a -/// document may hold, a few times over. A master shown on thousands of pages, or a huge one -/// on many, would otherwise multiply them. -const MAX_MASTER_COPIES: CopyCost = CopyCost { work: 1 << 23, pixels: 512 << 20 }; - -/// One object a master spread shows on the pages that show it: a top-level node of the spread -/// (`child` `None`) or one object in one of its layers, with its bounds and what copying it takes. -#[derive(Clone, Copy, Debug)] -struct MasterPart { - node: usize, - child: Option, - bounds: Option, - cost: CopyCost, -} - -fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { - if depth > MAX_DEPTH { - return CopyCost::default(); - } - let len = |runs: &[af::TextRun]| runs.iter().map(|r| r.text.len() as u64).sum::(); - let own = match &n.kind { - Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, - Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, - Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, - _ => CopyCost { work: 1, pixels: 0 }, - }; - n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) -} - -/// Deepest node tree mapped (the reader bounds trees well below this). -const MAX_DEPTH: usize = 160; - -fn rect_of(r: af::Rect) -> Rect { - Rect::new(r.x0, r.y0, r.x1, r.y1) -} - -fn affine(a: af::Affine) -> Affine { - Affine::new(a.0) -} - -/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node -/// space into it (applied to the frame's geometry, not the type), plus the type's own scale. -struct Placement { - /// Node space to the frame's space, before `place`. - to_frame: Affine, - place: Affine, - /// Points per node unit of type size. - size_scale: f64, - /// Horizontal scale of the type relative to `size_scale`. - h_scale: f64, -} - -impl Builder { - fn new(k: f64) -> Self { - Self { - doc: Document::new(0.0, 0.0), - warnings: Vec::new(), - k, - layer: 0, - embedded: 0, - masters: Vec::new(), - cmyk_profile: None, - cmyk_lut: None, - working_cmyk: None, - overlay_work: vectorcraft_affinity::overlay::MAX_WORK, - copy_left: MAX_MASTER_COPIES, - master_parts: std::collections::HashMap::new(), - } - } - - fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) { - self.warnings.push(w.to_string()); - } - } - - fn document(&mut self, d: &af::Document) -> Result<()> { - self.cmyk_profile = d.cmyk_profile.clone(); - // Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin. - let mut layers = Vec::new(); - let mut artboards = Vec::new(); - let mut x = 0.0; - for (i, spread) in d.spreads.iter().enumerate() { - let shift = if i == 0 { 0.0 } else { x - spread.bounds.x0 * self.k }; - let to_doc = Affine::translate((shift, 0.0)) * Affine::scale(self.k); - let before = artboards.len(); - let mut loose: Vec> = Vec::new(); - for n in &spread.nodes { - let top = match &n.kind { - Kind::Layer => self.node(n, to_doc, &mut artboards, true, 0), - Kind::Artboard { .. } => { - self.node(n, to_doc, &mut artboards, true, 0).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)) - } - _ => { - if let Some(art) = self.node(n, to_doc, &mut artboards, false, 0) { - loose.push(art); - } - continue; - } - }; - if !loose.is_empty() { - layers.push(self.layer_with(None, std::mem::take(&mut loose))); - } - layers.extend(top); - } - if !loose.is_empty() { - layers.push(self.layer_with(None, loose)); - } - self.master_pages(d, spread, to_doc); - // Without artboards, each page (or the spread) becomes one. - if artboards.len() == before { - let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; - for r in rects { - let name = format!("{} {}", if d.spreads.len() > 1 || spread.pages.len() > 1 { "Page" } else { "Artboard" }, artboards.len() + 1); - artboards.push((name, to_doc.transform_rect_bbox(rect_of(r)))); - } - } - // (Earlier spreads' artboards are all left of `x` already.) - x = artboards.get(before..).unwrap_or_default().iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0; - if spread.transparent && i == 0 { - self.warn("transparent page backgrounds show as white artboards"); - } - } - self.doc.artboards = artboards - .into_iter() - .enumerate() - .filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0) - .map(|(i, (name, rect))| Artboard { - id: i as u32 + 1, - name, - rect, - locked: false, - show_center_mark: false, - show_cross_hairs: false, - show_video_safe_areas: false, - video_ruler_pixel_aspect_ratio: 1.0, - color: None, - }) - .collect(); - if self.doc.artboards.is_empty() { - return Err(err("the Affinity document has no page with a size")); - } - // Master pages are behind everything on the pages that show them. - let mut masters = std::mem::take(&mut self.masters); - masters.sort_by_key(|(m, _)| *m); - let mut bottom = Vec::new(); - for (m, art) in masters { - let mut l = (*self.layer_with(Some(&format!("Master {}", master_prefix(m))), art)).clone(); - l.locked = true; - bottom.push(Arc::new(l)); - } - // Bottom to top, as Affinity lists them. - bottom.extend(layers); - self.doc.layers = bottom; - if self.doc.layers.is_empty() { - let empty = self.layer_with(None, Vec::new()); - self.doc.layers.push(empty); - } - Ok(()) - } - - /// The master pages `spread`'s pages show, placed on those pages (`to_doc` places the spread). - /// Each master page shows only the objects on it, moved from its page to the one showing it. - fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) { - let instances = master_instances(&spread.nodes); - if instances.is_empty() { - return; - } - let owners = page_masters(&instances, spread.pages.len()); - for (pi, page) in spread.pages.iter().enumerate() { - let Some(&Some((master, first, master_page))) = owners.get(pi) else { continue }; - let Some(m) = d.masters.get(master) else { continue }; - let index = master_page.saturating_add(pi.saturating_sub(first)); - let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds); - let parts = self.master_parts(master, m); - let on_page = |b: &Option| b.is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1); - // Looking through the master's objects takes one each, copying them what they hold. - let looked = CopyCost { work: parts.len() as u64, pixels: 0 }; - let cost = parts.iter().filter(|p| on_page(&p.bounds)).fold(looked, |c, p| c.plus(p.cost)); - let Some(left) = self.copy_left.minus(cost) else { - self.warn("master pages shown on more pages than one import copies them to (left out on the rest)"); - return; - }; - self.copy_left = left; - let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0)); - let mut art = Vec::new(); - let mut boards = Vec::new(); - self.embedded += 1; - let mut i = 0; - while let Some(p) = parts.get(i).copied() { - let Some(n) = m.nodes.get(p.node) else { - i += 1; - continue; - }; - if p.child.is_none() { - if on_page(&p.bounds) { - art.extend(self.node(n, place, &mut boards, false, 0)); - } - i += 1; - continue; - } - // A layer of the master spread: just its objects on this page. - let mut kids: Vec> = Vec::new(); - while let Some(q) = parts.get(i).copied().filter(|q| q.node == p.node) { - if on_page(&q.bounds) - && let Some(c) = q.child.and_then(|c| n.children.get(c)) - { - kids.extend(self.node(c, place, &mut boards, false, 1)); - } - i += 1; - } - if !kids.is_empty() { - let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); - g.isolate = n.blend != Blend::PassThrough; - art.push(Arc::new(g)); - } - } - self.embedded -= 1; - if art.is_empty() { - continue; - } - let mut g = Node::group(self.doc.alloc_id(), art); - g.name = Some(format!("Master {} (page {})", master_prefix(master), pi + 1)); - match self.masters.iter_mut().find(|(i, _)| *i == master) { - Some((_, v)) => v.push(Arc::new(g)), - None => self.masters.push((master, vec![Arc::new(g)])), - } - } - } - - /// The objects of master spread `m` (number `index`) as its pages show them: its layers' - /// objects one by one and its other top-level objects, with their bounds and costs. - fn master_parts(&mut self, index: usize, m: &af::Spread) -> Arc<[MasterPart]> { - if let Some(parts) = self.master_parts.get(&index) { - return parts.clone(); - } - if !master_instances(&m.nodes).is_empty() { - self.warn("master pages based on other master pages (only their own objects are used)"); - } - let part = |node: usize, child: Option, n: &af::Node| MasterPart { - node, - child, - bounds: af::nodes_bounds(std::slice::from_ref(n)), - cost: copy_cost(n, 0), - }; - let mut parts = Vec::new(); - for (i, n) in m.nodes.iter().enumerate() { - if matches!(n.kind, Kind::Layer) { - parts.extend(n.children.iter().enumerate().map(|(c, child)| part(i, Some(c), child))); - } else { - parts.push(part(i, None, n)); - } - } - let parts: Arc<[MasterPart]> = parts.into(); - self.master_parts.insert(index, parts.clone()); - parts - } - - fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { - let id = self.doc.alloc_id(); - self.layer = self.layer.wrapping_add(1); - let label = name.map(str::to_string).unwrap_or_else(|| format!("Layer {}", self.layer)); - let mut n = Node::layer(id, &label, LayerColor::Preset(self.layer % 9)); - if let Some(c) = n.children_mut() { - *c = children; - } - Arc::new(n) - } - - /// An artboard at the top level becomes a layer of its name holding its clip group. - fn layer_of(&mut self, name: &str, art: Arc, visible: bool, locked: bool) -> Arc { - let mut l = (*self.layer_with(Some(if name.is_empty() { "Artboard" } else { name }), vec![art])).clone(); - l.visible = visible; - l.locked = locked; - Arc::new(l) - } - - fn base(&mut self, n: &af::Node, kind: NodeKind) -> Node { - let mut out = Node::new(self.doc.alloc_id(), kind); - out.name = (!n.name.is_empty()).then(|| n.name.clone()); - out.visible = n.visible; - out.locked = n.locked; - out.opacity = n.opacity as f32; - out.blend = self.blend(n.blend); - out - } - - fn blend(&mut self, b: Blend) -> BlendMode { - match b { - Blend::Normal | Blend::PassThrough => BlendMode::Normal, - Blend::Darken => BlendMode::Darken, - Blend::Multiply => BlendMode::Multiply, - Blend::ColorBurn => BlendMode::ColorBurn, - Blend::Lighten => BlendMode::Lighten, - Blend::Screen => BlendMode::Screen, - Blend::ColorDodge => BlendMode::ColorDodge, - Blend::Overlay => BlendMode::Overlay, - Blend::SoftLight => BlendMode::SoftLight, - Blend::HardLight => BlendMode::HardLight, - Blend::Difference => BlendMode::Difference, - Blend::Exclusion => BlendMode::Exclusion, - Blend::Hue => BlendMode::Hue, - Blend::Saturation => BlendMode::Saturation, - Blend::Color => BlendMode::Color, - Blend::Luminosity => BlendMode::Luminosity, - _ => { - self.warn("blend modes VectorCraft doesn't have (Add, Linear Light, Vivid Light, Pin Light, Hard Mix, Subtract, Darker/Lighter Colour, Average, Negation, Reflect, Glow, Erase) import as Normal"); - BlendMode::Normal - } - } - } - - /// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has - /// no overlay effect). - fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> { - // Each kind of message once, however often it is heard. - let mut warnings: Vec = Vec::new(); - let mut work = self.overlay_work; - let out = vectorcraft_affinity::overlay::apply(n, &mut work, &mut |w| { - if !warnings.iter().any(|x| x == w) { - warnings.push(w.to_string()); - } - }); - self.overlay_work = work; - for w in warnings { - self.warn(&w); - } - out - } - - /// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to - /// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer. - fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool, depth: usize) -> Option> { - if depth > MAX_DEPTH { - self.warn("objects nested too deeply (left out)"); - return None; - } - // VectorCraft documents stay within `MAX_COORD` of the origin (renderers and exports rely - // on it): objects reaching beyond it are left out. - if let Some(b) = af::kind_bounds(&n.kind) { - let r = to_doc.transform_rect_bbox(rect_of(b)); - if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) { - self.warn("objects beyond the document range VectorCraft supports (left out)"); - return None; - } - } - let n = &*self.overlaid(n); - // Register before descending so nested artboards keep their document order. Inside an - // embedded document an artboard is just its content's frame. - if let Kind::Artboard { rect, .. } = &n.kind - && self.embedded == 0 - { - boards.push(( - if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, - to_doc.transform_rect_bbox(rect_of(*rect)), - )); - } - let child_level = matches!(n.kind, Kind::Layer) && layer_level; - let embeds = n.class == vectorcraft_affinity::stream::Tag::of(b"EmbN"); - self.embedded += usize::from(embeds); - let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level, depth + 1)).collect(); - self.embedded -= usize::from(embeds); - let mut out = match &n.kind { - Kind::Layer if layer_level => { - let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone(); - l.visible = n.visible; - l.locked = n.locked; - l.opacity = n.opacity as f32; - l.blend = self.blend(n.blend); - l - } - Kind::Layer | Kind::Group => { - let mut g = self.base(n, NodeKind::Group { children, clip: false }); - // Affinity groups isolate their blending unless they pass through. - g.isolate = n.blend != Blend::PassThrough; - g - } - Kind::Shape { path, fills, strokes, even_odd } => { - let data = self.path(path, to_doc); - let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; - let mut appearance = self.appearance(fills, strokes, to_doc); - // An outline effect on a shape is one more stroke. - self.outline_stroke(&n.effects, &mut appearance); - if children.is_empty() { - let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false }); - p.appearance = appearance.into(); - p - } else { - // A shape clips its children: VectorCraft's clipping path keeps its paint, its - // fill behind the clipped art and its stroke over it, as Affinity draws it. - let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false }); - clip.appearance = appearance.into(); - let mut kids = vec![Arc::new(clip)]; - kids.extend(children); - self.base(n, NodeKind::Group { children: kids, clip: true }) - } - } - Kind::Artboard { path, even_odd, background, strokes, .. } => { - let mut clip = Node::new( - self.doc.alloc_id(), - NodeKind::Path { - path: self.path(path, to_doc), - rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, - live: None, - clipping: true, - guide: false, - }, - ); - clip.appearance = self.appearance(background, strokes, to_doc).into(); - let mut kids = vec![Arc::new(clip)]; - kids.extend(children); - let mut g = self.base(n, NodeKind::Group { children: kids, clip: true }); - g.name = Some(if n.name.is_empty() { "Artboard".into() } else { n.name.clone() }); - g - } - Kind::Text(t) => { - let Some((mut text, alpha)) = self.text(t, to_doc) else { - // The text is left out (warned); what it holds still comes in. - if children.is_empty() { - return None; - } - let g = self.base(n, NodeKind::Group { children, clip: false }); - return Some(Arc::new(g)); - }; - self.outline_text(&n.effects, &mut text); - let mut node = self.base(n, NodeKind::Text(Box::new(text))); - node.opacity *= alpha; - if !children.is_empty() { - let mut kids = vec![Arc::new(node)]; - kids.extend(children); - node = Node::group(self.doc.alloc_id(), kids); - } - node - } - Kind::Table(t) => { - let mut kids = self.table(t, to_doc); - if kids.is_empty() && children.is_empty() { - return None; - } - kids.extend(children); - let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); - g.name.get_or_insert_with(|| "Table".into()); - g - } - Kind::Image(img) => match self.image(img, to_doc) { - Some(kind) => self.base(n, kind), - None if children.is_empty() => return None, - None => self.base(n, NodeKind::Group { children, clip: false }), - }, - // Shown on the pages through the master's layer (see `master_pages`). - Kind::MasterInstance { .. } => return None, - Kind::Unsupported if children.is_empty() => return None, - Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }), - }; - if !matches!(n.kind, Kind::Shape { .. } | Kind::Text(_)) && n.effects.iter().any(|e| matches!(e, af::Effect::Outline { .. })) { - self.warn("outline effects on groups, images and tables (left out)"); - } - if let Some(img) = &n.pixel_mask { - // A pixel mask: its grey levels show (white) or hide (black) the node, and nothing - // shows outside it, as an opacity mask that clips. - if let Some(kind) = self.image(img, to_doc) { - let art = Node::new(self.doc.alloc_id(), kind); - out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); - } - } - if let Some(mask) = &n.mask { - // A vector mask: the node shows only inside it. - let clip = Node::new( - self.doc.alloc_id(), - NodeKind::Path { path: self.path(mask, to_doc), rule: FillRule::NonZero, live: None, clipping: true, guide: false }, - ); - let (opacity, blend, visible, name) = (out.opacity, out.blend, out.visible, out.name.clone()); - out.opacity = 1.0; - out.blend = BlendMode::Normal; - let mut g = Node::new(self.doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(out)], clip: true }); - (g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name); - out = g; - } - // Shadows and glows apply to what the node shows, mask and all. - let fx = self.effects(&n.effects); - if !fx.is_empty() { - out.appearance.effects.extend(fx); - } - Some(Arc::new(out)) - } - - /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's - /// blur radius sets the same Gaussian as VectorCraft's blur (σ = half of it), and its angle - /// is the direction the shadow goes in. - fn effects(&mut self, effects: &[af::Effect]) -> Vec { - let k = self.k; - let len = move |v: f64| if v.is_finite() { (v * k).clamp(0.0, 1000.0) } else { 0.0 }; - let num = |v: f64| (v * 1000.0).round() / 1000.0; - let mut out = Vec::new(); - for e in effects { - let (id, params) = match e { - af::Effect::DropShadow { color, opacity, radius, distance, angle } => { - let (color, alpha) = Self::color(*color); - let d = len(*distance); - let a = if angle.is_finite() { *angle } else { 0.0 }; - let p = json!({ - "mode": "normal", - "opacity": num(opacity * f64::from(alpha) * 100.0), - "x": num(d * a.cos()), - "y": num(d * a.sin()), - "blur": num(len(*radius)), - "color": color, - }); - ("stylize.dropShadow", p) - } - af::Effect::InnerShadow { color, opacity, radius, distance, .. } => { - self.warn("inner shadows (imported as inner glows at the edge, without their offset)"); - let (color, alpha) = Self::color(*color); - let p = json!({ - "mode": "normal", - "opacity": num(opacity * f64::from(alpha) * 100.0), - "blur": num(len(*radius) + len(*distance)), - "color": color, - "source": "edge", - }); - ("stylize.innerGlow", p) - } - af::Effect::OuterGlow { color, opacity, radius } => { - let (color, alpha) = Self::color(*color); - let p = - json!({ "mode": "normal", "opacity": num(opacity * f64::from(alpha) * 100.0), "blur": num(len(*radius)), "color": color }); - ("stylize.outerGlow", p) - } - af::Effect::Bevel { .. } => { - self.warn("bevel and emboss effects (left out)"); - continue; - } - // Outlines become strokes (`outline_stroke`, `outline_text`), overlays are baked - // into the paint (`overlaid`). - af::Effect::Outline { .. } | af::Effect::ColorOverlay { .. } | af::Effect::GradientOverlay { .. } => continue, - }; - out.push(Effect { id: id.into(), params, visible: true }); - } - out - } - - /// An outline effect on a shape: a stroke layer. Affinity outlines what the shape shows, its own - /// strokes included: an outline outside goes under them (from the path out to as far past them as - /// it is wide), one centred on or inside that edge over them. A centred one reaches as far into - /// the shape as out of it here, which only differs by the width of the shape's own stroke. - fn outline_stroke(&mut self, effects: &[af::Effect], appearance: &mut Appearance) { - // How far the shape's own strokes reach outside its path. - let reach = appearance - .items - .iter() - .filter_map(|i| match i { - AppearanceItem::Stroke(s) => Some(match s.align { - StrokeAlign::Center => s.width / 2.0, - StrokeAlign::Outside => s.width, - StrokeAlign::Inside => 0.0, - }), - _ => None, - }) - .filter(|w| w.is_finite()) - .fold(0.0, f64::max); - for e in effects { - let af::Effect::Outline { color, opacity, width, align } = e else { continue }; - let w = width * self.k; - if !(w.is_finite() && w > 0.0) { - continue; - } - let (c, alpha) = Self::color(*color); - let (align, w) = match align { - 1 => (StrokeAlign::Inside, w), - 2 => (StrokeAlign::Center, w + 2.0 * reach), - _ => (StrokeAlign::Outside, w + reach), - }; - let mut l = StrokeLayer::new(Paint::solid(c), w.min(1000.0)); - l.opacity = (opacity.clamp(0.0, 1.0) as f32) * alpha; - l.join = LineJoin::Round; - l.align = align; - let item = AppearanceItem::Stroke(l); - if align == StrokeAlign::Outside { - // Under the shape's strokes, over its fills. - let at = appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap_or(appearance.items.len()); - appearance.items.insert(at, item); - } else { - appearance.items.push(item); - } - } - } - - /// An outline effect on text: the characters' stroke, where they have none of their own. - fn outline_text(&mut self, effects: &[af::Effect], text: &mut TextObject) { - for e in effects { - let af::Effect::Outline { color, opacity, width, align } = e else { continue }; - let w = width * self.k; - if !(w.is_finite() && w > 0.0) { - continue; - } - let (c, alpha) = Self::color(*color); - if opacity * f64::from(alpha) < 0.999 { - self.warn("semi-transparent outline effects on text (imported opaque)"); - } - if *align != 2 { - self.warn("outline effects on text inside or outside the characters (centred on them)"); - } - for r in &mut text.runs { - if r.style.stroke == Paint::None { - r.style.stroke = Paint::solid(c); - // Centred on the outline, so twice as wide to show as far outside. - r.style.stroke_width = if *align == 2 { w } else { 2.0 * w }.min(1000.0); - } - } - } - } - - fn path(&self, p: &af::Path, to_doc: Affine) -> PathData { - let mut bp = BezPath::new(); - for s in &p.subpaths { - let pt = |q: af::Point| Point::new(q.x, q.y); - bp.move_to(pt(s.start)); - for [c1, c2, e] in &s.segments { - if c1 == c2 && c2 == e { - bp.line_to(pt(*e)); - } else { - bp.curve_to(pt(*c1), pt(*c2), pt(*e)); - } - } - if s.closed { - bp.close_path(); - } - } - PathData::from_bezpath(&(to_doc * bp)) - } - - fn color(c: ap::Color) -> (Color, f32) { - let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; - match c { - ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)), - ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)), - ap::Color::Gray { v, a } => (Color::Gray { k: 1.0 - f(v) }, f(a)), - ap::Color::Lab { l, a, b, alpha } => (Color::Lab { l: l as f32, a: a as f32, b: b as f32 }, f(alpha)), - } - } - - /// A paint and the opacity its colour's alpha becomes. - fn paint(&mut self, p: &ap::Paint, to_doc: Affine) -> Option<(Paint, f32)> { - match p { - ap::Paint::None => None, - ap::Paint::Solid(c) => { - let (color, a) = Self::color(*c); - Some((Paint::solid(color), a)) - } - ap::Paint::Gradient(g) => { - let stops = g - .stops - .iter() - .map(|s| { - let (color, opacity) = Self::color(s.color); - let mut stop = GradientStop::new(s.offset as f32, color); - stop.opacity = opacity; - stop.midpoint = s.half_point().clamp(0.13, 0.87) as f32; - stop - }) - .collect(); - let m = to_doc * affine(g.transform); - let (o, x, y) = (m * Point::ZERO, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0)); - let kind = match g.kind { - AfKind::Linear => GradientKind::Linear, - AfKind::Radial => GradientKind::Radial, - AfKind::Conical => { - self.warn("conical gradients import as radial gradients"); - GradientKind::Radial - } - }; - let (u, v): (Vec2, Vec2) = (x - o, y - o); - if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 { - self.warn("skewed elliptical gradients import as upright ellipses"); - } - let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 }; - let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None }; - let mut gp = GradientPaint::new(Gradient::new(kind, stops)); - gp.angle = (-(u.y)).atan2(u.x).to_degrees(); - gp.geom = Some(geom); - Some((Paint::Gradient(Box::new(gp)), 1.0)) - } - } - } - - fn stroke_layer(&mut self, s: &ap::Stroke, to_doc: Affine) -> Option { - let (paint, opacity) = self.paint(&s.paint, to_doc)?; - let mut l = StrokeLayer::new(paint, s.width * self.k); - l.opacity = opacity; - l.cap = match s.cap { - Cap::Butt => LineCap::Butt, - Cap::Round => LineCap::Round, - Cap::Square => LineCap::Square, - }; - l.join = match s.join { - Join::Miter => LineJoin::Miter, - Join::Round => LineJoin::Round, - Join::Bevel => LineJoin::Bevel, - }; - l.miter_limit = s.miter_limit; - l.align = match s.align { - ap::Align::Center => StrokeAlign::Center, - ap::Align::Inside => StrokeAlign::Inside, - ap::Align::Outside => StrokeAlign::Outside, - }; - l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { - pattern: pattern.iter().map(|v| v * self.k).collect(), - offset: phase * self.k, - align_corners: false, - }); - Some(l) - } - - fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance { - let mut items = Vec::new(); - let mut above = Vec::new(); - for f in fills { - if let Some((paint, opacity)) = self.paint(f, to_doc) { - let mut l = FillLayer::new(paint); - l.opacity = opacity; - items.push(AppearanceItem::Fill(l)); - } - } - for s in strokes { - let Some(l) = self.stroke_layer(s, to_doc) else { continue }; - if s.behind { - items.insert(0, AppearanceItem::Stroke(l)); - } else { - above.push(AppearanceItem::Stroke(l)); - } - } - items.extend(above); - Appearance { items, ..Appearance::default() } - } - - /// Where text placed by `m` (node space to points) goes, its type scaled by `type_m`. - fn placement(&mut self, m: Affine, type_m: Affine) -> Option { - let [a, b, c, d, e, f] = m.as_coeffs(); - let sx = a.hypot(b); - let det = a * d - b * c; - if !(sx > 1e-9 && det.is_finite() && det.abs() > 1e-12 && e.is_finite() && f.is_finite()) { - self.warn("text with an invalid transform (left out)"); - return None; - } - let sy = det / sx; - let shear = (a * c + b * d) / (sx * sx); - if shear.abs() > 1e-3 || sy < 0.0 { - self.warn("skewed or flipped text (imported without the skew or flip)"); - } - // The type's vertical scale gives the font size, its horizontal scale relative to that the - // characters' width. - let [ta, tb, tc, td, ..] = type_m.as_coeffs(); - let (tsx, tdet) = (ta.hypot(tb), ta * td - tb * tc); - let (size_scale, h_scale) = if tsx > 1e-9 && tdet.is_finite() && tdet.abs() > 1e-12 { - let tsy = (tdet / tsx).abs(); - (tsy, tsx / tsy) - } else { - self.warn("text with an invalid text scale (imported unscaled)"); - (det.abs().sqrt(), 1.0) - }; - Some(Placement { - to_frame: Affine::scale_non_uniform(sx, sy.abs()), - place: Affine::translate((e, f)) * Affine::rotate(b.atan2(a)), - size_scale, - h_scale, - }) - } - - /// Text → its object and the opacity its characters' shared transparency gives the node. - fn text(&mut self, t: &af::Text, to_doc: Affine) -> Option<(TextObject, f32)> { - let p = self.placement(to_doc * affine(t.transform), to_doc * affine(t.text_transform))?; - let alpha = self.text_alpha(&t.runs); - let mut obj = self.type_object(&t.runs, &p)?; - obj.para.justify = justify(t.align); - if let Some(first) = obj.paras.first_mut() { - first.justify = obj.para.justify; - } - match t.frame { - Some(f) => { - let (a, b) = (p.to_frame * Point::new(f.x0, f.y0), p.to_frame * Point::new(f.x1, f.y1)); - let r = Rect::from_points(a, b); - obj.xf = p.place * Affine::translate(r.origin().to_vec2()); - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, r.width(), r.height())) }; - // Affinity hangs a frame's first line from its capitals' tops, not the font's ascent. - obj.area.first_baseline = FirstBaseline::CapHeight; - if fit_lines(&mut obj, r.width(), r.height()) { - self.warn("text frames whose last lines hang out of them (made taller so the lines show)"); - } - // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. - if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { - self.warn("text frames too short for their first line (made taller so the line shows)"); - } - } - None => { - align_lines(&mut obj); - obj.xf = p.place * Affine::translate((p.to_frame * Point::new(t.anchor.x, t.anchor.y)).to_vec2()); - } - } - Some((obj, alpha)) - } - - /// The opacity text takes from its characters' colour: VectorCraft's characters have none of - /// their own, so text whose characters share one becomes an object with that opacity. - fn text_alpha(&mut self, runs: &[af::TextRun]) -> f32 { - let alphas: Vec = - runs.iter().filter_map(|r| if let ap::Paint::Solid(c) = &r.fill { Some(c.alpha()) } else { None }).filter(|a| a.is_finite()).collect(); - let (lo, hi) = alphas.iter().fold((1.0f64, 0.0f64), |(lo, hi), a| (lo.min(*a), hi.max(*a))); - if alphas.is_empty() || lo >= 1.0 { - return 1.0; - } - if hi - lo > 0.01 { - self.warn("text with characters of different transparency (imported opaque)"); - return 1.0; - } - lo.clamp(0.0, 1.0) as f32 - } - - /// The characters and paragraphs of `runs` as a text object at the origin of text space. - fn type_object(&mut self, runs: &[af::TextRun], p: &Placement) -> Option { - let (v, h) = (p.size_scale, p.size_scale * p.h_scale); - let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); - let mut formats: Vec = Vec::new(); - let mut out: Vec = Vec::new(); - let mut paragraph = 0usize; - // Whether each paragraph here continues the one before after a line break (U+2028): - // VectorCraft has no line break inside a paragraph, so the lines become paragraphs. - let mut continues = vec![false]; - for r in runs { - for c in r.text.chars() { - match c { - '\u{2028}' => continues.push(true), - '\u{2029}' | '\r' | '\n' => continues.push(false), - _ => {} - } - } - // Affinity separates paragraphs with U+2029 and lines with U+2028; other control - // characters don't show. - let text: String = r - .text - .chars() - .filter_map(|c| match c { - '\u{2029}' | '\u{2028}' | '\r' | '\n' => Some('\n'), - '\t' => Some('\t'), - c if c.is_control() => None, - c => Some(c), - }) - .collect(); - if text.is_empty() { - continue; - } - for c in text.chars() { - if formats.len() <= paragraph { - formats.push(r.paragraph); - } - if c == '\n' { - paragraph += 1; - } - } - let mut style = self.char_style(r, (v, p.h_scale)); - if style.leading.is_none() { - style.leading = r.paragraph.leading.map(|l| l * v).filter(|l| l.is_finite() && *l > 0.0).map(|l| l.min(5000.0)); - } - out.push(TextRun::new(text, style)); - } - // Affinity ends a story with a paragraph mark; VectorCraft's last paragraph has none. - if let Some(last) = out.last_mut() - && last.text.ends_with('\n') - { - last.text.pop(); - if last.text.is_empty() { - out.pop(); - } - } - obj.runs = out; - let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::() + 1; - let mut paras = Vec::with_capacity(count); - let mut previous_after = 0.0; - // The run of each paragraph's first character. - let mut first_runs = Vec::with_capacity(count); - { - let mut at_start = true; - for (i, r) in obj.runs.iter().enumerate() { - for c in r.text.chars() { - if at_start { - first_runs.push(i); - at_start = false; - } - if c == '\n' { - at_start = true; - } - } - } - } - // Line gaps by font, each looked up once. - let mut gaps: std::collections::HashMap<(&str, &str), f64> = std::collections::HashMap::new(); - for i in 0..count { - let f = formats.get(i).or(formats.last()).copied().unwrap_or_default(); - let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 }; - let mut ps = vectorcraft_doc::text::ParaStyle { justify: justify(f.align), ..Default::default() }; - // Affinity places the first line and the others from the left edge; VectorCraft's - // first-line indent is relative to its left indent. - ps.left_indent = pt(f.left, h); - ps.first_line_indent = pt(f.first - f.left, h); - ps.right_indent = pt(f.right, h); - // Between two paragraphs Affinity keeps the larger of the space after the first and - // before the second; VectorCraft adds them. - let (mut before, mut after) = (pt(f.space_before, v).max(0.0), pt(f.space_after, v).max(0.0)); - // The lines of one Affinity paragraph: no space between them, and the first-line - // indent only on its first. - if continues.get(i).copied().unwrap_or(false) { - before = 0.0; - ps.first_line_indent = 0.0; - } - if continues.get(i + 1).copied().unwrap_or(false) { - after = 0.0; - } - ps.space_before = if i == 0 { before } else { (before - previous_after).max(0.0) }; - ps.space_after = after; - previous_after = after; - // Automatic leading: the font size plus the font's line gap, times the paragraph's - // factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas - // Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em, - // and the public Arial frame fixture fits this better than the font's line height. - let (family, style) = - first_runs.get(i).and_then(|r| obj.runs.get(*r)).map_or(("", ""), |r| (r.style.font_family.as_str(), r.style.font_style.as_str())); - let gap = *gaps.entry((family, style)).or_insert_with(|| line_gap(family, style)); - let ratio = (1.0 + gap) * f.auto_leading; - // Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft - // takes 0–500 %. - if ratio.is_finite() && (0.01..=5.0).contains(&ratio) { - ps.justification.auto_leading = ratio * 100.0; - } - paras.push(ps); - } - if let Some(first) = paras.first() { - obj.para = first.clone(); - } - obj.paras = paras; - obj.normalize_paras(); - Some(obj) - } - - /// A run's character style; `(size_scale, h_scale)` the type's vertical scale to points and - /// its horizontal scale relative to that. - fn char_style(&mut self, r: &af::TextRun, (size_scale, h_scale): (f64, f64)) -> CharStyle { - let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; - let mut style = CharStyle::default(); - if !r.family.is_empty() { - style.font_family = r.family.clone(); - } else if !r.postscript.is_empty() { - style.font_family = r.postscript.clone(); - } else { - self.warn("text without a font name uses the default font"); - } - // A family/weight pair cannot identify width variants such as Arial Narrow. - // Resolve a known PostScript identity to the exact installed family and style. - if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { - style.font_family = family; - style.font_style = face_style; - } else { - style.font_style = font_style(r.weight, r.italic); - } - style.features = r.features.clone(); - if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { - self.warn("unsupported text OpenType features (retained but not rendered)"); - } - style.all_caps = r.all_caps; - style.underline = r.underline; - style.strikethrough = r.strikethrough; - style.size = finite(r.size * size_scale, 12.0).clamp(0.1, 1296.0); - style.leading = r.leading.map(|l| finite(l * size_scale, 0.0)).filter(|l| *l > 0.0).map(|l| l.min(5000.0)); - style.tracking = finite(r.tracking * 1000.0, 0.0).clamp(-1000.0, 10000.0); - // Text squeezed or stretched by its transform keeps that as horizontal scale, on top of - // its own. - let hs = h_scale * r.h_scale; - if hs.is_finite() && (hs - 1.0).abs() > 1e-3 { - style.h_scale = (hs * 100.0).clamp(1.0, 1000.0); - } - // Transparency is the object's (see `text_alpha`); the paint's transform doesn't matter - // for a solid colour, and gradients on type are laid out in text space. - style.fill = match self.paint(&r.fill, Affine::scale(size_scale)) { - Some((p, _)) => p, - None => Paint::None, - }; - if let Some(st) = &r.stroke { - let w = st.width * size_scale; - if let Some((paint, _)) = self.paint(&st.paint, Affine::scale(size_scale)) - && w.is_finite() - && w > 0.0 - { - style.stroke = paint; - style.stroke_width = w.min(1000.0); - } - } - style - } - - /// A table: its cells as area type and the lines on their edges, as VectorCraft has no - /// tables. - fn table(&mut self, t: &af::Table, to_doc: Affine) -> Vec> { - let mut out = Vec::new(); - let (ncols, nrows) = (t.ncols(), t.nrows()); - if ncols == 0 || nrows == 0 { - return out; - } - self.warn("tables (imported as text frames and lines)"); - let m = to_doc * affine(t.transform); - let Some(p) = self.placement(m, to_doc * affine(t.text_transform)) else { return out }; - let col = |i: usize| t.columns.get(i).copied().unwrap_or(0.0); - let row = |i: usize| t.rows.get(i).copied().unwrap_or(0.0); - for (i, cell) in t.cells.iter().enumerate() { - if cell.merged_left || cell.runs.is_empty() { - continue; - } - let (r, c) = (i / ncols, i % ncols); - // Cells to the right merged into this one widen it. - let span = 1 + t.cells.iter().skip(i + 1).take(ncols - c - 1).take_while(|n| n.merged_left).count(); - let [l, tp, rt, bt] = cell.inset; - let node = af::Rect { x0: col(c) + l, y0: row(r) + tp, x1: col(c + span) - rt, y1: row(r + 1) - bt }; - let (a, b) = (p.to_frame * Point::new(node.x0, node.y0), p.to_frame * Point::new(node.x1, node.y1)); - let rect = Rect::from_points(a, b); - if !(rect.width() > 0.0 && rect.height() > 0.0) { - continue; - } - let Some(mut obj) = self.type_object(&cell.runs, &p) else { continue }; - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, rect.width(), rect.height())) }; - obj.area.vertical_align = match cell.valign { - af::VAlign::Top => VerticalAlign::Top, - af::VAlign::Center => VerticalAlign::Center, - af::VAlign::Bottom => VerticalAlign::Bottom, - }; - obj.area.first_baseline = FirstBaseline::CapHeight; - let dy = place_cell(&mut obj, rect.width(), rect.height()); - obj.xf = p.place * Affine::translate(rect.origin().to_vec2() + Vec2::new(0.0, dy)); - // A cell's text never vanishes for want of a line's height. - vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj); - let alpha = self.text_alpha(&cell.runs); - let mut n = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))); - n.opacity = alpha; - out.push(Arc::new(n)); - } - // Lines over the cells: the vertical edges row by row (none inside merged cells), then - // the horizontal ones. - let mut line = |b: &mut Self, s: &Option, from: (f64, f64), to: (f64, f64)| { - let Some(s) = s else { return }; - let Some(layer) = b.stroke_layer(s, to_doc) else { return }; - let mut bp = BezPath::new(); - bp.move_to(m * Point::new(from.0, from.1)); - bp.line_to(m * Point::new(to.0, to.1)); - let mut n = Node::new( - b.doc.alloc_id(), - NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false }, - ); - n.appearance = Appearance { items: vec![AppearanceItem::Stroke(layer)], ..Appearance::default() }.into(); - out.push(Arc::new(n)); - }; - for r in 0..nrows { - for k in 0..=ncols { - let merged = k > 0 && k < ncols && t.cells.get(r * ncols + k).is_some_and(|c| c.merged_left); - if merged { - continue; - } - if let Some(s) = t.vertical.get(r * (ncols + 1) + k) { - line(self, s, (col(k), row(r)), (col(k), row(r + 1))); - } - } - } - for j in 0..=nrows { - for k in 0..ncols { - if let Some(s) = t.horizontal.get(j * ncols + k) { - line(self, s, (col(k), row(j)), (col(k + 1), row(j))); - } - } - } - out - } - - /// A CMYK pixel layer as an image file. Through the document's own CMYK profile when it - /// embeds one (as Affinity shows it): RGBA. Otherwise, opaque, a CMYK image that keeps its - /// inks (shown and separated through the working CMYK profile, as placed CMYK images are); - /// with transparency, RGBA through the working CMYK profile. - fn cmyk_image(&mut self, width: u32, height: u32, cmyka: &[u8]) -> Option> { - let n = (width as usize).checked_mul(height as usize)?; - let px = cmyka.get(..n.checked_mul(5)?)?.as_chunks::<5>().0; - if let Some(lut) = self.document_cmyk() { - let rgba = px - .iter() - .flat_map(|p| { - let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); - [r, g, b, p[4]] - }) - .collect(); - return png(width, height, rgba); - } - if px.iter().all(|p| p[4] == 255) { - let inks = px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).collect(); - if let Some(blob) = vectorcraft_doc::cmyk::Inks::new(width, height, inks).and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) { - return Some(Arc::unwrap_or_clone(blob.bytes)); - } - } - self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)"); - let lut = self - .working_cmyk - .get_or_insert_with(|| { - Arc::new(vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| { - let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb(); - [r, g, b, 0.0] - })) - }) - .clone(); - let rgba = px - .iter() - .flat_map(|p| { - let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); - [r, g, b, p[4]] - }) - .collect(); - png(width, height, rgba) - } - - /// The document's CMYK profile as a table from ink amounts to sRGB (relative colorimetric, - /// with black-point compensation, as Affinity's pixel layers convert), made once; `None` when - /// the document embeds none, or not one that converts. - fn document_cmyk(&mut self) -> Option> { - use vectorcraft_color::cms::{self, CmykLut, IccProfile, Intent}; - if let Some(lut) = &self.cmyk_lut { - return lut.clone(); - } - let embedded = self.cmyk_profile.is_some(); - let lut = self.cmyk_profile.take().and_then(|bytes| { - let profile = IccProfile::from_bytes(None, &bytes).ok()?; - let srgb = cms::icc::builtin_rgb(cms::SRGB)?; - profile.to_rgb_of(&srgb, &[0.0; 4], Intent::RelativeColorimetric, true)?; - Some(Arc::new(CmykLut::build(|inks| { - let [r, g, b] = profile - .to_rgb_of(&srgb, &inks, Intent::RelativeColorimetric, true) - .unwrap_or_else(|| Color::cmyk(inks[0], inks[1], inks[2], inks[3]).to_rgb()); - [r, g, b, 0.0] - }))) - }); - if embedded && lut.is_none() { - self.warn("CMYK pixel layers whose document's colour profile could not be read (shown through the working CMYK profile)"); - } - self.cmyk_lut = Some(lut.clone()); - lut - } - - fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { - let bytes = match &img.pixels { - Pixels::Encoded(b) => b.clone(), - Pixels::Rgba8(rgba) => png(img.width, img.height, rgba.clone())?, - Pixels::Cmyka8(cmyka) => self.cmyk_image(img.width, img.height, cmyka)?, - }; - let r = match super::load::raster_image(&bytes) { - Ok(r) => r, - Err(_) => { - self.warn("an embedded image that could not be decoded was left out"); - return None; - } - }; - // The pixel grid maps onto the Affinity image's own size, whatever the stored file's. - let sx = img.width as f64 / f64::from(r.width.max(1)); - let sy = img.height as f64 / f64::from(r.height.max(1)); - let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); - self.doc.images.entry(r.key.clone()).or_insert(r.blob); - Some(NodeKind::Image(Box::new(ImageObject { - key: r.key, - width: r.width, - height: r.height, - xf, - link: None, - placement: Default::default(), - pixels: None, - }))) - } -} - -/// The master page instances among `nodes` (also inside layers and groups): (master, first page, -/// first master page, page count). -fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> { - fn visit(n: &af::Node, depth: usize, out: &mut Vec<(usize, usize, usize, usize)>) { - if depth > MAX_DEPTH || !n.visible { - return; - } - match n.kind { - Kind::MasterInstance { master, page, master_page, count } => out.push((master, page, master_page, count)), - Kind::Layer | Kind::Group => n.children.iter().for_each(|c| visit(c, depth + 1, out)), - _ => {} - } - } - let mut out = Vec::new(); - nodes.iter().for_each(|n| visit(n, 0, &mut out)); - out -} - -/// Which master page instance each of `pages` pages shows: (master, first page, first master -/// page) of the first instance in `instances` that covers it. Each page is settled once (the -/// next page still open is found by a path-halving jump), so this is about linear in pages and -/// instances however much their ranges overlap. -fn page_masters(instances: &[(usize, usize, usize, usize)], pages: usize) -> Vec> { - fn open_from(next: &mut [usize], mut i: usize) -> usize { - while let Some(&n) = next.get(i) { - if n == i { - break; - } - let skip = next.get(n).copied().unwrap_or(n); - if let Some(slot) = next.get_mut(i) { - *slot = skip; - } - i = n; - } - i - } - let mut owner = vec![None; pages]; - // `next[p]` leads towards the first page from `p` on with no master yet (`pages`: none). - let mut next: Vec = (0..=pages).collect(); - for &(master, first, master_page, count) in instances { - let end = first.saturating_add(count).min(pages); - let mut p = open_from(&mut next, first.min(pages)); - while p < end { - if let Some(o) = owner.get_mut(p) { - *o = Some((master, first, master_page)); - } - if let Some(n) = next.get_mut(p) { - *n = p + 1; - } - p = open_from(&mut next, p + 1); - } - } - owner -} - -/// Master names: A, B … Z, then A1, B1 …. -fn master_prefix(index: usize) -> String { - let letter = char::from(b'A' + (index % 26) as u8); - match index / 26 { - 0 => letter.to_string(), - n => format!("{letter}{n}"), - } -} - -/// A table cell's text `w` × `h` at the origin, placed as Affinity places it. Affinity aligns the -/// block from the first line's capitals' tops to the last baseline in the cell, and draws it even -/// where it doesn't fit (centred cells measured on a document's thumbnail: two lines in a cell -/// shorter than them reach out of it at both ends). VectorCraft aligns the block down to the last -/// line's descenders and keeps lines inside the frame: the frame moves by what this returns (down -/// the cell) and grows where the lines need more room. -fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 { - let share = match obj.area.vertical_align { - VerticalAlign::Center => 0.5, - VerticalAlign::Bottom => 1.0, - _ => 0.0, - }; - let mut tall = obj.clone(); - tall.area.vertical_align = VerticalAlign::Top; - tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; - let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tall).lines; - let Some(last) = lines.iter().rfind(|l| l.region == 0) else { return 0.0 }; - let (baseline, descent) = (last.baseline, last.descent); - if !(baseline.is_finite() && descent.is_finite() && h.is_finite()) { - return 0.0; - } - // (A little more than the lines need, for the rounding of the layout.) - let frame = h.max(baseline + descent + 0.05); - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, frame)) }; - // Where Affinity has the block's top, less where VectorCraft puts it in the frame. - (h - baseline) * share - (frame - baseline - descent) * share -} - -/// Area type `w` × `h` at the origin whose lines don't all fit. Affinity shows each line whose -/// baseline is inside the frame, its descenders hanging out of it; VectorCraft's area type leaves -/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was. -fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool { - let db = vectorcraft_text::FontDb::global(); - if !(w > 0.0 && h > 0.0 && h.is_finite()) { - return false; - } - let first = vectorcraft_text::layout(db, obj); - if !first.overflow { - return false; - } - // Where the lines go in a frame with room for all of them. - let mut tall = obj.clone(); - tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; - let need = vectorcraft_text::layout(db, &tall) - .lines - .iter() - .filter(|l| l.region == 0 && l.baseline <= h + 1e-6) - .map(|l| l.baseline + l.descent) - .filter(|b| b.is_finite()) - .fold(0.0, f64::max); - if need <= h { - return false; - } - let before = first.lines.len(); - // (A little more than they need, for the rounding of the layout.) - let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) }); - // Something else keeps the lines out: as the source has it. - if vectorcraft_text::layout(db, obj).lines.len() <= before { - obj.kind = short; - return false; - } - true -} - -/// RGBA pixels as a PNG file. -fn png(width: u32, height: u32, rgba: Vec) -> Option> { - let buf = image::RgbaImage::from_raw(width, height, rgba)?; - let mut png = Vec::new(); - buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - Some(png) -} - -/// Where a line of `w` starts in a span from 0 to `room` (as a share of `room - w`). -fn share(j: Justify) -> f64 { - match j { - Justify::Center | Justify::JustifyCenter => 0.5, - Justify::Right | Justify::JustifyRight => 1.0, - _ => 0.0, - } -} - -/// Artistic text whose paragraphs are aligned differently. Affinity aligns each line within the -/// width of the longest, and places that block at the anchor by the text's own alignment (its -/// first paragraph's); VectorCraft's point type aligns each line on the anchor. Indents move the -/// lines to where Affinity has them and keep their alignment: a centred line centres between its -/// indents and a right-aligned one ends at its right indent, whatever its width. -fn align_lines(obj: &mut TextObject) { - let block = share(obj.para.justify); - if obj.paras.iter().all(|p| share(p.justify) == block) { - return; - } - let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; - let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); - if !(w > 0.0 && w < 1e6) { - return; - } - // The block's left edge, from the anchor. - let left = -w * block; - for p in &mut obj.paras { - let at = share(p.justify); - let shift = left + w * at; - // A left-aligned line starts at its left indent, a right-aligned one ends at minus its right - // indent, and a centred one centres on half their difference. - if at < 0.25 { - p.left_indent += shift; - } else if at > 0.75 { - p.right_indent -= shift; - } else { - p.left_indent += shift; - p.right_indent -= shift; - } - } - if let Some(first) = obj.paras.first() { - obj.para = first.clone(); - } -} - -fn justify(a: AfAlign) -> Justify { - match a { - AfAlign::Left => Justify::Left, - AfAlign::Center => Justify::Center, - AfAlign::Right => Justify::Right, - AfAlign::Justify => Justify::JustifyLeft, - } -} - -/// A font's line gap in em, as VectorCraft will set it (the installed face, or the fallback for -/// a missing one); 0 when no font is loaded. -fn line_gap(family: &str, style: &str) -> f64 { - let defaults = CharStyle::default(); - let family = if family.is_empty() { defaults.font_family.as_str() } else { family }; - let style = if style.is_empty() { defaults.font_style.as_str() } else { style }; - let Some(face) = vectorcraft_text::FontDb::global().face(family, style) else { return 0.0 }; - let upem = face.units_per_em(); - let gap = if upem > 0.0 { face.line_gap() / upem } else { 0.0 }; - if gap.is_finite() { gap.clamp(0.0, 2.0) } else { 0.0 } -} - -fn font_style(weight: i64, italic: bool) -> String { - let w = match weight { - ..=150 => "Thin", - 151..=250 => "ExtraLight", - 251..=325 => "Light", - 326..=450 => "Regular", - 451..=550 => "Medium", - 551..=650 => "SemiBold", - 651..=750 => "Bold", - 751..=850 => "ExtraBold", - _ => "Black", - }; - match (w, italic) { - ("Regular", true) => "Italic".into(), - (w, true) => format!("{w} Italic"), - (w, false) => w.into(), - } -} diff --git a/crates/engine/src/cmd/fileio/affinity/document.rs b/crates/engine/src/cmd/fileio/affinity/document.rs new file mode 100644 index 000000000..0d74d1d81 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/document.rs @@ -0,0 +1,211 @@ +use super::layout::{master_instances, master_prefix, page_masters}; +use super::master_copies::{CopyCost, MAX_MASTER_COPIES, MasterPart, copy_cost}; +use super::nodes::rect_of; +use super::*; + +impl Builder { + pub(super) fn new(k: f64) -> Self { + Self { + doc: Document::new(0.0, 0.0), + warnings: Vec::new(), + k, + layer: 0, + embedded: 0, + masters: Vec::new(), + cmyk_profile: None, + cmyk_lut: None, + working_cmyk: None, + overlay_work: vectorcraft_affinity::overlay::MAX_WORK, + copy_left: MAX_MASTER_COPIES, + master_parts: std::collections::HashMap::new(), + } + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + pub(super) fn document(&mut self, d: &af::Document) -> Result<()> { + self.cmyk_profile = d.cmyk_profile.clone(); + // Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin. + let mut layers = Vec::new(); + let mut artboards = Vec::new(); + let mut x = 0.0; + for (i, spread) in d.spreads.iter().enumerate() { + let shift = if i == 0 { 0.0 } else { x - spread.bounds.x0 * self.k }; + let to_doc = Affine::translate((shift, 0.0)) * Affine::scale(self.k); + let before = artboards.len(); + let mut loose: Vec> = Vec::new(); + for n in &spread.nodes { + let top = match &n.kind { + Kind::Layer => self.node(n, to_doc, &mut artboards, true, 0), + Kind::Artboard { .. } => { + self.node(n, to_doc, &mut artboards, true, 0).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)) + } + _ => { + if let Some(art) = self.node(n, to_doc, &mut artboards, false, 0) { + loose.push(art); + } + continue; + } + }; + if !loose.is_empty() { + layers.push(self.layer_with(None, std::mem::take(&mut loose))); + } + layers.extend(top); + } + if !loose.is_empty() { + layers.push(self.layer_with(None, loose)); + } + self.master_pages(d, spread, to_doc); + // Without artboards, each page (or the spread) becomes one. + if artboards.len() == before { + let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; + for r in rects { + let name = format!("{} {}", if d.spreads.len() > 1 || spread.pages.len() > 1 { "Page" } else { "Artboard" }, artboards.len() + 1); + artboards.push((name, to_doc.transform_rect_bbox(rect_of(r)))); + } + } + // (Earlier spreads' artboards are all left of `x` already.) + x = artboards.get(before..).unwrap_or_default().iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0; + if spread.transparent && i == 0 { + self.warn("transparent page backgrounds show as white artboards"); + } + } + self.doc.artboards = artboards + .into_iter() + .enumerate() + .filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0) + .map(|(i, (name, rect))| Artboard { + id: i as u32 + 1, + name, + rect, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }) + .collect(); + if self.doc.artboards.is_empty() { + return Err(err("the Affinity document has no page with a size")); + } + // Master pages are behind everything on the pages that show them. + let mut masters = std::mem::take(&mut self.masters); + masters.sort_by_key(|(m, _)| *m); + let mut bottom = Vec::new(); + for (m, art) in masters { + let mut l = (*self.layer_with(Some(&format!("Master {}", master_prefix(m))), art)).clone(); + l.locked = true; + bottom.push(Arc::new(l)); + } + // Bottom to top, as Affinity lists them. + bottom.extend(layers); + self.doc.layers = bottom; + if self.doc.layers.is_empty() { + let empty = self.layer_with(None, Vec::new()); + self.doc.layers.push(empty); + } + Ok(()) + } + + /// The master pages `spread`'s pages show, placed on those pages (`to_doc` places the spread). + /// Each master page shows only the objects on it, moved from its page to the one showing it. + fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) { + let instances = master_instances(&spread.nodes); + if instances.is_empty() { + return; + } + let owners = page_masters(&instances, spread.pages.len()); + for (pi, page) in spread.pages.iter().enumerate() { + let Some(&Some((master, first, master_page))) = owners.get(pi) else { continue }; + let Some(m) = d.masters.get(master) else { continue }; + let index = master_page.saturating_add(pi.saturating_sub(first)); + let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds); + let parts = self.master_parts(master, m); + let on_page = |b: &Option| b.is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1); + // Looking through the master's objects takes one each, copying them what they hold. + let looked = CopyCost { work: parts.len() as u64, pixels: 0 }; + let cost = parts.iter().filter(|p| on_page(&p.bounds)).fold(looked, |c, p| c.plus(p.cost)); + let Some(left) = self.copy_left.minus(cost) else { + self.warn("master pages shown on more pages than one import copies them to (left out on the rest)"); + return; + }; + self.copy_left = left; + let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0)); + let mut art = Vec::new(); + let mut boards = Vec::new(); + self.embedded += 1; + let mut i = 0; + while let Some(p) = parts.get(i).copied() { + let Some(n) = m.nodes.get(p.node) else { + i += 1; + continue; + }; + if p.child.is_none() { + if on_page(&p.bounds) { + art.extend(self.node(n, place, &mut boards, false, 0)); + } + i += 1; + continue; + } + // A layer of the master spread: just its objects on this page. + let mut kids: Vec> = Vec::new(); + while let Some(q) = parts.get(i).copied().filter(|q| q.node == p.node) { + if on_page(&q.bounds) + && let Some(c) = q.child.and_then(|c| n.children.get(c)) + { + kids.extend(self.node(c, place, &mut boards, false, 1)); + } + i += 1; + } + if !kids.is_empty() { + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.isolate = n.blend != Blend::PassThrough; + art.push(Arc::new(g)); + } + } + self.embedded -= 1; + if art.is_empty() { + continue; + } + let mut g = Node::group(self.doc.alloc_id(), art); + g.name = Some(format!("Master {} (page {})", master_prefix(master), pi + 1)); + match self.masters.iter_mut().find(|(i, _)| *i == master) { + Some((_, v)) => v.push(Arc::new(g)), + None => self.masters.push((master, vec![Arc::new(g)])), + } + } + } + + /// The objects of master spread `m` (number `index`) as its pages show them: its layers' + /// objects one by one and its other top-level objects, with their bounds and costs. + fn master_parts(&mut self, index: usize, m: &af::Spread) -> Arc<[MasterPart]> { + if let Some(parts) = self.master_parts.get(&index) { + return parts.clone(); + } + if !master_instances(&m.nodes).is_empty() { + self.warn("master pages based on other master pages (only their own objects are used)"); + } + let part = |node: usize, child: Option, n: &af::Node| MasterPart { + node, + child, + bounds: af::nodes_bounds(std::slice::from_ref(n)), + cost: copy_cost(n, 0), + }; + let mut parts = Vec::new(); + for (i, n) in m.nodes.iter().enumerate() { + if matches!(n.kind, Kind::Layer) { + parts.extend(n.children.iter().enumerate().map(|(c, child)| part(i, Some(c), child))); + } else { + parts.push(part(i, None, n)); + } + } + let parts: Arc<[MasterPart]> = parts.into(); + self.master_parts.insert(index, parts.clone()); + parts + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/import.rs b/crates/engine/src/cmd/fileio/affinity/import.rs new file mode 100644 index 000000000..625df1e3b --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/import.rs @@ -0,0 +1,48 @@ +use super::*; + +/// What an Affinity file opened as. +pub(in super::super) struct Imported { + pub doc: Document, + pub warnings: Vec, + /// Only the embedded preview: never placed, used as a template or mined for styles. + pub preview_only: bool, +} + +pub(in super::super) fn import(name: &str, bytes: &[u8]) -> Result { + match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) { + Ok(d) => { + let mut b = Builder::new(72.0 / d.dpi); + b.document(&d)?; + b.doc.limit_depth_noting(&mut b.warnings); + let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); + warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}."))); + Ok(Imported { doc: b.doc, warnings, preview_only: false }) + } + Err(native) => { + let (doc, w) = preview(name, bytes, &native)?; + Ok(Imported { doc, warnings: vec![w], preview_only: true }) + } + } +} + +/// The embedded preview, for files whose native document can't be read. +fn preview(name: &str, bytes: &[u8], native: &vectorcraft_affinity::Error) -> Result<(Document, String)> { + let p = vectorcraft_affinity::preview(bytes).map_err(|e| err(format!("{native}; its embedded preview can't be read either ({e})")))?; + // `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this + // untrusted preview's complete pixels first, so corrupt data cannot create a blank image. + let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(128 << 20); + reader.limits(limits); + let _pixels = reader.decode().map_err(err)?; + let d = raster_doc(name, p.png)?; + Ok(( + d, + format!( + "The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.", + p.width, p.height + ), + )) +} diff --git a/crates/engine/src/cmd/fileio/affinity/layout.rs b/crates/engine/src/cmd/fileio/affinity/layout.rs new file mode 100644 index 000000000..157a38836 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/layout.rs @@ -0,0 +1,223 @@ +use super::nodes::MAX_DEPTH; +use super::*; + +/// The master page instances among `nodes` (also inside layers and groups): (master, first page, +/// first master page, page count). +pub(super) fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> { + fn visit(n: &af::Node, depth: usize, out: &mut Vec<(usize, usize, usize, usize)>) { + if depth > MAX_DEPTH || !n.visible { + return; + } + match n.kind { + Kind::MasterInstance { master, page, master_page, count } => out.push((master, page, master_page, count)), + Kind::Layer | Kind::Group => n.children.iter().for_each(|c| visit(c, depth + 1, out)), + _ => {} + } + } + let mut out = Vec::new(); + nodes.iter().for_each(|n| visit(n, 0, &mut out)); + out +} + +/// Which master page instance each of `pages` pages shows: (master, first page, first master +/// page) of the first instance in `instances` that covers it. Each page is settled once (the +/// next page still open is found by a path-halving jump), so this is about linear in pages and +/// instances however much their ranges overlap. +pub(super) fn page_masters(instances: &[(usize, usize, usize, usize)], pages: usize) -> Vec> { + fn open_from(next: &mut [usize], mut i: usize) -> usize { + while let Some(&n) = next.get(i) { + if n == i { + break; + } + let skip = next.get(n).copied().unwrap_or(n); + if let Some(slot) = next.get_mut(i) { + *slot = skip; + } + i = n; + } + i + } + let mut owner = vec![None; pages]; + // `next[p]` leads towards the first page from `p` on with no master yet (`pages`: none). + let mut next: Vec = (0..=pages).collect(); + for &(master, first, master_page, count) in instances { + let end = first.saturating_add(count).min(pages); + let mut p = open_from(&mut next, first.min(pages)); + while p < end { + if let Some(o) = owner.get_mut(p) { + *o = Some((master, first, master_page)); + } + if let Some(n) = next.get_mut(p) { + *n = p + 1; + } + p = open_from(&mut next, p + 1); + } + } + owner +} + +/// Master names: A, B … Z, then A1, B1 …. +pub(super) fn master_prefix(index: usize) -> String { + let letter = char::from(b'A' + (index % 26) as u8); + match index / 26 { + 0 => letter.to_string(), + n => format!("{letter}{n}"), + } +} + +/// A table cell's text `w` × `h` at the origin, placed as Affinity places it. Affinity aligns the +/// block from the first line's capitals' tops to the last baseline in the cell, and draws it even +/// where it doesn't fit (centred cells measured on a document's thumbnail: two lines in a cell +/// shorter than them reach out of it at both ends). VectorCraft aligns the block down to the last +/// line's descenders and keeps lines inside the frame: the frame moves by what this returns (down +/// the cell) and grows where the lines need more room. +pub(super) fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 { + let share = match obj.area.vertical_align { + VerticalAlign::Center => 0.5, + VerticalAlign::Bottom => 1.0, + _ => 0.0, + }; + let mut tall = obj.clone(); + tall.area.vertical_align = VerticalAlign::Top; + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tall).lines; + let Some(last) = lines.iter().rfind(|l| l.region == 0) else { return 0.0 }; + let (baseline, descent) = (last.baseline, last.descent); + if !(baseline.is_finite() && descent.is_finite() && h.is_finite()) { + return 0.0; + } + // (A little more than the lines need, for the rounding of the layout.) + let frame = h.max(baseline + descent + 0.05); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, frame)) }; + // Where Affinity has the block's top, less where VectorCraft puts it in the frame. + (h - baseline) * share - (frame - baseline - descent) * share +} + +/// Area type `w` × `h` at the origin whose lines don't all fit. Affinity shows each line whose +/// baseline is inside the frame, its descenders hanging out of it; VectorCraft's area type leaves +/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was. +pub(super) fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool { + let db = vectorcraft_text::FontDb::global(); + if !(w > 0.0 && h > 0.0 && h.is_finite()) { + return false; + } + let first = vectorcraft_text::layout(db, obj); + if !first.overflow { + return false; + } + // Where the lines go in a frame with room for all of them. + let mut tall = obj.clone(); + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let need = vectorcraft_text::layout(db, &tall) + .lines + .iter() + .filter(|l| l.region == 0 && l.baseline <= h + 1e-6) + .map(|l| l.baseline + l.descent) + .filter(|b| b.is_finite()) + .fold(0.0, f64::max); + if need <= h { + return false; + } + let before = first.lines.len(); + // (A little more than they need, for the rounding of the layout.) + let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) }); + // Something else keeps the lines out: as the source has it. + if vectorcraft_text::layout(db, obj).lines.len() <= before { + obj.kind = short; + return false; + } + true +} + +/// RGBA pixels as a PNG file. +pub(super) fn png(width: u32, height: u32, rgba: Vec) -> Option> { + let buf = image::RgbaImage::from_raw(width, height, rgba)?; + let mut png = Vec::new(); + buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + +/// Where a line of `w` starts in a span from 0 to `room` (as a share of `room - w`). +fn share(j: Justify) -> f64 { + match j { + Justify::Center | Justify::JustifyCenter => 0.5, + Justify::Right | Justify::JustifyRight => 1.0, + _ => 0.0, + } +} + +/// Artistic text whose paragraphs are aligned differently. Affinity aligns each line within the +/// width of the longest, and places that block at the anchor by the text's own alignment (its +/// first paragraph's); VectorCraft's point type aligns each line on the anchor. Indents move the +/// lines to where Affinity has them and keep their alignment: a centred line centres between its +/// indents and a right-aligned one ends at its right indent, whatever its width. +pub(super) fn align_lines(obj: &mut TextObject) { + let block = share(obj.para.justify); + if obj.paras.iter().all(|p| share(p.justify) == block) { + return; + } + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; + let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); + if !(w > 0.0 && w < 1e6) { + return; + } + // The block's left edge, from the anchor. + let left = -w * block; + for p in &mut obj.paras { + let at = share(p.justify); + let shift = left + w * at; + // A left-aligned line starts at its left indent, a right-aligned one ends at minus its right + // indent, and a centred one centres on half their difference. + if at < 0.25 { + p.left_indent += shift; + } else if at > 0.75 { + p.right_indent -= shift; + } else { + p.left_indent += shift; + p.right_indent -= shift; + } + } + if let Some(first) = obj.paras.first() { + obj.para = first.clone(); + } +} + +pub(super) fn justify(a: AfAlign) -> Justify { + match a { + AfAlign::Left => Justify::Left, + AfAlign::Center => Justify::Center, + AfAlign::Right => Justify::Right, + AfAlign::Justify => Justify::JustifyLeft, + } +} + +/// A font's line gap in em, as VectorCraft will set it (the installed face, or the fallback for +/// a missing one); 0 when no font is loaded. +pub(super) fn line_gap(family: &str, style: &str) -> f64 { + let defaults = CharStyle::default(); + let family = if family.is_empty() { defaults.font_family.as_str() } else { family }; + let style = if style.is_empty() { defaults.font_style.as_str() } else { style }; + let Some(face) = vectorcraft_text::FontDb::global().face(family, style) else { return 0.0 }; + let upem = face.units_per_em(); + let gap = if upem > 0.0 { face.line_gap() / upem } else { 0.0 }; + if gap.is_finite() { gap.clamp(0.0, 2.0) } else { 0.0 } +} + +pub(super) fn font_style(weight: i64, italic: bool) -> String { + let w = match weight { + ..=150 => "Thin", + 151..=250 => "ExtraLight", + 251..=325 => "Light", + 326..=450 => "Regular", + 451..=550 => "Medium", + 551..=650 => "SemiBold", + 651..=750 => "Bold", + 751..=850 => "ExtraBold", + _ => "Black", + }; + match (w, italic) { + ("Regular", true) => "Italic".into(), + (w, true) => format!("{w} Italic"), + (w, false) => w.into(), + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/master_copies.rs b/crates/engine/src/cmd/fileio/affinity/master_copies.rs new file mode 100644 index 000000000..bc55dbc25 --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/master_copies.rs @@ -0,0 +1,47 @@ +use super::nodes::MAX_DEPTH; +use super::*; + +/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct CopyCost { + pub(super) work: u64, + pub(super) pixels: u64, +} + +impl CopyCost { + pub(super) fn plus(self, o: CopyCost) -> CopyCost { + CopyCost { work: self.work.saturating_add(o.work), pixels: self.pixels.saturating_add(o.pixels) } + } + pub(super) fn minus(self, o: CopyCost) -> Option { + Some(CopyCost { work: self.work.checked_sub(o.work)?, pixels: self.pixels.checked_sub(o.pixels)? }) + } +} + +/// Master page copies of one import together: as many objects, characters and pixels as a +/// document may hold, a few times over. A master shown on thousands of pages, or a huge one +/// on many, would otherwise multiply them. +pub(super) const MAX_MASTER_COPIES: CopyCost = CopyCost { work: 1 << 23, pixels: 512 << 20 }; + +/// One object a master spread shows on the pages that show it: a top-level node of the spread +/// (`child` `None`) or one object in one of its layers, with its bounds and what copying it takes. +#[derive(Clone, Copy, Debug)] +pub(super) struct MasterPart { + pub(super) node: usize, + pub(super) child: Option, + pub(super) bounds: Option, + pub(super) cost: CopyCost, +} + +pub(super) fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { + if depth > MAX_DEPTH { + return CopyCost::default(); + } + let len = |runs: &[af::TextRun]| runs.iter().map(|r| r.text.len() as u64).sum::(); + let own = match &n.kind { + Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, + Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, + Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, + _ => CopyCost { work: 1, pixels: 0 }, + }; + n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) +} diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs new file mode 100644 index 000000000..a3867d0bd --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -0,0 +1,249 @@ +use super::*; + +/// Deepest node tree mapped (the reader bounds trees well below this). +pub(super) const MAX_DEPTH: usize = 160; + +pub(super) fn rect_of(r: af::Rect) -> Rect { + Rect::new(r.x0, r.y0, r.x1, r.y1) +} + +pub(super) fn affine(a: af::Affine) -> Affine { + Affine::new(a.0) +} + +impl Builder { + pub(super) fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { + let id = self.doc.alloc_id(); + self.layer = self.layer.wrapping_add(1); + let label = name.map(str::to_string).unwrap_or_else(|| format!("Layer {}", self.layer)); + let mut n = Node::layer(id, &label, LayerColor::Preset(self.layer % 9)); + if let Some(c) = n.children_mut() { + *c = children; + } + Arc::new(n) + } + + /// An artboard at the top level becomes a layer of its name holding its clip group. + pub(super) fn layer_of(&mut self, name: &str, art: Arc, visible: bool, locked: bool) -> Arc { + let mut l = (*self.layer_with(Some(if name.is_empty() { "Artboard" } else { name }), vec![art])).clone(); + l.visible = visible; + l.locked = locked; + Arc::new(l) + } + + pub(super) fn base(&mut self, n: &af::Node, kind: NodeKind) -> Node { + let mut out = Node::new(self.doc.alloc_id(), kind); + out.name = (!n.name.is_empty()).then(|| n.name.clone()); + out.visible = n.visible; + out.locked = n.locked; + out.opacity = n.opacity as f32; + out.blend = self.blend(n.blend); + out + } + + fn blend(&mut self, b: Blend) -> BlendMode { + match b { + Blend::Normal | Blend::PassThrough => BlendMode::Normal, + Blend::Darken => BlendMode::Darken, + Blend::Multiply => BlendMode::Multiply, + Blend::ColorBurn => BlendMode::ColorBurn, + Blend::Lighten => BlendMode::Lighten, + Blend::Screen => BlendMode::Screen, + Blend::ColorDodge => BlendMode::ColorDodge, + Blend::Overlay => BlendMode::Overlay, + Blend::SoftLight => BlendMode::SoftLight, + Blend::HardLight => BlendMode::HardLight, + Blend::Difference => BlendMode::Difference, + Blend::Exclusion => BlendMode::Exclusion, + Blend::Hue => BlendMode::Hue, + Blend::Saturation => BlendMode::Saturation, + Blend::Color => BlendMode::Color, + Blend::Luminosity => BlendMode::Luminosity, + _ => { + self.warn("blend modes VectorCraft doesn't have (Add, Linear Light, Vivid Light, Pin Light, Hard Mix, Subtract, Darker/Lighter Colour, Average, Negation, Reflect, Glow, Erase) import as Normal"); + BlendMode::Normal + } + } + } + + /// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has + /// no overlay effect). + fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> { + // Each kind of message once, however often it is heard. + let mut warnings: Vec = Vec::new(); + let mut work = self.overlay_work; + let out = vectorcraft_affinity::overlay::apply(n, &mut work, &mut |w| { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } + }); + self.overlay_work = work; + for w in warnings { + self.warn(&w); + } + out + } + + /// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to + /// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer. + pub(super) fn node( + &mut self, + n: &af::Node, + to_doc: Affine, + boards: &mut Vec<(String, Rect)>, + layer_level: bool, + depth: usize, + ) -> Option> { + if depth > MAX_DEPTH { + self.warn("objects nested too deeply (left out)"); + return None; + } + // VectorCraft documents stay within `MAX_COORD` of the origin (renderers and exports rely + // on it): objects reaching beyond it are left out. + if let Some(b) = af::kind_bounds(&n.kind) { + let r = to_doc.transform_rect_bbox(rect_of(b)); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) { + self.warn("objects beyond the document range VectorCraft supports (left out)"); + return None; + } + } + let n = &*self.overlaid(n); + // Register before descending so nested artboards keep their document order. Inside an + // embedded document an artboard is just its content's frame. + if let Kind::Artboard { rect, .. } = &n.kind + && self.embedded == 0 + { + boards.push(( + if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, + to_doc.transform_rect_bbox(rect_of(*rect)), + )); + } + let child_level = matches!(n.kind, Kind::Layer) && layer_level; + let embeds = n.class == vectorcraft_affinity::stream::Tag::of(b"EmbN"); + self.embedded += usize::from(embeds); + let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level, depth + 1)).collect(); + self.embedded -= usize::from(embeds); + let mut out = match &n.kind { + Kind::Layer if layer_level => { + let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone(); + l.visible = n.visible; + l.locked = n.locked; + l.opacity = n.opacity as f32; + l.blend = self.blend(n.blend); + l + } + Kind::Layer | Kind::Group => { + let mut g = self.base(n, NodeKind::Group { children, clip: false }); + // Affinity groups isolate their blending unless they pass through. + g.isolate = n.blend != Blend::PassThrough; + g + } + Kind::Shape { path, fills, strokes, even_odd } => { + let data = self.path(path, to_doc); + let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; + let mut appearance = self.appearance(fills, strokes, to_doc); + // An outline effect on a shape is one more stroke. + self.outline_stroke(&n.effects, &mut appearance); + if children.is_empty() { + let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false }); + p.appearance = appearance.into(); + p + } else { + // A shape clips its children: VectorCraft's clipping path keeps its paint, its + // fill behind the clipped art and its stroke over it, as Affinity draws it. + let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false }); + clip.appearance = appearance.into(); + let mut kids = vec![Arc::new(clip)]; + kids.extend(children); + self.base(n, NodeKind::Group { children: kids, clip: true }) + } + } + Kind::Artboard { path, even_odd, background, strokes, .. } => { + let mut clip = Node::new( + self.doc.alloc_id(), + NodeKind::Path { + path: self.path(path, to_doc), + rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, + live: None, + clipping: true, + guide: false, + }, + ); + clip.appearance = self.appearance(background, strokes, to_doc).into(); + let mut kids = vec![Arc::new(clip)]; + kids.extend(children); + let mut g = self.base(n, NodeKind::Group { children: kids, clip: true }); + g.name = Some(if n.name.is_empty() { "Artboard".into() } else { n.name.clone() }); + g + } + Kind::Text(t) => { + let Some((mut text, alpha)) = self.text(t, to_doc) else { + // The text is left out (warned); what it holds still comes in. + if children.is_empty() { + return None; + } + let g = self.base(n, NodeKind::Group { children, clip: false }); + return Some(Arc::new(g)); + }; + self.outline_text(&n.effects, &mut text); + let mut node = self.base(n, NodeKind::Text(Box::new(text))); + node.opacity *= alpha; + if !children.is_empty() { + let mut kids = vec![Arc::new(node)]; + kids.extend(children); + node = Node::group(self.doc.alloc_id(), kids); + } + node + } + Kind::Table(t) => { + let mut kids = self.table(t, to_doc); + if kids.is_empty() && children.is_empty() { + return None; + } + kids.extend(children); + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.name.get_or_insert_with(|| "Table".into()); + g + } + Kind::Image(img) => match self.image(img, to_doc) { + Some(kind) => self.base(n, kind), + None if children.is_empty() => return None, + None => self.base(n, NodeKind::Group { children, clip: false }), + }, + // Shown on the pages through the master's layer (see `master_pages`). + Kind::MasterInstance { .. } => return None, + Kind::Unsupported if children.is_empty() => return None, + Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }), + }; + if !matches!(n.kind, Kind::Shape { .. } | Kind::Text(_)) && n.effects.iter().any(|e| matches!(e, af::Effect::Outline { .. })) { + self.warn("outline effects on groups, images and tables (left out)"); + } + if let Some(img) = &n.pixel_mask { + // A pixel mask: its grey levels show (white) or hide (black) the node, and nothing + // shows outside it, as an opacity mask that clips. + if let Some(kind) = self.image(img, to_doc) { + let art = Node::new(self.doc.alloc_id(), kind); + out.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + } + } + if let Some(mask) = &n.mask { + // A vector mask: the node shows only inside it. + let clip = Node::new( + self.doc.alloc_id(), + NodeKind::Path { path: self.path(mask, to_doc), rule: FillRule::NonZero, live: None, clipping: true, guide: false }, + ); + let (opacity, blend, visible, name) = (out.opacity, out.blend, out.visible, out.name.clone()); + out.opacity = 1.0; + out.blend = BlendMode::Normal; + let mut g = Node::new(self.doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(out)], clip: true }); + (g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name); + out = g; + } + // Shadows and glows apply to what the node shows, mask and all. + let fx = self.effects(&n.effects); + if !fx.is_empty() { + out.appearance.effects.extend(fx); + } + Some(Arc::new(out)) + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/paint.rs b/crates/engine/src/cmd/fileio/affinity/paint.rs new file mode 100644 index 000000000..a60911e0d --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/paint.rs @@ -0,0 +1,387 @@ +use super::layout::png; +use super::nodes::affine; +use super::text::Placement; +use super::*; + +impl Builder { + /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's + /// blur radius sets the same Gaussian as VectorCraft's blur (σ = half of it), and its angle + /// is the direction the shadow goes in. + pub(super) fn effects(&mut self, effects: &[af::Effect]) -> Vec { + let k = self.k; + let len = move |v: f64| if v.is_finite() { (v * k).clamp(0.0, 1000.0) } else { 0.0 }; + let num = |v: f64| (v * 1000.0).round() / 1000.0; + let mut out = Vec::new(); + for e in effects { + let (id, params) = match e { + af::Effect::DropShadow { color, opacity, radius, distance, angle } => { + let (color, alpha) = Self::color(*color); + let d = len(*distance); + let a = if angle.is_finite() { *angle } else { 0.0 }; + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "x": num(d * a.cos()), + "y": num(d * a.sin()), + "blur": num(len(*radius)), + "color": color, + }); + ("stylize.dropShadow", p) + } + af::Effect::InnerShadow { color, opacity, radius, distance, .. } => { + self.warn("inner shadows (imported as inner glows at the edge, without their offset)"); + let (color, alpha) = Self::color(*color); + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "blur": num(len(*radius) + len(*distance)), + "color": color, + "source": "edge", + }); + ("stylize.innerGlow", p) + } + af::Effect::OuterGlow { color, opacity, radius } => { + let (color, alpha) = Self::color(*color); + let p = + json!({ "mode": "normal", "opacity": num(opacity * f64::from(alpha) * 100.0), "blur": num(len(*radius)), "color": color }); + ("stylize.outerGlow", p) + } + af::Effect::Bevel { .. } => { + self.warn("bevel and emboss effects (left out)"); + continue; + } + // Outlines become strokes (`outline_stroke`, `outline_text`), overlays are baked + // into the paint (`overlaid`). + af::Effect::Outline { .. } | af::Effect::ColorOverlay { .. } | af::Effect::GradientOverlay { .. } => continue, + }; + out.push(Effect { id: id.into(), params, visible: true }); + } + out + } + + /// An outline effect on a shape: a stroke layer. Affinity outlines what the shape shows, its own + /// strokes included: an outline outside goes under them (from the path out to as far past them as + /// it is wide), one centred on or inside that edge over them. A centred one reaches as far into + /// the shape as out of it here, which only differs by the width of the shape's own stroke. + pub(super) fn outline_stroke(&mut self, effects: &[af::Effect], appearance: &mut Appearance) { + // How far the shape's own strokes reach outside its path. + let reach = appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Stroke(s) => Some(match s.align { + StrokeAlign::Center => s.width / 2.0, + StrokeAlign::Outside => s.width, + StrokeAlign::Inside => 0.0, + }), + _ => None, + }) + .filter(|w| w.is_finite()) + .fold(0.0, f64::max); + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + let (align, w) = match align { + 1 => (StrokeAlign::Inside, w), + 2 => (StrokeAlign::Center, w + 2.0 * reach), + _ => (StrokeAlign::Outside, w + reach), + }; + let mut l = StrokeLayer::new(Paint::solid(c), w.min(1000.0)); + l.opacity = (opacity.clamp(0.0, 1.0) as f32) * alpha; + l.join = LineJoin::Round; + l.align = align; + let item = AppearanceItem::Stroke(l); + if align == StrokeAlign::Outside { + // Under the shape's strokes, over its fills. + let at = appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap_or(appearance.items.len()); + appearance.items.insert(at, item); + } else { + appearance.items.push(item); + } + } + } + + /// An outline effect on text: the characters' stroke, where they have none of their own. + pub(super) fn outline_text(&mut self, effects: &[af::Effect], text: &mut TextObject) { + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + if opacity * f64::from(alpha) < 0.999 { + self.warn("semi-transparent outline effects on text (imported opaque)"); + } + if *align != 2 { + self.warn("outline effects on text inside or outside the characters (centred on them)"); + } + for r in &mut text.runs { + if r.style.stroke == Paint::None { + r.style.stroke = Paint::solid(c); + // Centred on the outline, so twice as wide to show as far outside. + r.style.stroke_width = if *align == 2 { w } else { 2.0 * w }.min(1000.0); + } + } + } + } + + pub(super) fn path(&self, p: &af::Path, to_doc: Affine) -> PathData { + let mut bp = BezPath::new(); + for s in &p.subpaths { + let pt = |q: af::Point| Point::new(q.x, q.y); + bp.move_to(pt(s.start)); + for [c1, c2, e] in &s.segments { + if c1 == c2 && c2 == e { + bp.line_to(pt(*e)); + } else { + bp.curve_to(pt(*c1), pt(*c2), pt(*e)); + } + } + if s.closed { + bp.close_path(); + } + } + PathData::from_bezpath(&(to_doc * bp)) + } + + fn color(c: ap::Color) -> (Color, f32) { + let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; + match c { + ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)), + ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)), + ap::Color::Gray { v, a } => (Color::Gray { k: 1.0 - f(v) }, f(a)), + ap::Color::Lab { l, a, b, alpha } => (Color::Lab { l: l as f32, a: a as f32, b: b as f32 }, f(alpha)), + } + } + + /// A paint and the opacity its colour's alpha becomes. + pub(super) fn paint(&mut self, p: &ap::Paint, to_doc: Affine) -> Option<(Paint, f32)> { + match p { + ap::Paint::None => None, + ap::Paint::Solid(c) => { + let (color, a) = Self::color(*c); + Some((Paint::solid(color), a)) + } + ap::Paint::Gradient(g) => { + let stops = g + .stops + .iter() + .map(|s| { + let (color, opacity) = Self::color(s.color); + let mut stop = GradientStop::new(s.offset as f32, color); + stop.opacity = opacity; + stop.midpoint = s.half_point().clamp(0.13, 0.87) as f32; + stop + }) + .collect(); + let m = to_doc * affine(g.transform); + let (o, x, y) = (m * Point::ZERO, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0)); + let kind = match g.kind { + AfKind::Linear => GradientKind::Linear, + AfKind::Radial => GradientKind::Radial, + AfKind::Conical => { + self.warn("conical gradients import as radial gradients"); + GradientKind::Radial + } + }; + let (u, v): (Vec2, Vec2) = (x - o, y - o); + if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 { + self.warn("skewed elliptical gradients import as upright ellipses"); + } + let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 }; + let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None }; + let mut gp = GradientPaint::new(Gradient::new(kind, stops)); + gp.angle = (-(u.y)).atan2(u.x).to_degrees(); + gp.geom = Some(geom); + Some((Paint::Gradient(Box::new(gp)), 1.0)) + } + } + } + + pub(super) fn stroke_layer(&mut self, s: &ap::Stroke, to_doc: Affine) -> Option { + let (paint, opacity) = self.paint(&s.paint, to_doc)?; + let mut l = StrokeLayer::new(paint, s.width * self.k); + l.opacity = opacity; + l.cap = match s.cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }; + l.join = match s.join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }; + l.miter_limit = s.miter_limit; + l.align = match s.align { + ap::Align::Center => StrokeAlign::Center, + ap::Align::Inside => StrokeAlign::Inside, + ap::Align::Outside => StrokeAlign::Outside, + }; + l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { + pattern: pattern.iter().map(|v| v * self.k).collect(), + offset: phase * self.k, + align_corners: false, + }); + Some(l) + } + + pub(super) fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance { + let mut items = Vec::new(); + let mut above = Vec::new(); + for f in fills { + if let Some((paint, opacity)) = self.paint(f, to_doc) { + let mut l = FillLayer::new(paint); + l.opacity = opacity; + items.push(AppearanceItem::Fill(l)); + } + } + for s in strokes { + let Some(l) = self.stroke_layer(s, to_doc) else { continue }; + if s.behind { + items.insert(0, AppearanceItem::Stroke(l)); + } else { + above.push(AppearanceItem::Stroke(l)); + } + } + items.extend(above); + Appearance { items, ..Appearance::default() } + } + + /// Where text placed by `m` (node space to points) goes, its type scaled by `type_m`. + pub(super) fn placement(&mut self, m: Affine, type_m: Affine) -> Option { + let [a, b, c, d, e, f] = m.as_coeffs(); + let sx = a.hypot(b); + let det = a * d - b * c; + if !(sx > 1e-9 && det.is_finite() && det.abs() > 1e-12 && e.is_finite() && f.is_finite()) { + self.warn("text with an invalid transform (left out)"); + return None; + } + let sy = det / sx; + let shear = (a * c + b * d) / (sx * sx); + if shear.abs() > 1e-3 || sy < 0.0 { + self.warn("skewed or flipped text (imported without the skew or flip)"); + } + // The type's vertical scale gives the font size, its horizontal scale relative to that the + // characters' width. + let [ta, tb, tc, td, ..] = type_m.as_coeffs(); + let (tsx, tdet) = (ta.hypot(tb), ta * td - tb * tc); + let (size_scale, h_scale) = if tsx > 1e-9 && tdet.is_finite() && tdet.abs() > 1e-12 { + let tsy = (tdet / tsx).abs(); + (tsy, tsx / tsy) + } else { + self.warn("text with an invalid text scale (imported unscaled)"); + (det.abs().sqrt(), 1.0) + }; + Some(Placement { + to_frame: Affine::scale_non_uniform(sx, sy.abs()), + place: Affine::translate((e, f)) * Affine::rotate(b.atan2(a)), + size_scale, + h_scale, + }) + } + + /// A CMYK pixel layer as an image file. Through the document's own CMYK profile when it + /// embeds one (as Affinity shows it): RGBA. Otherwise, opaque, a CMYK image that keeps its + /// inks (shown and separated through the working CMYK profile, as placed CMYK images are); + /// with transparency, RGBA through the working CMYK profile. + fn cmyk_image(&mut self, width: u32, height: u32, cmyka: &[u8]) -> Option> { + let n = (width as usize).checked_mul(height as usize)?; + let px = cmyka.get(..n.checked_mul(5)?)?.as_chunks::<5>().0; + if let Some(lut) = self.document_cmyk() { + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + return png(width, height, rgba); + } + if px.iter().all(|p| p[4] == 255) { + let inks = px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).collect(); + if let Some(blob) = vectorcraft_doc::cmyk::Inks::new(width, height, inks).and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) { + return Some(Arc::unwrap_or_clone(blob.bytes)); + } + } + self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)"); + let lut = self + .working_cmyk + .get_or_insert_with(|| { + Arc::new(vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| { + let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb(); + [r, g, b, 0.0] + })) + }) + .clone(); + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + png(width, height, rgba) + } + + /// The document's CMYK profile as a table from ink amounts to sRGB (relative colorimetric, + /// with black-point compensation, as Affinity's pixel layers convert), made once; `None` when + /// the document embeds none, or not one that converts. + fn document_cmyk(&mut self) -> Option> { + use vectorcraft_color::cms::{self, CmykLut, IccProfile, Intent}; + if let Some(lut) = &self.cmyk_lut { + return lut.clone(); + } + let embedded = self.cmyk_profile.is_some(); + let lut = self.cmyk_profile.take().and_then(|bytes| { + let profile = IccProfile::from_bytes(None, &bytes).ok()?; + let srgb = cms::icc::builtin_rgb(cms::SRGB)?; + profile.to_rgb_of(&srgb, &[0.0; 4], Intent::RelativeColorimetric, true)?; + Some(Arc::new(CmykLut::build(|inks| { + let [r, g, b] = profile + .to_rgb_of(&srgb, &inks, Intent::RelativeColorimetric, true) + .unwrap_or_else(|| Color::cmyk(inks[0], inks[1], inks[2], inks[3]).to_rgb()); + [r, g, b, 0.0] + }))) + }); + if embedded && lut.is_none() { + self.warn("CMYK pixel layers whose document's colour profile could not be read (shown through the working CMYK profile)"); + } + self.cmyk_lut = Some(lut.clone()); + lut + } + + pub(super) fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { + let bytes = match &img.pixels { + Pixels::Encoded(b) => b.clone(), + Pixels::Rgba8(rgba) => png(img.width, img.height, rgba.clone())?, + Pixels::Cmyka8(cmyka) => self.cmyk_image(img.width, img.height, cmyka)?, + }; + let r = match crate::cmd::fileio::load::raster_image(&bytes) { + Ok(r) => r, + Err(_) => { + self.warn("an embedded image that could not be decoded was left out"); + return None; + } + }; + // The pixel grid maps onto the Affinity image's own size, whatever the stored file's. + let sx = img.width as f64 / f64::from(r.width.max(1)); + let sy = img.height as f64 / f64::from(r.height.max(1)); + let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); + self.doc.images.entry(r.key.clone()).or_insert(r.blob); + Some(NodeKind::Image(Box::new(ImageObject { + key: r.key, + width: r.width, + height: r.height, + xf, + link: None, + placement: Default::default(), + pixels: None, + }))) + } +} diff --git a/crates/engine/src/cmd/fileio/affinity/text.rs b/crates/engine/src/cmd/fileio/affinity/text.rs new file mode 100644 index 000000000..cae76dc7b --- /dev/null +++ b/crates/engine/src/cmd/fileio/affinity/text.rs @@ -0,0 +1,326 @@ +use super::layout::{align_lines, fit_lines, font_style, justify, line_gap, place_cell}; +use super::nodes::affine; +use super::*; + +/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node +/// space into it (applied to the frame's geometry, not the type), plus the type's own scale. +pub(super) struct Placement { + /// Node space to the frame's space, before `place`. + pub(super) to_frame: Affine, + pub(super) place: Affine, + /// Points per node unit of type size. + pub(super) size_scale: f64, + /// Horizontal scale of the type relative to `size_scale`. + pub(super) h_scale: f64, +} + +impl Builder { + /// Text → its object and the opacity its characters' shared transparency gives the node. + pub(super) fn text(&mut self, t: &af::Text, to_doc: Affine) -> Option<(TextObject, f32)> { + let p = self.placement(to_doc * affine(t.transform), to_doc * affine(t.text_transform))?; + let alpha = self.text_alpha(&t.runs); + let mut obj = self.type_object(&t.runs, &p)?; + obj.para.justify = justify(t.align); + if let Some(first) = obj.paras.first_mut() { + first.justify = obj.para.justify; + } + match t.frame { + Some(f) => { + let (a, b) = (p.to_frame * Point::new(f.x0, f.y0), p.to_frame * Point::new(f.x1, f.y1)); + let r = Rect::from_points(a, b); + obj.xf = p.place * Affine::translate(r.origin().to_vec2()); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, r.width(), r.height())) }; + // Affinity hangs a frame's first line from its capitals' tops, not the font's ascent. + obj.area.first_baseline = FirstBaseline::CapHeight; + if fit_lines(&mut obj, r.width(), r.height()) { + self.warn("text frames whose last lines hang out of them (made taller so the lines show)"); + } + // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. + if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { + self.warn("text frames too short for their first line (made taller so the line shows)"); + } + } + None => { + align_lines(&mut obj); + obj.xf = p.place * Affine::translate((p.to_frame * Point::new(t.anchor.x, t.anchor.y)).to_vec2()); + } + } + Some((obj, alpha)) + } + + /// The opacity text takes from its characters' colour: VectorCraft's characters have none of + /// their own, so text whose characters share one becomes an object with that opacity. + fn text_alpha(&mut self, runs: &[af::TextRun]) -> f32 { + let alphas: Vec = + runs.iter().filter_map(|r| if let ap::Paint::Solid(c) = &r.fill { Some(c.alpha()) } else { None }).filter(|a| a.is_finite()).collect(); + let (lo, hi) = alphas.iter().fold((1.0f64, 0.0f64), |(lo, hi), a| (lo.min(*a), hi.max(*a))); + if alphas.is_empty() || lo >= 1.0 { + return 1.0; + } + if hi - lo > 0.01 { + self.warn("text with characters of different transparency (imported opaque)"); + return 1.0; + } + lo.clamp(0.0, 1.0) as f32 + } + + /// The characters and paragraphs of `runs` as a text object at the origin of text space. + fn type_object(&mut self, runs: &[af::TextRun], p: &Placement) -> Option { + let (v, h) = (p.size_scale, p.size_scale * p.h_scale); + let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); + let mut formats: Vec = Vec::new(); + let mut out: Vec = Vec::new(); + let mut paragraph = 0usize; + // Whether each paragraph here continues the one before after a line break (U+2028): + // VectorCraft has no line break inside a paragraph, so the lines become paragraphs. + let mut continues = vec![false]; + for r in runs { + for c in r.text.chars() { + match c { + '\u{2028}' => continues.push(true), + '\u{2029}' | '\r' | '\n' => continues.push(false), + _ => {} + } + } + // Affinity separates paragraphs with U+2029 and lines with U+2028; other control + // characters don't show. + let text: String = r + .text + .chars() + .filter_map(|c| match c { + '\u{2029}' | '\u{2028}' | '\r' | '\n' => Some('\n'), + '\t' => Some('\t'), + c if c.is_control() => None, + c => Some(c), + }) + .collect(); + if text.is_empty() { + continue; + } + for c in text.chars() { + if formats.len() <= paragraph { + formats.push(r.paragraph); + } + if c == '\n' { + paragraph += 1; + } + } + let mut style = self.char_style(r, (v, p.h_scale)); + if style.leading.is_none() { + style.leading = r.paragraph.leading.map(|l| l * v).filter(|l| l.is_finite() && *l > 0.0).map(|l| l.min(5000.0)); + } + out.push(TextRun::new(text, style)); + } + // Affinity ends a story with a paragraph mark; VectorCraft's last paragraph has none. + if let Some(last) = out.last_mut() + && last.text.ends_with('\n') + { + last.text.pop(); + if last.text.is_empty() { + out.pop(); + } + } + obj.runs = out; + let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::() + 1; + let mut paras = Vec::with_capacity(count); + let mut previous_after = 0.0; + // The run of each paragraph's first character. + let mut first_runs = Vec::with_capacity(count); + { + let mut at_start = true; + for (i, r) in obj.runs.iter().enumerate() { + for c in r.text.chars() { + if at_start { + first_runs.push(i); + at_start = false; + } + if c == '\n' { + at_start = true; + } + } + } + } + // Line gaps by font, each looked up once. + let mut gaps: std::collections::HashMap<(&str, &str), f64> = std::collections::HashMap::new(); + for i in 0..count { + let f = formats.get(i).or(formats.last()).copied().unwrap_or_default(); + let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 }; + let mut ps = vectorcraft_doc::text::ParaStyle { justify: justify(f.align), ..Default::default() }; + // Affinity places the first line and the others from the left edge; VectorCraft's + // first-line indent is relative to its left indent. + ps.left_indent = pt(f.left, h); + ps.first_line_indent = pt(f.first - f.left, h); + ps.right_indent = pt(f.right, h); + // Between two paragraphs Affinity keeps the larger of the space after the first and + // before the second; VectorCraft adds them. + let (mut before, mut after) = (pt(f.space_before, v).max(0.0), pt(f.space_after, v).max(0.0)); + // The lines of one Affinity paragraph: no space between them, and the first-line + // indent only on its first. + if continues.get(i).copied().unwrap_or(false) { + before = 0.0; + ps.first_line_indent = 0.0; + } + if continues.get(i + 1).copied().unwrap_or(false) { + after = 0.0; + } + ps.space_before = if i == 0 { before } else { (before - previous_after).max(0.0) }; + ps.space_after = after; + previous_after = after; + // Automatic leading: the font size plus the font's line gap, times the paragraph's + // factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas + // Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em, + // and the public Arial frame fixture fits this better than the font's line height. + let (family, style) = + first_runs.get(i).and_then(|r| obj.runs.get(*r)).map_or(("", ""), |r| (r.style.font_family.as_str(), r.style.font_style.as_str())); + let gap = *gaps.entry((family, style)).or_insert_with(|| line_gap(family, style)); + let ratio = (1.0 + gap) * f.auto_leading; + // Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft + // takes 0–500 %. + if ratio.is_finite() && (0.01..=5.0).contains(&ratio) { + ps.justification.auto_leading = ratio * 100.0; + } + paras.push(ps); + } + if let Some(first) = paras.first() { + obj.para = first.clone(); + } + obj.paras = paras; + obj.normalize_paras(); + Some(obj) + } + + /// A run's character style; `(size_scale, h_scale)` the type's vertical scale to points and + /// its horizontal scale relative to that. + fn char_style(&mut self, r: &af::TextRun, (size_scale, h_scale): (f64, f64)) -> CharStyle { + let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; + let mut style = CharStyle::default(); + if !r.family.is_empty() { + style.font_family = r.family.clone(); + } else if !r.postscript.is_empty() { + style.font_family = r.postscript.clone(); + } else { + self.warn("text without a font name uses the default font"); + } + // A family/weight pair cannot identify width variants such as Arial Narrow. + // Resolve a known PostScript identity to the exact installed family and style. + if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { + style.font_family = family; + style.font_style = face_style; + } else { + style.font_style = font_style(r.weight, r.italic); + } + style.features = r.features.clone(); + if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { + self.warn("unsupported text OpenType features (retained but not rendered)"); + } + style.all_caps = r.all_caps; + style.underline = r.underline; + style.strikethrough = r.strikethrough; + style.size = finite(r.size * size_scale, 12.0).clamp(0.1, 1296.0); + style.leading = r.leading.map(|l| finite(l * size_scale, 0.0)).filter(|l| *l > 0.0).map(|l| l.min(5000.0)); + style.tracking = finite(r.tracking * 1000.0, 0.0).clamp(-1000.0, 10000.0); + // Text squeezed or stretched by its transform keeps that as horizontal scale, on top of + // its own. + let hs = h_scale * r.h_scale; + if hs.is_finite() && (hs - 1.0).abs() > 1e-3 { + style.h_scale = (hs * 100.0).clamp(1.0, 1000.0); + } + // Transparency is the object's (see `text_alpha`); the paint's transform doesn't matter + // for a solid colour, and gradients on type are laid out in text space. + style.fill = match self.paint(&r.fill, Affine::scale(size_scale)) { + Some((p, _)) => p, + None => Paint::None, + }; + if let Some(st) = &r.stroke { + let w = st.width * size_scale; + if let Some((paint, _)) = self.paint(&st.paint, Affine::scale(size_scale)) + && w.is_finite() + && w > 0.0 + { + style.stroke = paint; + style.stroke_width = w.min(1000.0); + } + } + style + } + + /// A table: its cells as area type and the lines on their edges, as VectorCraft has no + /// tables. + pub(super) fn table(&mut self, t: &af::Table, to_doc: Affine) -> Vec> { + let mut out = Vec::new(); + let (ncols, nrows) = (t.ncols(), t.nrows()); + if ncols == 0 || nrows == 0 { + return out; + } + self.warn("tables (imported as text frames and lines)"); + let m = to_doc * affine(t.transform); + let Some(p) = self.placement(m, to_doc * affine(t.text_transform)) else { return out }; + let col = |i: usize| t.columns.get(i).copied().unwrap_or(0.0); + let row = |i: usize| t.rows.get(i).copied().unwrap_or(0.0); + for (i, cell) in t.cells.iter().enumerate() { + if cell.merged_left || cell.runs.is_empty() { + continue; + } + let (r, c) = (i / ncols, i % ncols); + // Cells to the right merged into this one widen it. + let span = 1 + t.cells.iter().skip(i + 1).take(ncols - c - 1).take_while(|n| n.merged_left).count(); + let [l, tp, rt, bt] = cell.inset; + let node = af::Rect { x0: col(c) + l, y0: row(r) + tp, x1: col(c + span) - rt, y1: row(r + 1) - bt }; + let (a, b) = (p.to_frame * Point::new(node.x0, node.y0), p.to_frame * Point::new(node.x1, node.y1)); + let rect = Rect::from_points(a, b); + if !(rect.width() > 0.0 && rect.height() > 0.0) { + continue; + } + let Some(mut obj) = self.type_object(&cell.runs, &p) else { continue }; + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, rect.width(), rect.height())) }; + obj.area.vertical_align = match cell.valign { + af::VAlign::Top => VerticalAlign::Top, + af::VAlign::Center => VerticalAlign::Center, + af::VAlign::Bottom => VerticalAlign::Bottom, + }; + obj.area.first_baseline = FirstBaseline::CapHeight; + let dy = place_cell(&mut obj, rect.width(), rect.height()); + obj.xf = p.place * Affine::translate(rect.origin().to_vec2() + Vec2::new(0.0, dy)); + // A cell's text never vanishes for want of a line's height. + vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj); + let alpha = self.text_alpha(&cell.runs); + let mut n = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))); + n.opacity = alpha; + out.push(Arc::new(n)); + } + // Lines over the cells: the vertical edges row by row (none inside merged cells), then + // the horizontal ones. + let mut line = |b: &mut Self, s: &Option, from: (f64, f64), to: (f64, f64)| { + let Some(s) = s else { return }; + let Some(layer) = b.stroke_layer(s, to_doc) else { return }; + let mut bp = BezPath::new(); + bp.move_to(m * Point::new(from.0, from.1)); + bp.line_to(m * Point::new(to.0, to.1)); + let mut n = Node::new( + b.doc.alloc_id(), + NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false }, + ); + n.appearance = Appearance { items: vec![AppearanceItem::Stroke(layer)], ..Appearance::default() }.into(); + out.push(Arc::new(n)); + }; + for r in 0..nrows { + for k in 0..=ncols { + let merged = k > 0 && k < ncols && t.cells.get(r * ncols + k).is_some_and(|c| c.merged_left); + if merged { + continue; + } + if let Some(s) = t.vertical.get(r * (ncols + 1) + k) { + line(self, s, (col(k), row(r)), (col(k), row(r + 1))); + } + } + } + for j in 0..=nrows { + for k in 0..ncols { + if let Some(s) = t.horizontal.get(j * ncols + k) { + line(self, s, (col(k), row(j)), (col(k + 1), row(j))); + } + } + } + out + } +}