mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user