From a51083733e2796068fceebc7a1dd066145963b64 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 12:40:06 +0700 Subject: [PATCH 1/2] 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 --- crates/doc/src/lib.rs | 8 + crates/engine/src/cmd/fileio/mod.rs | 4 +- crates/engine/src/cmd/fileio/pdfimport.rs | 43 +- .../src/cmd/fileio/tests_pdffidelity.rs | 46 ++ crates/engine/tests/import_fuzz.rs | 70 ++ crates/pdf/src/import.rs | 675 ++++++++++++++---- crates/pdf/src/import_mask.rs | 128 ++++ crates/pdf/src/import_scan.rs | 181 +++++ crates/pdf/src/import_shading.rs | 311 ++++++++ crates/pdf/src/import_text.rs | 225 ++++++ crates/pdf/src/lib.rs | 28 +- crates/pdf/src/tests_import_fidelity.rs | 361 ++++++++++ docs/mcp.md | 11 + 13 files changed, 1925 insertions(+), 166 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_pdffidelity.rs create mode 100644 crates/pdf/src/import_mask.rs create mode 100644 crates/pdf/src/import_scan.rs create mode 100644 crates/pdf/src/import_shading.rs create mode 100644 crates/pdf/src/import_text.rs create mode 100644 crates/pdf/src/tests_import_fidelity.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a0e8b3ce..0d28b3a3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -887,6 +887,14 @@ fn default_graphic_styles() -> Vec { .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::*; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index e18efbbc..ff65d803 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "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 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 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; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 5c600ef9..4b5cf32f 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -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, + /// 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 { 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(cmd: &str, key: &str, id: Option<&str>) -> Result { + 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)> { 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)> { - 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) } diff --git a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs new file mode 100644 index 00000000..fdaeac61 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs @@ -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 { + 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); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ca5816fa..1a8a67cb 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -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 { + 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 { + 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::(), + ) { + 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. diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 0edd1d9f..db16b924 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -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 = ""; +/// 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 { @@ -35,6 +43,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result>> = Arc::default(); let sink2 = sink.clone(); @@ -58,8 +67,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result = 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 Result]) -> 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>, + isolate: bool, + knockout: bool, }, } @@ -163,6 +195,12 @@ struct Frame { children: Vec>, } +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, String)>); + +/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back. +struct Saved { + stack: Vec, + blend: BlendMode, + mask: Option>, + 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, + text: Option, + text_as: TextAs, images: HashMap, image_keys: HashMap, warnings: Vec, + /// Fonts by cache key → base font name (from [`scan_page`]). + fonts: HashMap, + font_names: HashMap, + families: Option, + missing_fonts: Vec, + /// The soft mask of the graphics state, the art drawn through it so far, and the masks read. + mask: Option>, + masked: Vec>, + masks: HashMap>, + /// Pattern swatches made, and per (pattern, stroke) the art signatures read and their names. + patterns: Vec, + pattern_keys: HashMap<(u128, bool), PatternReadings>, + /// Swatch names in use. + taken: Vec, + /// 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]) -> Vec { + 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]) -> Option { + 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> { - self.flush_glyphs(); + self.close_frames() + } + + /// Finish the open runs and frames → the root frame's children. + fn close_frames(&mut self) -> Vec> { + 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) { + 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; 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("".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>> { + 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 { + 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 { + 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 { - 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, region: &BezPath, opacity: f32) { + let nodes: Vec> = 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; 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::>()) - } 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, w: u32, h: u32) -> Option> { @@ -491,24 +823,67 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { impl<'a> Device<'a> for Builder<'_> { fn set_soft_mask(&mut self, mask: Option>) { - 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("".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>, 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(); } } diff --git a/crates/pdf/src/import_mask.rs b/crates/pdf/src/import_mask.rs new file mode 100644 index 00000000..db446f01 --- /dev/null +++ b/crates/pdf/src/import_mask.rs @@ -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>, + alpha: bool, + backdrop: f32, + inverted: bool, + page: Rect, + group: impl FnOnce(Vec>) -> 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; 1]>::try_from(art) { + Ok([one]) => Arc::unwrap_or_clone(one), + Err(all) => group(all), + }; + MaskSpec::Mask(OpacityMask { invert, ..OpacityMask::new(art, clip) }) +} diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs new file mode 100644 index 00000000..3b9fc058 --- /dev/null +++ b/crates/pdf/src/import_scan.rs @@ -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, +} + +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::>(xref.root_id()).and_then(|c| c.get::>(b"OCProperties")) else { + return o; + }; + let config = props.get::>(b"D").unwrap_or_default(); + let refs = |d: &Dict<'_>, key: &[u8]| -> Vec { + d.get::>(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::>(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, + fonts: &'w mut HashMap, + visible: Vec, + 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) { + let membership = id.is_none() || d.get::>(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 = match d.get::>(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::>(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::>(key.as_ref()) + && let Some(name) = f.get::>(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::>(n.as_ref()).unwrap_or_default(), r)) + } + o => dict_or_stream(o) + .and_then(|(props, _)| props.get_ref(b"OC").map(|r| (props.get::>(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::>(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::>(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::>(b"Group") + && self.out.groups.len() < MAX_GROUPS + { + self.out.groups.push((g.get::(b"I").unwrap_or(false), g.get::(b"K").unwrap_or(false))); + } + let inner = Resources::from_parent(d.get::>(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) -> 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 +} diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs new file mode 100644 index 00000000..86c0f6dd --- /dev/null +++ b/crates/pdf/src/import_shading.rs @@ -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 { + 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 = 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::>()) + } 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 { + 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 { + 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> { + 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 { + 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 = 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 }) +} diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs new file mode 100644 index 00000000..5230d5c3 --- /dev/null +++ b/crates/pdf/src/import_text.rs @@ -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 { + 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, + 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 = 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); + +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, 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), + } + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 614e5862..b6a27783 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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, + /// 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; diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs new file mode 100644 index 00000000..83329b3b --- /dev/null +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -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 { + 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 { + 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 { + 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 { + 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 = 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::() * 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 = 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 = 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 = 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 { + 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::(); + 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(""))); +} + +#[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::>().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 = + 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 = 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(_))); +} diff --git a/docs/mcp.md b/docs/mcp.md index 0230e53b..f94e26f9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 From 8046b9be8492a43e9ace7b0282d627ff04fece70 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 12:46:40 +0700 Subject: [PATCH 2/2] M4.62: PDF/.ai layers from optional content: named layers with visibility, print state and lock; art that is off as hidden layers Optional content groups become layers (in paint order, shared by every page) with their name, default visibility, print usage and lock; art outside them goes to a layer per page. Art that is off isn't lost: an update appended to the file drops its /OCProperties so the interpreter draws every group, and the marked-content sequences found walking the content (checked by tag) route the art to its group's layer, which comes in hidden. A form hidden by its own /OC keeps the file as it is. document.open layers: false keeps one layer per page of only what shows (Save then asks for a name). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/cmd/fileio/pdfimport.rs | 52 +++-- .../src/cmd/fileio/tests_pdffidelity.rs | 37 +++- crates/engine/tests/import_fuzz.rs | 31 ++- crates/pdf/src/import.rs | 199 +++++++++++++++--- crates/pdf/src/import_scan.rs | 194 +++++++++++++++-- crates/pdf/src/lib.rs | 11 +- crates/pdf/src/tests_import_layers.rs | 136 ++++++++++++ crates/testkit/src/pdf.rs | 7 +- docs/mcp.md | 6 +- 10 files changed, 587 insertions(+), 88 deletions(-) create mode 100644 crates/pdf/src/tests_import_layers.rs diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index ff65d803..dd1d24db 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "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), 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 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 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), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), 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 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 ), diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 4b5cf32f..a1712acc 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -9,9 +9,9 @@ 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, text) are -/// ignored by other formats. -#[derive(Clone, Debug, Default, PartialEq)] +/// The `document.open` options besides the file. The PDF ones (pages, box, password, text, +/// layers) are ignored by other formats. +#[derive(Clone, Debug, PartialEq)] pub struct LoadOptions { /// 1-based pages to import, such as `"2-3, 5"`; `None` = every page. pub pages: Option, @@ -24,12 +24,21 @@ pub struct LoadOptions { pub color_mode: Option, /// What a PDF's text becomes. pub text_as: TextAs, + /// A PDF's optional content groups become layers (else one layer per page, without the art + /// that is off). + pub layers: bool, +} + +impl Default for LoadOptions { + fn default() -> Self { + Self { pages: None, crop: CropTo::default(), password: None, color_mode: None, text_as: TextAs::default(), layers: true } + } } 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`, `colorMode` (`rgb` | `cmyk`) and - /// `textAs` (`text` | `outlines`). + /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`), + /// `textAs` (`text` | `outlines`) and `layers` (true | false). pub fn from_params(cmd: &str, p: &Value) -> Result { let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) { None => None, @@ -47,13 +56,18 @@ impl LoadOptions { Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))), }; let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?; - Ok(Self { pages, crop, password, color_mode, text_as }) + let layers = match p.get("layers").filter(|v| !v.is_null()) { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?, + }; + Ok(Self { pages, crop, password, color_mode, text_as, layers }) } /// Does the document read only part of the file, or read it differently (a page range, another - /// box, a password)? Writing it back would lose the rest, so Save doesn't (it asks for a name). + /// box, a password, without its hidden layers)? Writing it back would lose the rest, so Save + /// doesn't (it asks for a name). pub fn is_partial(&self) -> bool { - self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() + self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers } /// The PDF import options: `pages` resolved against the file's page count. @@ -65,7 +79,14 @@ impl LoadOptions { Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?) } }; - Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), text_as: self.text_as, ..Default::default() }) + Ok(ImportOptions { + pages, + crop: self.crop, + password: self.password.clone(), + text_as: self.text_as, + layers: self.layers, + ..Default::default() + }) } } @@ -86,10 +107,17 @@ pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec Result<(Document, Vec)> { - let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: TextAs::Outlines, ..Default::default() }; + let opts = ImportOptions { + pages: Some(vec![page]), + crop: o.crop, + password: o.password.clone(), + text_as: TextAs::Outlines, + layers: false, + ..Default::default() + }; vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err) } diff --git a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs index fdaeac61..69c8bf3e 100644 --- a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs @@ -1,8 +1,9 @@ -//! `document.open` PDF option `textAs`. +//! `document.open` PDF options `textAs` and `layers`. use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; use vectorcraft_pdf::TextAs; -use vectorcraft_testkit::pdf::{PdfPage, pdf}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with_catalog}; use super::*; @@ -38,9 +39,31 @@ fn text_opens_as_type_or_outlines() { } #[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); +fn layers_option_keeps_or_flattens_optional_content() { + let (a, b) = (first_extra(1), first_extra(1) + 1); + let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /OFF [{b} 0 R] >> >> "); + let page = PdfPage { + resources: format!("/Properties << /A {a} 0 R /B {b} 0 R >>"), + ..PdfPage::new(100.0, 100.0, "/OC /A BDC 0 g 0 0 10 10 re f EMC /OC /B BDC 0 g 20 20 10 10 re f EMC") + }; + let bytes = pdf_with_catalog(&[page], &["<< /Type /OCG /Name (Ink) >>", "<< /Type /OCG /Name (Notes) >>"], &catalog, None); + let mut s = Session::new(); + open(&mut s, &bytes, json!({})).unwrap(); + let layers: Vec<(String, bool)> = s.active().unwrap().doc.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.visible)).collect(); + assert_eq!(layers, [("Ink".to_string(), true), ("Notes".to_string(), false)]); + open(&mut s, &bytes, json!({"layers": false})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert_eq!(doc.layers[0].children().unwrap().len(), 1, "the hidden art is left out"); + assert!(matches!(doc.layers[0].kind, NodeKind::Layer { .. })); + assert!(open(&mut s, &bytes, json!({"layers": "no"})).unwrap_err().to_string().contains("layers must be true or false")); +} + +#[test] +fn load_options_read_text_and_layers() { + let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines", "layers": false})).unwrap(); + assert_eq!((o.text_as, o.layers), (TextAs::Outlines, false)); + assert!(o.is_partial(), "without its hidden layers, Save doesn't write the file back"); + let d = LoadOptions::default(); + assert_eq!((d.text_as, d.layers, d.is_partial()), (TextAs::Text, true, false)); } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 1a8a67cb..98860b60 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -435,16 +435,18 @@ 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 { - use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; +/// Hostile content over layers (optional content, one group off), soft masks, tiling patterns, +/// a patch mesh, an unextended shading and fonts: the import's own readings of the file. +fn rich_resources_pdf(content: &str, config: &str) -> Vec { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog}; let x = first_extra(1); let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); let extra = [ + "<< /Type /OCG /Name (A) >>".to_string(), + "<< /Type /OCG /Name /Usage << /Print << /PrintState /OFF >> >> >>".to_string(), 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", + "1 g 0 0 50 50 re f /OC /MC0 BDC 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 << >>", @@ -454,23 +456,29 @@ fn rich_resources_pdf(content: &str) -> Vec { "/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode", "000A0A0A250A3F0A5A255A3F5A5A5A5A3F5A255A0A3F0A250AFF000000FF000000FFFFFFFF>", ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC 6 0 R", "/X0 Do"), ]; - let (form, pat, mesh) = (x, x + 1, x + 2); + let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5); 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] >>" + "/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 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 catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R 99 0 R] /D << {} >> >> ", config.replace("{B}", &format!("{b} 0 R"))); let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); - pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, None) + pdf_with_catalog(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, &catalog, None) } fn arb_rich_op() -> impl Strategy { prop_oneof![ arb_pdf_op(), prop::sample::select(vec![ + "/OC /MC0 BDC", + "/OC /MC1 BDC", + "/OC /Nope BDC", "/Span << /MCID 3 >> BDC", "/X BMC", "EMC", "/X0 Do", + "/X1 Do", "/GS0 gs", "/GS1 gs", "/CS0 cs 1 0 0 /P0 scn", @@ -492,12 +500,15 @@ proptest! { #![proptest_config(config())] #[test] - fn pdf_masks_patterns_and_text_never_panic( + fn pdf_layers_masks_patterns_and_text_never_panic( ops in prop::collection::vec(arb_rich_op(), 0..40), + config in prop::sample::select(vec!["/OFF [{B}]", "/BaseState /OFF", "/BaseState /Unchanged /ON [{B}] /Locked [{B}]", "", "/OFF {B}"]), + layers in any::(), outlines in any::(), ) { - let bytes = rich_resources_pdf(&ops.join("\n")); + let bytes = rich_resources_pdf(&ops.join("\n"), config); let opts = vectorcraft_pdf::ImportOptions { + layers, text_as: if outlines { vectorcraft_pdf::TextAs::Outlines } else { vectorcraft_pdf::TextAs::Text }, ..Default::default() }; diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index db16b924..05f6b8ea 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec}; -use crate::import_scan::{Ocgs, Scan, scan_page}; +use crate::import_scan::{Ocgs, Scan, all_on, hides_forms, 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}; @@ -38,12 +38,35 @@ pub fn import(bytes: &[u8]) -> Result { import_with_report(bytes, &ImportOptions::default()).map(|r| r.document) } +/// A layer of the imported document, in paint order. +enum Slot { + /// A page's art outside optional content groups. + Page(Box), + /// An optional content group (index into [`Ocgs::list`]). + Group(usize), +} + /// Import a PDF, returning the document plus warnings about content that was approximated or skipped. pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { - let pdf = crate::pages::open(bytes, opts.password.as_deref())?; + let original = crate::pages::open(bytes, opts.password.as_deref())?; + let picked = crate::pages::picked(opts, original.pages().len())?; + let mut ocgs = Ocgs::read(&original); + let route = opts.layers && !ocgs.list.is_empty(); + // Content that is off imports as hidden layers: an update of the file turns every group on + // (unless a form is hidden by its own group: its art couldn't be told apart). + let hide = route && ocgs.any_off(); + let turned_on = (hide && !picked.iter().filter_map(|&n| original.pages().get(n)).any(|p| hides_forms(p, &mut ocgs))) + .then(|| all_on(bytes, &original)) + .flatten() + .and_then(|b| crate::pages::open(&b, opts.password.as_deref()).ok()) + .filter(|p| Ocgs::read(p).list.is_empty()); + let mut notes = vec![]; + if hide && turned_on.is_none() { + notes.push("the art of hidden layers couldn't be read and was left out".to_string()); + } + let all_on = turned_on.is_some(); + let pdf = turned_on.unwrap_or(original); let pages = pdf.pages(); - let picked = crate::pages::picked(opts, pages.len())?; - let ocgs = Ocgs::read(&pdf); let sink: Arc>> = Arc::default(); let sink2 = sink.clone(); @@ -68,12 +91,15 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result = 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); + let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as, route); b.taken = taken; + b.warnings = notes; let cache = InterpreterCache::new(); let mut x = 0.0; // Every page only a placeholder (text) over private data: the file's art isn't in its PDF part. let mut placeholder = true; + let mut slots: Vec = vec![]; + let mut group_art: HashMap>> = HashMap::new(); for (i, &number) in picked.iter().enumerate() { let Some(page) = pages.get(number) else { continue }; // The chosen box sits at (x, 0); the page draws round it. @@ -82,10 +108,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); placeholder &= crate::pages::has_private_data(page) && only_text(&children); let mut right = ab.x1; if opts.crop == CropTo::Bounding @@ -102,15 +129,48 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result, art: Vec>| { + let mut layer = Node::layer(b.id(), &format!("Page {}", number + 1), LayerColor::Preset((i % 27) as u8)); + if let NodeKind::Layer { children: c, .. } = &mut layer.kind { + *c = art; + } + Slot::Page(Box::new(layer)) + }; + if parts.is_empty() { + slots.push(page_layer(&mut b, vec![])); + } + for (key, art) in parts { + match key { + None => slots.push(page_layer(&mut b, art)), + Some(g) => { + let into = group_art.entry(g).or_insert_with(|| { + slots.push(Slot::Group(g)); + vec![] + }); + into.extend(art); + } + } } - doc.layers.push(Arc::new(layer)); } if placeholder { return Err(PdfError::PlaceholderOnly); } + for (n, slot) in slots.into_iter().enumerate() { + let layer = match slot { + Slot::Page(l) => *l, + Slot::Group(g) => { + let Some(ocg) = ocgs.list.get(g) else { continue }; + let mut l = Node::layer(b.id(), &ocg.name, LayerColor::Preset((n % 27) as u8)); + (l.visible, l.locked) = (ocg.on, ocg.locked); + if let NodeKind::Layer { children, printable, .. } = &mut l.kind { + *children = group_art.remove(&g).unwrap_or_default(); + *printable = ocg.print; + } + l + } + }; + doc.layers.push(Arc::new(layer)); + } for (k, blob) in b.images.drain() { doc.images.insert(k, blob); } @@ -193,11 +253,13 @@ enum FrameKind { struct Frame { kind: FrameKind, children: Vec>, + /// The first optional content group entered inside (where the frame's art goes). + group: Option, } impl Frame { fn new(kind: FrameKind) -> Self { - Self { kind, children: vec![] } + Self { kind, children: vec![], group: None } } } @@ -217,6 +279,7 @@ type PatternReadings = (u32, Vec<(Vec, String)>); /// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back. struct Saved { stack: Vec, + root_groups: Vec>, blend: BlendMode, mask: Option>, page: Rect, @@ -227,6 +290,8 @@ struct Builder<'p> { colors: Colors<'p>, next: u64, stack: Vec, + /// The optional content group of each child of the root frame. + root_groups: Vec>, blend: BlendMode, page: Rect, glyphs: Option, @@ -249,9 +314,15 @@ struct Builder<'p> { pattern_keys: HashMap<(u128, bool), PatternReadings>, /// Swatch names in use. taken: Vec, - /// The page as walked by [`scan_page`], and how far the interpreter is through its groups. + /// The page as walked by [`scan_page`], and how far the interpreter is through it. scan: Scan, + tag_at: usize, group_at: usize, + aligned: bool, + /// Whether optional content groups become layers. + route: bool, + /// The marked-content sequences open: the group each marks. + marked: Vec>, /// A transparency group was just pushed: a form's (whose flags come next) or an image's. pending: bool, nested: u32, @@ -329,11 +400,12 @@ fn bounds(nodes: &[Arc]) -> Option { } impl<'p> Builder<'p> { - fn new(next: u64, colors: Colors<'p>, text_as: TextAs) -> Self { + fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self { Self { colors, next, stack: vec![], + root_groups: vec![], blend: BlendMode::Normal, page: Rect::ZERO, glyphs: None, @@ -353,7 +425,11 @@ impl<'p> Builder<'p> { pattern_keys: HashMap::new(), taken: vec![], scan: Scan::default(), + tag_at: 0, group_at: 0, + aligned: true, + route, + marked: vec![], pending: false, nested: 0, } @@ -390,18 +466,29 @@ impl<'p> Builder<'p> { fn begin_page(&mut self, scan: Scan) { self.stack = vec![Frame::new(FrameKind::Root)]; + self.root_groups.clear(); self.blend = BlendMode::Normal; self.glyphs = None; self.text = None; self.mask = None; self.masked.clear(); + self.marked.clear(); self.pending = false; - (self.scan, self.group_at) = (scan, 0); + (self.scan, self.tag_at, self.group_at, self.aligned) = (scan, 0, 0, true); } - /// The page's art. - fn end_page(&mut self) -> Vec> { - self.close_frames() + /// The page's art, split by optional content group (in the order they first paint). + fn end_page(&mut self) -> Vec<(Option, Vec>)> { + let children = self.close_frames(); + let groups = std::mem::take(&mut self.root_groups); + let mut parts: Vec<(Option, Vec>)> = vec![]; + for (n, g) in children.into_iter().zip(groups.into_iter().chain(std::iter::repeat(None))) { + match parts.iter_mut().find(|(k, _)| *k == g) { + Some((_, v)) => v.push(n), + None => parts.push((g, vec![n])), + } + } + parts } /// Finish the open runs and frames → the root frame's children. @@ -413,10 +500,22 @@ impl<'p> Builder<'p> { self.stack.pop().map(|f| f.children).unwrap_or_default() } - /// Add `n` to the open frame. - fn emit(&mut self, n: Arc) { - if let Some(f) = self.stack.last_mut() { - f.children.push(n); + /// The group art drawn now belongs to (the innermost marked as one). + fn current_group(&self) -> Option { + self.marked.iter().rev().find_map(|g| *g) + } + + /// Add `n` to the open frame; `group`: the optional content group it was drawn in, if it + /// knows one (else the current one). + fn emit(&mut self, n: Arc, group: Option) { + let root = self.stack.len() == 1; + let current = self.current_group(); + let Some(f) = self.stack.last_mut() else { return }; + f.children.push(n); + if root { + self.root_groups.push(group.or(current)); + } else if f.group.is_none() { + f.group = group; } } @@ -427,7 +526,7 @@ impl<'p> Builder<'p> { if self.mask.is_some() { self.masked.push(Arc::new(n)); } else { - self.emit(Arc::new(n)); + self.emit(Arc::new(n), None); } } @@ -445,7 +544,7 @@ impl<'p> Builder<'p> { } let art = std::mem::take(&mut self.masked); let Some(spec) = self.mask.clone() else { - art.into_iter().for_each(|n| self.emit(n)); + art.into_iter().for_each(|n| self.emit(n, None)); return; }; match spec.as_ref() { @@ -453,7 +552,7 @@ impl<'p> Builder<'p> { MaskSpec::Opacity(a) => { for mut n in art { Arc::make_mut(&mut n).opacity *= a; - self.emit(n); + self.emit(n, None); } } MaskSpec::Mask(m) => { @@ -473,7 +572,7 @@ impl<'p> Builder<'p> { g } }; - self.emit(Arc::new(node)); + self.emit(Arc::new(node), None); } } } @@ -490,10 +589,11 @@ impl<'p> Builder<'p> { return; } let Some(f) = self.stack.pop() else { return }; + let group = f.group; let node = match f.kind { FrameKind::Root => None, FrameKind::Skip => { - f.children.into_iter().for_each(|c| self.emit(c)); + f.children.into_iter().for_each(|c| self.emit(c, group)); None } FrameKind::Clip(clip) => { @@ -502,7 +602,7 @@ impl<'p> Builder<'p> { 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)); + f.children.into_iter().for_each(|c| self.emit(c, group)); None } else { let mut ch = vec![Arc::new(*clip)]; @@ -525,7 +625,7 @@ impl<'p> Builder<'p> { if children.is_empty() { None } else if plain { - children.into_iter().for_each(|c| self.emit(c)); + children.into_iter().for_each(|c| self.emit(c, group)); None } else if let [only] = children.as_slice() && !only.is_container() @@ -556,7 +656,7 @@ impl<'p> Builder<'p> { } }; if let Some(n) = node { - self.emit(Arc::new(n)); + self.emit(Arc::new(n), group); } } @@ -623,6 +723,7 @@ impl<'p> Builder<'p> { self.flush(); let saved = Saved { stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]), + root_groups: std::mem::take(&mut self.root_groups), blend: std::mem::replace(&mut self.blend, BlendMode::Normal), mask: self.mask.take(), page: std::mem::replace(&mut self.page, page), @@ -632,7 +733,8 @@ impl<'p> Builder<'p> { 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); + (self.stack, self.root_groups, self.blend, self.mask, self.page, self.pending) = + (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.pending); Some(art) } @@ -1080,4 +1182,39 @@ impl<'a> Device<'a> for Builder<'_> { self.pending = false; self.pop_frame(); } + + fn begin_marked_content(&mut self, tag: &[u8], _mcid: Option) { + if self.nested > 0 { + return; + } + self.flush(); + let at = self.tag_at; + self.tag_at += 1; + let group = match self.scan.tags.get(at) { + Some((t, g)) if self.aligned && t.as_slice() == tag => *g, + _ => { + if self.route && self.aligned { + self.aligned = false; + self.warn("some art couldn't be told apart by layer and went to its page's layer"); + } + None + } + } + .filter(|_| self.route); + if let Some(g) = group + && self.stack.len() > 1 + && let Some(f) = self.stack.last_mut() + { + f.group.get_or_insert(g); + } + self.marked.push(group); + } + + fn end_marked_content(&mut self) { + if self.nested > 0 { + return; + } + self.flush(); + self.marked.pop(); + } } diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 3b9fc058..673a4fbf 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -1,10 +1,14 @@ -//! 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. +//! What the interpreter doesn't tell the import, read from the file itself: the optional content +//! groups (layers) with their states, which group each marked-content sequence belongs to, 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 +//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group +//! without its flags, and skips the content of groups that are off. 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. +//! skipping what it skips): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag +//! checks that), and the k-th form transparency group is the k-th group found here. To import +//! content that is off as hidden layers, [`all_on`] gives the file an update that turns every +//! group on. use std::collections::{HashMap, HashSet}; @@ -12,7 +16,7 @@ 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::object::{Array, Dict, FromBytes, Name, Object, ObjectIdentifier, dict_or_stream}; use hayro_syntax::page::{Page, Resources}; /// The interpreter's limit on nested form XObjects. @@ -22,9 +26,22 @@ const MAX_OPS: usize = 2_000_000; /// The most groups read. const MAX_GROUPS: usize = 10_000; +/// An optional content group: a layer. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Ocg { + pub name: String, + /// On in the file's default configuration (shown by viewers). + pub on: bool, + /// Printed (its print usage isn't off). + pub print: bool, + pub locked: bool, +} + /// The file's optional content groups. #[derive(Default)] pub(crate) struct Ocgs { + pub list: Vec, + index: HashMap, /// The groups that are off (as the interpreter decides it). off: HashSet, } @@ -43,34 +60,101 @@ impl Ocgs { .map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).take(MAX_GROUPS).collect()) .unwrap_or_default() }; + let all = refs(&props, b"OCGs"); if config.get::>(b"BaseState").is_some_and(|n| n.as_ref() == b"OFF") { - o.off.extend(refs(&props, b"OCGs")); + o.off.extend(all.iter().copied()); } for id in refs(&config, b"ON") { o.off.remove(&id); } o.off.extend(refs(&config, b"OFF")); + let locked: HashSet<_> = refs(&config, b"Locked").into_iter().collect(); + for id in all { + if let Some(d) = xref.get::>(id) { + o.add(id, &d, locked.contains(&id)); + } + } o } + + /// Note group `id` (its dictionary `d`) → its index. + fn add(&mut self, id: ObjectIdentifier, d: &Dict<'_>, locked: bool) -> usize { + if let Some(i) = self.index.get(&id) { + return *i; + } + let name = d.get::>(b"Name").map(|s| text_string(s.as_bytes())).unwrap_or_default(); + let print = d + .get::>(b"Usage") + .and_then(|u| u.get::>(b"Print")) + .and_then(|p| p.get::>(b"PrintState")) + .is_none_or(|s| s.as_ref() != b"OFF"); + let on = !self.off.contains(&id); + self.list.push(Ocg { name: if name.is_empty() { format!("Layer {}", self.list.len() + 1) } else { name }, on, print, locked }); + self.index.insert(id, self.list.len() - 1); + self.list.len() - 1 + } + + /// Is any group off? + pub fn any_off(&self) -> bool { + self.list.iter().any(|g| !g.on) + } + + /// The group content marked with `/OC` (`d`: the group or membership dictionary, `id` its + /// object) belongs to: a membership dictionary's first group. + fn group_of(&mut self, d: &Dict<'_>, id: ObjectIdentifier, xref: &hayro_syntax::xref::XRef) -> Option { + if d.get::>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD") { + let first = d.get::>(b"OCGs").and_then(|a| a.raw_iter().find_map(|i| i.as_obj_ref())).or_else(|| d.get_ref(b"OCGs"))?; + let id = ObjectIdentifier::from(first); + return self.note(id, &xref.get::>(id)?); + } + self.note(id, d) + } + + /// The index of group `id` (dictionary `d`), noted if new (up to [`MAX_GROUPS`]). + fn note(&mut self, id: ObjectIdentifier, d: &Dict<'_>) -> Option { + match self.index.get(&id) { + Some(i) => Some(*i), + None => (self.list.len() < MAX_GROUPS).then(|| self.add(id, d, false)), + } + } +} + +/// A PDF text string: UTF-16 (with its byte order mark), UTF-8 (with one) or PDFDocEncoding +/// (read as Latin-1, which it matches for letters). +pub(crate) fn text_string(b: &[u8]) -> String { + if let Some(rest) = b.strip_prefix(&[0xFE, 0xFF]) { + let units: Vec = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + return String::from_utf16_lossy(&units); + } + if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(rest).into_owned(); + } + b.iter().map(|&c| c as char).collect() } /// One page as the interpreter walks it. #[derive(Default)] pub(crate) struct Scan { + /// Each marked-content sequence: its tag and the group (index into [`Ocgs::list`]) it marks. + pub tags: Vec<(Vec, Option)>, /// 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, +struct Walker<'w, 'p> { + ocgs: &'w mut Ocgs, + /// The groups that are off for the interpreter (none once [`all_on`] turned them on). + off: HashSet, + xref: &'p hayro_syntax::xref::XRef, fonts: &'w mut HashMap, visible: Vec, ops: usize, out: Scan, + /// A form XObject hidden by its own optional content was met. + hidden_form: bool, } -impl Walker<'_> { +impl Walker<'_, '_> { fn is_visible(&self) -> bool { self.visible.last().copied().unwrap_or(true) } @@ -130,12 +214,23 @@ impl Walker<'_> { o => dict_or_stream(o) .and_then(|(props, _)| props.get_ref(b"OC").map(|r| (props.get::>(b"OC").unwrap_or_default(), r))), }; - match oc { - Some((d, r)) => self.begin_oc(&d, Some(ObjectIdentifier::from(r))), - None => self.begin(true), - } + let group = match oc { + Some((d, r)) => { + let id = ObjectIdentifier::from(r); + self.begin_oc(&d, Some(id)); + self.ocgs.group_of(&d, id, self.xref) + } + None => { + self.begin(true); + None + } + }; + self.out.tags.push((bdc.0.to_vec(), group)); + } + TypedInstruction::BeginMarkedContent(bmc) => { + self.begin(true); + self.out.tags.push((bmc.0.to_vec(), None)); } - TypedInstruction::BeginMarkedContent(_) => self.begin(true), TypedInstruction::EndMarkedContent(_) => { self.visible.pop(); } @@ -152,6 +247,7 @@ impl Walker<'_> { let oc = d.get::>(b"OC"); if let Some(oc) = &oc { self.begin_oc(oc, d.get_ref(b"OC").map(ObjectIdentifier::from)); + self.hidden_form |= !self.is_visible(); } if self.is_visible() { if let Some(g) = d.get::>(b"Group") @@ -172,10 +268,64 @@ impl Walker<'_> { } } -/// 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) -> 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 +/// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts +/// found are noted in `fonts` (their cache key → base font name). +pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap) -> Scan { + walk_page(page, ocgs, all_on, fonts).out +} + +fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap) -> Walker<'w, 'p> { + let off = if all_on { HashSet::new() } else { ocgs.off.clone() }; + let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, out: Scan::default(), hidden_form: false }; + w.walk(page.typed_operations(), page.resources(), 0); + w +} + +/// Does `page` draw a form XObject that is off by its own optional content (not a marked-content +/// sequence)? Its art couldn't be told apart by group once every group is on. +pub(crate) fn hides_forms(page: &Page<'_>, ocgs: &mut Ocgs) -> bool { + walk_page(page, ocgs, false, &mut HashMap::new()).hidden_form +} + +/// `bytes` with an update appended that drops the catalog's `/OCProperties`, so every group +/// draws; `None` when the file's structure can't take one. +pub(crate) fn all_on(bytes: &[u8], pdf: &Pdf) -> Option> { + let root = pdf.xref().root_id(); + let catalog = pdf.xref().get::>(root)?; + let body = catalog.data().strip_suffix(b">>")?; + let prev = last_xref(bytes)?; + let trailer = trailer_at(bytes, prev)?; + let inner = trailer.strip_prefix(b"<<")?.strip_suffix(b">>")?; + let mut out = bytes.to_vec(); + if !out.ends_with(b"\n") { + out.push(b'\n'); + } + let at = out.len(); + out.extend(format!("{} {} obj\n", root.obj_number, root.gen_number).bytes()); + out.extend_from_slice(body); + // A later key wins: the groups' configuration is gone, so the interpreter draws them all. + out.extend_from_slice(b" /OCProperties null >>\nendobj\n"); + let xref = out.len(); + out.extend(format!("xref\n{} 1\n{at:010} {:05} n \ntrailer\n<<", root.obj_number, root.gen_number).bytes()); + out.extend_from_slice(inner); + out.extend(format!(" /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").bytes()); + Some(out) +} + +/// The offset the file's last `startxref` points to. +fn last_xref(bytes: &[u8]) -> Option { + let at = bytes.windows(9).rposition(|w| w == b"startxref")?; + let rest = bytes.get(at + 9..)?; + let digits: String = rest.iter().skip_while(|c| c.is_ascii_whitespace()).take_while(|c| c.is_ascii_digit()).map(|&c| c as char).collect(); + digits.parse().ok().filter(|&p: &usize| p < bytes.len()) +} + +/// The trailer dictionary of the cross-reference section at `at` (a table's `trailer`, or a +/// cross-reference stream's dictionary), as written. +fn trailer_at(bytes: &[u8], at: usize) -> Option<&[u8]> { + let rest = bytes.get(at..)?; + let from = if rest.trim_ascii_start().starts_with(b"xref") { rest.windows(7).position(|w| w == b"trailer")? } else { 0 }; + let open = from + rest.get(from..)?.windows(2).position(|w| w == b"<<")?; + let d = Dict::from_bytes(rest.get(open..)?)?; + Some(d.data()) } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b6a27783..36237249 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -11,7 +11,9 @@ //! 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 (with isolation and knockout), soft +//! [`Document`]: one artboard per page, and a layer per page or per optional content group +//! (with its visibility, print state and lock; art that is off comes in as a hidden layer), +//! 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 @@ -84,11 +86,14 @@ pub struct ImportOptions { pub password: Option, /// What text becomes. pub text_as: TextAs, + /// Optional content groups become layers (else each page is one layer, without the art + /// that is off). + pub layers: bool, } impl Default for ImportOptions { fn default() -> Self { - Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default() } + Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default(), layers: true } } } @@ -188,6 +193,8 @@ mod tests_import_color; #[cfg(test)] mod tests_import_fidelity; #[cfg(test)] +mod tests_import_layers; +#[cfg(test)] mod tests_import_options; #[cfg(test)] mod tests_presets; diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs new file mode 100644 index 00000000..5bcfd065 --- /dev/null +++ b/crates/pdf/src/tests_import_layers.rs @@ -0,0 +1,136 @@ +//! Optional content groups become layers: name, visibility, print state and lock; art that is +//! off comes in as a hidden layer. + +use vectorcraft_color::Color; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog}; + +use crate::tests_import_fidelity::stream; +use crate::*; + +/// Groups "Shapes" and "Hidden" (not printed) as the first extra objects of a file of `pages` +/// pages, the catalog entries listing them with `config` (where `{A}` and `{B}` stand for them) +/// as the default configuration, and their object numbers. +fn groups(pages: usize, config: &str) -> ([String; 2], String, [usize; 2]) { + let (a, b) = (first_extra(pages), first_extra(pages) + 1); + let objs = [ + "<< /Type /OCG /Name (Shapes) >>".to_string(), + "<< /Type /OCG /Name /Usage << /Print << /PrintState /OFF >> >> >>".to_string(), + ]; + let catalog = format!( + "/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /Order [{a} 0 R {b} 0 R] {} >> >> ", + config.replace("{A}", &format!("{a} 0 R")).replace("{B}", &format!("{b} 0 R")) + ); + (objs, catalog, [a, b]) +} + +/// A page drawing red in "Shapes", blue in "Hidden" and green outside any group. +fn page(ids: [usize; 2]) -> PdfPage { + PdfPage { + resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]), + ..PdfPage::new(100.0, 100.0, "/OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f") + } +} + +fn file(config: &str) -> Vec { + let (objs, catalog, ids) = groups(1, config); + pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, None) +} + +fn names(d: &Document) -> Vec { + d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() +} + +/// The fill colours of a layer's art. +fn colors(l: &Node) -> Vec { + l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect() +} + +fn printable(l: &Node) -> bool { + matches!(l.kind, NodeKind::Layer { printable: true, .. }) +} + +#[test] +fn optional_content_groups_become_layers_and_off_art_a_hidden_layer() { + let d = import(&file("/OFF [{B}]")).unwrap(); + // In paint order (bottom first): the groups, then the art outside them. + assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]); + let [shapes, hidden, rest] = [0, 1, 2].map(|i| d.layers[i].clone()); + assert!(shapes.visible && printable(&shapes) && !shapes.locked); + assert_eq!(colors(&shapes), [Color::rgb(1.0, 0.0, 0.0)]); + // The group that is off is a hidden layer that still has its art; it doesn't print. + assert!(!hidden.visible && !printable(&hidden)); + assert_eq!(colors(&hidden), [Color::rgb(0.0, 0.0, 1.0)]); + assert_eq!(colors(&rest), [Color::rgb(0.0, 1.0, 0.0)]); +} + +#[test] +fn base_state_on_lists_and_locks_are_read() { + let d = import(&file("/BaseState /OFF /ON [{A}] /Locked [{A}]")).unwrap(); + assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]); + assert!(d.layers[0].visible && d.layers[0].locked); + assert!(!d.layers[1].visible && !d.layers[1].locked); +} + +#[test] +fn without_layers_each_page_is_one_layer_of_what_shows() { + let r = import_with_report(&file("/OFF [{B}]"), &ImportOptions { layers: false, ..Default::default() }).unwrap(); + assert_eq!(names(&r.document), ["Page 1"]); + assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 1.0, 0.0)]); +} + +#[test] +fn pages_share_the_layers_of_their_groups() { + let (objs, catalog, ids) = groups(2, "/OFF [{B}]"); + let bytes = pdf_with_catalog(&[page(ids), page(ids)], &[&objs[0], &objs[1]], &catalog, None); + let d = import(&bytes).unwrap(); + assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1", "Page 2"]); + assert_eq!(colors(&d.layers[0]).len(), 2, "both pages' art"); + assert_eq!(colors(&d.layers[1]).len(), 2); + // The second page's art sits on its artboard. + let x = d.layers[0].children().unwrap()[1].geometric_bounds().unwrap().x0; + assert!(x > d.artboards[1].rect.x0, "{x}"); +} + +#[test] +fn groups_marked_inside_forms_and_clips_reach_their_layers() { + let (objs, catalog, ids) = groups(1, "/OFF [{B}]"); + let form_no = first_extra(1) + 2; + let form = stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Properties << /MC1 {} 0 R >> >>", ids[1]), + "/OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC", + ); + let page = PdfPage { + resources: format!("/Properties << /MC0 {} 0 R >> /XObject << /X0 {form_no} 0 R >>", ids[0]), + ..PdfPage::new(100.0, 100.0, "q 0 0 25 25 re W n /OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC Q /X0 Do") + }; + let d = import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None)).unwrap(); + assert_eq!(names(&d), ["Shapes", "Hidden"]); + let shapes = &d.layers[0].children().unwrap()[0]; + assert!(matches!(shapes.kind, NodeKind::Group { clip: true, .. }), "the clip group goes with its art"); + assert!(!d.layers[1].visible); + assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]); +} + +#[test] +fn a_form_hidden_by_its_own_group_stays_hidden() { + let (objs, catalog, ids) = groups(1, "/OFF [{B}]"); + let form_no = first_extra(1) + 2; + let form = stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC {} 0 R", ids[1]), "0 0 1 rg 50 50 30 30 re f"); + let page = PdfPage { resources: format!("/XObject << /X0 {form_no} 0 R >>"), ..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f /X0 Do") }; + let r = import_with_report(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None), &ImportOptions::default()).unwrap(); + assert_eq!(names(&r.document), ["Page 1"]); + assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0)], "the hidden form isn't shown"); + assert!(r.warnings.iter().any(|w| w.contains("hidden layers")), "{:?}", r.warnings); +} + +#[test] +fn an_encrypted_file_keeps_its_hidden_layers() { + let (objs, catalog, ids) = groups(1, "/OFF [{B}]"); + let bytes = pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, Some("pw")); + let r = import_with_report(&bytes, &ImportOptions { password: Some("pw".into()), ..Default::default() }).unwrap(); + let d = r.document; + assert_eq!(d.layers.len(), 3, "{:?}", r.warnings); + assert!(d.layers[0].visible && !d.layers[1].visible); + assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]); +} diff --git a/crates/testkit/src/pdf.rs b/crates/testkit/src/pdf.rs index 2dccda88..88a8085d 100644 --- a/crates/testkit/src/pdf.rs +++ b/crates/testkit/src/pdf.rs @@ -65,6 +65,11 @@ pub fn first_extra(pages: usize) -> usize { /// [`pdf`] with `extra` objects (written as given, not encrypted: streams such as functions and /// ICC profiles that resources refer to by number, from [`first_extra`] on). pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Vec { + pdf_with_catalog(pages, extra, "", password) +} + +/// [`pdf_with`] with more entries in the catalog (`/OCProperties << … >>`). +pub fn pdf_with_catalog(pages: &[PdfPage], extra: &[&str], catalog: &str, password: Option<&str>) -> Vec { let id = md5(b"vectorcraft test file"); // (O, U, file key) of the standard security handler, revision 2, permissions -4. let crypt = password.map(|pw| { @@ -80,7 +85,7 @@ pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Ve }); let mut objects: Vec> = vec![]; let kids: Vec = (0..pages.len()).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); - objects.push(b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()); + objects.push(format!("<< /Type /Catalog /Pages 2 0 R {catalog}>>").into_bytes()); objects.push(format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), pages.len()).into_bytes()); for (i, p) in pages.iter().enumerate() { let mut d = format!("<< /Type /Page /Parent 2 0 R /MediaBox {} /Contents {} 0 R /Resources << {} >>", rect(p.media), 4 + 2 * i, p.resources); diff --git a/docs/mcp.md b/docs/mcp.md index f94e26f9..50781e20 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -240,10 +240,12 @@ the backdrop colour gives Clip, an inverting transfer function Invert), transpar 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: +`textAs: "outlines"` keeps glyph outlines instead. Optional content groups (the layers of PDF and PDF-compatible `.ai` +files) become layers with their name, visibility, print state and lock, art that is off coming in as a hidden layer; +art outside them goes to a layer per page. `layers: false` gives one layer per page of only what shows: ```json -{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines"}}} +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines","layers":false}}} ``` Preserve Editing (`preserveEditing`, on in the `VectorCraft Default` preset) embeds the native document in the PDF as