mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
1501 lines
69 KiB
Rust
1501 lines
69 KiB
Rust
//! Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): the native layers, curves,
|
||
//! shapes, artboards, text, tables, master pages, layer effects and images that
|
||
//! `vectorcraft-affinity` reads, mapped to editable VectorCraft art. Whatever the reader could not map is listed in the warnings. A file whose
|
||
//! native document can't be read opens as its embedded preview instead, with a warning that says
|
||
//! why, and can't be placed.
|
||
|
||
use std::io::Cursor;
|
||
use std::sync::Arc;
|
||
|
||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||
use serde_json::json;
|
||
use vectorcraft_affinity::model::{self as af, Align as AfAlign, Blend, Kind, Pixels};
|
||
use vectorcraft_affinity::paint::{self as ap, Cap, GradientKind as AfKind, Join};
|
||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||
use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||
use vectorcraft_doc::geom::{FillRule, PathData, shapes};
|
||
use vectorcraft_doc::text::{CharStyle, FirstBaseline, Justify, TextKind, TextObject, TextRun, VerticalAlign};
|
||
use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKind, OpacityMask};
|
||
|
||
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<String>,
|
||
/// Only the embedded preview: never placed, used as a template or mined for styles.
|
||
pub preview_only: bool,
|
||
}
|
||
|
||
pub(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
|
||
),
|
||
))
|
||
}
|
||
|
||
struct Builder {
|
||
doc: Document,
|
||
warnings: Vec<String>,
|
||
/// Points per document pixel.
|
||
k: f64,
|
||
/// Layers made so far (their colours cycle).
|
||
layer: u8,
|
||
/// How deep inside embedded Affinity documents the node being mapped is: their artboards
|
||
/// are only clip groups, never artboards of this document.
|
||
embedded: usize,
|
||
/// Each master spread's art as shown on pages, in the order the masters are first used.
|
||
masters: Vec<(usize, Vec<Arc<Node>>)>,
|
||
/// The document's CMYK profile, until [`Builder::document_cmyk`] makes its table.
|
||
cmyk_profile: Option<Vec<u8>>,
|
||
/// That table, once made (`Some(None)`: there is none).
|
||
cmyk_lut: Option<Option<Arc<vectorcraft_color::cms::CmykLut>>>,
|
||
/// The working CMYK profile's table, once a CMYK pixel layer needs it.
|
||
working_cmyk: Option<Arc<vectorcraft_color::cms::CmykLut>>,
|
||
/// What baking colour and gradient overlays may still take ([`overlay::MAX_WORK`]).
|
||
overlay_work: u64,
|
||
/// What copying master pages onto the pages that show them may still take.
|
||
copy_left: CopyCost,
|
||
/// Each master spread's objects as its pages show them, made the first time one does.
|
||
master_parts: std::collections::HashMap<usize, Arc<[MasterPart]>>,
|
||
}
|
||
|
||
/// 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<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.
|
||
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<usize>,
|
||
bounds: Option<af::Rect>,
|
||
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::<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)))
|
||
}
|
||
|
||
/// 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<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
|
||
}
|
||
|
||
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.
|
||
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)
|
||
}
|
||
|
||
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.
|
||
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))
|
||
}
|
||
|
||
/// 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<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.
|
||
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<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)
|
||
}
|
||
|
||
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<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,
|
||
})
|
||
}
|
||
|
||
/// 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<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.
|
||
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
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
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 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() })))
|
||
}
|
||
}
|
||
|
||
/// 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<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 ….
|
||
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<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.
|
||
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(),
|
||
}
|
||
}
|