Files
vectorcraft/crates/pdf/src/import.rs
T
bflatasticandClaude Opus 5.5 8a837fd4b9 M3.68: groups, layers and type have their own fills, strokes and effects, with Contents and Characters rows
A group's or layer's own fills and strokes paint every member's geometry, below or above the
members as the new contents slot (Appearance.contents_index) says; its geometry effects reshape
the members as one piece and its raster effects apply to the composite (one combined shadow),
respecting knockout groups. Type's own fills and strokes paint under or over its characters.
Render, SVG/PDF (via bake) and Expand Appearance evaluate them alike.

Commands: appearance.* / effect.* take target: "object"|"contents" and layer ids;
appearance.moveItem moves the Contents slot (from: "contents") or an item across it
(contents); appearance.targetContents selects a group's members. Graphic styles applied to a
group now style the group itself. The Appearance panel shows Contents/Characters rows that
drag, and double-click to target the members or edit the characters' paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:59:34 +07:00

647 lines
25 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::Pdf;
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, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind,
StrokeLayer,
};
use vectorcraft_geom::{FillRule, PathData};
use crate::{ImportOptions, ImportReport, PdfError};
/// 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 = Pdf::new(bytes.to_vec()).map_err(|e| PdfError::Parse(format!("{e:?}")))?;
let pages = pdf.pages();
let n = opts.max_pages.map_or(pages.len(), |m| m.min(pages.len()));
if n == 0 {
return Err(PdfError::NoPages);
}
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 mut b = Builder::new(doc.peek_next_id());
let cache = InterpreterCache::new();
let mut x = 0.0;
for (i, page) in pages.iter().take(n).enumerate() {
let (w, h) = page.render_dimensions();
let (w, h) = (w as f64, h as f64);
let ab = Rect::new(x, 0.0, x + w, h);
x += w + 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 init = Affine::translate((ab.x0, ab.y0)) * Affine::new(page.initial_transform(true).as_coeffs());
let mut ctx = Context::new(init, ab, &cache, pdf.xref(), settings.clone());
b.page = ab;
b.begin_page();
interpret_page(page, &mut ctx, &mut b);
let children = b.end_page();
let mut layer = Node::layer(b.id(), &format!("Page {}", i + 1), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
*c = children;
}
doc.layers.push(Arc::new(layer));
}
for (k, blob) in b.images.drain() {
doc.images.insert(k, blob);
}
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 })
}
/// 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 {
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 Builder {
fn new(next: u64) -> Self {
Self {
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 f = self.stack.pop().expect("frame");
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();
(Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), a)
}
hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() {
Pattern::Shading(sp) => match shading_gradient(sp) {
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;
};
let k = format!("pdf-image-{}", self.image_keys.len() + 1);
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).
fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> 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 sample = |t: f32| -> Option<(Color, f32)> {
let x = domain[0] + (domain[1] - domain[0]) * t;
let v = function.eval(&smallvec::smallvec![x])?;
let [r, g, b, a] = cs.to_rgba(&v, 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().expect("non-empty")];
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 stop_at = |t: f32, offset: f32| -> GradientStop {
let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0));
GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }
};
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 };
(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));
let centre = c(coords[3], coords[4]);
let k = m.as_coeffs();
let ex = Vec2::new(k[0], k[1]) * r1 as f64;
let ey = Vec2::new(k[2], k[3]) * r1 as f64;
let aspect = if ex.hypot() > 1e-9 { ey.hypot() / ex.hypot() } else { 1.0 };
let geom = GradientGeom { start: centre, end: centre + ex, aspect };
// 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.chunks_exact(3).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();
}
}