M4.61: PDF import fidelity: opacity masks, isolate/knockout, pattern swatches, editable text, gradient meshes, stop opacity and Extend

Luminosity soft masks become opacity masks (alpha masks as a white copy of
their art; backdrop colour gives Clip, an inverting transfer function Invert;
the PDF export's backdrop and Difference covers read back as the options; a
constant alpha mask is opacity). Transparency groups keep their /I and /K
flags (read by walking the content as the interpreter does). Tiling patterns
become pattern swatches (one per colour for uncoloured ones). Text becomes
point type, one object per line with a run per font and paint, fonts named
from the file (document.open textAs: text | outlines). Patch and triangle
mesh shadings become gradient meshes; gradients drawn through their grey
opacity mask get stop opacity back; shadings that don't extend clip the art.
A rectangular clip around all its art (a form's box) is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 12:40:06 +07:00
parent 66e92bbd4c
commit a51083733e
13 changed files with 1925 additions and 166 deletions

View File

@ -887,6 +887,14 @@ fn default_graphic_styles() -> Vec<GraphicStyle> {
.collect()
}
impl Document {
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
pub fn reserve_ids(&mut self, next: u64) {
self.next_id = self.next_id.max(next);
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
@ -655,6 +655,8 @@ mod tests_pdfcolor;
#[cfg(test)]
mod tests_pdfedit;
#[cfg(test)]
mod tests_pdffidelity;
#[cfg(test)]
mod tests_pdfimport;
#[cfg(test)]
mod tests_svg;

View File

@ -4,13 +4,13 @@
use serde_json::{Value, json};
use vectorcraft_doc::{ColorMode, Document};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
use super::super::*;
use super::{err, source};
/// The `document.open` options besides the file. The PDF ones (pages, box, password) are ignored
/// by other formats.
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text) are
/// ignored by other formats.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LoadOptions {
/// 1-based pages to import, such as `"2-3, 5"`; `None` = every page.
@ -22,11 +22,14 @@ pub struct LoadOptions {
/// The colour mode the document opens in, its colours converted as Document Color Mode does
/// (default: the file's; a PDF painted mostly in CMYK opens in CMYK).
pub color_mode: Option<ColorMode>,
/// What a PDF's text becomes.
pub text_as: TextAs,
}
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password` and `colorMode` (`rgb` | `cmyk`).
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) and
/// `textAs` (`text` | `outlines`).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
@ -35,15 +38,7 @@ impl LoadOptions {
Some(Value::Number(n)) => Some(n.to_string()),
Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))),
};
let crop = match str_param(p, "cropTo").or_else(|| str_param(p, "crop")) {
None => CropTo::default(),
Some(c) => CropTo::ALL
.iter()
.zip(CropTo::IDS)
.find(|(_, id)| id.eq_ignore_ascii_case(c))
.map(|(c, _)| *c)
.ok_or_else(|| bad(cmd, format!("cropTo must be one of {}", CropTo::IDS.join(", "))))?,
};
let crop = choice(cmd, "cropTo", str_param(p, "cropTo").or_else(|| str_param(p, "crop")))?;
let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string);
let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() {
None => None,
@ -51,7 +46,8 @@ impl LoadOptions {
Some("cmyk") => Some(ColorMode::Cmyk),
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
};
Ok(Self { pages, crop, password, color_mode })
let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?;
Ok(Self { pages, crop, password, color_mode, text_as })
}
/// Does the document read only part of the file, or read it differently (a page range, another
@ -69,20 +65,31 @@ impl LoadOptions {
Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?)
}
};
Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), ..Default::default() })
Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), text_as: self.text_as, ..Default::default() })
}
}
/// The option of `T` named `id` (any case), for param `key`; not given: the default.
fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result<T> {
let Some(id) = id else { return Ok(T::default()) };
T::ALL
.iter()
.zip(T::IDS)
.find(|(_, i)| i.eq_ignore_ascii_case(id))
.map(|(t, _)| *t)
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
}
/// Import the pages `o` pick → the document and import notes.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
Ok((r.document, r.warnings))
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and the
/// import notes.
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box), its text as
/// outlines (it looks as printed), and the import notes.
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), ..Default::default() };
let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: TextAs::Outlines, ..Default::default() };
vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err)
}

View File

@ -0,0 +1,46 @@
//! `document.open` PDF option `textAs`.
use serde_json::{Value, json};
use vectorcraft_pdf::TextAs;
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use super::*;
fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result<Value> {
let mut p = json!({"name": "art.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)});
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
s.execute("document.open", &p)
}
fn kinds(s: &Session) -> Vec<&'static str> {
let mut out = vec![];
s.active().unwrap().doc.walk(|n| out.push(n.kind_label()));
out
}
#[test]
fn text_opens_as_type_or_outlines() {
let page = PdfPage {
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
..PdfPage::new(200.0, 100.0, "BT /F1 12 Tf 10 50 Td (Hello) Tj ET")
};
let bytes = pdf(&[page], None);
let mut s = Session::new();
open(&mut s, &bytes, json!({})).unwrap();
assert!(kinds(&s).contains(&"Type"), "{:?}", kinds(&s));
let r = open(&mut s, &bytes, json!({"textAs": "outlines"})).unwrap();
assert!(!kinds(&s).contains(&"Type"));
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
let e = open(&mut s, &bytes, json!({"textAs": "glyphs"})).unwrap_err().to_string();
assert!(e.contains("textAs must be one of text, outlines"), "{e}");
}
#[test]
fn load_options_read_text() {
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines"})).unwrap();
assert_eq!(o.text_as, TextAs::Outlines);
assert!(!o.is_partial());
assert_eq!(LoadOptions::default().text_as, TextAs::Text);
}

View File

@ -435,6 +435,76 @@ proptest! {
}
}
/// Hostile content over soft masks, isolated and knockout groups, tiling patterns, a patch mesh,
/// an unextended shading and fonts: the import's own readings of the file.
fn rich_resources_pdf(content: &str) -> Vec<u8> {
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
let x = first_extra(1);
let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len());
let extra = [
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB /I true /K true >>",
"1 g 0 0 50 50 re f /X BMC 0 g 0 0 5 5 re f EMC",
),
stream(
"/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 1e-9 10] /XStep 0.001 /YStep 10 /Resources << >>",
"0 0 5 5 re f /P0 scn 0 0 1 1 re f",
),
stream(
"/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
"000A0A0A250A3F0A5A255A3F5A5A5A5A3F5A255A0A3F0A250AFF000000FF000000FFFFFFFF>",
),
];
let (form, pat, mesh) = (x, x + 1, x + 2);
let resources = format!(
"/XObject << /X0 {form} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
);
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, None)
}
fn arb_rich_op() -> impl Strategy<Value = String> {
prop_oneof![
arb_pdf_op(),
prop::sample::select(vec![
"/Span << /MCID 3 >> BDC",
"/X BMC",
"EMC",
"/X0 Do",
"/GS0 gs",
"/GS1 gs",
"/CS0 cs 1 0 0 /P0 scn",
"/Pattern cs /P0 scn",
"/Sh0 sh",
"/Sh1 sh",
"BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET",
"7 Tr",
"q 10 10 50 50 re W n",
"Q",
"0 0 100 100 re f",
"10 10 20 20 re B",
])
.prop_map(String::from),
]
}
proptest! {
#![proptest_config(config())]
#[test]
fn pdf_masks_patterns_and_text_never_panic(
ops in prop::collection::vec(arb_rich_op(), 0..40),
outlines in any::<bool>(),
) {
let bytes = rich_resources_pdf(&ops.join("\n"));
let opts = vectorcraft_pdf::ImportOptions {
text_as: if outlines { vectorcraft_pdf::TextAs::Outlines } else { vectorcraft_pdf::TextAs::Text },
..Default::default()
};
survive(&ops.join(" "), || vectorcraft_pdf::import_with_report(&bytes, &opts).ok().map(|r| r.document))?;
}
}
// ---------- library files ----------
/// The `data` of a library file `cmd` writes for the rich document.

View File

@ -4,26 +4,34 @@ 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::hayro_cmap::BfString;
use hayro_interpret::pattern::{Pattern, TilingPattern};
use hayro_interpret::{
ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, PathDrawMode,
SoftMask, StrokeProps, interpret_page,
CacheKey, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData,
MaskType, 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 kurbo::{Affine, BezPath, Rect, Shape};
use vectorcraft_color::{BlendMode, Color, Paint, Swatch};
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId,
NodeKind, StrokeLayer,
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
Node, NodeId, NodeKind, OpacityMask, PatternDef, StrokeLayer,
};
use vectorcraft_geom::{FillRule, PathData};
use crate::import_color::{Colors, Native};
use crate::{CropTo, ImportOptions, ImportReport, PdfError};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec};
use crate::import_scan::{Ocgs, Scan, scan_page};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
use crate::import_text::{Families, Look, Placement, TextLine};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
/// The name of the paths text imports as.
const TEXT_OUTLINES: &str = "<Text Outlines>";
/// The deepest soft masks, patterns and type 3 glyphs are read within each other.
const MAX_NESTED: u32 = 8;
/// A tiling pattern read this many times with the same art is taken to always draw it.
const PATTERN_REUSE: u32 = 16;
/// Import a PDF (or PDF-compatible `.ai`) with default options.
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
@ -35,6 +43,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let pdf = crate::pages::open(bytes, opts.password.as_deref())?;
let pages = pdf.pages();
let picked = crate::pages::picked(opts, pages.len())?;
let ocgs = Ocgs::read(&pdf);
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
let sink2 = sink.clone();
@ -58,8 +67,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
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 taken: Vec<String> = 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.clone()), opts.text_as);
b.taken = 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.
@ -72,7 +82,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
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();
let scan = scan_page(page, &ocgs, &mut b.fonts);
b.begin_page(scan);
interpret_page(page, &mut ctx, &mut b);
let children = b.end_page();
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
@ -109,10 +120,25 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
(doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
}
doc.swatches.extend(b.colors.swatches());
// Tiling patterns are pattern swatches.
for p in b.patterns.drain(..) {
doc.swatches.push(Swatch {
name: p.name.clone(),
paint: Paint::Pattern { pattern: p.name.clone(), xf: Affine::IDENTITY },
global: false,
spot: false,
});
doc.patterns.push(p);
}
if b.colors.mixed {
b.warn("colours mixing several inks (DeviceN) were imported as RGB");
}
if !b.missing_fonts.is_empty() {
let list = b.missing_fonts.join(", ");
b.warn(&format!("fonts that aren't available show in the fallback font until they are: {list}"));
}
doc.fix_next_id();
doc.reserve_ids(b.next);
let mut warnings = b.warnings;
if let Ok(v) = sink.lock() {
@ -134,6 +160,10 @@ fn only_text(nodes: &[Arc<Node>]) -> bool {
*text = true;
true
}
NodeKind::Text(_) => {
*text = true;
true
}
NodeKind::Group { children, .. } => walk(children, text),
_ => false,
})
@ -154,7 +184,9 @@ enum FrameKind {
Group {
opacity: f32,
blend: BlendMode,
masked: bool,
mask: Option<Arc<MaskSpec>>,
isolate: bool,
knockout: bool,
},
}
@ -163,6 +195,12 @@ struct Frame {
children: Vec<Arc<Node>>,
}
impl Frame {
fn new(kind: FrameKind) -> Self {
Self { kind, children: vec![] }
}
}
/// Glyphs drawn consecutively with the same paint, merged into one path.
struct GlyphRun {
path: BezPath,
@ -172,6 +210,19 @@ struct GlyphRun {
scale: f64,
}
/// How often a tiling pattern was read, and the art (as its paints) each reading drew with the
/// swatch made for it.
type PatternReadings = (u32, Vec<(Vec<Paint>, String)>);
/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back.
struct Saved {
stack: Vec<Frame>,
blend: BlendMode,
mask: Option<Arc<MaskSpec>>,
page: Rect,
pending: bool,
}
struct Builder<'p> {
colors: Colors<'p>,
next: u64,
@ -179,9 +230,31 @@ struct Builder<'p> {
blend: BlendMode,
page: Rect,
glyphs: Option<GlyphRun>,
text: Option<TextLine>,
text_as: TextAs,
images: HashMap<String, ImageBlob>,
image_keys: HashMap<u128, (String, u32, u32)>,
warnings: Vec<String>,
/// Fonts by cache key → base font name (from [`scan_page`]).
fonts: HashMap<u128, String>,
font_names: HashMap<u128, (String, String)>,
families: Option<Families>,
missing_fonts: Vec<String>,
/// The soft mask of the graphics state, the art drawn through it so far, and the masks read.
mask: Option<Arc<MaskSpec>>,
masked: Vec<Arc<Node>>,
masks: HashMap<u128, Arc<MaskSpec>>,
/// Pattern swatches made, and per (pattern, stroke) the art signatures read and their names.
patterns: Vec<PatternDef>,
pattern_keys: HashMap<(u128, bool), PatternReadings>,
/// Swatch names in use.
taken: Vec<String>,
/// The page as walked by [`scan_page`], and how far the interpreter is through its groups.
scan: Scan,
group_at: usize,
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
pending: bool,
nested: u32,
}
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
@ -237,12 +310,26 @@ fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> Stro
st
}
fn round3(v: f32) -> f32 {
pub(crate) fn round3(v: f32) -> f32 {
(v * 1000.0).round() / 1000.0
}
/// The paints of `nodes`' leaves, in order: what tells two readings of a pattern cell apart.
fn paints(nodes: &[Arc<Node>]) -> Vec<Paint> {
let mut out = vec![];
for n in nodes {
n.walk(&mut |c| out.extend(c.appearance.items.iter().map(|i| i.paint().clone())));
}
out
}
/// Visual bounds of `nodes`; `None` when one has none.
fn bounds(nodes: &[Arc<Node>]) -> Option<Rect> {
nodes.iter().try_fold(None, |acc, n| n.visual_bounds().map(|b| Some(acc.map_or(b, |a: Rect| a.union(b)))))?
}
impl<'p> Builder<'p> {
fn new(next: u64, colors: Colors<'p>) -> Self {
fn new(next: u64, colors: Colors<'p>, text_as: TextAs) -> Self {
Self {
colors,
next,
@ -250,9 +337,25 @@ impl<'p> Builder<'p> {
blend: BlendMode::Normal,
page: Rect::ZERO,
glyphs: None,
text: None,
text_as,
images: HashMap::new(),
image_keys: HashMap::new(),
warnings: vec![],
fonts: HashMap::new(),
font_names: HashMap::new(),
families: None,
missing_fonts: vec![],
mask: None,
masked: vec![],
masks: HashMap::new(),
patterns: vec![],
pattern_keys: HashMap::new(),
taken: vec![],
scan: Scan::default(),
group_at: 0,
pending: false,
nested: 0,
}
}
@ -262,35 +365,126 @@ impl<'p> Builder<'p> {
id
}
/// A deep copy of `node` with fresh ids.
fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();
n.id = self.id();
if let Some(ch) = n.children_mut() {
let old = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
}
n
}
/// `m` with fresh ids for its art: a mask read once can mask several objects.
fn fresh_mask(&mut self, mut m: OpacityMask) -> OpacityMask {
m.art = Arc::new(self.reid(&m.art));
m
}
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![] }];
fn begin_page(&mut self, scan: Scan) {
self.stack = vec![Frame::new(FrameKind::Root)];
self.blend = BlendMode::Normal;
self.glyphs = None;
self.text = None;
self.mask = None;
self.masked.clear();
self.pending = false;
(self.scan, self.group_at) = (scan, 0);
}
/// The page's art.
fn end_page(&mut self) -> Vec<Arc<Node>> {
self.flush_glyphs();
self.close_frames()
}
/// Finish the open runs and frames → the root frame's children.
fn close_frames(&mut self) -> Vec<Arc<Node>> {
self.flush();
while self.stack.len() > 1 {
self.pop_frame();
}
self.stack.pop().map(|f| f.children).unwrap_or_default()
}
/// Add `n` to the open frame.
fn emit(&mut self, n: Arc<Node>) {
if let Some(f) = self.stack.last_mut() {
f.children.push(n);
}
}
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));
if self.mask.is_some() {
self.masked.push(Arc::new(n));
} else {
self.emit(Arc::new(n));
}
}
/// Finish the open glyph runs and the art drawn through the current soft mask.
fn flush(&mut self) {
self.flush_glyphs();
self.flush_text();
self.flush_masked();
}
/// The art drawn through the graphics state's soft mask so far, as one masked object.
fn flush_masked(&mut self) {
if self.masked.is_empty() {
return;
}
let art = std::mem::take(&mut self.masked);
let Some(spec) = self.mask.clone() else {
art.into_iter().for_each(|n| self.emit(n));
return;
};
match spec.as_ref() {
// Each object is drawn through the mask on its own.
MaskSpec::Opacity(a) => {
for mut n in art {
Arc::make_mut(&mut n).opacity *= a;
self.emit(n);
}
}
MaskSpec::Mask(m) => {
let m = self.fresh_mask(m.clone());
let node = match <[Arc<Node>; 1]>::try_from(art) {
Ok([one]) if one.mask.is_none() => {
fold_stop_opacity(&one, &m).unwrap_or_else(|| Node { mask: Some(Box::new(m)), ..Arc::unwrap_or_clone(one) })
}
Ok([one]) => {
let mut g = Node::group(self.id(), vec![one]);
g.mask = Some(Box::new(m));
g
}
Err(all) => {
let mut g = Node::group(self.id(), all);
g.mask = Some(Box::new(m));
g
}
};
self.emit(Arc::new(node));
}
}
}
/// A clip path node.
fn clip_node(&mut self, path: &BezPath, rule: FillRule) -> Node {
let mut clip = Node::new(self.id(), NodeKind::Path { path: PathData::from_bezpath(path), rule, live: None, clipping: true, guide: false });
clip.name = Some("<Clipping Path>".into());
clip
}
fn pop_frame(&mut self) {
if self.stack.len() <= 1 {
return;
@ -299,48 +493,70 @@ impl<'p> Builder<'p> {
let node = match f.kind {
FrameKind::Root => None,
FrameKind::Skip => {
if let Some(p) = self.stack.last_mut() {
p.children.extend(f.children);
}
f.children.into_iter().for_each(|c| self.emit(c));
None
}
FrameKind::Clip(clip) => {
let noop = clip.path_data().map(|p| p.to_bezpath()).filter(is_rectangle).map(|p| p.bounding_box().inflate(0.01, 0.01));
if f.children.is_empty() {
None
} else if noop.is_some_and(|r| bounds(&f.children).is_some_and(|b| contains(r, b))) {
// A rectangle around all of its art (a form's box) clips nothing.
f.children.into_iter().for_each(|c| self.emit(c));
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() {
FrameKind::Group { opacity, blend, mask, isolate, knockout } => {
let children = f.children;
// A constant alpha mask is opacity (of a group that isn't isolated).
let (opacity, mask) = match mask.as_deref() {
Some(MaskSpec::Opacity(a)) => (opacity * a, None),
Some(MaskSpec::Mask(m)) => (opacity, Some(m.clone())),
None => (opacity, None),
};
// Isolation only shows with blending inside; knockout with several objects.
let isolate = isolate && Node::children_blend(&children, &mut |c| c.blends_through());
let knockout = knockout && children.len() > 1;
let plain = opacity >= 0.999 && blend == BlendMode::Normal && mask.is_none() && !isolate && !knockout;
if 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);
}
} else if plain {
children.into_iter().for_each(|c| self.emit(c));
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();
} else if let [only] = children.as_slice()
&& !only.is_container()
&& !isolate
&& !knockout
&& (mask.is_none() || only.mask.is_none())
{
// A single object in a group: fold opacity, blend and mask into the object.
let mut only = (**only).clone();
only.opacity *= opacity;
if blend != BlendMode::Normal {
only.blend = blend;
}
if let Some(m) = mask {
let m = self.fresh_mask(m);
only = fold_stop_opacity(&only, &m).unwrap_or(Node { mask: Some(Box::new(m)), ..only });
}
Some(only)
} else {
let mut g = Node::group(self.id(), f.children);
g.opacity = opacity;
g.blend = blend;
let mut g = Node::group(self.id(), children);
(g.opacity, g.blend, g.isolate) = (opacity, blend, isolate);
if knockout {
g.knockout = Knockout::On;
}
g.mask = mask.map(|m| Box::new(self.fresh_mask(m)));
Some(g)
}
}
};
if let Some(n) = node
&& let Some(p) = self.stack.last_mut()
{
p.children.push(Arc::new(n));
if let Some(n) = node {
self.emit(Arc::new(n));
}
}
@ -364,9 +580,15 @@ impl<'p> Builder<'p> {
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) {
fn flush_text(&mut self) {
let Some((t, opacity)) = self.text.take().and_then(TextLine::finish) else { return };
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
n.opacity = opacity;
self.push_node(n);
}
/// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke).
fn paint(&mut self, p: &hayro_interpret::Paint<'_>, stroke: bool) -> (Paint, f32) {
match p {
hayro_interpret::Paint::Color(c) => {
let [r, g, b, a] = c.to_rgba().components();
@ -375,20 +597,107 @@ impl<'p> Builder<'p> {
}
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),
Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity),
None => {
self.warn("mesh/function shadings are imported as a flat colour");
self.warn("function-based shadings (and very large meshes) 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)
}
Pattern::Tiling(t) => match self.tiling(t, stroke) {
Some(paint) => (paint, 1.0),
None => {
self.warn("tiling patterns nested too deeply are imported as a flat grey");
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0)
}
},
},
}
}
/// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the
/// coordinates `page` has. `None` when nested too deeply.
fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option<Vec<Arc<Node>>> {
if self.nested >= MAX_NESTED {
return None;
}
self.flush();
let saved = Saved {
stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]),
blend: std::mem::replace(&mut self.blend, BlendMode::Normal),
mask: self.mask.take(),
page: std::mem::replace(&mut self.page, page),
pending: std::mem::take(&mut self.pending),
};
self.nested += 1;
read(self);
let art = self.close_frames();
self.nested -= 1;
(self.stack, self.blend, self.mask, self.page, self.pending) = (saved.stack, saved.blend, saved.mask, saved.page, saved.pending);
Some(art)
}
/// What soft mask `m` does (read once per mask and placement).
fn soft_mask(&mut self, m: &SoftMask<'_>) -> Arc<MaskSpec> {
let key = m.cache_key();
if let Some(s) = self.masks.get(&key) {
return s.clone();
}
let page = self.page;
let spec = match self.nested(page, |b| m.interpret(b)) {
Some(art) => {
let [r, g, b, _] = m.background_color().to_rgba().components();
let backdrop = luminance(&Color::rgb(r, g, b));
let inverted = m.transfer_function().is_some_and(|tf| tf.apply(0.0) > tf.apply(1.0));
let id = self.id();
mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all))
}
None => {
self.warn("soft masks nested too deeply were left out (content drawn unmasked)");
MaskSpec::Opacity(1.0)
}
};
let spec = Arc::new(spec);
self.masks.insert(key, spec.clone());
spec
}
/// Tiling pattern `t` as a pattern swatch's paint (the swatch made the first time).
fn tiling(&mut self, t: &TilingPattern<'_>, stroke: bool) -> Option<Paint> {
let key = (t.cache_key(), stroke);
let tile = Rect::from_origin_size(t.bbox.origin(), (t.x_step.abs().max(1e-3) as f64, t.y_step.abs().max(1e-3) as f64));
let xf = t.matrix * Affine::translate(tile.origin().to_vec2());
// An uncoloured pattern draws in the current colour: read it again unless it always
// drew the same.
if let Some((n, seen)) = self.pattern_keys.get(&key)
&& *n >= PATTERN_REUSE
&& let [(_, name)] = seen.as_slice()
{
return Some(Paint::Pattern { pattern: name.clone(), xf });
}
let art = self.nested(Rect::ZERO, |b| {
// A cell whose content can't be decoded is empty.
let _ = t.interpret(b, Affine::IDENTITY, stroke);
})?;
let sig = paints(&art);
let entry = self.pattern_keys.entry(key).or_default();
entry.0 += 1;
let name = match entry.1.iter().find(|(s, _)| *s == sig) {
Some((_, name)) => name.clone(),
None => {
let name = (self.patterns.len() + 1..).map(|i| format!("Pattern {i}")).find(|n| !self.taken.contains(n)).unwrap_or_default();
self.taken.push(name.clone());
let mut def = PatternDef::new(&name, art);
def.tile = tile;
self.patterns.push(def);
if let Some(e) = self.pattern_keys.get_mut(&key) {
e.1.push((sig, name.clone()));
}
name
}
};
Some(Paint::Pattern { pattern: name, xf })
}
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
@ -406,70 +715,93 @@ impl<'p> Builder<'p> {
let id = self.id();
self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() })));
}
}
/// 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);
/// Mesh shadings filling `region` (document space): gradient meshes, clipped to the region
/// when they reach past it.
fn draw_meshes(&mut self, meshes: Vec<vectorcraft_doc::live::GradientMesh>, region: &BezPath, opacity: f32) {
let nodes: Vec<Arc<Node>> = meshes.into_iter().map(|m| Arc::new(Node::new(self.id(), NodeKind::Mesh(m)))).collect();
let Some(art) = bounds(&nodes) else { return };
let mut content = match <[Arc<Node>; 1]>::try_from(nodes) {
Ok([one]) => Arc::unwrap_or_clone(one),
Err(all) => Node::group(self.id(), all),
};
content.opacity *= opacity;
if !contains(region.bounding_box().inflate(0.5, 0.5), art) {
let clip = self.clip_node(region, FillRule::NonZero);
content = clipped(clip, content, self.id());
}
self.push_node(content);
}
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),
/// The family and style of font `key` (drawing glyph `o`).
fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> (String, String) {
if let Some(n) = self.font_names.get(&key) {
return n.clone();
}
};
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)
let data = o.font_data();
let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic));
let families = self.families.get_or_insert_with(Families::available);
let n = match name {
Some(name) => {
let (family, style, found) = families.resolve(&name, weight, italic);
if !found && !family.is_empty() && !self.missing_fonts.contains(&family) {
self.missing_fonts.push(family.clone());
}
(family, style)
}
None => {
let d = vectorcraft_doc::CharStyle::default();
(d.font_family, d.font_style)
}
};
self.font_names.insert(key, n.clone());
n
}
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
/// its outline (no Unicode text, mirrored, or not type mode).
fn type_glyph(
&mut self,
o: &hayro_interpret::font::OutlineGlyph,
m: Affine,
scale: f64,
paint: &hayro_interpret::Paint<'_>,
stroke: Option<&StrokeProps>,
) -> bool {
if self.text_as != TextAs::Text || self.nested > 0 {
return false;
}
let text = match o.as_unicode() {
Some(BfString::Char(c)) => c.to_string(),
Some(BfString::String(s)) => s,
None => return false,
};
let Some(at) = Placement::of(m) else { return false };
if text.is_empty() || text.chars().any(|c| c.is_control()) {
return false;
}
self.flush_glyphs();
let font = o.font_cache_key();
let (paint, opacity) = self.paint(paint, stroke.is_some());
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
// A stroke over the glyph just filled (fill and stroke rendering).
if let (Some(s), Some(line)) = (&stroke, &mut self.text)
&& line.stroke_last(font, at, s.clone())
{
return true;
}
let (family, style) = self.font_name(font, o);
let look = Look { font, family, style, size: at.size, h_scale: at.h_scale, fill: stroke.is_none().then_some(paint), stroke };
let advance = o.advance_width().filter(|w| w.is_finite()).map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
if !self.text.as_mut().is_some_and(|l| l.push(&look, at, opacity, advance, &text)) {
self.flush_text();
let mut line = TextLine::new(at, opacity);
line.push(&look, at, opacity, advance, &text);
self.text = Some(line);
}
true
}
}
fn rgba_png(rgba: Vec<u8>, w: u32, h: u32) -> Option<Vec<u8>> {
@ -491,24 +823,67 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
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)");
let spec = mask.map(|m| self.soft_mask(&m));
let same = match (&spec, &self.mask) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if !same {
self.flush();
self.mask = spec;
}
}
fn set_blend_mode(&mut self, blend_mode: hayro_interpret::BlendMode) {
self.blend = blend(blend_mode);
let b = blend(blend_mode);
if b != self.blend {
self.flush_glyphs();
self.flush_text();
self.blend = b;
}
}
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
self.flush_glyphs();
self.flush_text();
self.pending = false;
let mut bp = path.clone();
bp.apply_affine(transform);
if bp.elements().is_empty() {
return;
}
let shading = match paint {
hayro_interpret::Paint::Pattern(p) => match p.as_ref() {
Pattern::Shading(sp) => Some(sp),
Pattern::Tiling(_) => None,
},
hayro_interpret::Paint::Color(_) => None,
};
// Patch and triangle meshes are gradient meshes.
if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
&& let Some(meshes) = mesh_shading(sp, &mut self.colors)
{
self.draw_meshes(meshes, &bp, sp.opacity);
return;
}
let pd = PathData::from_bezpath(&bp);
let (paint, opacity) = self.paint(paint);
let stroke = matches!(draw_mode, PathDrawMode::Stroke(_));
let (paint, opacity) = self.paint(paint, stroke);
// A gradient that doesn't extend past its ends paints only between them.
let width = match draw_mode {
PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
PathDrawMode::Fill(_) => 0.0,
};
let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width)));
let wrap = |b: &mut Self, n: Node| match &band {
Some(band) => {
let clip = b.clip_node(band, FillRule::EvenOdd);
let id = b.id();
clipped(clip, n, id)
}
None => n,
};
match draw_mode {
PathDrawMode::Fill(rule) => {
let mut f = FillLayer::new(paint);
@ -517,14 +892,19 @@ impl<'a> Device<'a> for Builder<'_> {
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = fill_rule(*rule);
}
let n = wrap(self, n);
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()
let last = match self.mask {
Some(_) => self.masked.last_mut(),
None => self.stack.last_mut().and_then(|f| f.children.last_mut()),
};
if band.is_none()
&& let Some(last) = last
&& last.blend == blend
&& last.path_data() == Some(&pd)
&& last.appearance.stroke().is_none()
@ -533,13 +913,25 @@ impl<'a> Device<'a> for Builder<'_> {
return;
}
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
let n = wrap(self, n);
self.push_node(n);
}
}
}
fn push_clip_path(&mut self, clip_path: &ClipPath) {
self.flush_glyphs();
self.flush();
// A form's box right after its transparency group: the group's isolate and knockout
// flags are the next ones read from the file.
if std::mem::take(&mut self.pending) {
let flags = self.scan.groups.get(self.group_at).copied();
self.group_at += 1;
if let Some((i, k)) = flags
&& let Some(Frame { kind: FrameKind::Group { isolate, knockout, .. }, .. }) = self.stack.last_mut()
{
(*isolate, *knockout) = (i, k);
}
}
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
@ -548,32 +940,15 @@ impl<'a> Device<'a> for Builder<'_> {
&& 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![] });
let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) };
self.stack.push(Frame::new(kind));
}
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![] });
self.flush();
let mask = mask.map(|m| self.soft_mask(&m));
self.stack.push(Frame::new(FrameKind::Group { opacity, blend: blend(blend_mode), mask, isolate: false, knockout: false }));
self.pending = self.nested == 0;
// Blend mode applies to the group as a whole, not to its children.
self.blend = BlendMode::Normal;
}
@ -586,6 +961,7 @@ impl<'a> Device<'a> for Builder<'_> {
paint: &hayro_interpret::Paint<'a>,
draw_mode: &GlyphDrawMode,
) {
self.pending = false;
let stroke = match draw_mode {
GlyphDrawMode::Invisible => return,
GlyphDrawMode::Fill => None,
@ -593,10 +969,14 @@ impl<'a> Device<'a> for Builder<'_> {
};
match glyph {
Glyph::Outline(o) => {
let scale = mean_scale(transform);
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref()) {
return;
}
self.flush_text();
let mut bp = o.outline();
bp.apply_affine(transform * glyph_transform);
let (paint, opacity) = self.paint(paint);
let scale = mean_scale(transform);
let (paint, opacity) = self.paint(paint, stroke.is_some());
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == paint
&& r.opacity == opacity
@ -613,14 +993,21 @@ impl<'a> Device<'a> for Builder<'_> {
self.warn("text was converted to outlines");
}
Glyph::Type3(t3) => {
self.flush_glyphs();
if self.nested >= MAX_NESTED {
return;
}
self.flush();
self.nested += 1;
t3.interpret(self, transform, glyph_transform, paint);
self.nested -= 1;
}
}
}
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
self.flush_glyphs();
self.flush_text();
self.pending = false;
match image {
Image::Raster(r) => {
let key = hayro_interpret::CacheKey::cache_key(&r);
@ -683,12 +1070,14 @@ impl<'a> Device<'a> for Builder<'_> {
}
fn pop_clip_path(&mut self) {
self.flush_glyphs();
self.flush();
self.pending = false;
self.pop_frame();
}
fn pop_transparency_group(&mut self) {
self.flush_glyphs();
self.flush();
self.pending = false;
self.pop_frame();
}
}

View File

@ -0,0 +1,128 @@
//! Soft masks → opacity masks. A luminosity mask's art becomes the mask art; an alpha mask's art
//! is turned white (its opacity kept), so its luminance over the black backdrop is its alpha.
//! The backdrop colour gives Clip (black: outside the art is hidden), an inverting transfer
//! function gives Invert, and the rectangles the PDF export draws for those (a backdrop cover, a
//! white Difference cover) read back as the options rather than as art. An alpha mask that is one
//! rectangle of constant opacity over everything is plain opacity.
use std::sync::Arc;
use kurbo::{Rect, Shape};
use vectorcraft_color::blend::MASK_LUM;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask};
/// What a soft mask does to what it masks.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum MaskSpec {
/// A constant opacity (a group drawn through it is not isolated).
Opacity(f32),
Mask(OpacityMask),
}
/// The luminance of `c` (0 black … 1 white), as opacity masks weigh it.
pub(crate) fn luminance(c: &Color) -> f32 {
let rgb = c.to_rgb();
rgb.iter().zip(MASK_LUM).map(|(v, w)| v * w).sum()
}
/// A rectangle (at most 6 path elements, its area that of its bounds).
pub(crate) fn is_rectangle(p: &kurbo::BezPath) -> bool {
let b = p.bounding_box();
p.elements().len() <= 6 && b.area() > 0.0 && (p.area().abs() - b.area()).abs() <= b.area() * 1e-3
}
/// A path covering `page` filled with one solid colour and no stroke → its colour and opacity.
fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> {
let NodeKind::Path { path, .. } = &n.kind else { return None };
let [AppearanceItem::Fill(f)] = n.appearance.items.as_slice() else { return None };
let Paint::Solid { color, .. } = &f.paint else { return None };
let b = path.to_bezpath();
let covers = page.area() > 1.0 && contains(b.bounding_box(), page.inset(-0.5)) && is_rectangle(&b);
(covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity))
}
/// Does `outer` contain `inner`?
pub(crate) fn contains(outer: Rect, inner: Rect) -> bool {
outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
}
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as a
/// luminosity mask's.
fn whiten(n: &mut Node) {
let white = |p: &mut Paint| match p {
Paint::Solid { color, swatch, tint } => {
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
}
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
}
}
Paint::None | Paint::Pattern { .. } => {}
};
for item in &mut n.appearance.items {
white(item.paint_mut());
}
match &mut n.kind {
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
white(&mut r.style.fill);
white(&mut r.style.stroke);
}),
_ => {}
}
if let Some(ch) = n.children_mut() {
for c in ch {
whiten(Arc::make_mut(c));
}
}
}
/// How a soft mask drawing `art` acts: `alpha` for an alpha mask, `backdrop` the luminance outside
/// the art, `inverted` when its transfer function inverts it. `page`: the area masks cover.
pub(crate) fn mask_spec(
mut art: Vec<Arc<Node>>,
alpha: bool,
backdrop: f32,
inverted: bool,
page: Rect,
group: impl FnOnce(Vec<Arc<Node>>) -> Node,
) -> MaskSpec {
if alpha {
if let [only] = art.as_slice()
&& let Some((_, a)) = cover(only, page)
{
return MaskSpec::Opacity(a);
}
art.iter_mut().for_each(|n| whiten(Arc::make_mut(n)));
}
let mut clip = alpha || backdrop < 0.5;
let mut invert = inverted;
// The PDF export's covers: a white Difference rectangle on top inverts, an opaque one at the
// bottom is the backdrop (white: no clip).
if !alpha {
if art.len() > 1
&& let Some(last) = art.last()
&& last.blend == BlendMode::Difference
&& cover(last, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999)
{
art.pop();
invert = !invert;
}
if art.len() > 1
&& let Some(first) = art.first()
&& first.blend == BlendMode::Normal
&& let Some((c, a)) = cover(first, page)
&& a >= 0.999
{
clip = luminance(&c) < 0.5;
art.remove(0);
}
}
let art = match <[Arc<Node>; 1]>::try_from(art) {
Ok([one]) => Arc::unwrap_or_clone(one),
Err(all) => group(all),
};
MaskSpec::Mask(OpacityMask { invert, ..OpacityMask::new(art, clip) })
}

View File

@ -0,0 +1,181 @@
//! What the interpreter doesn't tell the import, read from the file itself: the isolate and
//! knockout flags of transparency groups, and the names of the fonts.
//!
//! hayro-interpret reports a transparency group without its flags. So each page's content is
//! walked here in the interpreter's order (page content, then form XObjects as they are drawn,
//! skipping what it skips, such as optional content that is off): the k-th form transparency
//! group it reports is the k-th group found here.
use std::collections::{HashMap, HashSet};
use hayro_interpret::CacheKey;
use hayro_syntax::Pdf;
use hayro_syntax::content::TypedIter;
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, dict_or_stream};
use hayro_syntax::page::{Page, Resources};
/// The interpreter's limit on nested form XObjects.
const MAX_DEPTH: u32 = 50;
/// The most content operators walked per page: past it the rest of the page isn't told apart.
const MAX_OPS: usize = 2_000_000;
/// The most groups read.
const MAX_GROUPS: usize = 10_000;
/// The file's optional content groups.
#[derive(Default)]
pub(crate) struct Ocgs {
/// The groups that are off (as the interpreter decides it).
off: HashSet<ObjectIdentifier>,
}
impl Ocgs {
/// The groups listed in the catalog's `/OCProperties`, with their default states.
pub fn read(pdf: &Pdf) -> Self {
let mut o = Self::default();
let xref = pdf.xref();
let Some(props) = xref.get::<Dict<'_>>(xref.root_id()).and_then(|c| c.get::<Dict<'_>>(b"OCProperties")) else {
return o;
};
let config = props.get::<Dict<'_>>(b"D").unwrap_or_default();
let refs = |d: &Dict<'_>, key: &[u8]| -> Vec<ObjectIdentifier> {
d.get::<Array<'_>>(key)
.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).take(MAX_GROUPS).collect())
.unwrap_or_default()
};
if config.get::<Name<'_>>(b"BaseState").is_some_and(|n| n.as_ref() == b"OFF") {
o.off.extend(refs(&props, b"OCGs"));
}
for id in refs(&config, b"ON") {
o.off.remove(&id);
}
o.off.extend(refs(&config, b"OFF"));
o
}
}
/// One page as the interpreter walks it.
#[derive(Default)]
pub(crate) struct Scan {
/// Each form transparency group: isolated, knockout.
pub groups: Vec<(bool, bool)>,
}
struct Walker<'w> {
/// The groups that are off for the interpreter.
off: &'w HashSet<ObjectIdentifier>,
fonts: &'w mut HashMap<u128, String>,
visible: Vec<bool>,
ops: usize,
out: Scan,
}
impl Walker<'_> {
fn is_visible(&self) -> bool {
self.visible.last().copied().unwrap_or(true)
}
fn begin(&mut self, on: bool) {
let v = self.is_visible() && on;
self.visible.push(v);
}
/// Enter optional content `d` (object `id`): a group, or a membership dictionary whose policy
/// decides from its groups.
fn begin_oc(&mut self, d: &Dict<'_>, id: Option<ObjectIdentifier>) {
let membership = id.is_none() || d.get::<Name<'_>>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD");
let on = match id {
Some(id) if !membership => !self.off.contains(&id),
_ => {
let ids: Vec<ObjectIdentifier> = match d.get::<Array<'_>>(b"OCGs") {
Some(a) => a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect(),
None => d.get_ref(b"OCGs").map(ObjectIdentifier::from).into_iter().collect(),
};
let on = |i: &ObjectIdentifier| !self.off.contains(i);
ids.is_empty()
|| match d.get::<Name<'_>>(b"P").as_deref() {
Some(b"AllOn") => ids.iter().all(on),
Some(b"AnyOff") => !ids.iter().all(on),
Some(b"AllOff") => !ids.iter().any(on),
_ => ids.iter().any(on),
}
}
};
self.begin(on);
}
fn note_fonts(&mut self, res: &Resources<'_>) {
for (key, _) in res.fonts.entries() {
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
&& let Some(name) = f.get::<Name<'_>>(b"BaseFont")
{
self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned());
}
}
}
fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) {
self.note_fonts(res);
while let Some(op) = iter.next() {
self.ops += 1;
if self.ops > MAX_OPS {
return;
}
match op {
TypedInstruction::BeginMarkedContentWithProperties(bdc) => {
let oc = match bdc.1 {
Object::Name(n) => {
res.properties.get_ref(n.as_ref()).map(|r| (res.properties.get::<Dict<'_>>(n.as_ref()).unwrap_or_default(), r))
}
o => dict_or_stream(o)
.and_then(|(props, _)| props.get_ref(b"OC").map(|r| (props.get::<Dict<'_>>(b"OC").unwrap_or_default(), r))),
};
match oc {
Some((d, r)) => self.begin_oc(&d, Some(ObjectIdentifier::from(r))),
None => self.begin(true),
}
}
TypedInstruction::BeginMarkedContent(_) => self.begin(true),
TypedInstruction::EndMarkedContent(_) => {
self.visible.pop();
}
TypedInstruction::XObject(x) => {
if !self.is_visible() || depth >= MAX_DEPTH {
continue;
}
let Some(s) = res.get_x_object(x.0) else { continue };
let d = s.dict();
if d.get::<Name<'_>>(b"Subtype").as_deref() != Some(b"Form") || d.get::<[f32; 4]>(b"BBox").is_none() {
continue;
}
let Ok(data) = s.decoded() else { continue };
let oc = d.get::<Dict<'_>>(b"OC");
if let Some(oc) = &oc {
self.begin_oc(oc, d.get_ref(b"OC").map(ObjectIdentifier::from));
}
if self.is_visible() {
if let Some(g) = d.get::<Dict<'_>>(b"Group")
&& self.out.groups.len() < MAX_GROUPS
{
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
}
let inner = Resources::from_parent(d.get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
}
if oc.is_some() {
self.visible.pop();
}
}
_ => {}
}
}
}
}
/// Walk `page` as the interpreter will. Fonts found are noted in `fonts` (their cache key → base
/// font name).
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &Ocgs, fonts: &mut HashMap<u128, String>) -> Scan {
let mut w = Walker { off: &ocgs.off, fonts, visible: vec![], ops: 0, out: Scan::default() };
w.walk(page.typed_operations(), page.resources(), 0);
w.out
}

View File

@ -0,0 +1,311 @@
//! Shadings: axial and radial ones become gradients (their stops sampled from the shading
//! function in its own colour model; an end that doesn't extend clips the painted area), patch
//! and triangle meshes become gradient meshes, and a gradient drawn through a luminosity mask that
//! is the same gradient in grey (how stop opacity is written) gets its opacity stops back.
use std::sync::Arc;
use hayro_interpret::pattern::ShadingPattern;
use hayro_interpret::shading::{ShadingFunction, ShadingType};
use kurbo::{BezPath, Circle, Point, Rect, Shape, Vec2};
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::live::{GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, lerp_color};
use vectorcraft_doc::{Node, NodeKind, OpacityMask};
use crate::import::round3;
use crate::import_color::{Colors, Native};
use crate::import_mask::luminance;
/// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat
/// colour.
const MAX_PATCHES: usize = 4096;
/// An axial or radial shading as a gradient paint, with whether it extends past its start and
/// its end.
pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<(GradientPaint, [bool; 2])> {
let ShadingType::RadialAxial { coords, domain, function, axial, extend } = 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> {
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components();
Some(Color::rgb(round3(r), round3(g), round3(b)))
};
// 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;
pts.push((t, sample(t)?.to_rgb()));
}
let mut keep = vec![0usize];
for w in 1..N {
let (Some(a), Some(p), Some(b)) = (keep.last().and_then(|&k| pts.get(k)), pts.get(w), pts.get(w + 1)) else { continue };
let u = (p.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 - p.1[k]).abs()).fold(0.0f32, f32::max);
if off > 1.5 / 255.0 {
keep.push(w);
}
}
keep.push(N);
let ts: Vec<f32> = keep.iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect();
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
let rgb = sample(t).unwrap_or(Color::BLACK);
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, ts.iter().map(|&t| stop_at(t, t)).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 = ts.iter().map(|&t| stop_at(t, (r0 + (r1 - r0) * t) / r1)).collect();
(GradientKind::Radial, geom, stops)
};
let mut g = GradientPaint::new(Gradient { kind, stops });
g.geom = Some(geom);
Some((g, *extend))
}
/// Where an axial or radial shading that doesn't extend at both ends paints, as a clip path in
/// document space (even-odd) for art filling `bounds`; `None` when it extends at both.
pub(crate) fn extend_clip(sp: &ShadingPattern, bounds: Rect) -> Option<BezPath> {
let ShadingType::RadialAxial { coords, axial, extend, .. } = sp.shading.shading_type.as_ref() else {
return None;
};
if extend[0] && extend[1] {
return None;
}
let m = sp.matrix;
let inv = m.inverse();
let local = inv.transform_rect_bbox(bounds);
if !local.is_finite() || m.determinant().abs() < 1e-12 {
return None;
}
let f = |i: usize| coords.get(i).copied().unwrap_or(0.0) as f64;
let mut out = BezPath::new();
if *axial {
let (p0, p1) = (Point::new(f(0), f(1)), Point::new(f(2), f(3)));
let len = (p1 - p0).hypot();
if len < 1e-9 {
return None;
}
let u = (p1 - p0) / len;
let n = Vec2::new(-u.y, u.x);
let corners = [local.origin(), Point::new(local.x1, local.y0), Point::new(local.x1, local.y1), Point::new(local.x0, local.y1)];
let along = corners.map(|c| (c - p0).dot(u));
let across = corners.map(|c| (c - p0).dot(n));
let min = |v: [f64; 4]| v.iter().copied().fold(f64::INFINITY, f64::min);
let max = |v: [f64; 4]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lo = if extend[0] { min(along) } else { min(along).max(0.0) };
let hi = if extend[1] { max(along) } else { max(along).min(len) };
let (b0, b1) = (min(across), max(across));
if hi > lo {
let q = |a: f64, b: f64| m * (p0 + u * a + n * b);
out.move_to(q(lo, b0));
out.line_to(q(hi, b0));
out.line_to(q(hi, b1));
out.line_to(q(lo, b1));
out.close_path();
}
return Some(out);
}
let start = Circle::new((f(0), f(1)), f(2).max(0.0));
let end = Circle::new((f(3), f(4)), f(5).max(0.0));
// Past the end: the end circle bounds the paint; before the start: the start circle is a hole.
let outer = if extend[1] { local.inflate(1.0, 1.0).to_path(0.1) } else { end.to_path(0.1) };
out.extend(m * outer);
if !extend[0] && start.radius > 1e-9 {
out.extend(m * start.to_path(0.1));
}
Some(out)
}
/// The colour of shading components `v` (the input of the shading's function, when it has one).
fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern, colors: &mut Colors<'_>) -> Color {
let comps = match function {
Some(f) => f.eval(&v.iter().copied().collect()).map(|c| c.to_vec()).unwrap_or_default(),
None => v.to_vec(),
};
let space = colors.shading_space(&sp.shading.color_space, comps.len());
colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| {
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components();
Color::rgb(round3(r), round3(g), round3(b))
})
}
/// A 1×1 gradient mesh from a patch's four corners (bottom-left, top-left, top-right,
/// bottom-right in the patch's u/v order), each with its handles towards its neighbours.
fn patch_mesh(corners: [(Point, [Vec2; 4], Color); 4]) -> GradientMesh {
let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles };
let [a, b, c, d] = corners;
// Row 0 is v = 0 (corners a, d), row 1 is v = 1 (b, c); columns follow u.
GradientMesh { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] }
}
/// Handles of a mesh corner: right, left, down, up (unused ones stay at the point).
fn handles(right: Vec2, left: Vec2, down: Vec2, up: Vec2) -> [Vec2; 4] {
let mut h = [Vec2::ZERO; 4];
(h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up);
h
}
/// A 1×1 mesh from a patch's twelve boundary points (in the PDF's order: u runs cp0 → cp9 along
/// cp11 and cp10, v runs cp0 → cp3 along cp1 and cp2) and its corner colours (at cp0, cp3, cp6,
/// cp9); `None` if a point isn't finite.
fn boundary_mesh(cp: &[Point], colors: [Color; 4]) -> Option<GradientMesh> {
let cp: &[Point; 12] = cp.get(..12)?.try_into().ok()?;
if !cp.iter().all(|p| p.is_finite()) {
return None;
}
let h = |a: usize, b: usize| cp[b] - cp[a];
let [c0, c1, c2, c3] = colors;
Some(patch_mesh([
(cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0),
(cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1),
(cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2),
(cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3),
]))
}
/// A patch mesh or triangle mesh shading as gradient meshes in document space (one per patch;
/// a triangle is a patch with two corners together). `None`: not a mesh, or too big.
pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<Vec<GradientMesh>> {
let m = sp.matrix;
let mut out = vec![];
match sp.shading.shading_type.as_ref() {
ShadingType::CoonsPatchMesh { patches, function } => {
if patches.len() > MAX_PATCHES {
return None;
}
for p in patches {
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors));
out.extend(boundary_mesh(&p.control_points.map(|q| m * q), colors));
}
}
// A tensor patch's boundary is a Coons patch's (its four inner points are dropped).
ShadingType::TensorProductPatchMesh { patches, function } => {
if patches.len() > MAX_PATCHES {
return None;
}
for p in patches {
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors));
out.extend(boundary_mesh(&p.control_points.map(|q| m * q), colors));
}
}
ShadingType::TriangleMesh { triangles, function } => {
if triangles.len() > MAX_PATCHES {
return None;
}
for t in triangles {
let v = [&t.p0, &t.p1, &t.p2].map(|v| (m * v.point, mesh_color(v.colors.as_slice(), function.as_ref(), sp, colors)));
if !v.iter().all(|(p, _)| p.is_finite()) {
continue;
}
let [a, b, c] = v;
out.push(patch_mesh([
(a.0, [Vec2::ZERO; 4], a.1),
(b.0, [Vec2::ZERO; 4], b.1),
(c.0, [Vec2::ZERO; 4], c.1),
(c.0, [Vec2::ZERO; 4], c.1),
]));
}
}
_ => return None,
}
Some(out)
}
/// The only painted leaf of mask art (through groups of one object and clip groups of one).
fn only_leaf(n: &Node) -> Option<&Node> {
match &n.kind {
NodeKind::Group { children, clip } => {
let rest = if *clip { children.get(1..)? } else { children.as_slice() };
match rest {
[one] if n.opacity >= 0.999 && n.mask.is_none() => only_leaf(one),
_ => None,
}
}
NodeKind::Path { .. } => Some(n),
_ => None,
}
}
/// The gradient a path paints with its only fill or stroke.
fn only_gradient(n: &Node) -> Option<&GradientPaint> {
match n.appearance.items.as_slice() {
[item] => match item.paint() {
Paint::Gradient(g) => Some(g),
_ => None,
},
_ => None,
}
}
fn same_geom(a: &GradientGeom, b: &GradientGeom) -> bool {
let near = |p: Point, q: Point| p.distance(q) < 0.5;
near(a.start, b.start) && near(a.end, b.end) && near(a.focal_point(), b.focal_point()) && (a.aspect - b.aspect).abs() < 0.01
}
/// `content` (a path painting one gradient) drawn through `mask` (clipped, not inverted, the same
/// gradient in shades of grey: how gradient stop opacity is written) as one gradient with the
/// mask's luminance as stop opacity.
pub(crate) fn fold_stop_opacity(content: &Node, mask: &OpacityMask) -> Option<Node> {
if !mask.clip || mask.invert || mask.disabled || content.is_container() {
return None;
}
let g = only_gradient(content)?;
let leaf = only_leaf(&mask.art)?;
let mg = only_gradient(leaf)?;
let opaque = |n: &Node| n.opacity >= 0.999 && n.appearance.items.iter().all(|i| i.opacity() >= 0.999);
if mg.gradient.kind != g.gradient.kind || !opaque(leaf) || !same_geom(g.geom.as_ref()?, mg.geom.as_ref()?) {
return None;
}
let mut offsets: Vec<f32> = g.gradient.stops.iter().chain(&mg.gradient.stops).map(|s| s.offset).collect();
offsets.sort_by(f32::total_cmp);
offsets.dedup_by(|a, b| (*a - *b).abs() < 1e-4);
let stops = offsets
.iter()
.map(|&t| {
let (mc, mo) = mg.gradient.sample(t);
let alpha = round3(luminance(&mc) * mo);
let mut s = match g.gradient.stops.iter().find(|s| (s.offset - t).abs() < 1e-4) {
Some(s) => s.clone(),
None => {
let (c, o) = g.gradient.sample_with(t, lerp_color);
GradientStop { opacity: o, ..GradientStop::new(t, c) }
}
};
s.opacity = round3(s.opacity * alpha);
s
})
.collect();
let mut out = content.clone();
let mut paint = (*g).clone();
paint.gradient.stops = stops;
if let Some(item) = out.appearance.items.first_mut() {
*item.paint_mut() = Paint::Gradient(Box::new(paint));
}
Some(out)
}
/// `node` (filling `bounds`) clipped to `clip`: a clip group.
pub(crate) fn clipped(clip: Node, node: Node, id: vectorcraft_doc::NodeId) -> Node {
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(node)], clip: true })
}

View File

@ -0,0 +1,225 @@
//! Text as editable point type: glyphs drawn one after another on the same baseline make one
//! point text object of their Unicode text, a run per font, size and paint (a gap wider than a
//! fifth of the size reads as a space). Fonts are named from the file's base font name (its
//! subset prefix dropped, the family matched against the fonts available); a font that isn't
//! available keeps its name, and the text shows in the fallback font until it is.
use std::collections::HashMap;
use kurbo::{Affine, Point, Vec2};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, TextObject, TextRun};
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Placement {
pub origin: Point,
/// Unit vector along the baseline.
pub dir: Vec2,
/// Font size in points (the em's height).
pub size: f64,
/// Width of the em ÷ its height, in percent.
pub h_scale: f64,
}
impl Placement {
/// Where a glyph drawn with `m` (glyph space, 1000 units per em, y up → document) sits;
/// `None` when it is mirrored, degenerate or not finite (those keep their outlines).
pub fn of(m: Affine) -> Option<Self> {
let origin = m * Point::ORIGIN;
let ex = m * Point::new(1000.0, 0.0) - origin;
let ey = m * Point::new(0.0, 1000.0) - origin;
let (w, size) = (ex.hypot(), ey.hypot());
// Upright in y-down document space: x to the right of up.
let upright = ex.cross(ey) < 0.0;
(origin.is_finite() && w.is_finite() && size.is_finite() && w > 0.01 && size > 0.01 && size < 1e5 && upright).then(|| Self {
origin,
dir: ex / w,
size,
h_scale: w / size * 100.0,
})
}
fn up(&self) -> Vec2 {
Vec2::new(self.dir.y, -self.dir.x)
}
}
/// What a run of type is drawn with: its font (cache key, family and style), size, horizontal
/// scale, fill and stroke (paint and width).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Look {
pub font: u128,
pub family: String,
pub style: String,
pub size: f64,
pub h_scale: f64,
pub fill: Option<Paint>,
pub stroke: Option<(Paint, f64)>,
}
impl Look {
/// Can a glyph drawn with `other` join a run drawn with this? A fill joins a run whose
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice).
fn takes(&self, other: &Look) -> bool {
self.font == other.font
&& (self.size - other.size).abs() <= self.size * 0.01
&& (self.h_scale - other.h_scale).abs() < 0.5
&& self.fill == other.fill
&& (other.stroke.is_none() || self.stroke == other.stroke)
}
fn style(&self) -> CharStyle {
let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0));
CharStyle {
font_family: self.family.clone(),
font_style: self.style.clone(),
size: round(self.size),
h_scale: round(self.h_scale),
fill: self.fill.clone().unwrap_or(Paint::None),
stroke,
stroke_width,
..CharStyle::default()
}
}
}
/// A line of type being gathered: runs of glyphs on one baseline.
pub(crate) struct TextLine {
/// The first glyph's placement.
at: Placement,
opacity: f32,
/// Where the next glyph would start without spacing.
next: Point,
/// The last glyph's origin.
last: Point,
runs: Vec<(Look, String)>,
}
impl TextLine {
pub fn new(at: Placement, opacity: f32) -> Self {
Self { at, opacity, next: at.origin, last: at.origin, runs: vec![] }
}
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
/// it continues this line; `false`: it starts another.
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
if let Some((last, run)) = self.runs.last_mut() {
let size = self.at.size.max(at.size);
let gap = (at.origin - self.next).dot(self.at.dir);
let on_line = opacity == self.opacity
&& at.dir.dot(self.at.dir) > 0.9995
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
&& gap > -size * 0.3
&& gap < size * 3.0;
if !on_line {
return false;
}
// A gap wider than a fifth of an em reads as a space.
if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') {
run.push(' ');
}
if last.takes(look) {
run.push_str(text);
} else {
self.runs.push((look.clone(), text.to_string()));
}
} else {
self.runs.push((look.clone(), text.to_string()));
}
self.next = at.origin + self.at.dir * advance;
self.last = at.origin;
true
}
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
return false;
};
look.stroke = Some(stroke);
true
}
/// The point type object and its opacity.
pub fn finish(mut self) -> Option<(TextObject, f32)> {
if let Some((_, last)) = self.runs.last_mut() {
last.truncate(last.trim_end().len());
}
self.runs.retain(|(_, t)| !t.is_empty());
if self.runs.iter().all(|(_, t)| t.trim().is_empty()) {
return None;
}
let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style() });
let first = runs.next()?;
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
t.runs.extend(runs);
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2());
Some((t, self.opacity))
}
}
fn round(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
/// Lower-case letters and digits only (`"Source Sans 3"` → `"sourcesans3"`).
fn norm(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// `"TimesNewRoman"` → `"Times New Roman"`, `"SourceSans3"` → `"Source Sans 3"`.
fn spaced(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
let mut prev: Option<char> = None;
for c in s.chars() {
if let Some(p) = prev
&& ((p.is_ascii_lowercase() && c.is_ascii_uppercase()) || (p.is_ascii_alphabetic() && c.is_ascii_digit()))
{
out.push(' ');
}
out.push(c);
prev = Some(c);
}
out
}
/// `name` without a PostScript name's trailing `PSMT`, `MT` or `PS`.
fn strip_ps(name: &str) -> &str {
["PSMT", "MT", "PS"].iter().find_map(|s| name.strip_suffix(s).filter(|r| !r.is_empty())).unwrap_or(name)
}
/// The families available, by [`norm`]ed name.
pub(crate) struct Families(HashMap<String, String>);
impl Families {
pub fn available() -> Self {
Self(vectorcraft_text::FontDb::global().families().into_iter().map(|f| (norm(&f), f)).collect())
}
/// The family and style of a font with base (PostScript) name `name`, or of weight `weight`
/// and slant `italic` when the name has no style; whether the family is available.
pub fn resolve(&self, name: &str, weight: Option<u32>, italic: bool) -> (String, String, bool) {
// A subset's six-letter tag.
let name = match name.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
_ => name,
};
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
let style = match spaced(strip_ps(style)).as_str() {
"" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) {
(true, true) => "Bold Italic".to_string(),
(true, false) => "Bold".to_string(),
(false, true) => "Italic".to_string(),
(false, false) => "Regular".to_string(),
},
s => s.to_string(),
};
match found {
Some(f) => (f, style, true),
None => (spaced(strip_ps(fam)).trim().to_string(), style, false),
}
}
}

View File

@ -11,9 +11,11 @@
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
//! Editing the native document rides along as an embedded file ([`editing()`]).
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups,
//! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others
//! re-encoded as PNG) and text as glyph outlines. Colours keep their model: CMYK, Gray, and spot
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups, transparency groups (with isolation and knockout), soft
//! masks → opacity masks, axial/radial shadings → gradients (stop opacity and unextended ends
//! kept), mesh shadings → gradient meshes, tiling patterns → pattern swatches, images (JPEG
//! passthrough, others re-encoded as PNG) and text as point type (or glyph outlines, see
//! [`TextAs`]). Colours keep their model: CMYK, Gray, and spot
//! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a
//! CMYK document. [`ImportOptions`] pick the pages, the box each
//! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes.
@ -26,6 +28,10 @@ mod export;
mod images;
mod import;
mod import_color;
mod import_mask;
mod import_scan;
mod import_shading;
mod import_text;
mod lab_spot;
mod pages;
mod presets;
@ -76,14 +82,26 @@ pub struct ImportOptions {
pub crop: CropTo,
/// The (user) password of an encrypted PDF.
pub password: Option<String>,
/// What text becomes.
pub text_as: TextAs,
}
impl Default for ImportOptions {
fn default() -> Self {
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None }
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default() }
}
}
settings::choice! {
/// What imported text becomes.
TextAs {
/// Point type, one object per line run, in the file's fonts (by name).
Text = "text", "Text";
/// Paths of the glyphs' outlines (looks the same without the fonts).
Outlines = "outlines", "Outlines";
} default Text
}
settings::choice! {
/// The page box an imported or placed page is cropped to: its artboard (or placed frame).
CropTo {
@ -168,6 +186,8 @@ mod tests_fx;
#[cfg(test)]
mod tests_import_color;
#[cfg(test)]
mod tests_import_fidelity;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_presets;

View File

@ -0,0 +1,361 @@
//! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns →
//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity
//! and shading Extend flags.
use std::sync::Arc;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Knockout, Node, NodeId, NodeKind, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use crate::*;
/// A stream object with dictionary entries `dict`.
pub(crate) fn stream(dict: &str, data: &str) -> String {
format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
}
/// A 100 × 100 pt page drawing `content` with `resources`, plus `extra` objects.
fn one_page(content: &str, resources: &str, extra: &[&str]) -> Vec<u8> {
let page = PdfPage { resources: resources.into(), ..PdfPage::new(100.0, 100.0, content) };
pdf_with(&[page], extra, None)
}
fn open(bytes: &[u8]) -> Document {
import(bytes).unwrap()
}
fn open_with(bytes: &[u8], f: impl FnOnce(&mut ImportOptions)) -> ImportReport {
let mut o = ImportOptions::default();
f(&mut o);
import_with_report(bytes, &o).unwrap()
}
/// Every node of the document, depth first.
fn all(d: &Document) -> Vec<Node> {
let mut out = vec![];
d.walk(|n| out.push(n.clone()));
out
}
fn rect(r: Rect, c: Color) -> Node {
Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(c), Paint::None, 0.0))
}
fn add(d: &mut Document, mut n: Node) {
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}
fn roundtrip(d: &Document) -> Document {
open(&export(d, &PdfOptions::default()).unwrap())
}
/// The fill colour of a node's first fill.
fn fill_color(n: &Node) -> Option<Color> {
n.appearance.fill().and_then(|f| f.paint.color())
}
fn near(a: Rect, b: Rect) -> bool {
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.6)
}
// ---------- soft masks ----------
/// A page filling a red square through soft mask `smask` (its group, object 5, draws `mask`).
fn masked(smask: &str, mask: &str) -> Vec<u8> {
let g = first_extra(1);
let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >>", mask);
one_page("/GS0 gs 1 0 0 rg 0 0 100 100 re f", &format!("/ExtGState << /GS0 << /SMask << /Type /Mask {smask} /G {g} 0 R >> >> >>"), &[&group])
}
fn the_mask(d: &Document) -> (Node, OpacityMask) {
let n = all(d).into_iter().find(|n| n.mask.is_some()).expect("a masked object");
let m = n.mask.as_deref().unwrap().clone();
(n, m)
}
#[test]
fn a_luminosity_soft_mask_becomes_an_opacity_mask() {
let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f"));
let (n, m) = the_mask(&d);
assert!(n.path_data().is_some(), "the mask is on the square itself");
assert!(m.clip && !m.invert);
assert_eq!(fill_color(&m.art).map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0]));
assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0)), "{:?}", m.art.geometric_bounds());
// A white backdrop: outside the art stays visible (no clip); an inverting transfer function inverts.
let (_, m) = the_mask(&open(&masked("/S /Luminosity /BC [1 1 1]", "0 0 0 rg 0 0 50 100 re f")));
assert!(!m.clip);
let tr = "/TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>";
let (_, m) = the_mask(&open(&masked(&format!("/S /Luminosity {tr}"), "1 1 1 rg 0 0 50 100 re f")));
assert!(m.invert && m.clip);
}
#[test]
fn an_alpha_soft_mask_becomes_an_opacity_mask_of_its_shape() {
// The art is painted white with its opacity kept: its luminance is its alpha.
let bytes = {
let g = first_extra(1);
let group = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /ExtGState << /GA << /ca 0.5 >> >> >>",
"/GA gs 0 0 1 rg 0 0 50 100 re f",
);
one_page(
"/GS0 gs 1 0 0 rg 0 0 100 100 re f",
&format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /Alpha /G {g} 0 R >> >> >>"),
&[&group],
)
};
let (_, m) = the_mask(&open(&bytes));
assert!(m.clip && !m.invert);
let f = m.art.appearance.fill().unwrap();
assert_eq!(f.paint.color().map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0]));
assert!((f.opacity * m.art.opacity - 0.5).abs() < 0.01);
}
#[test]
fn opacity_masks_round_trip_through_pdf() {
for (clip, invert) in [(true, false), (false, false), (true, true), (false, true)] {
let mut d = Document::new(100.0, 100.0);
let mut n = rect(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0));
let art = rect(Rect::new(10.0, 10.0, 50.0, 90.0), Color::gray(0.25));
n.mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) }));
add(&mut d, n);
let back = roundtrip(&d);
let (n, m) = the_mask(&back);
assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0)));
assert_eq!((m.clip, m.invert), (clip, invert), "clip {clip}, invert {invert}");
assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0)), "{:?}", m.art.geometric_bounds());
let lum = crate::import_mask::luminance(&fill_color(&m.art).unwrap());
assert!((lum - 0.75).abs() < 0.02, "{lum}");
}
}
// ---------- isolation and knockout ----------
/// A 50% group (isolated or not) holding a Multiply square over a backdrop.
fn half_group(isolate: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.8, 0.6, 0.2)));
let mut m = rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.5, 0.5, 1.0));
(m.id, m.blend) = (d.alloc_id(), BlendMode::Multiply);
let mut g = Node::group(NodeId(0), vec![Arc::new(m)]);
(g.opacity, g.isolate) = (0.5, isolate);
add(&mut d, g);
d
}
#[test]
fn isolated_and_non_isolated_groups_round_trip() {
let back = roundtrip(&half_group(true));
let groups: Vec<Node> = all(&back).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: false, .. })).collect();
let [g] = groups.as_slice() else { panic!("one group: {groups:?}") };
assert!(g.isolate && g.mask.is_none());
assert!((g.opacity - 0.5).abs() < 0.01, "{}", g.opacity);
assert_eq!(g.children().unwrap()[0].blend, BlendMode::Multiply);
// Not isolated: its 50% (a constant alpha mask in the file) is opacity again, here on the
// only object inside, which blends with the backdrop as before.
let back = roundtrip(&half_group(false));
let nodes = all(&back);
assert!(nodes.iter().all(|n| !n.isolate && n.mask.is_none()));
let m = nodes.iter().find(|n| n.blend == BlendMode::Multiply).unwrap();
let opacity: f32 =
nodes.iter().filter(|n| n.children().is_some_and(|c| c.iter().any(|c| c.id == m.id))).map(|g| g.opacity).product::<f32>() * m.opacity;
assert!((opacity - 0.5).abs() < 0.01, "{opacity}");
}
#[test]
fn isolate_and_knockout_flags_of_a_group_are_kept() {
let x = first_extra(1);
let form = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true /K true >> /Resources << /ExtGState << /M << /BM /Multiply >> >> >>",
"/M gs 1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f",
);
let d = open(&one_page("0 1 0 rg 0 0 100 100 re f /X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&form]));
let groups: Vec<Node> = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).collect();
let [g] = groups.as_slice() else { panic!("one group: {groups:?}") };
assert!(g.isolate);
assert_eq!(g.knockout, Knockout::On);
assert_eq!(g.children().unwrap().len(), 2);
// Without the flags, the form's group is just its art.
let plain = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>",
"1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f",
);
let d = open(&one_page("/X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&plain]));
assert!(all(&d).iter().all(|n| !matches!(n.kind, NodeKind::Group { .. })));
}
// ---------- tiling patterns ----------
#[test]
fn tiling_patterns_become_pattern_swatches() {
let p = first_extra(1);
let colored = stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "1 0 0 rg 0 0 5 5 re f");
let d = open(&one_page("/Pattern cs /P0 scn 0 0 100 50 re f 0 50 100 50 re f", &format!("/Pattern << /P0 {p} 0 R >>"), &[&colored]));
assert_eq!(d.patterns.len(), 1, "one swatch for both fills");
let def = &d.patterns[0];
assert!(near(def.tile, Rect::new(0.0, 0.0, 10.0, 10.0)), "{:?}", def.tile);
assert_eq!(def.art.len(), 1);
assert_eq!(fill_color(&def.art[0]), Some(Color::rgb(1.0, 0.0, 0.0)));
assert!(d.swatches.iter().any(|s| matches!(&s.paint, Paint::Pattern { pattern, .. } if *pattern == def.name)), "a pattern swatch");
let fills: Vec<Paint> = all(&d).iter().filter_map(|n| n.appearance.fill().map(|f| f.paint.clone())).collect();
assert_eq!(fills.len(), 2);
assert!(fills.iter().all(|f| matches!(f, Paint::Pattern { pattern, .. } if *pattern == def.name)), "{fills:?}");
// An uncoloured pattern draws in the current colour: one swatch per colour.
let stencil = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "0 0 5 5 re f");
let d = open(&one_page(
"/CS0 cs 1 0 0 /P0 scn 0 0 100 50 re f 0 0 1 /P0 scn 0 50 100 50 re f",
&format!("/Pattern << /P0 {p} 0 R >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"),
&[&stencil],
));
let colors: Vec<Color> = d.patterns.iter().filter_map(|p| fill_color(&p.art[0])).collect();
assert_eq!(colors, vec![Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]);
}
// ---------- text ----------
const HELVETICA: &str =
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> /F2 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >>";
fn texts(d: &Document) -> Vec<vectorcraft_doc::TextObject> {
all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = n.kind { Some(*t) } else { None }).collect()
}
#[test]
fn text_becomes_point_type() {
let bytes = one_page(
"BT /F1 24 Tf 1 0 0 rg 10 30 Td (Hello) Tj [(Wor) -20 (ld)] TJ ET BT /F1 12 Tf 10 70 Td [(Next) -1000 (line)] TJ /F2 12 Tf 0 0 1 rg ( in bold) Tj ET",
HELVETICA,
&[],
);
let r = open_with(&bytes, |_| {});
let t = texts(&r.document);
assert_eq!(t.len(), 2, "{t:?}");
let text = |o: &vectorcraft_doc::TextObject| o.runs.iter().map(|r| r.text.as_str()).collect::<String>();
assert_eq!(text(&t[0]), "HelloWorld");
let st = &t[0].runs[0].style;
assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Helvetica", "Regular", 24.0));
assert_eq!(st.fill.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
// The baseline origin, in y-down page space.
let o = t[0].xf * kurbo::Point::ORIGIN;
assert!((o.x - 10.0).abs() < 0.01 && (o.y - 70.0).abs() < 0.01, "{o:?}");
// A gap of an em between words reads as a space; another font on the line is another run.
assert_eq!(text(&t[1]), "Next line in bold");
let runs: Vec<(&str, &str)> = t[1].runs.iter().map(|r| (r.text.as_str(), r.style.font_style.as_str())).collect();
assert_eq!(runs, [("Next line", "Regular"), (" in bold", "Bold")]);
assert_eq!(t[1].runs[1].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings);
// As outlines: paths, no type.
let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines);
assert!(texts(&r.document).is_empty());
assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some("<Text Outlines>")));
}
#[test]
fn font_names_resolve_to_available_families() {
let f = crate::import_text::Families::available();
let (family, style, found) = f.resolve("ABCDEF+SourceSans3-Bold", None, false);
assert_eq!((family.as_str(), style.as_str(), found), ("Source Sans 3", "Bold", true));
let (family, style, found) = f.resolve("TimesNewRomanPS-BoldItalicMT", None, false);
assert_eq!((family.as_str(), style.as_str()), ("Times New Roman", "Bold Italic"));
let _ = found;
let (family, style, found) = f.resolve("MissingSansPro", Some(700), true);
assert_eq!((family.as_str(), style.as_str(), found), ("Missing Sans Pro", "Bold Italic", false));
}
// ---------- meshes, stops and extend ----------
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join("") + ">"
}
#[test]
fn a_coons_patch_mesh_becomes_a_gradient_mesh() {
// One patch over (10, 10)–(90, 90): corner colours red (0, 0), green (0, 1), blue (1, 1),
// white (1, 0) in u/v.
let pts: [(u8, u8); 12] =
[(10, 10), (10, 37), (10, 63), (10, 90), (37, 90), (63, 90), (90, 90), (90, 63), (90, 37), (90, 10), (63, 10), (37, 10)];
let mut data = vec![0u8];
for (x, y) in pts {
data.extend([x, y]);
}
data.extend([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]);
let s = first_extra(1);
let sh = stream(
"/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
&hex(&data),
);
let d = open(&one_page("/Sh0 sh", &format!("/Shading << /Sh0 {s} 0 R >>"), &[&sh]));
let meshes: Vec<vectorcraft_doc::live::GradientMesh> =
all(&d).into_iter().filter_map(|n| if let NodeKind::Mesh(m) = n.kind { Some(m) } else { None }).collect();
let [m] = meshes.as_slice() else { panic!("one mesh: {meshes:?}") };
assert_eq!((m.rows, m.cols), (1, 1));
let at = |r, c| &m.points[m.idx(r, c)];
// Row 0 is v = 0 (PDF y 10 → page y 90), column 1 is u = 1.
assert!((at(0, 0).p - kurbo::Point::new(10.0, 90.0)).hypot() < 0.01, "{:?}", at(0, 0).p);
assert!((at(1, 1).p - kurbo::Point::new(90.0, 10.0)).hypot() < 0.01, "{:?}", at(1, 1).p);
assert_eq!(at(0, 0).color.to_rgb(), [1.0, 0.0, 0.0]);
assert_eq!(at(1, 0).color.to_rgb(), [0.0, 1.0, 0.0]);
assert_eq!(at(1, 1).color.to_rgb(), [0.0, 0.0, 1.0]);
assert_eq!(at(0, 1).color.to_rgb(), [1.0, 1.0, 1.0]);
assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "the mesh lies inside the page");
}
#[test]
fn gradient_stop_opacity_round_trips() {
let mut d = Document::new(100.0, 100.0);
let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
stops[1].opacity = 0.2;
let g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
let n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)),
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
);
add(&mut d, n);
let back = roundtrip(&d);
let nodes = all(&back);
assert!(nodes.iter().all(|n| n.mask.is_none()), "the opacity is in the stops, not a mask");
let g = nodes.iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) {
Some(Paint::Gradient(g)) => Some(g.gradient.clone()),
_ => None,
});
let g = g.expect("a gradient fill");
let first = g.stops.first().unwrap();
let last = g.stops.last().unwrap();
assert!((first.opacity - 1.0).abs() < 0.02 && (last.opacity - 0.2).abs() < 0.02, "{:?}", g.stops);
assert_eq!(last.color.to_rgb(), [0.0, 0.0, 1.0]);
}
#[test]
fn a_shading_that_does_not_extend_paints_only_between_its_ends() {
let shading = |extend: &str| {
format!(
"/Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [20 0 80 0] /Extend [{extend}] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >>"
)
};
let d = open(&one_page("/Sh0 sh", &shading("false false"), &[]));
let clips: Vec<Node> = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
let [c] = clips.as_slice() else { panic!("one clip group: {clips:?}") };
let b = c.geometric_bounds().unwrap();
assert!((b.x0 - 20.0).abs() < 0.01 && (b.x1 - 80.0).abs() < 0.01 && b.y0 <= 0.0 && b.y1 >= 100.0, "{b:?}");
let d = open(&one_page("/Sh0 sh", &shading("true true"), &[]));
assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })));
// Extended at the start only: from the left edge to the end.
let d = open(&one_page("/Sh0 sh", &shading("true false"), &[]));
let b = all(&d).into_iter().find(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).unwrap().geometric_bounds().unwrap();
assert!(b.x0 <= 0.0 && (b.x1 - 80.0).abs() < 0.01, "{b:?}");
}
#[test]
fn appearance_items_are_untouched_for_plain_art() {
// Plain art keeps importing as before: no masks, groups or clips appear.
let d = open(&one_page("q 1 0 0 rg 10 10 30 30 re f Q 0 0 1 RG 2 w 50 50 30 30 re S", "", &[]));
let nodes = all(&d);
assert_eq!(nodes.iter().filter(|n| n.path_data().is_some()).count(), 2);
assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. })));
assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_)));
}

View File

@ -235,6 +235,17 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and
say so, as does an `.ai` whose PDF part is only a placeholder page.
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
tiling patterns become pattern swatches, patch and triangle mesh shadings become gradient meshes, and gradients keep
their stop opacity and stop where the shading doesn't extend. Text becomes point type, one object per run of a line in
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font) —
`textAs: "outlines"` keeps glyph outlines instead:
```json
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines"}}}
```
Preserve Editing (`preserveEditing`, on in the `VectorCraft Default` preset) embeds the native document in the PDF as
an embedded file (`vectorcraft-editing.vectorcraft`; files named `drawcraft-editing.drawcraft` are read too) with a
hash of the pages it was written with. `open_file` / `document.open` of such a PDF, `.ai` or `.ait` (no `pages` picked) restores the