mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
757 lines
31 KiB
Rust
757 lines
31 KiB
Rust
//! Document → PDF (krilla). Mirrors the tree walk of `vectorcraft-render`.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use krilla::color::{cmyk, luma, rgb};
|
||
use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion};
|
||
use krilla::geom::{Path, PathBuilder, Size, Transform};
|
||
use krilla::image::Image;
|
||
use krilla::metadata::Metadata;
|
||
use krilla::num::NormalizedF32;
|
||
use krilla::page::PageSettings;
|
||
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
|
||
use krilla::surface::Surface;
|
||
use kurbo::{ParamCurve, ParamCurveDeriv, PathEl, Shape, Vec2};
|
||
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
|
||
use vectorcraft_doc::{AppearanceItem, Arrowhead, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
||
|
||
use crate::{Compatibility, ExportReport, PdfError, PdfOptions};
|
||
|
||
/// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`).
|
||
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
|
||
export_with_report(doc, opts).map(|r| r.bytes)
|
||
}
|
||
|
||
/// Like [`export`], also returning warnings about approximated or dropped features.
|
||
pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
|
||
// Live geometry effects export as their result; raster effects are reported below.
|
||
let baked = vectorcraft_effects::bake_document(doc);
|
||
let doc = baked.as_ref().unwrap_or(doc);
|
||
if doc.artboards.is_empty() {
|
||
return Err(PdfError::NoArtboards);
|
||
}
|
||
let indices: Vec<usize> = match &opts.artboards {
|
||
Some(v) => v.clone(),
|
||
None => (0..doc.artboards.len()).collect(),
|
||
};
|
||
if indices.is_empty() {
|
||
return Err(PdfError::NoArtboards);
|
||
}
|
||
if let Some(bad) = indices.iter().find(|i| **i >= doc.artboards.len()) {
|
||
return Err(PdfError::BadArtboard(*bad));
|
||
}
|
||
|
||
let (version, archival) = match opts.compatibility {
|
||
Compatibility::Pdf14 => (PdfVersion::Pdf14, None),
|
||
Compatibility::Pdf15 => (PdfVersion::Pdf15, None),
|
||
Compatibility::Pdf16 => (PdfVersion::Pdf16, None),
|
||
Compatibility::Pdf17 => (PdfVersion::Pdf17, None),
|
||
Compatibility::Pdf20 => (PdfVersion::Pdf20, None),
|
||
Compatibility::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
|
||
Compatibility::PdfX4 => return Err(PdfError::Unsupported("PDF/X-4 output is not supported by the PDF writer yet".into())),
|
||
};
|
||
let mut cb = ConfigurationBuilder::new().with_version(version);
|
||
if let Some(a) = archival {
|
||
cb = cb.with_archival_validator(a);
|
||
}
|
||
let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() };
|
||
|
||
let mut pdf = krilla::Document::new_with(settings);
|
||
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
|
||
let mut meta = Metadata::new().creator("VectorCraft".into()).producer("VectorCraft".into());
|
||
if !title.is_empty() {
|
||
meta = meta.title(title);
|
||
}
|
||
if let Some(t) = opts.created.or_else(now_unix) {
|
||
meta = meta.creation_date(date_time(t));
|
||
}
|
||
pdf.set_metadata(meta);
|
||
|
||
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new() };
|
||
for i in indices {
|
||
let ab = &doc.artboards[i];
|
||
let r = ab.rect;
|
||
let size = Size::from_wh(r.width().max(1.0) as f32, r.height().max(1.0) as f32).ok_or(PdfError::BadArtboard(i))?;
|
||
let mut page = pdf.start_page_with(PageSettings::new(size));
|
||
let mut s = page.surface();
|
||
// krilla's page space is y-down with the origin at the top-left corner, like ours.
|
||
s.push_transform(&xf(Affine::translate((-r.x0, -r.y0))));
|
||
for layer in &doc.layers {
|
||
ex.node(&mut s, layer, r, false);
|
||
}
|
||
s.pop();
|
||
s.finish();
|
||
page.finish();
|
||
}
|
||
let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||
let mut warnings = ex.warnings;
|
||
warnings.dedup();
|
||
Ok(ExportReport { bytes, warnings })
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn now_unix() -> Option<i64> {
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn now_unix() -> Option<i64> {
|
||
None
|
||
}
|
||
|
||
/// Unix seconds (UTC) → krilla date (civil-from-days, proleptic Gregorian).
|
||
fn date_time(t: i64) -> krilla::metadata::DateTime {
|
||
let days = t.div_euclid(86_400);
|
||
let secs = t.rem_euclid(86_400);
|
||
let z = days + 719_468;
|
||
let era = z.div_euclid(146_097);
|
||
let doe = z - era * 146_097;
|
||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||
let mp = (5 * doy + 2) / 153;
|
||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||
krilla::metadata::DateTime::new(year.clamp(0, 9999) as u16)
|
||
.month(month as u8)
|
||
.day(day as u8)
|
||
.hour((secs / 3600) as u8)
|
||
.minute((secs / 60 % 60) as u8)
|
||
.second((secs % 60) as u8)
|
||
.utc_offset_hour(0)
|
||
.utc_offset_minute(0)
|
||
}
|
||
|
||
struct Exporter<'a> {
|
||
doc: &'a Document,
|
||
warnings: Vec<String>,
|
||
images: HashMap<String, Option<Image>>,
|
||
}
|
||
|
||
fn xf(a: Affine) -> Transform {
|
||
let c = a.as_coeffs();
|
||
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
|
||
}
|
||
|
||
fn to_path(bp: &BezPath) -> Option<Path> {
|
||
let mut pb = PathBuilder::new();
|
||
for el in bp.elements() {
|
||
match *el {
|
||
PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
|
||
PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
|
||
PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32),
|
||
PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32),
|
||
PathEl::ClosePath => pb.close(),
|
||
}
|
||
}
|
||
pb.finish()
|
||
}
|
||
|
||
fn norm(v: f32) -> NormalizedF32 {
|
||
NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE)
|
||
}
|
||
|
||
fn rule(r: FillRule) -> krilla::paint::FillRule {
|
||
match r {
|
||
FillRule::NonZero => krilla::paint::FillRule::NonZero,
|
||
FillRule::EvenOdd => krilla::paint::FillRule::EvenOdd,
|
||
}
|
||
}
|
||
|
||
fn blend(b: BlendMode) -> krilla::blend::BlendMode {
|
||
use krilla::blend::BlendMode as K;
|
||
match b {
|
||
BlendMode::Normal => K::Normal,
|
||
BlendMode::Darken => K::Darken,
|
||
BlendMode::Multiply => K::Multiply,
|
||
BlendMode::ColorBurn => K::ColorBurn,
|
||
BlendMode::Lighten => K::Lighten,
|
||
BlendMode::Screen => K::Screen,
|
||
BlendMode::ColorDodge => K::ColorDodge,
|
||
BlendMode::Overlay => K::Overlay,
|
||
BlendMode::SoftLight => K::SoftLight,
|
||
BlendMode::HardLight => K::HardLight,
|
||
BlendMode::Difference => K::Difference,
|
||
BlendMode::Exclusion => K::Exclusion,
|
||
BlendMode::Hue => K::Hue,
|
||
BlendMode::Saturation => K::Saturation,
|
||
BlendMode::Color => K::Color,
|
||
BlendMode::Luminosity => K::Luminosity,
|
||
}
|
||
}
|
||
|
||
fn q(v: f32) -> u8 {
|
||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||
}
|
||
|
||
fn color(c: &Color) -> krilla::color::Color {
|
||
match *c {
|
||
Color::Rgb { r, g, b } => rgb::Color::new(q(r), q(g), q(b)).into(),
|
||
Color::Cmyk { c, m, y, k } => cmyk::Color::new(q(c), q(m), q(y), q(k)).into(),
|
||
// VectorCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness.
|
||
Color::Gray { k } => luma::Color::new(q(1.0 - k)).into(),
|
||
}
|
||
}
|
||
|
||
fn rects_overlap(a: Rect, b: Rect) -> bool {
|
||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||
}
|
||
|
||
impl Exporter<'_> {
|
||
/// A colour for the page: in CMYK documents RGB colours are separated into DeviceCMYK through
|
||
/// the active colour settings, so the file carries press values.
|
||
fn col(&mut self, c: &Color) -> krilla::color::Color {
|
||
if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && matches!(c, Color::Rgb { .. }) {
|
||
let cms = vectorcraft_color::cms::active();
|
||
let [cc, m, y, k] = cms.to_cmyk(c, cms.settings().intent);
|
||
return cmyk::Color::new(q(cc), q(m), q(y), q(k)).into();
|
||
}
|
||
color(c)
|
||
}
|
||
|
||
/// A solid paint, as a Separation colour space when it's linked to a spot swatch (the
|
||
/// swatch's CMYK equivalent is the alternate space).
|
||
fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color {
|
||
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
|
||
let spot = swatch
|
||
.and_then(|n| self.doc.swatches.iter().find(|s| s.spot && s.name == n))
|
||
.and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc)));
|
||
let Some((name, sc)) = spot else { return self.col(c) };
|
||
let cms = vectorcraft_color::cms::active();
|
||
let intent = cms.settings().intent;
|
||
let full = cms.to_cmyk(&sc, intent);
|
||
let total: f32 = full.iter().sum();
|
||
let tint = if total <= 1e-4 { 1.0 } else { (cms.to_cmyk(c, intent).iter().sum::<f32>() / total).clamp(0.0, 1.0) };
|
||
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
|
||
SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into()
|
||
}
|
||
|
||
fn warn(&mut self, w: impl Into<String>) {
|
||
let w = w.into();
|
||
if !self.warnings.contains(&w) {
|
||
self.warnings.push(w);
|
||
}
|
||
}
|
||
|
||
/// Convert a paint. `bounds` resolves unset gradient geometry (like the renderer).
|
||
fn paint(&mut self, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
|
||
match p {
|
||
Paint::None => None,
|
||
Paint::Solid { color: c, swatch } => Some(self.solid(c, swatch.as_deref()).into()),
|
||
Paint::Gradient(g) => {
|
||
let geom = g.resolve(bounds);
|
||
let mut stops: Vec<Stop> = Vec::new();
|
||
let mut last = 0.0f32;
|
||
for (o, c, a) in g.gradient.expanded_stops() {
|
||
let o = o.clamp(last, 1.0);
|
||
last = o;
|
||
stops.push(Stop { offset: norm(o), color: self.col(&c), opacity: norm(a) });
|
||
}
|
||
if stops.is_empty() {
|
||
return None;
|
||
}
|
||
match g.gradient.kind {
|
||
GradientKind::Linear => {
|
||
let (s, mut e) = (geom.start, geom.end);
|
||
if s.distance(e) < 1e-9 {
|
||
e = s + Vec2::new(1.0, 0.0);
|
||
}
|
||
Some(
|
||
LinearGradient {
|
||
x1: s.x as f32,
|
||
y1: s.y as f32,
|
||
x2: e.x as f32,
|
||
y2: e.y as f32,
|
||
transform: Transform::identity(),
|
||
spread_method: SpreadMethod::Pad,
|
||
stops,
|
||
anti_alias: false,
|
||
}
|
||
.into(),
|
||
)
|
||
}
|
||
GradientKind::Radial => {
|
||
let r = geom.length().max(1e-6) as f32;
|
||
let angle = (geom.end - geom.start).atan2();
|
||
let t = Affine::translate(geom.start.to_vec2())
|
||
* Affine::rotate(angle)
|
||
* Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3))
|
||
* Affine::rotate(-angle)
|
||
* Affine::translate(-geom.start.to_vec2());
|
||
let (cx, cy) = (geom.start.x as f32, geom.start.y as f32);
|
||
Some(
|
||
RadialGradient {
|
||
fx: cx,
|
||
fy: cy,
|
||
fr: 0.0,
|
||
cx,
|
||
cy,
|
||
cr: r,
|
||
transform: xf(t),
|
||
spread_method: SpreadMethod::Pad,
|
||
stops,
|
||
anti_alias: false,
|
||
}
|
||
.into(),
|
||
)
|
||
}
|
||
GradientKind::Freeform => {
|
||
self.warn("freeform gradients are exported as their average colour");
|
||
let n = g.gradient.stops.len().max(1) as f32;
|
||
let mut acc = [0.0f32; 3];
|
||
for s in &g.gradient.stops {
|
||
let c = s.color.to_rgb();
|
||
for i in 0..3 {
|
||
acc[i] += c[i] / n;
|
||
}
|
||
}
|
||
Some(color(&Color::rgb(acc[0], acc[1], acc[2])).into())
|
||
}
|
||
}
|
||
}
|
||
Paint::Pattern { .. } => {
|
||
self.warn("pattern strokes (and missing patterns) are exported as mid-grey");
|
||
Some(rgb::Color::new(128, 128, 128).into())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white;
|
||
/// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)).
|
||
fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask {
|
||
let backdrop = to_path(&page.inflate(1.0, 1.0).to_path(0.1));
|
||
let white = || Fill { paint: rgb::Color::new(255, 255, 255).into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero };
|
||
let mut sb = s.stream_builder();
|
||
let mut ms = sb.surface();
|
||
// An opaque backdrop when it matters: white outside the art (no clip), or black for the
|
||
// inverting Difference pass below to turn white.
|
||
if let Some(bp) = &backdrop
|
||
&& (!m.clip || m.invert)
|
||
{
|
||
let c = if m.clip { rgb::Color::new(0, 0, 0) } else { rgb::Color::new(255, 255, 255) };
|
||
ms.set_stroke(None);
|
||
ms.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
|
||
ms.draw_path(bp);
|
||
}
|
||
self.node(&mut ms, &m.art, page, true);
|
||
if let Some(bp) = &backdrop
|
||
&& m.invert
|
||
{
|
||
ms.push_blend_mode(krilla::blend::BlendMode::Difference);
|
||
ms.set_stroke(None);
|
||
ms.set_fill(Some(white()));
|
||
ms.draw_path(bp);
|
||
ms.pop();
|
||
}
|
||
ms.finish();
|
||
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
|
||
}
|
||
|
||
fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
|
||
if !force && !n.visible {
|
||
return;
|
||
}
|
||
if let NodeKind::Layer { template: true, .. } = n.kind {
|
||
return;
|
||
}
|
||
match n.visual_bounds() {
|
||
Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return,
|
||
None if !n.is_container() => return,
|
||
_ => {}
|
||
}
|
||
if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
|
||
self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
|
||
}
|
||
let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. });
|
||
let mut pushes = 0;
|
||
if n.blend != BlendMode::Normal {
|
||
s.push_blend_mode(blend(n.blend));
|
||
pushes += 1;
|
||
}
|
||
if n.opacity < 1.0 {
|
||
s.push_opacity(norm(n.opacity));
|
||
pushes += 1;
|
||
} else if container && (n.isolate || n.blend != BlendMode::Normal) {
|
||
s.push_isolated();
|
||
pushes += 1;
|
||
}
|
||
if n.knockout {
|
||
self.warn("knockout groups are exported as normal groups");
|
||
}
|
||
if let Some(m) = n.mask.as_deref()
|
||
&& !m.disabled
|
||
{
|
||
let mask = self.soft_mask(s, m, page);
|
||
s.push_mask(mask);
|
||
pushes += 1;
|
||
}
|
||
match &n.kind {
|
||
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
|
||
for c in children {
|
||
self.node(s, c, page, false);
|
||
}
|
||
}
|
||
NodeKind::Group { children, clip: true } => {
|
||
let clip = children.first().and_then(|c| match &c.kind {
|
||
NodeKind::Path { path, rule, .. } => Some((path.to_bezpath(), *rule)),
|
||
NodeKind::Compound { children, rule } => {
|
||
let mut bp = BezPath::new();
|
||
for ch in children {
|
||
if let Some(p) = ch.path_data() {
|
||
bp.extend(p.to_bezpath());
|
||
}
|
||
}
|
||
Some((bp, *rule))
|
||
}
|
||
_ => None,
|
||
});
|
||
match clip.and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) {
|
||
Some((p, r)) => {
|
||
s.push_clip_path(&p, &rule(r));
|
||
for c in children.iter().skip(1) {
|
||
self.node(s, c, page, false);
|
||
}
|
||
s.pop();
|
||
}
|
||
None => {
|
||
for c in children {
|
||
self.node(s, c, page, false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
NodeKind::Path { path, rule, guide, .. } => {
|
||
if !*guide {
|
||
self.shape(s, n, &path.to_bezpath(), *rule);
|
||
}
|
||
}
|
||
NodeKind::Compound { children, rule } => {
|
||
let mut bp = BezPath::new();
|
||
for c in children {
|
||
if let Some(p) = c.path_data() {
|
||
bp.extend(p.to_bezpath());
|
||
}
|
||
}
|
||
self.shape(s, n, &bp, *rule);
|
||
}
|
||
NodeKind::Text(t) => self.text(s, n, t),
|
||
NodeKind::Image(im) => self.image(s, im),
|
||
NodeKind::SymbolInstance { symbol, xf } => {
|
||
if let Some(sym) = self.doc.symbols.iter().find(|x| &x.name == symbol) {
|
||
let mut art = (*sym.art).clone();
|
||
art.transform(*xf, false);
|
||
self.node(s, &art, page, true);
|
||
}
|
||
}
|
||
// Live blends/envelopes/meshes export their evaluated (expanded) form.
|
||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||
let g = vectorcraft_doc::live::expand_deep(n, None);
|
||
for c in g.children().into_iter().flatten() {
|
||
self.node(s, c, page, false);
|
||
}
|
||
}
|
||
}
|
||
for _ in 0..pushes {
|
||
s.pop();
|
||
}
|
||
}
|
||
|
||
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule) {
|
||
let Some(path) = to_path(bp) else { return };
|
||
let bounds = bp.bounding_box();
|
||
for item in &n.appearance.items {
|
||
match item {
|
||
AppearanceItem::Fill(fl) => {
|
||
if !fl.visible || fl.paint.is_none() {
|
||
continue;
|
||
}
|
||
if fl.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
|
||
self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
|
||
}
|
||
// Pattern fills: the tile instances covering the shape, clipped to it.
|
||
let doc = self.doc;
|
||
if let Paint::Pattern { pattern, xf } = &fl.paint
|
||
&& let Some(def) = doc.pattern(pattern)
|
||
{
|
||
s.push_clip_path(&path, &rule(r));
|
||
if fl.opacity < 1.0 {
|
||
s.push_opacity(norm(fl.opacity));
|
||
}
|
||
for inst in def.instances_in(*xf, bounds) {
|
||
self.node(s, &inst, bounds, true);
|
||
}
|
||
if fl.opacity < 1.0 {
|
||
s.pop();
|
||
}
|
||
s.pop();
|
||
continue;
|
||
}
|
||
let Some(paint) = self.paint(&fl.paint, bounds) else { continue };
|
||
let bl = fl.blend != BlendMode::Normal;
|
||
if bl {
|
||
s.push_blend_mode(blend(fl.blend));
|
||
}
|
||
s.set_stroke(None);
|
||
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) }));
|
||
s.draw_path(&path);
|
||
if bl {
|
||
s.pop();
|
||
}
|
||
}
|
||
AppearanceItem::Stroke(st) => {
|
||
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
|
||
continue;
|
||
}
|
||
self.stroke(s, bp, &path, r, st, bounds);
|
||
}
|
||
}
|
||
}
|
||
s.set_fill(None);
|
||
s.set_stroke(None);
|
||
}
|
||
|
||
fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, bounds: Rect) {
|
||
if st.profile.is_some() || st.brush.is_some() {
|
||
self.warn("variable-width profiles and brushes are exported as uniform strokes");
|
||
}
|
||
let Some(paint) = self.paint(&st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0)) else { return };
|
||
let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath));
|
||
let mut pushes = 0;
|
||
if st.blend != BlendMode::Normal {
|
||
s.push_blend_mode(blend(st.blend));
|
||
pushes += 1;
|
||
}
|
||
let width = match st.align {
|
||
StrokeAlign::Center => st.width,
|
||
_ if closed => st.width * 2.0,
|
||
_ => st.width,
|
||
};
|
||
match st.align {
|
||
StrokeAlign::Inside if closed => {
|
||
s.push_clip_path(path, &rule(r));
|
||
pushes += 1;
|
||
}
|
||
StrokeAlign::Outside if closed => {
|
||
// Clip to everything outside the path: a big frame plus the path, even-odd.
|
||
let big = bounds.inflate(width * 2.0 + 10.0, width * 2.0 + 10.0);
|
||
let mut outside = big.to_path(0.1);
|
||
outside.extend(bp.iter());
|
||
if let Some(p) = to_path(&outside) {
|
||
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
|
||
pushes += 1;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
let dash = st.dash.as_ref().filter(|d| d.pattern.iter().any(|v| *v > 0.0)).map(|d| {
|
||
let mut pat: Vec<f32> = d.pattern.iter().map(|v| v.max(0.0) as f32).collect();
|
||
if pat.len() % 2 == 1 {
|
||
pat.extend(pat.clone());
|
||
}
|
||
StrokeDash { array: pat, offset: d.offset as f32 }
|
||
});
|
||
s.set_fill(None);
|
||
s.set_stroke(Some(Stroke {
|
||
paint: paint.clone(),
|
||
width: width as f32,
|
||
miter_limit: st.miter_limit.max(1.0) as f32,
|
||
line_cap: match st.cap {
|
||
LineCap::Butt => krilla::paint::LineCap::Butt,
|
||
LineCap::Round => krilla::paint::LineCap::Round,
|
||
LineCap::Square => krilla::paint::LineCap::Square,
|
||
},
|
||
line_join: match st.join {
|
||
LineJoin::Miter => krilla::paint::LineJoin::Miter,
|
||
LineJoin::Round => krilla::paint::LineJoin::Round,
|
||
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
|
||
},
|
||
opacity: norm(st.opacity),
|
||
dash,
|
||
}));
|
||
s.draw_path(path);
|
||
s.set_stroke(None);
|
||
// Pop the alignment clip before drawing arrowheads.
|
||
if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 {
|
||
s.pop();
|
||
pushes -= 1;
|
||
}
|
||
if st.start_arrow.is_some() || st.end_arrow.is_some() {
|
||
for head in arrowheads(bp, st) {
|
||
if let Some(p) = to_path(&head) {
|
||
s.set_fill(Some(Fill { paint: paint.clone(), opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero }));
|
||
s.draw_path(&p);
|
||
}
|
||
}
|
||
s.set_fill(None);
|
||
}
|
||
for _ in 0..pushes {
|
||
s.pop();
|
||
}
|
||
}
|
||
|
||
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) {
|
||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||
let tb = t.xf.transform_rect_bbox(layout.bounds);
|
||
s.push_transform(&xf(t.xf));
|
||
for (i, run) in t.runs.iter().enumerate() {
|
||
let mut bp = BezPath::new();
|
||
for g in layout.glyphs.iter().filter(|g| g.run == i) {
|
||
bp.extend(g.outline.iter());
|
||
}
|
||
let Some(path) = to_path(&bp) else { continue };
|
||
if let Some(paint) = self.paint(&run.style.fill, layout.bounds) {
|
||
s.set_stroke(None);
|
||
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
|
||
s.draw_path(&path);
|
||
}
|
||
if run.style.stroke_width > 0.0
|
||
&& let Some(paint) = self.paint(&run.style.stroke, layout.bounds)
|
||
{
|
||
s.set_fill(None);
|
||
s.set_stroke(Some(Stroke { paint, width: run.style.stroke_width as f32, ..Default::default() }));
|
||
s.draw_path(&path);
|
||
}
|
||
}
|
||
s.set_fill(None);
|
||
s.set_stroke(None);
|
||
s.pop();
|
||
if !n.appearance.items.is_empty() {
|
||
let mut all = layout.to_bezpath();
|
||
all.apply_affine(t.xf);
|
||
let Some(path) = to_path(&all) else { return };
|
||
for item in &n.appearance.items {
|
||
match item {
|
||
AppearanceItem::Fill(fl) if fl.visible => {
|
||
if let Some(paint) = self.paint(&fl.paint, tb) {
|
||
s.set_stroke(None);
|
||
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
|
||
s.draw_path(&path);
|
||
}
|
||
}
|
||
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
|
||
if let Some(paint) = self.paint(&st.paint, tb) {
|
||
s.set_fill(None);
|
||
s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() }));
|
||
s.draw_path(&path);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
s.set_fill(None);
|
||
s.set_stroke(None);
|
||
}
|
||
}
|
||
|
||
fn load_image(&mut self, key: &str) -> Option<Image> {
|
||
if let Some(i) = self.images.get(key) {
|
||
return i.clone();
|
||
}
|
||
let img = self.doc.images.get(key).and_then(|blob| {
|
||
let bytes = blob.bytes.as_ref().clone();
|
||
let direct = match blob.mime.as_str() {
|
||
"image/png" => Image::from_png(bytes.clone().into(), true).ok(),
|
||
"image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), true).ok(),
|
||
"image/gif" => Image::from_gif(bytes.clone().into(), true).ok(),
|
||
"image/webp" => Image::from_webp(bytes.clone().into(), true).ok(),
|
||
_ => None,
|
||
};
|
||
direct.or_else(|| {
|
||
let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8();
|
||
let (w, h) = rgba.dimensions();
|
||
Some(Image::from_rgba8(rgba.into_raw(), w, h))
|
||
})
|
||
});
|
||
if img.is_none() {
|
||
self.warn(format!("image '{key}' could not be decoded and was skipped"));
|
||
}
|
||
self.images.insert(key.to_string(), img.clone());
|
||
img
|
||
}
|
||
|
||
fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject) {
|
||
let Some(img) = self.load_image(&im.key) else { return };
|
||
let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return };
|
||
s.push_transform(&xf(im.xf));
|
||
s.draw_image(img, size);
|
||
s.pop();
|
||
}
|
||
}
|
||
|
||
/// Arrowhead outlines for a stroke (same geometry as the renderer).
|
||
fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<BezPath> {
|
||
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
|
||
let mut out = Vec::new();
|
||
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out };
|
||
if let Some(a) = st.start_arrow {
|
||
out.push(head(a, first.eval(0.0), tangent(first, 0.0) * -1.0, st.width * st.arrow_scale.0 / 100.0));
|
||
}
|
||
if let Some(a) = st.end_arrow {
|
||
out.push(head(a, last.eval(1.0), tangent(last, 1.0), st.width * st.arrow_scale.1 / 100.0));
|
||
}
|
||
out
|
||
}
|
||
|
||
fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 {
|
||
let d = match s {
|
||
kurbo::PathSeg::Line(l) => l.p1 - l.p0,
|
||
kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
|
||
kurbo::PathSeg::Cubic(c) => {
|
||
let v = c.deriv().eval(t).to_vec2();
|
||
if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v }
|
||
}
|
||
};
|
||
let l = d.hypot();
|
||
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l }
|
||
}
|
||
|
||
fn head(kind: Arrowhead, tip: kurbo::Point, dir: Vec2, w: f64) -> BezPath {
|
||
let w = w.max(0.25);
|
||
let n = Vec2::new(-dir.y, dir.x);
|
||
let len = 4.0 * w;
|
||
let half = 2.0 * w;
|
||
let base = tip - dir * len;
|
||
let mut p = BezPath::new();
|
||
match kind {
|
||
Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => {
|
||
p.move_to(tip);
|
||
p.line_to(base + n * half);
|
||
if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) {
|
||
p.line_to(tip - dir * (len * 0.7));
|
||
}
|
||
p.line_to(base - n * half);
|
||
p.close_path();
|
||
}
|
||
Arrowhead::Circle | Arrowhead::CircleOpen => {
|
||
p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01);
|
||
}
|
||
Arrowhead::Square | Arrowhead::SquareOpen => {
|
||
let c = tip - dir * half;
|
||
p.move_to(c + dir * half + n * half);
|
||
p.line_to(c - dir * half + n * half);
|
||
p.line_to(c - dir * half - n * half);
|
||
p.line_to(c + dir * half - n * half);
|
||
p.close_path();
|
||
}
|
||
Arrowhead::Diamond => {
|
||
let c = tip - dir * half;
|
||
p.move_to(tip);
|
||
p.line_to(c + n * half);
|
||
p.line_to(c - dir * half);
|
||
p.line_to(c - n * half);
|
||
p.close_path();
|
||
}
|
||
Arrowhead::Bar => {
|
||
let t = w * 0.75;
|
||
p.move_to(tip + n * half);
|
||
p.line_to(tip + n * half - dir * t);
|
||
p.line_to(tip - n * half - dir * t);
|
||
p.line_to(tip - n * half);
|
||
p.close_path();
|
||
}
|
||
}
|
||
p
|
||
}
|