refactor(engine): move cmd/fileio/affinity.rs items to cmd/fileio/affinity/{document,import,layout,master_copies,nodes,paint,text}.rs

This commit is contained in:
aiongg
2026-10-26 22:01:25 +00:00
parent 71aef5cc85
commit 2a90e82736
8 changed files with 1500 additions and 1459 deletions
File diff suppressed because it is too large Load Diff
@@ -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<Arc<Node>> = 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<af::Rect>| 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<Arc<Node>> = 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<usize>, 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
}
}
@@ -0,0 +1,48 @@
use super::*;
/// What an Affinity file opened as.
pub(in super::super) struct Imported {
pub doc: Document,
pub warnings: Vec<String>,
/// 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<Imported> {
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::<Vec<_>>();
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
),
))
}
@@ -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<Option<(usize, usize, usize)>> {
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<usize> = (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<u8>) -> Option<Vec<u8>> {
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(),
}
}
@@ -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<CopyCost> {
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<usize>,
pub(super) bounds: Option<af::Rect>,
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::<u64>();
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::<u64>(), 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)))
}
@@ -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<Node>>) -> Arc<Node> {
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<Node>, visible: bool, locked: bool) -> Arc<Node> {
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<String> = 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<Arc<Node>> {
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<Arc<Node>> = 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))
}
}
@@ -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<Effect> {
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<StrokeLayer> {
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<Placement> {
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<Vec<u8>> {
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<Arc<vectorcraft_color::cms::CmykLut>> {
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<NodeKind> {
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,
})))
}
}
@@ -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<f64> =
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<TextObject> {
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<af::Paragraph> = Vec::new();
let mut out: Vec<TextRun> = 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::<usize>() + 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<Arc<Node>> {
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<ap::Stroke>, 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
}
}