mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
A PostScript file (a .ai saved in an older format or without PDF compatibility, or an .eps, plain or with a binary header) is refused by any name with a clear message instead of a parse error (there is no PostScript importer yet). An .ai whose pages are only placeholder text over the editor's private data (PieceInfo AIPrivateData) is detected and refused with advice to save it with PDF compatibility, instead of opening the placeholder. vectorcraft-pdf: is_postscript(), PdfError::PostScript / PlaceholderOnly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
699 lines
27 KiB
Rust
699 lines
27 KiB
Rust
//! PDF → Document (hayro-interpret device that builds a VectorCraft node tree).
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use hayro_interpret::font::Glyph;
|
|
use hayro_interpret::pattern::Pattern;
|
|
use hayro_interpret::shading::ShadingType;
|
|
use hayro_interpret::{
|
|
ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, PathDrawMode,
|
|
SoftMask, StrokeProps, interpret_page,
|
|
};
|
|
use hayro_syntax::object::Name;
|
|
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
|
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
|
use vectorcraft_doc::{
|
|
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId,
|
|
NodeKind, StrokeLayer,
|
|
};
|
|
use vectorcraft_geom::{FillRule, PathData};
|
|
|
|
use crate::import_color::{Colors, Native};
|
|
use crate::{CropTo, ImportOptions, ImportReport, PdfError};
|
|
|
|
/// The name of the paths text imports as.
|
|
const TEXT_OUTLINES: &str = "<Text Outlines>";
|
|
|
|
/// Import a PDF (or PDF-compatible `.ai`) with default options.
|
|
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
|
|
import_with_report(bytes, &ImportOptions::default()).map(|r| r.document)
|
|
}
|
|
|
|
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
|
|
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
|
|
let pdf = crate::pages::open(bytes, opts.password.as_deref())?;
|
|
let pages = pdf.pages();
|
|
let picked = crate::pages::picked(opts, pages.len())?;
|
|
|
|
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
|
|
let sink2 = sink.clone();
|
|
let settings = InterpreterSettings {
|
|
warning_sink: Arc::new(move |w| {
|
|
let msg = match w {
|
|
InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped",
|
|
InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped",
|
|
};
|
|
if let Ok(mut v) = sink2.lock()
|
|
&& !v.iter().any(|x| x == msg)
|
|
{
|
|
v.push(msg.to_string());
|
|
}
|
|
}),
|
|
render_annotations: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let mut doc = Document::new(1.0, 1.0);
|
|
doc.title = "Imported PDF".into();
|
|
doc.artboards.clear();
|
|
doc.layers.clear();
|
|
let taken = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
|
|
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken));
|
|
let cache = InterpreterCache::new();
|
|
let mut x = 0.0;
|
|
// Every page only a placeholder (text) over private data: the file's art isn't in its PDF part.
|
|
let mut placeholder = true;
|
|
for (i, &number) in picked.iter().enumerate() {
|
|
let Some(page) = pages.get(number) else { continue };
|
|
// The chosen box sits at (x, 0); the page draws round it.
|
|
let (init, frame) = crate::pages::frame(page, opts.crop);
|
|
let mut ab = Rect::new(x, 0.0, x + frame.width(), frame.height());
|
|
let xf = Affine::translate((x - frame.x0, -frame.y0)) * init;
|
|
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
|
|
b.page = ab;
|
|
b.begin_page();
|
|
interpret_page(page, &mut ctx, &mut b);
|
|
let children = b.end_page();
|
|
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
|
|
let mut right = ab.x1;
|
|
if opts.crop == CropTo::Bounding
|
|
&& let Some(art) = vectorcraft_doc::live::nodes_bounds(&children)
|
|
{
|
|
right = right.max(art.x1);
|
|
ab = art;
|
|
}
|
|
x = right + opts.artboard_gap;
|
|
doc.artboards.push(Artboard {
|
|
id: i as u32 + 1,
|
|
name: format!("Artboard {}", i + 1),
|
|
rect: ab,
|
|
show_center_mark: false,
|
|
show_cross_hairs: false,
|
|
});
|
|
let mut layer = Node::layer(b.id(), &format!("Page {}", number + 1), LayerColor::Preset((i % 27) as u8));
|
|
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
|
|
*c = children;
|
|
}
|
|
doc.layers.push(Arc::new(layer));
|
|
}
|
|
if placeholder {
|
|
return Err(PdfError::PlaceholderOnly);
|
|
}
|
|
for (k, blob) in b.images.drain() {
|
|
doc.images.insert(k, blob);
|
|
}
|
|
// A file painted mostly in CMYK opens as a CMYK document (with CMYK default swatches).
|
|
if b.colors.cmyk_document() {
|
|
doc.color_mode = ColorMode::Cmyk;
|
|
(doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
|
|
}
|
|
doc.swatches.extend(b.colors.swatches());
|
|
if b.colors.mixed {
|
|
b.warn("colours mixing several inks (DeviceN) were imported as RGB");
|
|
}
|
|
doc.fix_next_id();
|
|
|
|
let mut warnings = b.warnings;
|
|
if let Ok(v) = sink.lock() {
|
|
for w in v.iter() {
|
|
if !warnings.contains(w) {
|
|
warnings.push(w.clone());
|
|
}
|
|
}
|
|
}
|
|
Ok(ImportReport { document: doc, warnings })
|
|
}
|
|
|
|
/// Is this art text and nothing else (in groups and clips), with some text?
|
|
fn only_text(nodes: &[Arc<Node>]) -> bool {
|
|
fn walk(nodes: &[Arc<Node>], text: &mut bool) -> bool {
|
|
nodes.iter().all(|n| match &n.kind {
|
|
NodeKind::Path { clipping: true, .. } => true,
|
|
NodeKind::Path { .. } if n.name.as_deref() == Some(TEXT_OUTLINES) => {
|
|
*text = true;
|
|
true
|
|
}
|
|
NodeKind::Group { children, .. } => walk(children, text),
|
|
_ => false,
|
|
})
|
|
}
|
|
let mut text = false;
|
|
walk(nodes, &mut text) && text
|
|
}
|
|
|
|
/// Decoded RGBA pixels, width, height and hayro's scale factors.
|
|
type Decoded = (Vec<u8>, u32, u32, (f32, f32));
|
|
|
|
enum FrameKind {
|
|
Root,
|
|
/// A clip group whose clip path node is stored here.
|
|
Clip(Box<Node>),
|
|
/// A clip that was redundant (covers the page); children go straight to the parent.
|
|
Skip,
|
|
Group {
|
|
opacity: f32,
|
|
blend: BlendMode,
|
|
masked: bool,
|
|
},
|
|
}
|
|
|
|
struct Frame {
|
|
kind: FrameKind,
|
|
children: Vec<Arc<Node>>,
|
|
}
|
|
|
|
/// Glyphs drawn consecutively with the same paint, merged into one path.
|
|
struct GlyphRun {
|
|
path: BezPath,
|
|
paint: Paint,
|
|
opacity: f32,
|
|
stroke: Option<StrokeProps>,
|
|
scale: f64,
|
|
}
|
|
|
|
struct Builder<'p> {
|
|
colors: Colors<'p>,
|
|
next: u64,
|
|
stack: Vec<Frame>,
|
|
blend: BlendMode,
|
|
page: Rect,
|
|
glyphs: Option<GlyphRun>,
|
|
images: HashMap<String, ImageBlob>,
|
|
image_keys: HashMap<u128, (String, u32, u32)>,
|
|
warnings: Vec<String>,
|
|
}
|
|
|
|
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
|
|
use hayro_interpret::BlendMode as H;
|
|
match b {
|
|
H::Normal => BlendMode::Normal,
|
|
H::Multiply => BlendMode::Multiply,
|
|
H::Screen => BlendMode::Screen,
|
|
H::Overlay => BlendMode::Overlay,
|
|
H::Darken => BlendMode::Darken,
|
|
H::Lighten => BlendMode::Lighten,
|
|
H::ColorDodge => BlendMode::ColorDodge,
|
|
H::ColorBurn => BlendMode::ColorBurn,
|
|
H::HardLight => BlendMode::HardLight,
|
|
H::SoftLight => BlendMode::SoftLight,
|
|
H::Difference => BlendMode::Difference,
|
|
H::Exclusion => BlendMode::Exclusion,
|
|
H::Hue => BlendMode::Hue,
|
|
H::Saturation => BlendMode::Saturation,
|
|
H::Color => BlendMode::Color,
|
|
H::Luminosity => BlendMode::Luminosity,
|
|
}
|
|
}
|
|
|
|
fn fill_rule(r: hayro_interpret::FillRule) -> FillRule {
|
|
match r {
|
|
hayro_interpret::FillRule::NonZero => FillRule::NonZero,
|
|
hayro_interpret::FillRule::EvenOdd => FillRule::EvenOdd,
|
|
}
|
|
}
|
|
|
|
fn mean_scale(a: Affine) -> f64 {
|
|
a.determinant().abs().sqrt()
|
|
}
|
|
|
|
fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer {
|
|
let mut st = StrokeLayer::new(paint, p.line_width as f64 * scale);
|
|
st.opacity = opacity;
|
|
st.cap = match p.line_cap {
|
|
kurbo::Cap::Butt => LineCap::Butt,
|
|
kurbo::Cap::Round => LineCap::Round,
|
|
kurbo::Cap::Square => LineCap::Square,
|
|
};
|
|
st.join = match p.line_join {
|
|
kurbo::Join::Miter => LineJoin::Miter,
|
|
kurbo::Join::Round => LineJoin::Round,
|
|
kurbo::Join::Bevel => LineJoin::Bevel,
|
|
};
|
|
st.miter_limit = p.miter_limit as f64;
|
|
let dash = Dash { pattern: p.dash_array.iter().map(|v| *v as f64 * scale).collect(), offset: p.dash_offset as f64 * scale, align_corners: false };
|
|
// An invalid dash array (a negative value, or all zeros) strokes solid.
|
|
st.dash = dash.is_dashed().then_some(dash);
|
|
st
|
|
}
|
|
|
|
fn round3(v: f32) -> f32 {
|
|
(v * 1000.0).round() / 1000.0
|
|
}
|
|
|
|
impl<'p> Builder<'p> {
|
|
fn new(next: u64, colors: Colors<'p>) -> Self {
|
|
Self {
|
|
colors,
|
|
next,
|
|
stack: vec![],
|
|
blend: BlendMode::Normal,
|
|
page: Rect::ZERO,
|
|
glyphs: None,
|
|
images: HashMap::new(),
|
|
image_keys: HashMap::new(),
|
|
warnings: vec![],
|
|
}
|
|
}
|
|
|
|
fn id(&mut self) -> NodeId {
|
|
let id = NodeId(self.next);
|
|
self.next += 1;
|
|
id
|
|
}
|
|
|
|
fn warn(&mut self, w: &str) {
|
|
if !self.warnings.iter().any(|x| x == w) {
|
|
self.warnings.push(w.to_string());
|
|
}
|
|
}
|
|
|
|
fn begin_page(&mut self) {
|
|
self.stack = vec![Frame { kind: FrameKind::Root, children: vec![] }];
|
|
self.blend = BlendMode::Normal;
|
|
self.glyphs = None;
|
|
}
|
|
|
|
fn end_page(&mut self) -> Vec<Arc<Node>> {
|
|
self.flush_glyphs();
|
|
while self.stack.len() > 1 {
|
|
self.pop_frame();
|
|
}
|
|
self.stack.pop().map(|f| f.children).unwrap_or_default()
|
|
}
|
|
|
|
fn push_node(&mut self, mut n: Node) {
|
|
if self.blend != BlendMode::Normal && !n.is_container() {
|
|
n.blend = self.blend;
|
|
}
|
|
if let Some(f) = self.stack.last_mut() {
|
|
f.children.push(Arc::new(n));
|
|
}
|
|
}
|
|
|
|
fn pop_frame(&mut self) {
|
|
if self.stack.len() <= 1 {
|
|
return;
|
|
}
|
|
let Some(f) = self.stack.pop() else { return };
|
|
let node = match f.kind {
|
|
FrameKind::Root => None,
|
|
FrameKind::Skip => {
|
|
if let Some(p) = self.stack.last_mut() {
|
|
p.children.extend(f.children);
|
|
}
|
|
None
|
|
}
|
|
FrameKind::Clip(clip) => {
|
|
if f.children.is_empty() {
|
|
None
|
|
} else {
|
|
let mut ch = vec![Arc::new(*clip)];
|
|
ch.extend(f.children);
|
|
Some(Node::new(self.id(), NodeKind::Group { children: ch, clip: true }))
|
|
}
|
|
}
|
|
FrameKind::Group { opacity, blend, masked } => {
|
|
if f.children.is_empty() {
|
|
None
|
|
} else if opacity >= 0.999 && blend == BlendMode::Normal && !masked {
|
|
if let Some(p) = self.stack.last_mut() {
|
|
p.children.extend(f.children);
|
|
}
|
|
None
|
|
} else if f.children.len() == 1 && !f.children[0].is_container() && !masked {
|
|
// A single object in a group: fold opacity/blend into the object itself.
|
|
let mut only = (*f.children[0]).clone();
|
|
only.opacity *= opacity;
|
|
if blend != BlendMode::Normal {
|
|
only.blend = blend;
|
|
}
|
|
Some(only)
|
|
} else {
|
|
let mut g = Node::group(self.id(), f.children);
|
|
g.opacity = opacity;
|
|
g.blend = blend;
|
|
Some(g)
|
|
}
|
|
}
|
|
};
|
|
if let Some(n) = node
|
|
&& let Some(p) = self.stack.last_mut()
|
|
{
|
|
p.children.push(Arc::new(n));
|
|
}
|
|
}
|
|
|
|
fn flush_glyphs(&mut self) {
|
|
let Some(run) = self.glyphs.take() else { return };
|
|
if run.path.elements().is_empty() {
|
|
return;
|
|
}
|
|
let mut ap = Appearance::default();
|
|
match &run.stroke {
|
|
None => {
|
|
let mut f = FillLayer::new(run.paint);
|
|
f.opacity = run.opacity;
|
|
ap.items.push(AppearanceItem::Fill(f));
|
|
}
|
|
Some(p) => ap.items.push(AppearanceItem::Stroke(stroke_layer(run.paint, run.opacity, p, run.scale))),
|
|
}
|
|
let id = self.id();
|
|
let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap);
|
|
n.name = Some(TEXT_OUTLINES.into());
|
|
self.push_node(n);
|
|
}
|
|
|
|
/// Convert a hayro paint to a VectorCraft paint and opacity. `bbox` is the painted area in
|
|
/// document coordinates (for patterns).
|
|
fn paint(&mut self, p: &hayro_interpret::Paint<'_>) -> (Paint, f32) {
|
|
match p {
|
|
hayro_interpret::Paint::Color(c) => {
|
|
let [r, g, b, a] = c.to_rgba().components();
|
|
let paint = self.colors.solid(c).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint);
|
|
(paint, a)
|
|
}
|
|
hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() {
|
|
Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors) {
|
|
Some(g) => (Paint::Gradient(Box::new(g)), sp.opacity),
|
|
None => {
|
|
self.warn("mesh/function shadings are imported as a flat colour");
|
|
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity)
|
|
}
|
|
},
|
|
Pattern::Tiling(_) => {
|
|
self.warn("tiling patterns are imported as a flat grey");
|
|
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) {
|
|
let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() {
|
|
v
|
|
} else {
|
|
let Some((blob, w, h)) = make() else {
|
|
self.warn("an image could not be decoded and was skipped");
|
|
return;
|
|
};
|
|
// Content keys: placing this page into another document merges its images safely.
|
|
let k = blob.content_key();
|
|
self.images.insert(k.clone(), blob);
|
|
self.image_keys.insert(key, (k.clone(), w, h));
|
|
(k, w, h)
|
|
};
|
|
let id = self.id();
|
|
self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None })));
|
|
}
|
|
}
|
|
|
|
/// Axial/radial shading → gradient paint (stops sampled from the shading function, in the
|
|
/// shading's own colour model).
|
|
fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern, colors: &mut Colors<'_>) -> Option<GradientPaint> {
|
|
let ShadingType::RadialAxial { coords, domain, function, axial, .. } = sp.shading.shading_type.as_ref() else {
|
|
return None;
|
|
};
|
|
let m = sp.matrix;
|
|
let cs = &sp.shading.color_space;
|
|
let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]);
|
|
let space = colors.shading_space(cs, at(0.0)?.len());
|
|
// RGB to decide which samples are stops; the stops keep the shading's model.
|
|
let sample = |t: f32| -> Option<(Color, f32)> {
|
|
let [r, g, b, a] = cs.to_rgba(&at(t)?, 1.0, false).components();
|
|
Some((Color::rgb(round3(r), round3(g), round3(b)), a))
|
|
};
|
|
// Sample densely, then drop samples that linear interpolation reproduces.
|
|
const N: usize = 64;
|
|
let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1);
|
|
for i in 0..=N {
|
|
let t = i as f32 / N as f32;
|
|
let (c, _) = sample(t)?;
|
|
pts.push((t, c.to_rgb()));
|
|
}
|
|
let mut keep = vec![0usize];
|
|
for i in 1..N {
|
|
let a = pts[keep.last().copied().unwrap_or(0)];
|
|
let b = pts[i + 1];
|
|
let u = (pts[i].0 - a.0) / (b.0 - a.0).max(1e-6);
|
|
let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - pts[i].1[k]).abs()).fold(0.0f32, f32::max);
|
|
if off > 1.5 / 255.0 {
|
|
keep.push(i);
|
|
}
|
|
}
|
|
keep.push(N);
|
|
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
|
|
let (rgb, _) = sample(t).unwrap_or((Color::BLACK, 1.0));
|
|
match at(t).and_then(|v| colors.native(space, &v)) {
|
|
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
|
|
Some(Native { color, link: None }) => GradientStop::new(offset, color),
|
|
None => GradientStop::new(offset, rgb),
|
|
}
|
|
};
|
|
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
|
|
let (kind, geom, stops) = if *axial {
|
|
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None };
|
|
(GradientKind::Linear, geom, keep.iter().map(|&i| stop_at(pts[i].0, pts[i].0)).collect::<Vec<_>>())
|
|
} else {
|
|
let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6));
|
|
// The end circle, mapped (as an ellipse) into the page; a start circle off its centre
|
|
// gives the focal point.
|
|
let p = |x: f32, y: f32| Point::new(x as f64, y as f64);
|
|
let centre = p(coords[3], coords[4]);
|
|
let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None };
|
|
geom.set_focal(Some(p(coords[0], coords[1])));
|
|
geom.transform(m, GradientKind::Radial);
|
|
// Offsets are relative to the outer radius; an inner radius shifts them outwards.
|
|
let stops = keep.iter().map(|&i| stop_at(pts[i].0, (r0 + (r1 - r0) * pts[i].0) / r1)).collect();
|
|
(GradientKind::Radial, geom, stops)
|
|
};
|
|
let mut g = GradientPaint::new(Gradient { kind, stops });
|
|
g.geom = Some(geom);
|
|
Some(g)
|
|
}
|
|
|
|
fn rgba_png(rgba: Vec<u8>, w: u32, h: u32) -> Option<Vec<u8>> {
|
|
let img = image::RgbaImage::from_raw(w, h, rgba)?;
|
|
let mut out = Vec::new();
|
|
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
|
Some(out)
|
|
}
|
|
|
|
fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
|
|
if a.width == w && a.height == h {
|
|
return a.data.clone();
|
|
}
|
|
match image::GrayImage::from_raw(a.width, a.height, a.data.clone()) {
|
|
Some(g) => image::imageops::resize(&g, w, h, image::imageops::FilterType::Triangle).into_raw(),
|
|
None => vec![255; (w * h) as usize],
|
|
}
|
|
}
|
|
|
|
impl<'a> Device<'a> for Builder<'_> {
|
|
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
|
|
if mask.is_some() {
|
|
self.warn("soft masks are not imported (content drawn unmasked)");
|
|
}
|
|
}
|
|
|
|
fn set_blend_mode(&mut self, blend_mode: hayro_interpret::BlendMode) {
|
|
self.blend = blend(blend_mode);
|
|
}
|
|
|
|
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
|
|
self.flush_glyphs();
|
|
let mut bp = path.clone();
|
|
bp.apply_affine(transform);
|
|
if bp.elements().is_empty() {
|
|
return;
|
|
}
|
|
let pd = PathData::from_bezpath(&bp);
|
|
let (paint, opacity) = self.paint(paint);
|
|
match draw_mode {
|
|
PathDrawMode::Fill(rule) => {
|
|
let mut f = FillLayer::new(paint);
|
|
f.opacity = opacity;
|
|
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
|
|
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
|
|
*r = fill_rule(*rule);
|
|
}
|
|
self.push_node(n);
|
|
}
|
|
PathDrawMode::Stroke(props) => {
|
|
let st = stroke_layer(paint, opacity, props, mean_scale(transform));
|
|
// Fill-then-stroke of the same path (the `B` operator) becomes one object.
|
|
let blend = self.blend;
|
|
if let Some(f) = self.stack.last_mut()
|
|
&& let Some(last) = f.children.last_mut()
|
|
&& last.blend == blend
|
|
&& last.path_data() == Some(&pd)
|
|
&& last.appearance.stroke().is_none()
|
|
{
|
|
Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st));
|
|
return;
|
|
}
|
|
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
|
self.push_node(n);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_clip_path(&mut self, clip_path: &ClipPath) {
|
|
self.flush_glyphs();
|
|
let bb = clip_path.path.bounding_box();
|
|
// Clips that contain the whole page do nothing visible; don't create groups for them.
|
|
let redundant = clip_path.path.elements().len() <= 6
|
|
&& bb.x0 <= self.page.x0 + 0.01
|
|
&& bb.y0 <= self.page.y0 + 0.01
|
|
&& bb.x1 >= self.page.x1 - 0.01
|
|
&& bb.y1 >= self.page.y1 - 0.01
|
|
&& self.page.area() > 0.0;
|
|
let kind = if redundant {
|
|
FrameKind::Skip
|
|
} else {
|
|
let mut clip = Node::new(
|
|
self.id(),
|
|
NodeKind::Path {
|
|
path: PathData::from_bezpath(&clip_path.path),
|
|
rule: fill_rule(clip_path.fill),
|
|
live: None,
|
|
clipping: true,
|
|
guide: false,
|
|
},
|
|
);
|
|
clip.name = Some("<Clipping Path>".into());
|
|
FrameKind::Clip(Box::new(clip))
|
|
};
|
|
self.stack.push(Frame { kind, children: vec![] });
|
|
}
|
|
|
|
fn push_transparency_group(&mut self, opacity: f32, mask: Option<SoftMask<'a>>, blend_mode: hayro_interpret::BlendMode) {
|
|
self.flush_glyphs();
|
|
let masked = mask.is_some();
|
|
if masked {
|
|
self.warn("soft masks are not imported (content drawn unmasked)");
|
|
}
|
|
self.stack.push(Frame { kind: FrameKind::Group { opacity, blend: blend(blend_mode), masked }, children: vec![] });
|
|
// Blend mode applies to the group as a whole, not to its children.
|
|
self.blend = BlendMode::Normal;
|
|
}
|
|
|
|
fn draw_glyph(
|
|
&mut self,
|
|
glyph: &Glyph<'a>,
|
|
transform: Affine,
|
|
glyph_transform: Affine,
|
|
paint: &hayro_interpret::Paint<'a>,
|
|
draw_mode: &GlyphDrawMode,
|
|
) {
|
|
let stroke = match draw_mode {
|
|
GlyphDrawMode::Invisible => return,
|
|
GlyphDrawMode::Fill => None,
|
|
GlyphDrawMode::Stroke(p) => Some(p.clone()),
|
|
};
|
|
match glyph {
|
|
Glyph::Outline(o) => {
|
|
let mut bp = o.outline();
|
|
bp.apply_affine(transform * glyph_transform);
|
|
let (paint, opacity) = self.paint(paint);
|
|
let scale = mean_scale(transform);
|
|
let same = self.glyphs.as_ref().is_some_and(|r| {
|
|
r.paint == paint
|
|
&& r.opacity == opacity
|
|
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width)
|
|
&& (r.scale - scale).abs() < 1e-9
|
|
});
|
|
if !same {
|
|
self.flush_glyphs();
|
|
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale });
|
|
}
|
|
if let Some(r) = &mut self.glyphs {
|
|
r.path.extend(bp.iter());
|
|
}
|
|
self.warn("text was converted to outlines");
|
|
}
|
|
Glyph::Type3(t3) => {
|
|
self.flush_glyphs();
|
|
t3.interpret(self, transform, glyph_transform, paint);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
|
|
self.flush_glyphs();
|
|
match image {
|
|
Image::Raster(r) => {
|
|
let key = hayro_interpret::CacheKey::cache_key(&r);
|
|
// JPEG passthrough for plain DeviceRGB/DeviceGray DCT images.
|
|
let st = r.stream();
|
|
let dict = st.dict();
|
|
let filters = st.filters();
|
|
let cs_ok = dict.get::<Name<'_>>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray"));
|
|
let jpeg = filters.len() == 1
|
|
&& matches!(filters[0], hayro_syntax::Filter::DctDecode)
|
|
&& cs_ok
|
|
&& !dict.contains_key(b"SMask")
|
|
&& !dict.contains_key(b"Mask")
|
|
&& !dict.contains_key(b"Decode");
|
|
if jpeg {
|
|
let (w, h) = (r.width(), r.height());
|
|
let bytes = st.raw_data().to_vec();
|
|
self.add_image(key, || Some((ImageBlob { mime: "image/jpeg".into(), bytes: Arc::new(bytes) }, w, h)), transform);
|
|
return;
|
|
}
|
|
let mut decoded: Option<Decoded> = None;
|
|
r.with_rgba(
|
|
|img, alpha| {
|
|
let (w, h, sf) = (img.width(), img.height(), img.scale_factors());
|
|
let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]);
|
|
let rgba: Vec<u8> = match img {
|
|
ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(),
|
|
ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(),
|
|
};
|
|
decoded = Some((rgba, w, h, sf));
|
|
},
|
|
None,
|
|
);
|
|
let Some((rgba, w, h, sf)) = decoded else {
|
|
self.warn("an image could not be decoded and was skipped");
|
|
return;
|
|
};
|
|
let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64);
|
|
self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)), xf);
|
|
}
|
|
Image::Stencil(s) => {
|
|
let key = hayro_interpret::CacheKey::cache_key(&s);
|
|
let mut decoded: Option<Decoded> = None;
|
|
s.with_stencil(
|
|
|luma, paint| {
|
|
let rgb = match paint {
|
|
hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(),
|
|
_ => [0, 0, 0, 255],
|
|
};
|
|
let rgba = luma.data.iter().flat_map(|a| [rgb[0], rgb[1], rgb[2], *a]).collect();
|
|
decoded = Some((rgba, luma.width, luma.height, luma.scale_factors));
|
|
},
|
|
None,
|
|
);
|
|
let Some((rgba, w, h, sf)) = decoded else { return };
|
|
let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64);
|
|
self.add_image(
|
|
key ^ 0x5_7e9c,
|
|
|| rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)),
|
|
xf,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pop_clip_path(&mut self) {
|
|
self.flush_glyphs();
|
|
self.pop_frame();
|
|
}
|
|
|
|
fn pop_transparency_group(&mut self) {
|
|
self.flush_glyphs();
|
|
self.pop_frame();
|
|
}
|
|
}
|